Skip to main content

mati_core/store/enforcement/
seq_writer.rs

1use super::*;
2
3// ─────────────────────────────────────────────
4// Sequence Number Allocator
5// ─────────────────────────────────────────────
6
7/// Atomic sequence number allocator backed by the store.
8///
9/// Key: "enforcement:seq" — stores the current counter as a big-endian u64.
10/// The counter is persisted before `next()` returns — if the store write
11/// fails, the sequence number is not allocated.
12pub struct SeqAllocator {
13    current: u64,
14}
15
16impl SeqAllocator {
17    /// Load the current sequence number from the store, or initialize to 0.
18    pub async fn load(store: &Store) -> Self {
19        let current = match store.get_raw_bytes(SEQ_KEY).await {
20            Ok(Some(bytes)) if bytes.len() == 8 => {
21                u64::from_be_bytes(bytes[..8].try_into().unwrap_or([0; 8]))
22            }
23            _ => 0,
24        };
25        Self { current }
26    }
27
28    /// Allocate the next sequence number and persist it durably.
29    ///
30    /// Returns the allocated seq_no. If the store write fails, the seq is
31    /// NOT allocated and the caller gets an error.
32    pub async fn next(&mut self, store: &Store) -> Result<u64> {
33        self.current += 1;
34        store.put_raw(SEQ_KEY, &self.current.to_be_bytes()).await?;
35        Ok(self.current)
36    }
37
38    /// Return the current (last allocated) sequence number without incrementing.
39    pub fn current(&self) -> u64 {
40        self.current
41    }
42}
43
44// ─────────────────────────────────────────────
45// Installation ID
46// ─────────────────────────────────────────────
47
48/// Retrieve the installation_id from the store, or generate and persist one.
49///
50/// The installation_id is a UUIDv4 generated once at first init. It never
51/// changes after that. NOT derived from hostname — stable across renames.
52pub async fn get_or_create_installation_id(store: &Store) -> Result<String> {
53    if let Ok(Some(bytes)) = store.get_raw_bytes(INSTALLATION_ID_KEY).await {
54        if let Ok(id) = std::str::from_utf8(&bytes) {
55            if !id.is_empty() {
56                return Ok(id.to_string());
57            }
58        }
59    }
60    let id = uuid::Uuid::new_v4().to_string();
61    store.put_raw(INSTALLATION_ID_KEY, id.as_bytes()).await?;
62    Ok(id)
63}
64
65// ─────────────────────────────────────────────
66// Actor Identity
67// ─────────────────────────────────────────────
68
69/// Get the local OS actor identity. Unverified — v1 trusts the local OS.
70pub fn get_local_actor() -> Option<ActorLocal> {
71    let username = std::env::var("USER")
72        .or_else(|_| std::env::var("USERNAME"))
73        .ok()?;
74
75    #[cfg(unix)]
76    let uid = Some(unsafe { libc::getuid() } as u32);
77    #[cfg(not(unix))]
78    let uid = None;
79
80    Some(ActorLocal {
81        username,
82        uid,
83        verified: false,
84    })
85}
86
87// ─────────────────────────────────────────────
88// Canonical File Identity
89// ─────────────────────────────────────────────
90
91/// Canonicalize a file path for use as a subject_key in enforcement events.
92///
93/// Rules (frozen for v1):
94/// 1. Resolve relative paths against the repo root
95/// 2. Normalize path separators to forward slash
96/// 3. Remove `.` and `..` components
97/// 4. Resolve symlinks where possible (fall back to normalized path if resolution fails)
98/// 5. Strip the repo root prefix to produce a repo-relative path
99/// 6. On case-insensitive filesystems (macOS default, Windows), lowercase the path
100///
101/// The output is a stable, canonical string that survives path aliasing.
102///
103/// # Known limitation (v1)
104///
105/// Case sensitivity is detected by platform default, not per-volume. Some
106/// macOS volumes are case-sensitive and some Linux volumes (ecryptfs) are
107/// case-insensitive. For v1, the platform default is acceptable.
108pub fn canonicalize_file_key(path: &str, repo_root: &Path) -> String {
109    // Step 1: Make absolute
110    let abs_path = if Path::new(path).is_relative() {
111        repo_root.join(path)
112    } else {
113        PathBuf::from(path)
114    };
115
116    // Step 2+3: Normalize components (remove `.` and `..`)
117    let normalized = normalize_components(&abs_path);
118
119    // Step 4: Try symlink resolution, fall back to normalized
120    let resolved = std::fs::canonicalize(&normalized).unwrap_or(normalized);
121
122    // Step 5: Strip repo root to get repo-relative path
123    let repo_root_canonical =
124        std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
125    let relative = resolved
126        .strip_prefix(&repo_root_canonical)
127        .unwrap_or(&resolved);
128
129    // Convert to forward-slash string
130    let mut key = relative
131        .components()
132        .map(|c| c.as_os_str().to_string_lossy().to_string())
133        .collect::<Vec<_>>()
134        .join("/");
135
136    // Step 6: Case-fold on case-insensitive platforms
137    if is_case_insensitive() {
138        key = key.to_lowercase();
139    }
140
141    key
142}
143
144/// Normalize path components without filesystem access.
145/// Collapses `.` and `..` lexically.
146pub(crate) fn normalize_components(path: &Path) -> PathBuf {
147    let mut components = Vec::new();
148    for component in path.components() {
149        match component {
150            Component::CurDir => {} // skip "."
151            Component::ParentDir => {
152                // Pop last normal component; keep prefix/root
153                if matches!(components.last(), Some(Component::Normal(_))) {
154                    components.pop();
155                } else {
156                    components.push(component);
157                }
158            }
159            _ => components.push(component),
160        }
161    }
162    components.iter().collect()
163}
164
165/// Platform-default case sensitivity detection.
166///
167/// v1 simplification: macOS and Windows are case-insensitive,
168/// Linux is case-sensitive. Per-volume detection deferred to v2.
169pub(crate) fn is_case_insensitive() -> bool {
170    cfg!(target_os = "macos") || cfg!(target_os = "windows")
171}
172
173/// Compute a SHA-256 hash of the canonical file key for cross-reference stability.
174///
175/// Allows correlating events even after file renames.
176pub fn canonical_subject_hash(canonical_key: &str) -> String {
177    let mut hasher = Sha256::new();
178    hasher.update(canonical_key.as_bytes());
179    format!("{:x}", hasher.finalize())
180}
181
182// ─────────────────────────────────────────────
183// UUIDv7 generation
184// ─────────────────────────────────────────────
185
186/// Generate a UUIDv7 (time-ordered) string.
187///
188/// UUIDv7 encodes millisecond-precision Unix time in the high bits,
189/// producing lexicographically sortable IDs that cluster temporally.
190fn uuid7_string() -> String {
191    uuid::Uuid::now_v7().to_string()
192}
193
194/// Current time as Unix milliseconds.
195pub(crate) fn now_ms() -> u64 {
196    SystemTime::now()
197        .duration_since(UNIX_EPOCH)
198        .unwrap_or_default()
199        .as_millis() as u64
200}
201
202// ─────────────────────────────────────────────
203// Event Writer
204// ─────────────────────────────────────────────
205
206/// The enforcement event writer. Ties together sequence allocation,
207/// hash chaining, and store persistence into a single write path.
208///
209/// One writer per store lifetime. Not Clone — the seq counter and
210/// prev_hash chain are stateful.
211pub struct EnforcementEventWriter {
212    seq: SeqAllocator,
213    installation_id: String,
214    prev_hash: String,
215    /// Agent session (Claude Code `session_id`) to attribute written events to,
216    /// for per-actor audit (schema_version 2). `None` unless set before `write`.
217    pub(super) agent_session: Option<String>,
218    /// Subagent actor (Claude Code Task `agent_id`) that drove the write, for
219    /// one-level agent lineage (schema_version 3). `None` unless set before `write`.
220    pub(super) agent_id: Option<String>,
221    /// Agent that spawned `agent_id`, for nested agent→agent lineage
222    /// (schema_version 4). `None` unless set before `write` (and `None` when the
223    /// spawner is the root session). See `EnforcementEvent::parent_agent_id`.
224    pub(super) parent_agent_id: Option<String>,
225}
226
227impl EnforcementEventWriter {
228    /// Initialize the writer from store state.
229    ///
230    /// Loads the current seq counter, installation_id, and the hash of
231    /// the last event in the stream (for chain continuity).
232    pub async fn new(store: &Store) -> Result<Self> {
233        let seq = SeqAllocator::load(store).await;
234        let installation_id = get_or_create_installation_id(store).await?;
235        let prev_hash = Self::load_last_hash(store).await;
236
237        Ok(Self {
238            seq,
239            installation_id,
240            prev_hash,
241            agent_session: None,
242            agent_id: None,
243            parent_agent_id: None,
244        })
245    }
246
247    /// Load the hash of the most recent enforcement event.
248    ///
249    /// Scans for the highest seq_no enforcement event and returns its
250    /// event_hash. Returns empty string if no events exist (first event).
251    async fn load_last_hash(store: &Store) -> String {
252        // The last event key is "enforcement:event:{seq_no}" with zero-padded seq.
253        // Scan all event keys and find the highest.
254        let keys = match store.scan_keys(EVENT_PREFIX).await {
255            Ok(k) => k,
256            Err(_) => return String::new(),
257        };
258
259        if keys.is_empty() {
260            return String::new();
261        }
262
263        // Find the key with the highest seq_no
264        let last_key = keys
265            .iter()
266            .max_by_key(|k| {
267                k.strip_prefix(EVENT_PREFIX)
268                    .and_then(|s| s.parse::<u64>().ok())
269                    .unwrap_or(0)
270            })
271            .cloned();
272
273        if let Some(key) = last_key {
274            if let Ok(Some(bytes)) = store.get_raw_bytes(&key).await {
275                if let Ok(event) = serde_json::from_slice::<EnforcementEvent>(&bytes) {
276                    return event.event_hash;
277                }
278            }
279        }
280
281        String::new()
282    }
283
284    /// Write an enforcement event to the store.
285    ///
286    /// Allocates a seq_no (persisted before event write), computes the
287    /// hash chain, and writes the event as JSON under `enforcement:event:{seq_no}`.
288    ///
289    /// Returns the written event (with computed hashes) or an error.
290    #[allow(clippy::too_many_arguments)]
291    pub async fn write(
292        &mut self,
293        store: &Store,
294        event_type: EnforcementEventType,
295        subject_kind: SubjectKind,
296        subject_key: String,
297        agent_type: String,
298        receipt_id: Option<String>,
299        decision_reason_code: String,
300        decision_basis_hash: Option<String>,
301    ) -> Result<EnforcementEvent> {
302        let seq_no = self.seq.next(store).await?;
303
304        let canonical_subject_hash_value = if subject_kind == SubjectKind::File {
305            Some(canonical_subject_hash(&subject_key))
306        } else {
307            None
308        };
309
310        let mut event = EnforcementEvent {
311            event_id: uuid7_string(),
312            schema_version: SCHEMA_VERSION,
313            seq_no,
314            recorded_at_ms: now_ms(),
315            event_type,
316            event_hash: String::new(), // computed below
317            prev_hash: self.prev_hash.clone(),
318            installation_id: self.installation_id.clone(),
319            actor_local: get_local_actor(),
320            agent_type,
321            subject_kind,
322            subject_key,
323            canonical_subject_hash: canonical_subject_hash_value,
324            receipt_id,
325            decision_reason_code,
326            decision_basis_hash,
327            agent_session: self.agent_session.clone(),
328            agent_id: self.agent_id.clone(),
329            parent_agent_id: self.parent_agent_id.clone(),
330        };
331
332        // Compute and set the event hash
333        event.event_hash = event.compute_hash();
334
335        // Write to store — zero-padded seq for lexicographic ordering
336        let key = format!("{EVENT_PREFIX}{:020}", seq_no);
337        let json = serde_json::to_vec(&event)?;
338        store.put_raw(&key, &json).await?;
339
340        // Update prev_hash for the next event in this writer's lifetime
341        self.prev_hash = event.event_hash.clone();
342
343        // Attribution is per-write: clear it so a later write on this shared,
344        // long-lived writer (e.g. a `RecordingGap` from `detect_and_record_gap`,
345        // or any unattributed event) does not inherit this event's session/agent.
346        // Every attributed path sets these immediately before calling `write`.
347        self.agent_session = None;
348        self.agent_id = None;
349        self.parent_agent_id = None;
350
351        Ok(event)
352    }
353
354    /// Return the current installation ID.
355    pub fn installation_id(&self) -> &str {
356        &self.installation_id
357    }
358
359    /// Return the current sequence number (last allocated).
360    pub fn current_seq(&self) -> u64 {
361        self.seq.current()
362    }
363
364    /// Return the hash of the last written event.
365    pub fn prev_hash(&self) -> &str {
366        &self.prev_hash
367    }
368
369    /// Emit a RecordingGap event for the window `gap_start_ms..gap_end_ms`.
370    ///
371    /// Called by [`detect_startup_gap`](super::detect_startup_gap), which infers
372    /// the window from a timestamp delta rather than reading it off a record —
373    /// hence `Inferred` certainty and an `Unknown` missed-event count, neither
374    /// of which a caller can improve on.
375    ///
376    /// `enforcement_mode_during_gap` is the mode read now. A mode change writes
377    /// an `EnforcementConfigChanged` event, and the gap is by definition a
378    /// window with no event in it, so the mode read at gap end is the one that
379    /// held across the window — unless a change landed whose event write failed,
380    /// which advisory mode swallows.
381    pub async fn detect_and_record_gap(
382        &mut self,
383        store: &Store,
384        gap_start_ms: u64,
385        gap_end_ms: u64,
386        cause: GapCause,
387    ) -> Result<EnforcementEvent> {
388        let mode = get_enforcement_mode(store).await;
389        self.write(
390            store,
391            EnforcementEventType::RecordingGap {
392                gap_start_ms,
393                gap_end_ms,
394                cause,
395                enforcement_mode_during_gap: mode,
396                missed_event_count: MissedEventCount::Unknown,
397                certainty: GapCertainty::Inferred,
398            },
399            SubjectKind::System,
400            "enforcement:stream".to_string(),
401            "system".to_string(),
402            None,
403            "recording_gap_detected".to_string(),
404            None,
405        )
406        .await
407    }
408}
409
410// ─────────────────────────────────────────────
411// Store scan helpers
412// ─────────────────────────────────────────────
413
414/// Events in a seq range, plus the seq numbers this binary could not parse.
415///
416/// A skipped event is absent from `events`, so its successor's `prev_hash`
417/// points at a hash no present event carries. Without `skipped_seqs` that is
418/// indistinguishable from a deleted event, and
419/// [`verify_chain`](super::verify_chain) reports it as tampering.
420#[derive(Debug, Clone, Default, Serialize, Deserialize)]
421pub struct EnforcementEventsWithSkips {
422    pub events: Vec<EnforcementEvent>,
423    /// Seq numbers this scan could not read as an event: JSON that failed to
424    /// deserialize (most often an event type written by a newer binary), or a
425    /// key the store reported present but returned no bytes for, or an error,
426    /// on read. `verify_chain_with_skips` only needs "was this seq unread",
427    /// not why, so both reasons share one list.
428    pub skipped_seqs: Vec<u64>,
429}
430
431/// Read enforcement events within a seq_no range [since, until] inclusive.
432///
433/// Returns events in seq_no order. Events outside the range or with
434/// corrupt JSON are skipped with a warning. Callers that verify chain
435/// integrity want [`scan_enforcement_events_with_skips`] instead — this
436/// signature cannot report what it dropped.
437pub async fn scan_enforcement_events(
438    store: &Store,
439    since_seq: u64,
440    until_seq: u64,
441) -> Result<Vec<EnforcementEvent>> {
442    Ok(
443        scan_enforcement_events_with_skips(store, since_seq, until_seq)
444            .await?
445            .events,
446    )
447}
448
449/// [`scan_enforcement_events`], but reporting the seq numbers it could not
450/// parse so a caller can tell an unreadable event from a deleted one.
451pub async fn scan_enforcement_events_with_skips(
452    store: &Store,
453    since_seq: u64,
454    until_seq: u64,
455) -> Result<EnforcementEventsWithSkips> {
456    let keys = store.scan_keys(EVENT_PREFIX).await?;
457    let mut events = Vec::new();
458    let mut skipped_seqs = Vec::new();
459
460    let start = keys.partition_point(|key| {
461        key.strip_prefix(EVENT_PREFIX)
462            .and_then(|s| s.parse::<u64>().ok())
463            .map(|seq| seq < since_seq)
464            .unwrap_or(true)
465    });
466    for key in keys.iter().skip(start) {
467        let seq = match key
468            .strip_prefix(EVENT_PREFIX)
469            .and_then(|s| s.parse::<u64>().ok())
470        {
471            Some(s) => s,
472            None => continue,
473        };
474        if seq > until_seq {
475            break;
476        }
477        if seq < since_seq {
478            continue;
479        }
480        match store.get_raw_bytes(key).await {
481            Ok(Some(bytes)) => match serde_json::from_slice::<EnforcementEvent>(&bytes) {
482                Ok(event) => events.push(event),
483                Err(e) => {
484                    tracing::warn!(key, "skipping corrupt enforcement event: {e}");
485                    skipped_seqs.push(seq);
486                }
487            },
488            // The key was in the scan but has no value, or the read itself
489            // failed — either way the seq is unread, not absent.
490            Ok(None) => {
491                tracing::warn!(key, "skipping enforcement event key with no value");
492                skipped_seqs.push(seq);
493            }
494            Err(e) => {
495                tracing::warn!(key, "skipping unreadable enforcement event: {e}");
496                skipped_seqs.push(seq);
497            }
498        }
499    }
500
501    events.sort_by_key(|e| e.seq_no);
502    skipped_seqs.sort_unstable();
503    Ok(EnforcementEventsWithSkips {
504        events,
505        skipped_seqs,
506    })
507}
508
509/// Result of a time-bounded enforcement scan. The oldest timestamp is kept
510/// separate so callers can distinguish an empty retained window from history
511/// that predates the retention floor.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct EnforcementEventScan {
514    pub events: Vec<EnforcementEvent>,
515    pub oldest_recorded_at_ms: Option<u64>,
516    pub scanned_keys: usize,
517}
518
519/// Scan events from the first sequence whose recorded time reaches `since_ms`.
520/// Event keys are zero-padded and sequence allocation is monotonic, so the
521/// timestamp boundary is found with O(log n) raw reads and the matching suffix
522/// is read forward. This keeps an activity report from loading a year's event
523/// payloads just to discard them.
524pub async fn scan_enforcement_events_since_ms(
525    store: &Store,
526    since_ms: u64,
527    until_ms: u64,
528) -> Result<EnforcementEventScan> {
529    let keys = store.scan_keys(EVENT_PREFIX).await?;
530    let valid_keys: Vec<&String> = keys
531        .iter()
532        .filter(|key| {
533            key.strip_prefix(EVENT_PREFIX)
534                .and_then(|s| s.parse::<u64>().ok())
535                .is_some()
536        })
537        .collect();
538
539    async fn read_event(store: &Store, key: &str) -> Option<EnforcementEvent> {
540        let bytes = store.get_raw_bytes(key).await.ok()??;
541        serde_json::from_slice(&bytes).ok()
542    }
543
544    let oldest_recorded_at_ms = match valid_keys.first() {
545        Some(key) => read_event(store, key)
546            .await
547            .map(|event| event.recorded_at_ms),
548        None => None,
549    };
550
551    let mut low = 0;
552    let mut high = valid_keys.len();
553    while low < high {
554        let mid = low + (high - low) / 2;
555        match read_event(store, valid_keys[mid]).await {
556            Some(event) if event.recorded_at_ms < since_ms => low = mid + 1,
557            Some(_) => high = mid,
558            None => low = mid + 1,
559        }
560    }
561
562    let mut events = Vec::new();
563    let mut scanned_keys = 0;
564    for key in valid_keys.into_iter().skip(low) {
565        scanned_keys += 1;
566        let Some(event) = read_event(store, key).await else {
567            continue;
568        };
569        if event.recorded_at_ms > until_ms {
570            break;
571        }
572        if event.recorded_at_ms >= since_ms {
573            events.push(event);
574        }
575    }
576    events.sort_by_key(|event| event.seq_no);
577    Ok(EnforcementEventScan {
578        events,
579        oldest_recorded_at_ms,
580        scanned_keys,
581    })
582}
583
584// ─────────────────────────────────────────────
585// Enforcement Mode