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::path::{Component, Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use anyhow::Result;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use super::db::Store;
25
26// ─────────────────────────────────────────────
27// Constants (FROZEN for v1)
28// ─────────────────────────────────────────────
29
30/// Schema version for the enforcement event envelope. Frozen at 1 for v1.
31/// Increment only when fields are added or serialization changes.
32/// Verifiers must reject events with unknown schema versions.
33pub const SCHEMA_VERSION: u8 = 1;
34
35/// Hash algorithm used for event_hash and prev_hash.
36/// Frozen for v1. Do not change without incrementing SCHEMA_VERSION.
37pub const HASH_ALGORITHM: &str = "sha256";
38
39/// Store key for the global enforcement sequence counter.
40const SEQ_KEY: &str = "enforcement:seq";
41
42/// Store key for the installation identifier.
43pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
44
45/// Store key prefix for enforcement event records.
46pub const EVENT_PREFIX: &str = "enforcement:event:";
47
48// ─────────────────────────────────────────────
49// Event Envelope
50// ─────────────────────────────────────────────
51
52/// The canonical enforcement event envelope.
53///
54/// Every enforcement decision (deny, allow-after-receipt, bypass detection,
55/// control changes) is recorded as one of these events. They form a
56/// hash-chained, sequenced stream for tamper-evident audit.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct EnforcementEvent {
59    /// Globally unique event identifier. UUIDv7 (time-ordered).
60    pub event_id: String,
61
62    /// Schema version. Always SCHEMA_VERSION for v1.
63    pub schema_version: u8,
64
65    /// Global durable monotonic sequence number within this store.
66    /// Allocated atomically. Never reused. Never gaps except after crash
67    /// (which produces a RecordingGap event on recovery).
68    pub seq_no: u64,
69
70    /// Unix milliseconds UTC when this event was recorded.
71    pub recorded_at_ms: u64,
72
73    /// The type of event. Determines which optional fields are populated.
74    pub event_type: EnforcementEventType,
75
76    /// SHA-256 hash of this event's canonical serialization (see hash contract).
77    /// Computed AFTER all other fields are set, stored as lowercase hex.
78    pub event_hash: String,
79
80    /// SHA-256 hash of the previous event in the stream. Empty string for
81    /// the first event in the store. Forms a hash chain for tamper detection.
82    pub prev_hash: String,
83
84    /// Stable installation identifier. UUID generated once at first init,
85    /// persisted in the store, never changes. NOT derived from hostname.
86    pub installation_id: String,
87
88    /// Local OS identity of the actor. Structured, explicitly labeled as
89    /// unverified. None if identity cannot be determined.
90    pub actor_local: Option<ActorLocal>,
91
92    /// The AI agent type that triggered this event.
93    pub agent_type: String,
94
95    /// What kind of subject this event pertains to.
96    pub subject_kind: SubjectKind,
97
98    /// Canonical identifier of the subject. For files: the canonical file key
99    /// (normalized, symlink-resolved, case-folded where applicable).
100    /// For controls: the gotcha or config key.
101    pub subject_key: String,
102
103    /// Hash of the canonical file path for file-backed subjects. Allows
104    /// cross-referencing even if paths are later renamed.
105    pub canonical_subject_hash: Option<String>,
106
107    /// Links events back to the receipt that authorized them.
108    pub receipt_id: Option<String>,
109
110    /// Stable enum string for the reason. NOT freeform prose.
111    /// Examples: "gotcha_above_threshold", "receipt_valid", "receipt_expired",
112    /// "daemon_unreachable", "control_created", "control_deleted"
113    pub decision_reason_code: String,
114
115    /// Hash of the gotcha/config state that was used to make this decision.
116    /// Proves which rule text and thresholds were in force at decision time.
117    pub decision_basis_hash: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ActorLocal {
122    /// OS username (e.g. "ioni")
123    pub username: String,
124    /// OS user ID where available (Unix uid). None on platforms without uid.
125    pub uid: Option<u32>,
126    /// Explicitly labeled as local and unverified.
127    pub verified: bool, // always false in v1
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum SubjectKind {
133    File,
134    Control,
135    Config,
136    System,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "type", rename_all = "snake_case")]
141pub enum EnforcementEventType {
142    Deny,
143    AllowAfterReceipt,
144    ReceiptMinted,
145    BypassDetected,
146    ControlChanged {
147        change_kind: ControlChangeKind,
148    },
149    EnforcementConfigChanged {
150        setting: String,
151        old_value: String,
152        new_value: String,
153    },
154    RecordingGap {
155        gap_start_ms: u64,
156        gap_end_ms: u64,
157        cause: GapCause,
158        enforcement_mode_during_gap: EnforcementMode,
159        missed_event_count: MissedEventCount,
160        certainty: GapCertainty,
161    },
162    RetentionPruned {
163        pruned_count: u64,
164        oldest_pruned_seq: u64,
165        newest_pruned_seq: u64,
166    },
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ControlChangeKind {
172    Created,
173    Confirmed,
174    Updated,
175    Deleted,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum GapCause {
181    DaemonUnreachable,
182    StoreWriteFailure,
183    StoreLocked,
184    CorruptionRecovery,
185    Unknown,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum EnforcementMode {
191    Advisory,
192    Strict,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum MissedEventCount {
198    Known(u64),
199    Zero,
200    Unknown,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum GapCertainty {
206    Exact,
207    Inferred,
208}
209
210// ─────────────────────────────────────────────
211// Canonical Hash Contract (FROZEN for v1)
212// ─────────────────────────────────────────────
213
214/// Canonical serialization form — mirrors EnforcementEvent but excludes
215/// `event_hash` (which is the output, not the input).
216///
217/// Field order is load-bearing: changing it changes the hash. This struct
218/// exists solely to enforce a stable serialization order via serde's
219/// derive(Serialize) which uses declaration order.
220#[derive(Serialize)]
221struct CanonicalEvent<'a> {
222    event_id: &'a str,
223    schema_version: u8,
224    seq_no: u64,
225    recorded_at_ms: u64,
226    event_type: &'a EnforcementEventType,
227    prev_hash: &'a str,
228    installation_id: &'a str,
229    actor_local: &'a Option<ActorLocal>,
230    agent_type: &'a str,
231    subject_kind: SubjectKind,
232    subject_key: &'a str,
233    canonical_subject_hash: Option<&'a str>,
234    receipt_id: Option<&'a str>,
235    decision_reason_code: &'a str,
236    decision_basis_hash: Option<&'a str>,
237}
238
239impl EnforcementEvent {
240    /// Compute the canonical hash of this event.
241    ///
242    /// The hash covers all fields EXCEPT `event_hash` itself.
243    /// This function is frozen for schema_version 1 — do not modify
244    /// without incrementing SCHEMA_VERSION.
245    pub fn compute_hash(&self) -> String {
246        let canonical = CanonicalEvent {
247            event_id: &self.event_id,
248            schema_version: self.schema_version,
249            seq_no: self.seq_no,
250            recorded_at_ms: self.recorded_at_ms,
251            event_type: &self.event_type,
252            prev_hash: &self.prev_hash,
253            installation_id: &self.installation_id,
254            actor_local: &self.actor_local,
255            agent_type: &self.agent_type,
256            subject_kind: self.subject_kind,
257            subject_key: &self.subject_key,
258            canonical_subject_hash: self.canonical_subject_hash.as_deref(),
259            receipt_id: self.receipt_id.as_deref(),
260            decision_reason_code: &self.decision_reason_code,
261            decision_basis_hash: self.decision_basis_hash.as_deref(),
262        };
263
264        let json =
265            serde_json::to_string(&canonical).expect("canonical serialization must not fail");
266
267        let mut hasher = Sha256::new();
268        hasher.update(json.as_bytes());
269        format!("{:x}", hasher.finalize())
270    }
271}
272
273// ─────────────────────────────────────────────
274// Sequence Number Allocator
275// ─────────────────────────────────────────────
276
277/// Atomic sequence number allocator backed by the store.
278///
279/// Key: "enforcement:seq" — stores the current counter as a big-endian u64.
280/// The counter is persisted before `next()` returns — if the store write
281/// fails, the sequence number is not allocated.
282pub struct SeqAllocator {
283    current: u64,
284}
285
286impl SeqAllocator {
287    /// Load the current sequence number from the store, or initialize to 0.
288    pub async fn load(store: &Store) -> Self {
289        let current = match store.get_raw_bytes(SEQ_KEY).await {
290            Ok(Some(bytes)) if bytes.len() == 8 => {
291                u64::from_be_bytes(bytes[..8].try_into().unwrap_or([0; 8]))
292            }
293            _ => 0,
294        };
295        Self { current }
296    }
297
298    /// Allocate the next sequence number and persist it durably.
299    ///
300    /// Returns the allocated seq_no. If the store write fails, the seq is
301    /// NOT allocated and the caller gets an error.
302    pub async fn next(&mut self, store: &Store) -> Result<u64> {
303        self.current += 1;
304        store.put_raw(SEQ_KEY, &self.current.to_be_bytes()).await?;
305        Ok(self.current)
306    }
307
308    /// Return the current (last allocated) sequence number without incrementing.
309    pub fn current(&self) -> u64 {
310        self.current
311    }
312}
313
314// ─────────────────────────────────────────────
315// Installation ID
316// ─────────────────────────────────────────────
317
318/// Retrieve the installation_id from the store, or generate and persist one.
319///
320/// The installation_id is a UUIDv4 generated once at first init. It never
321/// changes after that. NOT derived from hostname — stable across renames.
322pub async fn get_or_create_installation_id(store: &Store) -> Result<String> {
323    if let Ok(Some(bytes)) = store.get_raw_bytes(INSTALLATION_ID_KEY).await {
324        if let Ok(id) = std::str::from_utf8(&bytes) {
325            if !id.is_empty() {
326                return Ok(id.to_string());
327            }
328        }
329    }
330    let id = uuid::Uuid::new_v4().to_string();
331    store.put_raw(INSTALLATION_ID_KEY, id.as_bytes()).await?;
332    Ok(id)
333}
334
335// ─────────────────────────────────────────────
336// Actor Identity
337// ─────────────────────────────────────────────
338
339/// Get the local OS actor identity. Unverified — v1 trusts the local OS.
340pub fn get_local_actor() -> Option<ActorLocal> {
341    let username = std::env::var("USER")
342        .or_else(|_| std::env::var("USERNAME"))
343        .ok()?;
344
345    #[cfg(unix)]
346    let uid = Some(unsafe { libc::getuid() } as u32);
347    #[cfg(not(unix))]
348    let uid = None;
349
350    Some(ActorLocal {
351        username,
352        uid,
353        verified: false,
354    })
355}
356
357// ─────────────────────────────────────────────
358// Canonical File Identity
359// ─────────────────────────────────────────────
360
361/// Canonicalize a file path for use as a subject_key in enforcement events.
362///
363/// Rules (frozen for v1):
364/// 1. Resolve relative paths against the repo root
365/// 2. Normalize path separators to forward slash
366/// 3. Remove `.` and `..` components
367/// 4. Resolve symlinks where possible (fall back to normalized path if resolution fails)
368/// 5. Strip the repo root prefix to produce a repo-relative path
369/// 6. On case-insensitive filesystems (macOS default, Windows), lowercase the path
370///
371/// The output is a stable, canonical string that survives path aliasing.
372///
373/// # Known limitation (v1)
374///
375/// Case sensitivity is detected by platform default, not per-volume. Some
376/// macOS volumes are case-sensitive and some Linux volumes (ecryptfs) are
377/// case-insensitive. For v1, the platform default is acceptable.
378pub fn canonicalize_file_key(path: &str, repo_root: &Path) -> String {
379    // Step 1: Make absolute
380    let abs_path = if Path::new(path).is_relative() {
381        repo_root.join(path)
382    } else {
383        PathBuf::from(path)
384    };
385
386    // Step 2+3: Normalize components (remove `.` and `..`)
387    let normalized = normalize_components(&abs_path);
388
389    // Step 4: Try symlink resolution, fall back to normalized
390    let resolved = std::fs::canonicalize(&normalized).unwrap_or(normalized);
391
392    // Step 5: Strip repo root to get repo-relative path
393    let repo_root_canonical =
394        std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
395    let relative = resolved
396        .strip_prefix(&repo_root_canonical)
397        .unwrap_or(&resolved);
398
399    // Convert to forward-slash string
400    let mut key = relative
401        .components()
402        .map(|c| c.as_os_str().to_string_lossy().to_string())
403        .collect::<Vec<_>>()
404        .join("/");
405
406    // Step 6: Case-fold on case-insensitive platforms
407    if is_case_insensitive() {
408        key = key.to_lowercase();
409    }
410
411    key
412}
413
414/// Normalize path components without filesystem access.
415/// Collapses `.` and `..` lexically.
416fn normalize_components(path: &Path) -> PathBuf {
417    let mut components = Vec::new();
418    for component in path.components() {
419        match component {
420            Component::CurDir => {} // skip "."
421            Component::ParentDir => {
422                // Pop last normal component; keep prefix/root
423                if matches!(components.last(), Some(Component::Normal(_))) {
424                    components.pop();
425                } else {
426                    components.push(component);
427                }
428            }
429            _ => components.push(component),
430        }
431    }
432    components.iter().collect()
433}
434
435/// Platform-default case sensitivity detection.
436///
437/// v1 simplification: macOS and Windows are case-insensitive,
438/// Linux is case-sensitive. Per-volume detection deferred to v2.
439fn is_case_insensitive() -> bool {
440    cfg!(target_os = "macos") || cfg!(target_os = "windows")
441}
442
443/// Compute a SHA-256 hash of the canonical file key for cross-reference stability.
444///
445/// Allows correlating events even after file renames.
446pub fn canonical_subject_hash(canonical_key: &str) -> String {
447    let mut hasher = Sha256::new();
448    hasher.update(canonical_key.as_bytes());
449    format!("{:x}", hasher.finalize())
450}
451
452// ─────────────────────────────────────────────
453// UUIDv7 generation
454// ─────────────────────────────────────────────
455
456/// Generate a UUIDv7 (time-ordered) string.
457///
458/// UUIDv7 encodes millisecond-precision Unix time in the high bits,
459/// producing lexicographically sortable IDs that cluster temporally.
460fn uuid7_string() -> String {
461    uuid::Uuid::now_v7().to_string()
462}
463
464/// Current time as Unix milliseconds.
465fn now_ms() -> u64 {
466    SystemTime::now()
467        .duration_since(UNIX_EPOCH)
468        .unwrap_or_default()
469        .as_millis() as u64
470}
471
472// ─────────────────────────────────────────────
473// Event Writer
474// ─────────────────────────────────────────────
475
476/// The enforcement event writer. Ties together sequence allocation,
477/// hash chaining, and store persistence into a single write path.
478///
479/// One writer per store lifetime. Not Clone — the seq counter and
480/// prev_hash chain are stateful.
481pub struct EnforcementEventWriter {
482    seq: SeqAllocator,
483    installation_id: String,
484    prev_hash: String,
485}
486
487impl EnforcementEventWriter {
488    /// Initialize the writer from store state.
489    ///
490    /// Loads the current seq counter, installation_id, and the hash of
491    /// the last event in the stream (for chain continuity).
492    pub async fn new(store: &Store) -> Result<Self> {
493        let seq = SeqAllocator::load(store).await;
494        let installation_id = get_or_create_installation_id(store).await?;
495        let prev_hash = Self::load_last_hash(store).await;
496
497        Ok(Self {
498            seq,
499            installation_id,
500            prev_hash,
501        })
502    }
503
504    /// Load the hash of the most recent enforcement event.
505    ///
506    /// Scans for the highest seq_no enforcement event and returns its
507    /// event_hash. Returns empty string if no events exist (first event).
508    async fn load_last_hash(store: &Store) -> String {
509        // The last event key is "enforcement:event:{seq_no}" with zero-padded seq.
510        // Scan all event keys and find the highest.
511        let keys = match store.scan_keys(EVENT_PREFIX).await {
512            Ok(k) => k,
513            Err(_) => return String::new(),
514        };
515
516        if keys.is_empty() {
517            return String::new();
518        }
519
520        // Find the key with the highest seq_no
521        let last_key = keys
522            .iter()
523            .max_by_key(|k| {
524                k.strip_prefix(EVENT_PREFIX)
525                    .and_then(|s| s.parse::<u64>().ok())
526                    .unwrap_or(0)
527            })
528            .cloned();
529
530        if let Some(key) = last_key {
531            if let Ok(Some(bytes)) = store.get_raw_bytes(&key).await {
532                if let Ok(event) = serde_json::from_slice::<EnforcementEvent>(&bytes) {
533                    return event.event_hash;
534                }
535            }
536        }
537
538        String::new()
539    }
540
541    /// Write an enforcement event to the store.
542    ///
543    /// Allocates a seq_no (persisted before event write), computes the
544    /// hash chain, and writes the event as JSON under `enforcement:event:{seq_no}`.
545    ///
546    /// Returns the written event (with computed hashes) or an error.
547    #[allow(clippy::too_many_arguments)]
548    pub async fn write(
549        &mut self,
550        store: &Store,
551        event_type: EnforcementEventType,
552        subject_kind: SubjectKind,
553        subject_key: String,
554        agent_type: String,
555        receipt_id: Option<String>,
556        decision_reason_code: String,
557        decision_basis_hash: Option<String>,
558    ) -> Result<EnforcementEvent> {
559        let seq_no = self.seq.next(store).await?;
560
561        let canonical_subject_hash_value = if subject_kind == SubjectKind::File {
562            Some(canonical_subject_hash(&subject_key))
563        } else {
564            None
565        };
566
567        let mut event = EnforcementEvent {
568            event_id: uuid7_string(),
569            schema_version: SCHEMA_VERSION,
570            seq_no,
571            recorded_at_ms: now_ms(),
572            event_type,
573            event_hash: String::new(), // computed below
574            prev_hash: self.prev_hash.clone(),
575            installation_id: self.installation_id.clone(),
576            actor_local: get_local_actor(),
577            agent_type,
578            subject_kind,
579            subject_key,
580            canonical_subject_hash: canonical_subject_hash_value,
581            receipt_id,
582            decision_reason_code,
583            decision_basis_hash,
584        };
585
586        // Compute and set the event hash
587        event.event_hash = event.compute_hash();
588
589        // Write to store — zero-padded seq for lexicographic ordering
590        let key = format!("{EVENT_PREFIX}{:020}", seq_no);
591        let json = serde_json::to_vec(&event)?;
592        store.put_raw(&key, &json).await?;
593
594        // Update prev_hash for the next event in this writer's lifetime
595        self.prev_hash = event.event_hash.clone();
596
597        Ok(event)
598    }
599
600    /// Return the current installation ID.
601    pub fn installation_id(&self) -> &str {
602        &self.installation_id
603    }
604
605    /// Return the current sequence number (last allocated).
606    pub fn current_seq(&self) -> u64 {
607        self.seq.current()
608    }
609
610    /// Return the hash of the last written event.
611    pub fn prev_hash(&self) -> &str {
612        &self.prev_hash
613    }
614
615    /// Detect gaps in the event stream and emit a RecordingGap event.
616    ///
617    /// Called on writer initialization when the seq counter is ahead of
618    /// the last stored event (indicating a crash between seq allocation
619    /// and event write).
620    pub async fn detect_and_record_gap(
621        &mut self,
622        store: &Store,
623        gap_start_ms: u64,
624        gap_end_ms: u64,
625        cause: GapCause,
626    ) -> Result<EnforcementEvent> {
627        self.write(
628            store,
629            EnforcementEventType::RecordingGap {
630                gap_start_ms,
631                gap_end_ms,
632                cause,
633                enforcement_mode_during_gap: EnforcementMode::Advisory,
634                missed_event_count: MissedEventCount::Unknown,
635                certainty: GapCertainty::Inferred,
636            },
637            SubjectKind::System,
638            "enforcement:stream".to_string(),
639            "system".to_string(),
640            None,
641            "recording_gap_detected".to_string(),
642            None,
643        )
644        .await
645    }
646}
647
648// ─────────────────────────────────────────────
649// Store scan helpers
650// ─────────────────────────────────────────────
651
652/// Read enforcement events within a seq_no range [since, until] inclusive.
653///
654/// Returns events in seq_no order. Events outside the range or with
655/// corrupt JSON are skipped with a warning.
656pub async fn scan_enforcement_events(
657    store: &Store,
658    since_seq: u64,
659    until_seq: u64,
660) -> Result<Vec<EnforcementEvent>> {
661    let keys = store.scan_keys(EVENT_PREFIX).await?;
662    let mut events = Vec::new();
663
664    for key in &keys {
665        let seq = match key
666            .strip_prefix(EVENT_PREFIX)
667            .and_then(|s| s.parse::<u64>().ok())
668        {
669            Some(s) => s,
670            None => continue,
671        };
672        if seq < since_seq || seq > until_seq {
673            continue;
674        }
675        if let Ok(Some(bytes)) = store.get_raw_bytes(key).await {
676            match serde_json::from_slice::<EnforcementEvent>(&bytes) {
677                Ok(event) => events.push(event),
678                Err(e) => {
679                    tracing::warn!(key, "skipping corrupt enforcement event: {e}");
680                }
681            }
682        }
683    }
684
685    events.sort_by_key(|e| e.seq_no);
686    Ok(events)
687}
688
689// ─────────────────────────────────────────────
690// Enforcement Mode
691// ─────────────────────────────────────────────
692
693/// Store key for the enforcement mode setting.
694const ENFORCEMENT_MODE_KEY: &str = "enforcement:mode";
695
696/// Default retention period in days.
697const DEFAULT_RETENTION_DAYS: u64 = 365;
698
699/// Store key for the retention period setting.
700const RETENTION_DAYS_KEY: &str = "enforcement:retention_days";
701
702/// Read the current enforcement mode from the store.
703/// Defaults to Advisory if not set or unreadable.
704pub async fn get_enforcement_mode(store: &Store) -> EnforcementMode {
705    match store.get_raw_bytes(ENFORCEMENT_MODE_KEY).await {
706        Ok(Some(bytes)) => match std::str::from_utf8(&bytes) {
707            Ok("strict") => EnforcementMode::Strict,
708            _ => EnforcementMode::Advisory,
709        },
710        _ => EnforcementMode::Advisory,
711    }
712}
713
714/// Persist the enforcement mode to the store. Returns the previous mode.
715/// Records an EnforcementConfigChanged event when the mode actually changes.
716pub async fn set_enforcement_mode(store: &Store, mode: EnforcementMode) -> Result<EnforcementMode> {
717    let old = get_enforcement_mode(store).await;
718    let value = match mode {
719        EnforcementMode::Advisory => "advisory",
720        EnforcementMode::Strict => "strict",
721    };
722    store
723        .put_raw(ENFORCEMENT_MODE_KEY, value.as_bytes())
724        .await?;
725
726    // Record config change event if the mode actually changed
727    if old != mode {
728        let old_str = match old {
729            EnforcementMode::Advisory => "advisory",
730            EnforcementMode::Strict => "strict",
731        };
732        // Best-effort — don't fail the config change if event recording fails
733        let _ = record_event(
734            store,
735            EnforcementEventType::EnforcementConfigChanged {
736                setting: "enforcement.mode".to_string(),
737                old_value: old_str.to_string(),
738                new_value: value.to_string(),
739            },
740            SubjectKind::Config,
741            "enforcement:mode".to_string(),
742            "developer".to_string(),
743            None,
744            "config_changed".to_string(),
745            None,
746        )
747        .await;
748    }
749    Ok(old)
750}
751
752/// Read the configured retention period in days.
753pub async fn get_retention_days(store: &Store) -> u64 {
754    match store.get_raw_bytes(RETENTION_DAYS_KEY).await {
755        Ok(Some(bytes)) => std::str::from_utf8(&bytes)
756            .ok()
757            .and_then(|s| s.parse::<u64>().ok())
758            .unwrap_or(DEFAULT_RETENTION_DAYS),
759        _ => DEFAULT_RETENTION_DAYS,
760    }
761}
762
763/// Persist the retention period.
764pub async fn set_retention_days(store: &Store, days: u64) -> Result<()> {
765    store
766        .put_raw(RETENTION_DAYS_KEY, days.to_string().as_bytes())
767        .await
768}
769
770// ─────────────────────────────────────────────
771// Decision Basis Hash
772// ─────────────────────────────────────────────
773
774/// Compute a hash of the gotcha state used for an enforcement decision.
775///
776/// Each gotcha contributes its key, rule text, and confidence value to the
777/// hash. This proves which exact rule state was in force at decision time.
778pub fn compute_decision_basis_hash(gotchas: &[(String, serde_json::Value)]) -> String {
779    let mut hasher = Sha256::new();
780    for (key, record_json) in gotchas {
781        hasher.update(key.as_bytes());
782        let rule = record_json
783            .pointer("/value")
784            .and_then(|v| v.as_str())
785            .unwrap_or("");
786        hasher.update(rule.as_bytes());
787        let conf = record_json
788            .pointer("/confidence/value")
789            .and_then(|v| v.as_f64())
790            .unwrap_or(0.0);
791        hasher.update(format!("{conf}").as_bytes());
792    }
793    format!("{:x}", hasher.finalize())
794}
795
796// ─────────────────────────────────────────────
797// Standalone Event Recording
798// ─────────────────────────────────────────────
799
800/// Record a single enforcement event without requiring a long-lived writer.
801///
802/// Creates a fresh writer on each call (loads seq + prev_hash from store).
803/// Correct for hash chain continuity. Slightly slower than using a shared
804/// writer (~2 extra reads), but avoids threading a writer through all paths.
805///
806/// Respects the enforcement mode: in advisory mode, write failures are
807/// logged but Ok(None) is returned. In strict mode, write failures propagate.
808#[allow(clippy::too_many_arguments)]
809pub async fn record_event(
810    store: &Store,
811    event_type: EnforcementEventType,
812    subject_kind: SubjectKind,
813    subject_key: String,
814    agent_type: String,
815    receipt_id: Option<String>,
816    decision_reason_code: String,
817    decision_basis_hash: Option<String>,
818) -> Result<Option<EnforcementEvent>> {
819    let mode = get_enforcement_mode(store).await;
820
821    let result = async {
822        let mut writer = EnforcementEventWriter::new(store).await?;
823        writer
824            .write(
825                store,
826                event_type,
827                subject_kind,
828                subject_key,
829                agent_type,
830                receipt_id,
831                decision_reason_code,
832                decision_basis_hash,
833            )
834            .await
835    }
836    .await;
837
838    match result {
839        Ok(event) => Ok(Some(event)),
840        Err(e) => match mode {
841            EnforcementMode::Advisory => {
842                tracing::warn!("enforcement event write failed (advisory mode): {e}");
843                Ok(None)
844            }
845            EnforcementMode::Strict => Err(e),
846        },
847    }
848}
849
850// ─────────────────────────────────────────────
851// Retention / Pruning
852// ─────────────────────────────────────────────
853
854/// Result of a retention enforcement run.
855#[derive(Debug)]
856pub enum PruneResult {
857    NothingToPrune,
858    Pruned {
859        count: u64,
860        oldest_seq: u64,
861        newest_seq: u64,
862    },
863}
864
865/// Prune enforcement events older than the configured retention period
866/// and record a RetentionPruned event for the deletion.
867///
868/// Called during `mati init` and `mati repair --full`.
869pub async fn enforce_retention(store: &Store) -> Result<PruneResult> {
870    let retention_days = get_retention_days(store).await;
871    let cutoff_ms = now_ms().saturating_sub(retention_days * 86_400_000);
872
873    let all_events = scan_enforcement_events(store, 0, u64::MAX).await?;
874    let old_events: Vec<&EnforcementEvent> = all_events
875        .iter()
876        .filter(|e| e.recorded_at_ms < cutoff_ms)
877        .collect();
878
879    if old_events.is_empty() {
880        return Ok(PruneResult::NothingToPrune);
881    }
882
883    let count = old_events.len() as u64;
884    let oldest_seq = old_events.first().expect("checked non-empty above").seq_no;
885    let newest_seq = old_events.last().expect("checked non-empty above").seq_no;
886
887    // Delete the old events
888    for event in &old_events {
889        let key = format!("{EVENT_PREFIX}{:020}", event.seq_no);
890        store.delete(&key).await?;
891    }
892
893    // Record the prune as an event
894    record_event(
895        store,
896        EnforcementEventType::RetentionPruned {
897            pruned_count: count,
898            oldest_pruned_seq: oldest_seq,
899            newest_pruned_seq: newest_seq,
900        },
901        SubjectKind::System,
902        "enforcement:retention".to_string(),
903        "system".to_string(),
904        None,
905        "retention_policy_enforced".to_string(),
906        None,
907    )
908    .await?;
909
910    Ok(PruneResult::Pruned {
911        count,
912        oldest_seq,
913        newest_seq,
914    })
915}
916
917// ─────────────────────────────────────────────
918// Gap Detection on Startup
919// ─────────────────────────────────────────────
920
921/// Check for and record gaps on writer startup.
922///
923/// If the store has events AND the last event's recorded_at_ms is older
924/// than `gap_threshold_ms`, emit a RecordingGap event. Conservative:
925/// may over-report gaps where the daemon was simply idle, but
926/// under-reporting is worse for a compliance tool.
927pub async fn detect_startup_gap(store: &Store, gap_threshold_ms: u64) -> Result<()> {
928    let events = scan_enforcement_events(store, 0, u64::MAX).await?;
929    if events.is_empty() {
930        return Ok(());
931    }
932    let last = events.last().expect("checked non-empty above");
933    let current = now_ms();
934    let age = current.saturating_sub(last.recorded_at_ms);
935
936    if age > gap_threshold_ms {
937        let mut writer = EnforcementEventWriter::new(store).await?;
938        writer
939            .detect_and_record_gap(store, last.recorded_at_ms, current, GapCause::Unknown)
940            .await?;
941    }
942    Ok(())
943}
944
945// ─────────────────────────────────────────────
946// Scan helpers for CLI display
947// ─────────────────────────────────────────────
948
949/// Scan enforcement events within a time window.
950pub async fn scan_events_since(store: &Store, since_ms: u64) -> Result<Vec<EnforcementEvent>> {
951    let all = scan_enforcement_events(store, 0, u64::MAX).await?;
952    Ok(all
953        .into_iter()
954        .filter(|e| e.recorded_at_ms >= since_ms)
955        .collect())
956}
957
958/// Count enforcement events by type within a time window.
959pub async fn count_events_by_type(store: &Store, since_ms: u64) -> Result<EnforcementEventCounts> {
960    let events = scan_events_since(store, since_ms).await?;
961    let mut counts = EnforcementEventCounts {
962        total: events.len() as u64,
963        ..Default::default()
964    };
965    for e in &events {
966        match &e.event_type {
967            EnforcementEventType::Deny => counts.denials += 1,
968            EnforcementEventType::AllowAfterReceipt => counts.allowed_after_receipt += 1,
969            EnforcementEventType::ReceiptMinted => counts.receipts_minted += 1,
970            EnforcementEventType::BypassDetected => counts.bypasses += 1,
971            EnforcementEventType::ControlChanged { .. } => counts.controls_changed += 1,
972            EnforcementEventType::EnforcementConfigChanged { .. } => counts.config_changes += 1,
973            EnforcementEventType::RecordingGap { .. } => counts.gaps += 1,
974            EnforcementEventType::RetentionPruned { .. } => counts.retention_prunes += 1,
975        }
976    }
977    Ok(counts)
978}
979
980/// Aggregated event counts for CLI display.
981#[derive(Debug, Default)]
982pub struct EnforcementEventCounts {
983    pub total: u64,
984    pub denials: u64,
985    pub allowed_after_receipt: u64,
986    pub receipts_minted: u64,
987    pub bypasses: u64,
988    pub controls_changed: u64,
989    pub config_changes: u64,
990    pub gaps: u64,
991    pub retention_prunes: u64,
992}
993
994/// Format an event type as a short display string.
995pub fn event_type_label(event_type: &EnforcementEventType) -> &'static str {
996    match event_type {
997        EnforcementEventType::Deny => "deny",
998        EnforcementEventType::AllowAfterReceipt => "allow_receipt",
999        EnforcementEventType::ReceiptMinted => "receipt_minted",
1000        EnforcementEventType::BypassDetected => "bypass",
1001        EnforcementEventType::ControlChanged { .. } => "control_changed",
1002        EnforcementEventType::EnforcementConfigChanged { .. } => "config_changed",
1003        EnforcementEventType::RecordingGap { .. } => "gap",
1004        EnforcementEventType::RetentionPruned { .. } => "retention_pruned",
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    /// Construct a deterministic event for hash testing.
1013    fn frozen_test_event() -> EnforcementEvent {
1014        EnforcementEvent {
1015            event_id: "01900000-0000-7000-8000-000000000001".to_string(),
1016            schema_version: 1,
1017            seq_no: 1,
1018            recorded_at_ms: 1700000000000,
1019            event_type: EnforcementEventType::Deny,
1020            event_hash: String::new(),
1021            prev_hash: String::new(),
1022            installation_id: "test-install-id".to_string(),
1023            actor_local: Some(ActorLocal {
1024                username: "testuser".to_string(),
1025                uid: Some(1000),
1026                verified: false,
1027            }),
1028            agent_type: "claude".to_string(),
1029            subject_kind: SubjectKind::File,
1030            subject_key: "file:src/billing/charges.rs".to_string(),
1031            canonical_subject_hash: Some("abc123".to_string()),
1032            receipt_id: None,
1033            decision_reason_code: "gotcha_above_threshold".to_string(),
1034            decision_basis_hash: Some("def456".to_string()),
1035        }
1036    }
1037
1038    #[test]
1039    fn canonical_hash_is_deterministic_and_frozen() {
1040        let event = frozen_test_event();
1041        let hash = event.compute_hash();
1042
1043        // This hash is frozen. If this test fails, either the canonical
1044        // serialization changed (which breaks all existing hash chains)
1045        // or the hash algorithm changed. Neither is acceptable without
1046        // incrementing SCHEMA_VERSION.
1047        assert_eq!(
1048            hash,
1049            "e8a42cb3c1c4dde12f807f46678c5d4393466a831007540a85ff84a003203e37"
1050        );
1051
1052        // Verify determinism — same input always produces same hash.
1053        assert_eq!(hash, event.compute_hash());
1054        assert_eq!(hash, event.compute_hash());
1055    }
1056
1057    #[test]
1058    fn hash_changes_when_field_changes() {
1059        let mut event = frozen_test_event();
1060        let hash1 = event.compute_hash();
1061
1062        event.seq_no = 2;
1063        let hash2 = event.compute_hash();
1064
1065        assert_ne!(hash1, hash2, "changing seq_no must change the hash");
1066    }
1067
1068    #[test]
1069    fn hash_excludes_event_hash_field() {
1070        let mut event = frozen_test_event();
1071        let hash1 = event.compute_hash();
1072
1073        // Setting event_hash should not affect compute_hash output
1074        event.event_hash = "something_completely_different".to_string();
1075        let hash2 = event.compute_hash();
1076
1077        assert_eq!(
1078            hash1, hash2,
1079            "event_hash field must be excluded from canonical form"
1080        );
1081    }
1082
1083    #[test]
1084    fn canonical_path_aliasing_produces_same_key() {
1085        let repo_root = PathBuf::from("/tmp/test-repo");
1086
1087        // These should all produce the same canonical key (lexical normalization)
1088        let paths = [
1089            "src/billing/charges.rs",
1090            "./src/billing/charges.rs",
1091            "src/billing/../billing/charges.rs",
1092            "src/./billing/charges.rs",
1093        ];
1094
1095        // Use normalize_components only (no fs access in test)
1096        let canonical_keys: Vec<String> = paths
1097            .iter()
1098            .map(|p| {
1099                let abs = repo_root.join(p);
1100                let normalized = normalize_components(&abs);
1101                let relative = normalized
1102                    .strip_prefix(&repo_root)
1103                    .unwrap_or(&normalized)
1104                    .to_string_lossy()
1105                    .replace('\\', "/");
1106                if is_case_insensitive() {
1107                    relative.to_lowercase()
1108                } else {
1109                    relative
1110                }
1111            })
1112            .collect();
1113
1114        for key in &canonical_keys {
1115            assert_eq!(
1116                key, &canonical_keys[0],
1117                "Path aliasing produced different keys"
1118            );
1119        }
1120
1121        assert_eq!(canonical_keys[0], "src/billing/charges.rs");
1122    }
1123
1124    #[test]
1125    fn canonical_subject_hash_is_deterministic() {
1126        let hash1 = canonical_subject_hash("src/billing/charges.rs");
1127        let hash2 = canonical_subject_hash("src/billing/charges.rs");
1128        assert_eq!(hash1, hash2);
1129
1130        let hash3 = canonical_subject_hash("src/billing/other.rs");
1131        assert_ne!(hash1, hash3);
1132    }
1133
1134    #[test]
1135    fn schema_version_is_one() {
1136        assert_eq!(SCHEMA_VERSION, 1);
1137        assert_eq!(HASH_ALGORITHM, "sha256");
1138    }
1139}