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