Skip to main content

mati_core/store/
enforcement.rs

1//! Enforcement event recording — the audit backbone.
2//!
3//! This module provides the canonical event envelope for all enforcement
4//! decisions made by mati hooks. Events form a hash-chained, monotonically
5//! sequenced, tamper-evident stream that can be exported for audit.
6//!
7//! # Invariants (FROZEN for schema_version 1)
8//!
9//! - The canonical hash contract (field order, serialization format, algorithm)
10//!   must not change without incrementing [`SCHEMA_VERSION`].
11//! - Sequence numbers are globally unique, monotonically increasing, and
12//!   persisted before the event that uses them.
13//! - The hash chain (`prev_hash`) links each event to its predecessor.
14//!   Gaps in seq_no are acceptable (crash recovery) but hash chain breaks
15//!   indicate tampering or corruption.
16
17use 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
28// ─────────────────────────────────────────────
29// Constants (FROZEN for v1)
30// ─────────────────────────────────────────────
31
32/// Schema version for the enforcement event envelope. v2 adds `agent_session`,
33/// appended to the canonical form and hashed only for v2+ events; v1 events keep
34/// their original 14-field canonical layout and hashes (see `compute_hash`).
35/// Increment only when fields are added or serialization changes.
36/// Verifiers must reject events with unknown schema versions.
37pub const SCHEMA_VERSION: u8 = 2;
38
39/// Hash algorithm used for event_hash and prev_hash.
40/// Frozen for v1. Do not change without incrementing SCHEMA_VERSION.
41pub const HASH_ALGORITHM: &str = "sha256";
42
43/// Store key for the global enforcement sequence counter.
44const SEQ_KEY: &str = "enforcement:seq";
45
46/// Store key for the installation identifier.
47pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
48
49/// Store key prefix for enforcement event records.
50pub const EVENT_PREFIX: &str = "enforcement:event:";
51
52// ─────────────────────────────────────────────
53// Event Envelope
54// ─────────────────────────────────────────────
55
56/// The canonical enforcement event envelope.
57///
58/// Every enforcement decision (deny, allow-after-receipt, bypass detection,
59/// control changes) is recorded as one of these events. They form a
60/// hash-chained, sequenced stream for tamper-evident audit.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EnforcementEvent {
63    /// Globally unique event identifier. UUIDv7 (time-ordered).
64    pub event_id: String,
65
66    /// Schema version. Always SCHEMA_VERSION for v1.
67    pub schema_version: u8,
68
69    /// Global durable monotonic sequence number within this store.
70    /// Allocated atomically. Never reused. Never gaps except after crash
71    /// (which produces a RecordingGap event on recovery).
72    pub seq_no: u64,
73
74    /// Unix milliseconds UTC when this event was recorded.
75    pub recorded_at_ms: u64,
76
77    /// The type of event. Determines which optional fields are populated.
78    pub event_type: EnforcementEventType,
79
80    /// SHA-256 hash of this event's canonical serialization (see hash contract).
81    /// Computed AFTER all other fields are set, stored as lowercase hex.
82    pub event_hash: String,
83
84    /// SHA-256 hash of the previous event in the stream. Empty string for
85    /// the first event in the store. Forms a hash chain for tamper detection.
86    pub prev_hash: String,
87
88    /// Stable installation identifier. UUID generated once at first init,
89    /// persisted in the store, never changes. NOT derived from hostname.
90    pub installation_id: String,
91
92    /// Local OS identity of the actor. Structured, explicitly labeled as
93    /// unverified. None if identity cannot be determined.
94    pub actor_local: Option<ActorLocal>,
95
96    /// The AI agent type that triggered this event.
97    pub agent_type: String,
98
99    /// What kind of subject this event pertains to.
100    pub subject_kind: SubjectKind,
101
102    /// Canonical identifier of the subject. For files: the canonical file key
103    /// (normalized, symlink-resolved, case-folded where applicable).
104    /// For controls: the gotcha or config key.
105    pub subject_key: String,
106
107    /// Hash of the canonical file path for file-backed subjects. Allows
108    /// cross-referencing even if paths are later renamed.
109    pub canonical_subject_hash: Option<String>,
110
111    /// Links events back to the receipt that authorized them.
112    pub receipt_id: Option<String>,
113
114    /// Stable enum string for the reason. NOT freeform prose.
115    /// Examples: "gotcha_above_threshold", "receipt_valid", "receipt_expired",
116    /// "daemon_unreachable", "control_created", "control_deleted"
117    pub decision_reason_code: String,
118
119    /// Hash of the gotcha/config state that was used to make this decision.
120    /// Proves which rule text and thresholds were in force at decision time.
121    pub decision_basis_hash: Option<String>,
122
123    /// The AI agent SESSION that triggered this event (Claude Code `session_id`).
124    /// Enables per-actor audit attribution — proving the same session that
125    /// consulted a file also acted on it. `None` for events with no session
126    /// (Codex, config changes, gaps). Added in schema_version 2; hashed only for
127    /// v2+ events (see `compute_hash`).
128    pub agent_session: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ActorLocal {
133    /// OS username (e.g. "ioni")
134    pub username: String,
135    /// OS user ID where available (Unix uid). None on platforms without uid.
136    pub uid: Option<u32>,
137    /// Explicitly labeled as local and unverified.
138    pub verified: bool, // always false in v1
139}
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// ─────────────────────────────────────────────
222// Canonical Hash Contract (FROZEN for v1)
223// ─────────────────────────────────────────────
224
225/// Canonical serialization form — mirrors EnforcementEvent but excludes
226/// `event_hash` (which is the output, not the input).
227///
228/// Field order is load-bearing: changing it changes the hash. This struct
229/// exists solely to enforce a stable serialization order via serde's
230/// derive(Serialize) which uses declaration order.
231#[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/// schema_version 2 canonical form: the v1 fields followed by `agent_session`,
251/// appended at the END so v1 events (serialized via `CanonicalEvent`) keep a
252/// byte-identical canonical form and their original hashes.
253#[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    /// Compute the canonical hash of this event.
275    ///
276    /// The hash covers all fields EXCEPT `event_hash` itself.
277    /// This function is frozen for schema_version 1 — do not modify
278    /// without incrementing SCHEMA_VERSION.
279    pub fn compute_hash(&self) -> String {
280        // schema_version 1 hashes the original 14-field canonical form; v2+ hashes
281        // the 15-field form with `agent_session` appended. Branching here keeps
282        // every pre-existing v1 event's hash byte-identical (no false tamper).
283        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// ─────────────────────────────────────────────
331// Chain Verification (read-side integrity check)
332// ─────────────────────────────────────────────
333
334/// The kind of integrity failure a [`ChainBreak`] records.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum ChainBreakKind {
338    /// `prev_hash` does not match the predecessor's `event_hash`. Caused by a
339    /// deleted/inserted/re-pointed event — or, most commonly on a busy store, a
340    /// concurrent write that captured the same `prev_hash` (distinguishable by a
341    /// near-zero gap between the break and its predecessor; see [`ChainBreak`]).
342    Linkage,
343    /// The stored `event_hash` does not match a fresh `compute_hash()` — the
344    /// event body was altered after recording.
345    Tampered,
346    /// The event's `schema_version` is newer than this binary understands, so
347    /// its canonical form cannot be reproduced for verification.
348    UnknownSchema,
349}
350
351/// A single integrity failure located in the chain, with enough context to
352/// characterize it. For a `Linkage` break, a near-zero delta between
353/// `recorded_at_ms` and `prev_recorded_at_ms` indicates a concurrent write
354/// rather than tampering.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct ChainBreak {
357    pub kind: ChainBreakKind,
358    /// Seq number of the offending event.
359    pub seq_no: u64,
360    pub recorded_at_ms: u64,
361    pub event_type: String,
362    /// Predecessor context — populated for `Linkage` breaks only.
363    pub prev_seq_no: Option<u64>,
364    pub prev_recorded_at_ms: Option<u64>,
365    pub prev_event_type: Option<String>,
366}
367
368/// Result of verifying the integrity of an enforcement event chain.
369///
370/// Verification is a READ-SIDE check over already-recorded events: it never
371/// mutates the store and performs no network I/O. It is the inverse of the
372/// write-time hash contract — it recomputes each event's hash AND re-checks the
373/// `prev_hash` linkage, so it detects both:
374///
375/// - **content tampering** — an event whose body was altered after recording
376///   while its stored `event_hash` was left untouched (a linkage-only check
377///   misses this, because the stored hashes still chain together); and
378/// - **linkage breaks** — a deleted, inserted, or re-pointed event, where one
379///   event's `prev_hash` no longer matches its predecessor's `event_hash`.
380///
381/// A full from-genesis rewrite (every hash recomputed consistently) is *not*
382/// detectable here by design — that is the inherent limit of a local,
383/// externally-unanchored chain, and is addressed at the custody layer, not here.
384#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
385pub struct ChainVerification {
386    /// Events whose hash was recomputed and compared (excludes unknown-schema).
387    pub checked: usize,
388    /// Events whose stored `event_hash` does not match a fresh `compute_hash()`
389    /// — i.e. the content was altered after recording.
390    pub tampered_events: usize,
391    /// Adjacent events where `prev_hash` does not match the predecessor's
392    /// `event_hash`. The earliest surviving event is never counted, so a
393    /// legitimately retention-pruned prefix is not a break.
394    pub linkage_breaks: usize,
395    /// Events with a `schema_version` newer than this binary understands. Their
396    /// canonical form cannot be reproduced, so they are reported, not verified.
397    pub unknown_schema: usize,
398    /// Every located break, in seq order. Empty when the chain is intact.
399    pub breaks: Vec<ChainBreak>,
400}
401
402impl ChainVerification {
403    /// True only when the chain is fully intact and fully verifiable: no content
404    /// tampering, no linkage breaks, and no events this binary cannot verify.
405    pub fn is_valid(&self) -> bool {
406        self.tampered_events == 0 && self.linkage_breaks == 0 && self.unknown_schema == 0
407    }
408}
409
410/// Verify the integrity of a set of enforcement events.
411///
412/// `events` may be in any order — they are sorted by `seq_no` for the linkage
413/// check. The linkage check compares only *consecutive present* events, so a
414/// pruned prefix (the earliest surviving event's dangling `prev_hash`) is not
415/// reported as a break.
416///
417/// Pure: no store access, no network, no mutation. A single shared primitive so
418/// every consumer verifies against one source of truth for the frozen hash
419/// contract.
420pub 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        // Linkage uses the stored hashes, so it is schema-independent.
429        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        // Content integrity: only events whose schema this binary can
445        // canonicalize are recomputed; newer schemas are reported as unknown.
446        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
479// ─────────────────────────────────────────────
480// Sequence Number Allocator
481// ─────────────────────────────────────────────
482
483/// Atomic sequence number allocator backed by the store.
484///
485/// Key: "enforcement:seq" — stores the current counter as a big-endian u64.
486/// The counter is persisted before `next()` returns — if the store write
487/// fails, the sequence number is not allocated.
488pub struct SeqAllocator {
489    current: u64,
490}
491
492impl SeqAllocator {
493    /// Load the current sequence number from the store, or initialize to 0.
494    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    /// Allocate the next sequence number and persist it durably.
505    ///
506    /// Returns the allocated seq_no. If the store write fails, the seq is
507    /// NOT allocated and the caller gets an error.
508    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    /// Return the current (last allocated) sequence number without incrementing.
515    pub fn current(&self) -> u64 {
516        self.current
517    }
518}
519
520// ─────────────────────────────────────────────
521// Installation ID
522// ─────────────────────────────────────────────
523
524/// Retrieve the installation_id from the store, or generate and persist one.
525///
526/// The installation_id is a UUIDv4 generated once at first init. It never
527/// changes after that. NOT derived from hostname — stable across renames.
528pub 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
541// ─────────────────────────────────────────────
542// Actor Identity
543// ─────────────────────────────────────────────
544
545/// Get the local OS actor identity. Unverified — v1 trusts the local OS.
546pub 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
563// ─────────────────────────────────────────────
564// Canonical File Identity
565// ─────────────────────────────────────────────
566
567/// Canonicalize a file path for use as a subject_key in enforcement events.
568///
569/// Rules (frozen for v1):
570/// 1. Resolve relative paths against the repo root
571/// 2. Normalize path separators to forward slash
572/// 3. Remove `.` and `..` components
573/// 4. Resolve symlinks where possible (fall back to normalized path if resolution fails)
574/// 5. Strip the repo root prefix to produce a repo-relative path
575/// 6. On case-insensitive filesystems (macOS default, Windows), lowercase the path
576///
577/// The output is a stable, canonical string that survives path aliasing.
578///
579/// # Known limitation (v1)
580///
581/// Case sensitivity is detected by platform default, not per-volume. Some
582/// macOS volumes are case-sensitive and some Linux volumes (ecryptfs) are
583/// case-insensitive. For v1, the platform default is acceptable.
584pub fn canonicalize_file_key(path: &str, repo_root: &Path) -> String {
585    // Step 1: Make absolute
586    let abs_path = if Path::new(path).is_relative() {
587        repo_root.join(path)
588    } else {
589        PathBuf::from(path)
590    };
591
592    // Step 2+3: Normalize components (remove `.` and `..`)
593    let normalized = normalize_components(&abs_path);
594
595    // Step 4: Try symlink resolution, fall back to normalized
596    let resolved = std::fs::canonicalize(&normalized).unwrap_or(normalized);
597
598    // Step 5: Strip repo root to get repo-relative path
599    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    // Convert to forward-slash string
606    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    // Step 6: Case-fold on case-insensitive platforms
613    if is_case_insensitive() {
614        key = key.to_lowercase();
615    }
616
617    key
618}
619
620/// Normalize path components without filesystem access.
621/// Collapses `.` and `..` lexically.
622fn normalize_components(path: &Path) -> PathBuf {
623    let mut components = Vec::new();
624    for component in path.components() {
625        match component {
626            Component::CurDir => {} // skip "."
627            Component::ParentDir => {
628                // Pop last normal component; keep prefix/root
629                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
641/// Platform-default case sensitivity detection.
642///
643/// v1 simplification: macOS and Windows are case-insensitive,
644/// Linux is case-sensitive. Per-volume detection deferred to v2.
645fn is_case_insensitive() -> bool {
646    cfg!(target_os = "macos") || cfg!(target_os = "windows")
647}
648
649/// Compute a SHA-256 hash of the canonical file key for cross-reference stability.
650///
651/// Allows correlating events even after file renames.
652pub 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
658// ─────────────────────────────────────────────
659// UUIDv7 generation
660// ─────────────────────────────────────────────
661
662/// Generate a UUIDv7 (time-ordered) string.
663///
664/// UUIDv7 encodes millisecond-precision Unix time in the high bits,
665/// producing lexicographically sortable IDs that cluster temporally.
666fn uuid7_string() -> String {
667    uuid::Uuid::now_v7().to_string()
668}
669
670/// Current time as Unix milliseconds.
671fn now_ms() -> u64 {
672    SystemTime::now()
673        .duration_since(UNIX_EPOCH)
674        .unwrap_or_default()
675        .as_millis() as u64
676}
677
678// ─────────────────────────────────────────────
679// Event Writer
680// ─────────────────────────────────────────────
681
682/// The enforcement event writer. Ties together sequence allocation,
683/// hash chaining, and store persistence into a single write path.
684///
685/// One writer per store lifetime. Not Clone — the seq counter and
686/// prev_hash chain are stateful.
687pub struct EnforcementEventWriter {
688    seq: SeqAllocator,
689    installation_id: String,
690    prev_hash: String,
691    /// Agent session (Claude Code `session_id`) to attribute written events to,
692    /// for per-actor audit (schema_version 2). `None` unless set before `write`.
693    agent_session: Option<String>,
694}
695
696impl EnforcementEventWriter {
697    /// Initialize the writer from store state.
698    ///
699    /// Loads the current seq counter, installation_id, and the hash of
700    /// the last event in the stream (for chain continuity).
701    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    /// Load the hash of the most recent enforcement event.
715    ///
716    /// Scans for the highest seq_no enforcement event and returns its
717    /// event_hash. Returns empty string if no events exist (first event).
718    async fn load_last_hash(store: &Store) -> String {
719        // The last event key is "enforcement:event:{seq_no}" with zero-padded seq.
720        // Scan all event keys and find the highest.
721        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        // Find the key with the highest seq_no
731        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    /// Write an enforcement event to the store.
752    ///
753    /// Allocates a seq_no (persisted before event write), computes the
754    /// hash chain, and writes the event as JSON under `enforcement:event:{seq_no}`.
755    ///
756    /// Returns the written event (with computed hashes) or an error.
757    #[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(), // computed below
784            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        // Compute and set the event hash
798        event.event_hash = event.compute_hash();
799
800        // Write to store — zero-padded seq for lexicographic ordering
801        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        // Update prev_hash for the next event in this writer's lifetime
806        self.prev_hash = event.event_hash.clone();
807
808        Ok(event)
809    }
810
811    /// Return the current installation ID.
812    pub fn installation_id(&self) -> &str {
813        &self.installation_id
814    }
815
816    /// Return the current sequence number (last allocated).
817    pub fn current_seq(&self) -> u64 {
818        self.seq.current()
819    }
820
821    /// Return the hash of the last written event.
822    pub fn prev_hash(&self) -> &str {
823        &self.prev_hash
824    }
825
826    /// Detect gaps in the event stream and emit a RecordingGap event.
827    ///
828    /// Called on writer initialization when the seq counter is ahead of
829    /// the last stored event (indicating a crash between seq allocation
830    /// and event write).
831    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
859// ─────────────────────────────────────────────
860// Store scan helpers
861// ─────────────────────────────────────────────
862
863/// Read enforcement events within a seq_no range [since, until] inclusive.
864///
865/// Returns events in seq_no order. Events outside the range or with
866/// corrupt JSON are skipped with a warning.
867pub 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
900// ─────────────────────────────────────────────
901// Enforcement Mode
902// ─────────────────────────────────────────────
903
904/// Store key for the enforcement mode setting.
905const ENFORCEMENT_MODE_KEY: &str = "enforcement:mode";
906
907/// Default retention period in days.
908const DEFAULT_RETENTION_DAYS: u64 = 365;
909
910/// Store key for the retention period setting.
911const RETENTION_DAYS_KEY: &str = "enforcement:retention_days";
912
913/// Read the current enforcement mode from the store.
914/// Defaults to Advisory if not set or unreadable.
915pub 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
925/// Persist the enforcement mode to the store. Returns the previous mode.
926/// Records an EnforcementConfigChanged event when the mode actually changes.
927pub 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    // Record config change event if the mode actually changed. The audit event
938    // uses the USER-FACING vocabulary (audit.write_durability / best_effort),
939    // even though the internal enum + stored value stay advisory/strict (frozen
940    // by the RecordingGap hash contract and the storage round-trip).
941    if old != mode {
942        let user_label = |m: EnforcementMode| match m {
943            EnforcementMode::Advisory => "best_effort",
944            EnforcementMode::Strict => "strict",
945        };
946        // Best-effort — don't fail the config change if event recording fails
947        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
966/// Read the configured retention period in days.
967pub 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
977/// Persist the retention period.
978pub 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
984// ─────────────────────────────────────────────
985// Decision Basis Hash
986// ─────────────────────────────────────────────
987
988/// Compute a hash of the gotcha state used for an enforcement decision.
989///
990/// Each gotcha contributes its key, rule text, and confidence value to the
991/// hash. This proves which exact rule state was in force at decision time.
992pub 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
1010// ─────────────────────────────────────────────
1011// Standalone Event Recording
1012// ─────────────────────────────────────────────
1013
1014/// Process-global registry of per-store serialized enforcement writers.
1015///
1016/// One long-lived [`EnforcementEventWriter`] per store (keyed by store root)
1017/// serializes the whole `prev_hash`-capture → seq-allocation → event-write
1018/// critical section. Without it, each `record_event` built a fresh writer that
1019/// independently captured `prev_hash`/seq, so concurrent writers collided on a
1020/// seq number (silently overwriting an already-recorded event) or shared a
1021/// `prev_hash` (breaking the chain). Caching the head in memory also drops the
1022/// O(N) `scan_keys` the per-call writer ran on every single event.
1023///
1024/// Cross-process correctness comes from SurrealKV's exclusive per-path lock:
1025/// only one process can open (and thus write to) a store at a time, and each
1026/// process loads the current head when it first writes.
1027static ENFORCEMENT_WRITERS: OnceLock<
1028    StdMutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<EnforcementEventWriter>>>>,
1029> = OnceLock::new();
1030
1031/// Get (or lazily create) the single serialized writer for `store`.
1032async 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    // Fast path: a writer already exists for this store.
1036    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    // Slow path: build the writer (async I/O) OUTSIDE the registry lock, then
1046    // insert. Double-checked — a concurrent caller may have inserted first, in
1047    // which case we keep theirs and drop ours (both loaded the same head).
1048    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/// Record a single enforcement event through the store's serialized writer.
1060///
1061/// All event writes funnel through one [`EnforcementEventWriter`] per store
1062/// (via the internal `shared_writer` registry), so the hash chain stays intact
1063/// and seq numbers never collide under concurrency.
1064///
1065/// Respects the enforcement mode: in advisory mode, write failures are
1066/// logged but Ok(None) is returned. In strict mode, write failures propagate.
1067#[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/// Like [`record_event`], but attributes the event to an AI agent SESSION
1093/// (Claude Code `session_id`) for per-actor audit (schema_version 2). Used by the
1094/// hook-event path, which carries the `session_id` from the PreToolUse input.
1095#[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// ─────────────────────────────────────────────
1141// Retention / Pruning
1142// ─────────────────────────────────────────────
1143
1144/// Result of a retention enforcement run.
1145#[derive(Debug)]
1146pub enum PruneResult {
1147    NothingToPrune,
1148    Pruned {
1149        count: u64,
1150        oldest_seq: u64,
1151        newest_seq: u64,
1152    },
1153}
1154
1155/// Prune enforcement events older than the configured retention period
1156/// and record a RetentionPruned event for the deletion.
1157///
1158/// Called during `mati init` and `mati repair --full`.
1159pub 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    // Delete the old events
1178    for event in &old_events {
1179        let key = format!("{EVENT_PREFIX}{:020}", event.seq_no);
1180        store.delete(&key).await?;
1181    }
1182
1183    // Record the prune as an event
1184    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
1207// ─────────────────────────────────────────────
1208// Gap Detection on Startup
1209// ─────────────────────────────────────────────
1210
1211/// Check for and record gaps on writer startup.
1212///
1213/// If the store has events AND the last event's recorded_at_ms is older
1214/// than `gap_threshold_ms`, emit a RecordingGap event. Conservative:
1215/// may over-report gaps where the daemon was simply idle, but
1216/// under-reporting is worse for a compliance tool.
1217pub 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        // Route through the shared writer so the cached chain head stays in sync
1228        // with this RecordingGap write.
1229        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
1239// ─────────────────────────────────────────────
1240// Scan helpers for CLI display
1241// ─────────────────────────────────────────────
1242
1243/// Scan enforcement events within a time window.
1244pub 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
1252/// Count enforcement events by type within a time window.
1253pub 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
1258/// Aggregate a slice of events into typed counts.
1259///
1260/// Pure (no store / no I/O) so the aggregation — including the `ControlChanged`
1261/// lifecycle breakdown — is unit-testable from hand-built events.
1262pub 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/// Aggregated event counts for CLI display.
1291#[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    /// Total `ControlChanged` events (sum of the four `controls_*` lifecycle
1299    /// counters below).
1300    pub controls_changed: u64,
1301    /// Lifecycle breakdown of `ControlChanged` events (control == confirmed
1302    /// gotcha). These let `mati stats` report gotcha created/confirmed/updated/
1303    /// removed velocity from the audit log without new capture.
1304    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/// Derived enforcement metrics (PMF/friction signals) computed from the
1314/// raw event stream. All derived from existing events — no new capture.
1315#[derive(Debug, Default)]
1316pub struct DerivedEnforcementMetrics {
1317    /// Distinct `agent_session` ids that produced at least one `Deny`.
1318    ///
1319    /// `Deny` is hook-path and carries a session id; `ReceiptMinted` is
1320    /// MCP-path and does not (schema_version 2). So only *blocks* can be
1321    /// attributed to sessions, not consultations.
1322    pub blocked_sessions: u64,
1323    /// `Deny` events that carry a session id — the numerator for the ratio.
1324    pub attributed_denials: u64,
1325    /// `attributed_denials / blocked_sessions`; `None` when no denied session
1326    /// carries an id (nothing to attribute).
1327    pub blocks_per_session: Option<f64>,
1328    /// Median milliseconds from a `Deny` to the next `ReceiptMinted` on the
1329    /// **same `subject_key`** (paired by subject + temporal order, since
1330    /// receipts lack a session id), counting only pairs within
1331    /// `CONSULTED_RECENT_TTL_SECS` — the system's own "recent consult" window.
1332    /// A later receipt is a separate interaction, not a response to the block.
1333    /// Measures how long an agent takes to consult after being blocked. `None`
1334    /// when no Deny→consult pair exists inside the window.
1335    pub median_time_to_consult_ms: Option<u64>,
1336    /// Number of Deny→consult pairs that fed the median.
1337    pub consult_pairs: u64,
1338}
1339
1340/// Compute [`DerivedEnforcementMetrics`] from a slice of events.
1341///
1342/// Pure (no store / no I/O) so it is unit-testable from hand-built events.
1343pub fn derive_enforcement_metrics(events: &[EnforcementEvent]) -> DerivedEnforcementMetrics {
1344    use std::collections::{BTreeSet, HashMap};
1345
1346    // --- blocks per session: Deny events carry session ids ---
1347    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    // --- time to consult: Deny -> next ReceiptMinted on the same subject ---
1364    // Index receipt timestamps per subject, sorted ascending, so each Deny can
1365    // find the first consultation at or after it.
1366    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    // Only a consult within this window counts as a response to the block;
1379    // a later receipt is a separate interaction (avoids cross-day/cross-session
1380    // pairs that would inflate the median).
1381    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                // First receipt at or after this deny (sorted ascending).
1387                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
1408/// Median of a slice (mutated: sorted in place). Even counts average the two
1409/// middle values (integer division). `None` for an empty slice.
1410fn 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
1424/// Format an event type as a short display string.
1425pub 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    /// Construct a deterministic event for hash testing.
1443    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        // This hash is frozen. If this test fails, either the canonical
1475        // serialization changed (which breaks all existing hash chains)
1476        // or the hash algorithm changed. Neither is acceptable without
1477        // incrementing SCHEMA_VERSION.
1478        assert_eq!(
1479            hash,
1480            "e8a42cb3c1c4dde12f807f46678c5d4393466a831007540a85ff84a003203e37"
1481        );
1482
1483        // Verify determinism — same input always produces same hash.
1484        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    /// Build an event of a given type from the frozen template.
1500    fn event_of(event_type: EnforcementEventType) -> EnforcementEvent {
1501        EnforcementEvent {
1502            event_type,
1503            ..frozen_test_event()
1504        }
1505    }
1506
1507    /// Build a valid, hash-linked chain of `n` events (seq 1..=n).
1508    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        // The exact attack the linkage-only verifier missed: alter the body but
1546        // leave the stored event_hash untouched, so the chain still links.
1547        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); // drop the middle event (seq 2)
1559        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); // prune the earliest event (seq 1)
1569        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); // delete seq 2; seq 3 now directly follows seq 1
1623        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    /// Regression: concurrent `record_event` calls must produce an intact,
1632    /// collision-free chain. Before the per-store serialized writer, each call
1633    /// built a fresh writer that independently captured `prev_hash`/seq, so racing
1634    /// writers shared a `prev_hash` (linkage break) or collided on a seq (event
1635    /// loss). Multi-threaded + 64 writers reliably exercises the race.
1636    #[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        // No seq collisions → every concurrent write persisted as a distinct event.
1667        assert_eq!(
1668            events.len() as u64,
1669            n,
1670            "all {n} concurrent writes must persist (no seq collision / event loss)"
1671        );
1672        // No prev_hash races → the chain verifies intact.
1673        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        // Lifecycle breakout.
1715        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        // The total must equal the sum of the four lifecycle counters.
1721        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    /// Build an event with explicit type/subject/time/session over the template.
1740    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            // sessA blocked on x@1000, consulted x@1500 (delta 500)
1760            ev(Deny, "file:x.rs", 1000, Some("sessA")),
1761            ev(ReceiptMinted, "file:x.rs", 1500, None),
1762            // sessB blocked on y@2000, consulted y@2300 (delta 300)
1763            ev(Deny, "file:y.rs", 2000, Some("sessB")),
1764            ev(ReceiptMinted, "file:y.rs", 2300, None),
1765            // sessA blocked again on y@3000 — no later receipt on y → no pair
1766            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)); // 3 denials / 2 sessions
1774        assert_eq!(m.consult_pairs, 2); // only denies with a later same-subject receipt
1775        assert_eq!(m.median_time_to_consult_ms, Some(400)); // median([300,500])
1776    }
1777
1778    #[test]
1779    fn derive_metrics_no_sessioned_denials_yields_none() {
1780        use EnforcementEventType::*;
1781        // Denies without a session id (e.g. pre-v2 events) cannot be attributed.
1782        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        // Time-to-consult still pairs by subject regardless of session.
1791        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            // In-window consult (delta == window boundary, inclusive) → counts.
1801            ev(Deny, "file:a.rs", 0, Some("s1")),
1802            ev(ReceiptMinted, "file:a.rs", window_ms, None),
1803            // Out-of-window consult (1ms past) → excluded (a separate interaction).
1804            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)); // odd → middle
1817        assert_eq!(median_u64(&mut [4, 1, 3, 2]), Some(2)); // even → (2+3)/2 = 2
1818    }
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        // Setting event_hash should not affect compute_hash output
1826        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        // These should all produce the same canonical key (lexical normalization)
1840        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        // Use normalize_components only (no fs access in test)
1848        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        // A v2 event hashes `agent_session`, so changing it changes the hash —
1895        // tamper-evident per-actor attribution. v1 events (frozen_test_event) are
1896        // unaffected: their hash stays the v1 golden value asserted above.
1897        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        // Determinism: same v2 event → same hash.
1911        assert_eq!(h_session, e_session.compute_hash());
1912
1913        // The v2 form (even with agent_session=None) differs from the v1 form of
1914        // the same event — proving compute_hash branches on schema_version.
1915        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}