Skip to main content

lean_ctx/core/
audit_trail.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, Read, Write};
5use std::path::{Path, PathBuf};
6
7mod checkpoint;
8mod segments;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AuditEntry {
12    pub timestamp: String,
13    pub agent_id: String,
14    pub tool: String,
15    pub action: Option<String>,
16    pub input_hash: String,
17    pub output_tokens: u32,
18    pub role: String,
19    pub event_type: AuditEventType,
20    pub prev_hash: String,
21    pub entry_hash: String,
22    /// Ed25519 signature over `entry_hash`, proving provenance from the local
23    /// lean-ctx identity. `None` only when the keypair is unavailable (early boot).
24    #[serde(skip_serializing_if = "Option::is_none", default)]
25    pub signature: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum AuditEventType {
30    ToolCall,
31    ToolDenied,
32    PathJailViolation,
33    BudgetExceeded,
34    CrossProjectAccess,
35    RateLimited,
36    SecurityViolation,
37    RoleChanged,
38    SecretDetected,
39    // Agent identity lifecycle (GL #433) — additive OCP Part 4 evolution.
40    AgentRegistered,
41    AgentSuspended,
42    AgentResumed,
43    AgentDecommissioned,
44    /// Attestation drift observed on heartbeat: the running binary and/or
45    /// the active role file no longer match the attestation the registry
46    /// holds for this identity (GL #433 / Phase 18 S3).
47    AgentDriftDetected,
48    /// An operator explicitly acknowledged one specific observed drift,
49    /// naming its evidence. Only this event clears the sticky drift mark —
50    /// resuming a suspended identity does not (GL #433 / Phase 18 S3).
51    AgentDriftAcknowledged,
52    /// Forward-compatible additive event emitted by a newer writer. Keep the
53    /// exact wire label so reports never silently discard security evidence.
54    Unknown(String),
55}
56
57impl AuditEventType {
58    fn wire_label(&self) -> &str {
59        match self {
60            Self::ToolCall => "tool_call",
61            Self::ToolDenied => "tool_denied",
62            Self::PathJailViolation => "path_jail_violation",
63            Self::BudgetExceeded => "budget_exceeded",
64            Self::CrossProjectAccess => "cross_project_access",
65            Self::RateLimited => "rate_limited",
66            Self::SecurityViolation => "security_violation",
67            Self::RoleChanged => "role_changed",
68            Self::SecretDetected => "secret_detected",
69            Self::AgentRegistered => "agent_registered",
70            Self::AgentSuspended => "agent_suspended",
71            Self::AgentResumed => "agent_resumed",
72            Self::AgentDecommissioned => "agent_decommissioned",
73            Self::AgentDriftDetected => "agent_drift_detected",
74            Self::AgentDriftAcknowledged => "agent_drift_acknowledged",
75            Self::Unknown(label) => label,
76        }
77    }
78}
79
80impl Serialize for AuditEventType {
81    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82    where
83        S: serde::Serializer,
84    {
85        if let Self::Unknown(label) = self {
86            if !valid_unknown_event_label_for_write(label) {
87                return Err(serde::ser::Error::custom(format!(
88                    "unknown event_type is not a safe additive label: {label}"
89                )));
90            }
91        }
92        serializer.serialize_str(self.wire_label())
93    }
94}
95
96impl<'de> Deserialize<'de> for AuditEventType {
97    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98    where
99        D: serde::Deserializer<'de>,
100    {
101        let label = String::deserialize(deserializer)?;
102        let event_type = match label.as_str() {
103            "tool_call" => Self::ToolCall,
104            "tool_denied" => Self::ToolDenied,
105            "path_jail_violation" => Self::PathJailViolation,
106            "budget_exceeded" => Self::BudgetExceeded,
107            "cross_project_access" => Self::CrossProjectAccess,
108            "rate_limited" => Self::RateLimited,
109            "security_violation" => Self::SecurityViolation,
110            "role_changed" => Self::RoleChanged,
111            "secret_detected" => Self::SecretDetected,
112            "agent_registered" => Self::AgentRegistered,
113            "agent_suspended" => Self::AgentSuspended,
114            "agent_resumed" => Self::AgentResumed,
115            "agent_decommissioned" => Self::AgentDecommissioned,
116            "agent_drift_detected" => Self::AgentDriftDetected,
117            "agent_drift_acknowledged" => Self::AgentDriftAcknowledged,
118            _ if valid_extension_event_label(&label) => Self::Unknown(label),
119            _ => {
120                return Err(serde::de::Error::custom(
121                    "unknown event_type must use the x- extension namespace",
122                ));
123            }
124        };
125        Ok(event_type)
126    }
127}
128
129fn valid_extension_event_label(label: &str) -> bool {
130    const MAX_EVENT_LABEL_BYTES: usize = 64;
131    if label.len() > MAX_EVENT_LABEL_BYTES {
132        return false;
133    }
134    let Some(rest) = label.strip_prefix("x-") else {
135        return false;
136    };
137    let mut chars = rest.chars();
138    chars.next().is_some_and(|c| c.is_ascii_lowercase())
139        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
140}
141
142fn valid_unknown_event_label_for_write(label: &str) -> bool {
143    valid_extension_event_label(label)
144}
145
146pub struct AuditEntryData {
147    pub agent_id: String,
148    pub tool: String,
149    pub action: Option<String>,
150    pub input_hash: String,
151    pub output_tokens: u32,
152    pub role: String,
153    pub event_type: AuditEventType,
154}
155
156pub struct ChainVerifyResult {
157    pub total_entries: usize,
158    pub valid: bool,
159    pub first_invalid_at: Option<usize>,
160    /// Whether the audit trail file existed and was readable. A missing trail
161    /// is a valid empty history for a fresh install, but callers that already
162    /// hold audited state must treat `false` as evidence loss.
163    pub source_present: bool,
164    /// Entries whose `event_type` this build does not know, but whose hash
165    /// links verify. Forward compatibility, NOT corruption: a reader older
166    /// than the writer must report those separately instead of raising a
167    /// false tamper alarm (OCP Part 4 additive-event rule). Actual hash or
168    /// structure damage still sets `valid = false`.
169    pub unknown_event_types: usize,
170}
171
172fn trail_path() -> Option<PathBuf> {
173    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
174    let audit_dir = dir.join("audit");
175    fs::create_dir_all(&audit_dir).ok()?;
176    Some(audit_dir.join("trail.jsonl"))
177}
178
179/// Read the chain tail from the file itself. Called under the exclusive
180/// file lock — the file is the ONLY source of truth for `prev_hash`. (A
181/// per-process cache forked the chain whenever two processes appended
182/// concurrently — found by `leanctx-verify` on a real trail, GL #425.)
183///
184/// Every I/O failure is propagated instead of being folded into `"genesis"`:
185/// a seek/read error on a NON-empty trail is indistinguishable from an empty
186/// one at that point, so the old fallback silently forked the chain and still
187/// returned `Ok` to a caller (attestation drift) that commits state on it.
188fn read_last_hash_tail(file: &fs::File) -> Result<String, String> {
189    Ok(read_tail_entry(file)?
190        .and_then(|entry| entry.get("entry_hash")?.as_str().map(str::to_string))
191        .unwrap_or_else(|| "genesis".to_string()))
192}
193
194/// The last entry of `file` as raw JSON; `None` for an empty file. A
195/// non-empty file without a readable entry in its tail is an error.
196fn read_tail_entry(file: &fs::File) -> Result<Option<serde_json::Value>, String> {
197    use std::io::{Read, Seek, SeekFrom};
198    const TAIL: i64 = 64 * 1024;
199
200    let mut f = file;
201    let len = f
202        .seek(SeekFrom::End(0))
203        .map_err(|e| format!("audit tail seek: {e}"))?;
204    if len == 0 {
205        return Ok(None);
206    }
207    let start = if (len as i64) > TAIL {
208        -TAIL
209    } else {
210        -(len as i64)
211    };
212    f.seek(SeekFrom::End(start))
213        .map_err(|e| format!("audit tail seek: {e}"))?;
214    let mut buf = String::new();
215    f.read_to_string(&mut buf)
216        .map_err(|e| format!("audit tail read: {e}"))?;
217    // Concurrent-append history may hold multiple objects per line; a
218    // stream parse of the last non-empty line yields the true tail entry.
219    for line in buf.lines().rev() {
220        if line.trim().is_empty() {
221            continue;
222        }
223        let mut last: Option<serde_json::Value> = None;
224        for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>() {
225            let v = parsed.map_err(|e| format!("audit tail parse: {e}"))?;
226            if v.get("entry_hash").and_then(|h| h.as_str()).is_some() {
227                last = Some(v);
228            }
229        }
230        if last.is_some() {
231            return Ok(last);
232        }
233    }
234    // Non-empty file with no parseable entry_hash in the tail window: the
235    // trail exists but has no usable head, so a fresh chain would fork it.
236    Err("audit tail: no readable chain head".to_string())
237}
238
239fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
240    let mut hasher = Sha256::new();
241    hasher.update(prev_hash.as_bytes());
242    hasher.update(data_json.as_bytes());
243    crate::core::agent_identity::hex_encode(&hasher.finalize())
244}
245
246/// Fire-and-forget append. The hot tool path must never fail a tool call
247/// because the audit sink is momentarily unavailable, so every error is
248/// swallowed here. Callers whose own state update is only sound *with* the
249/// evidence on disk (agent attestation drift) use [`try_record`] instead.
250pub fn record(data: AuditEntryData) {
251    let _ = try_record(data);
252}
253
254/// How long an append waits for the cross-process trail lock before giving
255/// up. Bounded on purpose: the drift path takes this lock while it holds the
256/// (also deadline-bounded) identity-registry lock, so an unbounded blocking
257/// `flock` would let one stalled process freeze every registry operation.
258const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(750);
259const LOCK_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
260
261/// Acquire the exclusive trail lock, bounded by [`LOCK_TIMEOUT`].
262fn lock_trail(file: &fs::File) -> Result<(), String> {
263    lock_trail_with(file, fs2::FileExt::try_lock_exclusive)
264}
265
266/// Acquire a shared trail lock for a reader, bounded by [`LOCK_TIMEOUT`].
267/// Windows file locks are mandatory: a reader without one that ran into an
268/// append's exclusive lock failed mid-file, and the verifier reported that
269/// I/O error as a corrupt chain ("audit chain invalid at entry 0"), e.g. a
270/// heartbeat racing a resume. The shared lock waits for the append and gives
271/// the verifier a consistent snapshot on every platform.
272fn lock_trail_shared(file: &fs::File) -> Result<(), String> {
273    lock_trail_with(file, fs2::FileExt::try_lock_shared)
274}
275
276/// End a reader's shared lock once it has taken the trail's length. Appends
277/// hold the exclusive lock and only ever extend the file (a failed append
278/// truncates back to its own start), so `file[..len]` stays exactly as it was
279/// and the reader goes on without the lock: a pass over a large trail no
280/// longer makes every append in every process time out behind it. Windows
281/// locks are mandatory, so a reader there keeps its lock to the end.
282///
283/// This rests on the trail being append-only in place. Anything that
284/// rewrites, repairs or rotates `trail.jsonl` must write a new file and
285/// rename it over the old one (an open reader keeps its own inode), never
286/// truncate or edit it in place; a verifier that finds its file shorter
287/// than the length it took reports the trail unavailable
288/// ([`still_holds_prefix`]).
289fn release_read_lock(file: &fs::File) {
290    #[cfg(not(windows))]
291    let _ = fs2::FileExt::unlock(file);
292    #[cfg(windows)]
293    let _ = file;
294}
295
296/// Whether `file` still holds the `len` bytes a reader took (see
297/// [`release_read_lock`]): false only if something truncated it in place.
298fn still_holds_prefix(file: &fs::File, len: u64) -> bool {
299    file.metadata().is_ok_and(|meta| meta.len() >= len)
300}
301
302fn lock_trail_with(
303    file: &fs::File,
304    try_lock: fn(&fs::File) -> std::io::Result<()>,
305) -> Result<(), String> {
306    use std::time::Instant;
307
308    let deadline = Instant::now() + LOCK_TIMEOUT;
309    loop {
310        match try_lock(file) {
311            Ok(()) => return Ok(()),
312            Err(error) if super::file_lock::is_contended(&error) => {
313                if Instant::now() >= deadline {
314                    return Err(format!(
315                        "audit trail lock timed out after {}ms; another append is still active",
316                        LOCK_TIMEOUT.as_millis()
317                    ));
318                }
319                std::thread::sleep(LOCK_RETRY_INTERVAL);
320            }
321            Err(error) => return Err(format!("audit trail lock: {error}")),
322        }
323    }
324}
325
326/// Fallible append. Returns the persisted entry, so a caller can commit its
327/// own state only once the evidence is durable. On `Err` nothing was
328/// appended — a short write is rolled back to the pre-append length — which
329/// makes a retry safe and leaves the hash chain intact.
330///
331/// Durability is AT-LEAST-ONCE, not exactly-once: the line is `fsync`ed, but
332/// the caller's own state file is a separate object with no cross-file
333/// atomicity, so a crash between the two can leave the entry on disk without
334/// the state update. The direction is deliberate — a duplicate observation on
335/// the next attempt, never a lost one.
336pub fn try_record(data: AuditEntryData) -> Result<AuditEntry, String> {
337    let path = trail_path().ok_or_else(|| "audit trail unavailable".to_string())?;
338    try_record_at(&path, data)
339}
340
341/// Append one entry to the trail at an explicit `path` (used by callers that
342/// keep their own chained trail, e.g. value proofs). Errors are swallowed like
343/// [`record`].
344pub fn record_at(path: &Path, data: AuditEntryData) {
345    let _ = try_record_at(path, data);
346}
347
348fn try_record_at(path: &Path, data: AuditEntryData) -> Result<AuditEntry, String> {
349    try_record_at_time(path, data, chrono::Utc::now())
350}
351
352/// [`try_record_at`] with the clock that decides whether a day has passed.
353fn try_record_at_time(
354    path: &Path,
355    data: AuditEntryData,
356    now: chrono::DateTime<chrono::Utc>,
357) -> Result<AuditEntry, String> {
358    // At most one seal per append: after it the next file is fresh.
359    let mut data = data;
360    for _ in 0..SNAPSHOT_ATTEMPTS {
361        let mut file = open_active_locked(path)?;
362        let appended = append_or_seal(path, &mut file, data, now);
363        let _ = fs2::FileExt::unlock(&file);
364        match appended? {
365            Appended::Done(entry) => return Ok(entry),
366            Appended::Moved(back) => data = back,
367        }
368    }
369    Err("audit trail: the active file kept moving".to_string())
370}
371
372/// What [`append_or_seal`] did with the entry.
373enum Appended {
374    Done(AuditEntry),
375    /// The file was sealed and moved: append the entry to the new one.
376    Moved(AuditEntryData),
377}
378
379/// Open the active trail and take the exclusive lock. Advisory lock
380/// serializes appends ACROSS processes; prev_hash is read from the file
381/// under the same lock, so the chain cannot fork. A writer that waited on a
382/// file another writer sealed and renamed meanwhile opens the new one.
383fn open_active_locked(path: &Path) -> Result<fs::File, String> {
384    for _ in 0..SNAPSHOT_ATTEMPTS {
385        let file = OpenOptions::new()
386            .create(true)
387            .append(true)
388            .read(true)
389            .open(path)
390            .map_err(|e| format!("audit trail open: {e}"))?;
391        lock_trail(&file)?;
392        if is_current(path, &file) {
393            return Ok(file);
394        }
395        let _ = fs2::FileExt::unlock(&file);
396    }
397    Err("audit trail: the active file kept moving".to_string())
398}
399
400/// Whether `file` is still the file at `path`.
401fn is_current(path: &Path, file: &fs::File) -> bool {
402    #[cfg(unix)]
403    {
404        use std::os::unix::fs::MetadataExt;
405        match (fs::metadata(path), file.metadata()) {
406            (Ok(named), Ok(open)) => (named.dev(), named.ino()) == (open.dev(), open.ino()),
407            _ => false,
408        }
409    }
410    // lean-ctx: std has no stable file id on Windows; a renamed file is
411    // caught there by its tail (a seal) on the next append instead.
412    #[cfg(not(unix))]
413    {
414        let _ = (path, file);
415        true
416    }
417}
418
419/// Append `data` to the locked active trail, sealing it first when it is
420/// due.
421fn append_or_seal(
422    path: &Path,
423    file: &mut fs::File,
424    data: AuditEntryData,
425    now: chrono::DateTime<chrono::Utc>,
426) -> Result<Appended, String> {
427    let Some(last) = read_tail_entry(file)? else {
428        // A fresh active file continues the chain of the newest segment.
429        let prev_hash = match segments::sealed(path).last() {
430            Some((_, segment)) => fs::File::open(segment)
431                .map_err(|e| format!("audit segment open: {e}"))
432                .and_then(|segment| read_last_hash_tail(&segment))?,
433            None => "genesis".to_string(),
434        };
435        return append_locked(file, data, prev_hash).map(Appended::Done);
436    };
437    let last_hash = last
438        .get("entry_hash")
439        .and_then(serde_json::Value::as_str)
440        .unwrap_or_default()
441        .to_string();
442    let next_seq = segments::sealed(path).last().map_or(0, |(seq, _)| seq + 1);
443    let sealed_seq = if last.get("event_type").and_then(serde_json::Value::as_str)
444        == Some(segments::SEAL_EVENT)
445    {
446        segments::sealed_seq(last.get("action").and_then(serde_json::Value::as_str))
447    } else {
448        None
449    };
450    let len = file
451        .metadata()
452        .map_err(|e| format!("audit stat: {e}"))?
453        .len();
454    let last_at = last.get("timestamp").and_then(serde_json::Value::as_str);
455    let seq = match sealed_seq {
456        // Sealed before, but the rename did not happen.
457        Some(seq) if seq == next_seq => seq,
458        // Already moved to its segment (a handle that outlived the rename).
459        Some(seq) if seq < next_seq => return Ok(Appended::Moved(data)),
460        _ if segments::due(len, last_at, now)
461            && !segments::backing_off()
462            && verified_once(path, file) =>
463        {
464            append_locked(file, seal_data(next_seq), last_hash.clone())?;
465            next_seq
466        }
467        _ => return append_locked(file, data, last_hash).map(Appended::Done),
468    };
469    match segments::retire(path, seq) {
470        Ok(()) => Ok(Appended::Moved(data)),
471        Err(error) => {
472            // The seal stays in the active file and the chain runs on; this
473            // process tries again after a back-off.
474            tracing::warn!("audit trail not sealed: {error}");
475            segments::back_off();
476            let prev_hash = read_last_hash_tail(file)?;
477            append_locked(file, data, prev_hash).map(Appended::Done)
478        }
479    }
480}
481
482/// Never seal a trail this build has not verified: only a trail with a
483/// valid checkpoint (from a successful pass) is sealed. Trails outside the
484/// data dir have no checkpoint and are never sealed.
485fn verified_once(path: &Path, file: &fs::File) -> bool {
486    let Some(store) = checkpoint::Store::for_trail(path) else {
487        return false;
488    };
489    checkpoint::FileStamp::of(file).is_some_and(|stamp| store.holds_prefix_of(file, &stamp))
490}
491
492fn seal_data(seq: u64) -> AuditEntryData {
493    AuditEntryData {
494        agent_id: "lean-ctx".to_string(),
495        tool: "audit_trail".to_string(),
496        action: Some(segments::seal_action(seq)),
497        input_hash: String::new(),
498        output_tokens: 0,
499        role: "system".to_string(),
500        event_type: AuditEventType::Unknown(segments::SEAL_EVENT.to_string()),
501    }
502}
503
504/// Whether an entry is the seal that closes a trail segment: chain
505/// bookkeeping, not an agent action.
506pub fn is_seal(event_type: &AuditEventType) -> bool {
507    matches!(event_type, AuditEventType::Unknown(label) if label == segments::SEAL_EVENT)
508}
509
510/// Build and append one entry after `prev_hash`. Caller holds the
511/// exclusive lock.
512fn append_locked(
513    file: &mut fs::File,
514    data: AuditEntryData,
515    prev_hash: String,
516) -> Result<AuditEntry, String> {
517    use std::io::{Seek, SeekFrom};
518
519    let authenticated_drift = matches!(
520        &data.event_type,
521        AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
522    ) || is_seal(&data.event_type);
523
524    let partial = serde_json::json!({
525        "agent_id": data.agent_id,
526        "tool": data.tool,
527        "action": data.action,
528        "input_hash": data.input_hash,
529        "output_tokens": data.output_tokens,
530        "role": data.role,
531        "event_type": data.event_type,
532    });
533    let data_json = serde_json::to_string(&partial).map_err(|e| format!("audit serialize: {e}"))?;
534    let entry_hash = compute_entry_hash(&prev_hash, &data_json);
535
536    let signature_result =
537        crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
538            .map(|sig| crate::core::agent_identity::hex_encode(&sig));
539    let signature = if authenticated_drift {
540        Some(signature_result.map_err(|e| format!("audit signature: {e}"))?)
541    } else {
542        signature_result.ok()
543    };
544
545    let entry = AuditEntry {
546        timestamp: chrono::Utc::now().to_rfc3339(),
547        agent_id: data.agent_id,
548        tool: data.tool,
549        action: data.action,
550        input_hash: data.input_hash,
551        output_tokens: data.output_tokens,
552        role: data.role,
553        event_type: data.event_type,
554        prev_hash,
555        entry_hash,
556        signature,
557    };
558
559    let mut line = serde_json::to_string(&entry).map_err(|e| format!("audit serialize: {e}"))?;
560    line.push('\n');
561    // A short write would leave a truncated JSON line and invalidate the
562    // chain from that point onwards; roll the file back to its pre-append
563    // length so a failed append is a no-op instead of corruption.
564    let rollback_len = file
565        .seek(SeekFrom::End(0))
566        .map_err(|e| format!("audit seek: {e}"))?;
567    if let Err(e) = file
568        .write_all(line.as_bytes())
569        .and_then(|()| file.flush())
570        .and_then(|()| file.sync_data())
571    {
572        let _ = file.set_len(rollback_len);
573        return Err(format!("audit append: {e}"));
574    }
575    Ok(entry)
576}
577
578/// Path of the local audit trail (`<data_dir>/audit/trail.jsonl`).
579pub fn default_trail_path() -> Option<PathBuf> {
580    trail_path()
581}
582
583/// The last `limit` entries of the default trail.
584pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
585    let Some(path) = trail_path() else {
586        return Vec::new();
587    };
588    load_recent_at(&path, limit)
589}
590
591/// Bytes read per step when [`load_recent_at`] walks back from the end.
592const TAIL_BLOCK: u64 = 256 * 1024;
593
594/// The last `limit` entries of the trail at `path`, read back from its end
595/// in blocks until enough whole lines are in, never the whole trail; a
596/// short active trail is topped up from the newest sealed segments.
597pub fn load_recent_at(path: &Path, limit: usize) -> Vec<AuditEntry> {
598    let mut entries: Vec<AuditEntry> = Vec::new();
599    for file in segments::chain(path).iter().rev() {
600        if entries.len() >= limit {
601            break;
602        }
603        let mut older = load_recent_in(file, limit - entries.len());
604        older.append(&mut entries);
605        entries = older;
606    }
607    entries
608}
609
610fn load_recent_in(path: &Path, limit: usize) -> Vec<AuditEntry> {
611    use std::io::{Seek, SeekFrom};
612
613    if limit == 0 {
614        return Vec::new();
615    }
616    let Some((file, len)) = open_for_listing(path) else {
617        return Vec::new();
618    };
619    let mut start = len;
620    let mut blocks: Vec<Vec<u8>> = Vec::new();
621    let mut newlines = 0usize;
622    while start > 0 && newlines <= limit {
623        let from = start.saturating_sub(TAIL_BLOCK);
624        let mut block = Vec::new();
625        let read = (&file)
626            .seek(SeekFrom::Start(from))
627            .and_then(|_| (&file).take(start - from).read_to_end(&mut block));
628        if read.is_err() {
629            break;
630        }
631        newlines += bytecount::count(&block, b'\n');
632        blocks.push(block);
633        start = from;
634    }
635    let mut tail: Vec<u8> = blocks.into_iter().rev().flatten().collect();
636    // Unless the walk reached the start, the first line may be cut.
637    if start > 0 {
638        let cut = tail
639            .iter()
640            .position(|&byte| byte == b'\n')
641            .map_or(tail.len(), |at| at + 1);
642        tail.drain(..cut);
643    }
644    let entries = parse_entries(std::io::BufReader::new(tail.as_slice()));
645    let skip = entries.len().saturating_sub(limit);
646    entries.into_iter().skip(skip).collect()
647}
648
649/// Every parseable entry of the trail at `path`, its sealed segments first.
650pub fn load_all_at(path: &Path) -> Vec<AuditEntry> {
651    let mut entries = Vec::new();
652    for file in segments::chain(path) {
653        if let Some((file, len)) = open_for_listing(&file) {
654            entries.extend(parse_entries(std::io::BufReader::new(file.take(len))));
655        }
656    }
657    entries
658}
659
660/// The files of the trail at `trail` in chain order: its sealed segments,
661/// oldest first, then the active trail if it exists. With `since`, a sealed
662/// segment whose last entry (its seal) is older than `since` is left out;
663/// that takes one tail read per segment. Lines keep the trail's format and
664/// run in time order across the files.
665pub fn trail_files_since(
666    trail: &Path,
667    since: Option<chrono::DateTime<chrono::Utc>>,
668) -> Vec<PathBuf> {
669    let mut files: Vec<PathBuf> = segments::sealed(trail)
670        .into_iter()
671        .map(|(_, path)| path)
672        .filter(|path| {
673            since.is_none_or(|since| {
674                fs::File::open(path)
675                    .ok()
676                    .and_then(|file| read_tail_entry(&file).ok().flatten())
677                    .and_then(|last| entry_time(&last))
678                    .is_none_or(|sealed_at| sealed_at >= since)
679            })
680        })
681        .collect();
682    if trail.exists() {
683        files.push(trail.to_path_buf());
684    }
685    files
686}
687
688/// The text of [`trail_files_since`] joined in order; `None` when the trail
689/// has no file at all. A seal that lands while the files are read moves the
690/// active trail away, so the read is repeated until the segment list held
691/// still across it.
692pub fn read_trail_since(
693    trail: &Path,
694    since: Option<chrono::DateTime<chrono::Utc>>,
695) -> std::io::Result<Option<String>> {
696    for _ in 0..SNAPSHOT_ATTEMPTS {
697        let before = segments::sealed(trail);
698        let files = trail_files_since(trail, since);
699        if files.is_empty() {
700            return Ok(None);
701        }
702        let mut text = String::new();
703        for file in files {
704            match fs::read_to_string(&file) {
705                Ok(part) => {
706                    text.push_str(&part);
707                    if !text.is_empty() && !text.ends_with('\n') {
708                        text.push('\n');
709                    }
710                }
711                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
712                Err(error) => return Err(error),
713            }
714        }
715        if segments::sealed(trail) == before {
716            return Ok(Some(text));
717        }
718    }
719    Err(std::io::Error::other(
720        "the audit trail kept being sealed while it was read",
721    ))
722}
723
724/// How often a reader retries when a seal moved the active trail under it.
725const SNAPSHOT_ATTEMPTS: usize = 3;
726
727fn entry_time(entry: &serde_json::Value) -> Option<chrono::DateTime<chrono::Utc>> {
728    let stamp = entry.get("timestamp")?.as_str()?;
729    Some(chrono::DateTime::parse_from_rfc3339(stamp).ok()?.to_utc())
730}
731
732/// Open the trail at `path` for a listing, with its length taken under the
733/// shared lock (see [`release_read_lock`]).
734fn open_for_listing(path: &Path) -> Option<(fs::File, u64)> {
735    let file = fs::File::open(path).ok()?;
736    // Best effort: without the shared lock a Windows read can stop at an
737    // in-progress append (see `lock_trail_shared`); the listing then ends early.
738    let locked = lock_trail_shared(&file);
739    if let Err(error) = &locked {
740        tracing::debug!("audit listing reads unlocked and may end early: {error}");
741    }
742    let len = file.metadata().ok()?.len();
743    if locked.is_ok() {
744        release_read_lock(&file);
745    }
746    Some((file, len))
747}
748
749fn parse_entries(reader: impl BufRead) -> Vec<AuditEntry> {
750    let mut entries = Vec::new();
751    for line in reader.lines().map_while(Result::ok) {
752        entries.extend(
753            serde_json::Deserializer::from_str(&line)
754                .into_iter::<AuditEntry>()
755                .map_while(Result::ok),
756        );
757    }
758    entries
759}
760
761/// Reconstruct the latest unacknowledged drift from the append-only trail.
762/// This is the downgrade-safe source of truth: an older binary may rewrite an
763/// `Attestation` without its newer `drift` field, but it cannot synthesize the
764/// explicit acknowledgement event required to clear this history.
765pub fn latest_unacknowledged_drift(agent_id: &str) -> Result<Option<(String, String)>, String> {
766    let (chain, pending) = verify_chain_with_drift(Some(agent_id));
767    if !chain.source_present {
768        return Err("audit trail unavailable".to_string());
769    }
770    if !chain.valid {
771        return Err(format!(
772            "audit chain invalid at entry {}",
773            chain.first_invalid_at.unwrap_or(chain.total_entries)
774        ));
775    }
776    if chain.total_entries == 0 {
777        // An existing zero-byte trail is evidence loss for any identity that
778        // has already reached an attested state. Keep the generic verifier's
779        // fresh-install view (`valid=true`, `source_present=true`) compatible,
780        // but do not let a caller that reconstructs durable identity state
781        // interpret an emptied trail as a clean history.
782        return Err("audit trail unavailable".to_string());
783    }
784
785    Ok(pending)
786}
787
788/// The hashed field set of an entry, kept as raw JSON values.
789///
790/// The chain hash covers exactly these fields in exactly this order, so a
791/// reader can re-derive it WITHOUT understanding `event_type` — that is what
792/// makes an additive event type verifiable by an older reader (OCP Part 4).
793/// A missing field is a structural defect and yields `None`.
794fn hashed_fields(value: &serde_json::Value) -> Option<serde_json::Value> {
795    let get = |key: &str| value.get(key).cloned();
796    Some(serde_json::json!({
797        "agent_id": get("agent_id")?,
798        "tool": get("tool")?,
799        "action": value.get("action").cloned().unwrap_or(serde_json::Value::Null),
800        "input_hash": get("input_hash")?,
801        "output_tokens": get("output_tokens")?,
802        "role": get("role")?,
803        "event_type": get("event_type")?,
804    }))
805}
806
807fn invalid_at(total: usize, unknown: usize) -> ChainVerifyResult {
808    ChainVerifyResult {
809        total_entries: total,
810        valid: false,
811        first_invalid_at: Some(total),
812        source_present: true,
813        unknown_event_types: unknown,
814    }
815}
816
817fn unavailable() -> ChainVerifyResult {
818    ChainVerifyResult {
819        total_entries: 0,
820        valid: false,
821        first_invalid_at: Some(0),
822        source_present: false,
823        unknown_event_types: 0,
824    }
825}
826
827/// Verify the hash chain over the whole trail.
828///
829/// A namespaced extension entry whose `event_type` this build does not know is counted in
830/// `unknown_event_types` and its links are still verified from the raw
831/// fields — a reader older than the writer must not report a forward-
832/// compatible `x-` event as tampering. Anything else (unparseable line,
833/// missing hashed field, broken `prev_hash` link, wrong `entry_hash`) is
834/// still reported as invalid at its index; corruption is never hidden.
835fn verify_chain_with_drift(
836    agent_id: Option<&str>,
837) -> (ChainVerifyResult, Option<(String, String)>) {
838    let Some(path) = trail_path() else {
839        return (unavailable(), None);
840    };
841    verify_chain_with_drift_at(&path, agent_id)
842}
843
844fn verify_chain_with_drift_at(
845    path: &Path,
846    agent_id: Option<&str>,
847) -> (ChainVerifyResult, Option<(String, String)>) {
848    verify_chain_mode(path, agent_id, false)
849}
850
851/// Chain state after a verified prefix: everything a verifier needs to
852/// continue with the next entry exactly as a full pass would.
853#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
854pub(super) struct ChainState {
855    prev_hash: String,
856    total: usize,
857    unknown: usize,
858    /// Latest unacknowledged drift per agent id, with the same transition
859    /// rule a single-agent pass applies (an agent's state depends only on
860    /// its own entries, so tracking all agents is exactly equivalent).
861    pending: std::collections::BTreeMap<String, (String, String)>,
862    /// Signed-tally security evidence of the verified entries.
863    tally: SignedTallyEvidence,
864    /// Security evidence of every kind (the lifetime value proof), so the
865    /// proof needs no second read of the trail.
866    security: SignedTallyEvidence,
867    /// The segment the last entry sealed, if it was a seal.
868    sealed: Option<u64>,
869}
870
871impl ChainState {
872    fn genesis() -> Self {
873        Self {
874            prev_hash: "genesis".to_string(),
875            total: 0,
876            unknown: 0,
877            pending: std::collections::BTreeMap::new(),
878            tally: SignedTallyEvidence::default(),
879            security: SignedTallyEvidence::default(),
880            sealed: None,
881        }
882    }
883
884    fn invalid(&self) -> (ChainVerifyResult, Option<(String, String)>) {
885        (invalid_at(self.total, self.unknown), None)
886    }
887
888    fn valid(&self, agent_id: Option<&str>) -> (ChainVerifyResult, Option<(String, String)>) {
889        (
890            ChainVerifyResult {
891                total_entries: self.total,
892                valid: true,
893                first_invalid_at: None,
894                source_present: true,
895                unknown_event_types: self.unknown,
896            },
897            agent_id.and_then(|id| self.pending.get(id).cloned()),
898        )
899    }
900
901    /// Verify one parsed entry and advance. `Err` leaves `total`/`unknown`
902    /// at the values a report of the failure must carry.
903    ///
904    /// Every signature present is verified against the local `lean-ctx`
905    /// key, not only the ones drift and seal entries must carry: a forged
906    /// or foreign signature on an ordinary entry is tampering too. The key
907    /// is loaded once per pass into `key`.
908    fn advance(
909        &mut self,
910        value: serde_json::Value,
911        key: &mut Option<Result<ed25519_dalek::VerifyingKey, ()>>,
912    ) -> Result<(), ()> {
913        let (Some(entry_prev), Some(entry_hash)) = (
914            value.get("prev_hash").and_then(serde_json::Value::as_str),
915            value.get("entry_hash").and_then(serde_json::Value::as_str),
916        ) else {
917            return Err(());
918        };
919        let partial = hashed_fields(&value).ok_or(())?;
920        if entry_prev != self.prev_hash {
921            return Err(());
922        }
923        let data_json = serde_json::to_string(&partial).unwrap_or_default();
924        if entry_hash != compute_entry_hash(&self.prev_hash, &data_json) {
925            return Err(());
926        }
927        let entry_hash = entry_hash.to_string();
928        let entry = serde_json::from_value::<AuditEntry>(value).map_err(|_| ())?;
929        let seal = is_seal(&entry.event_type);
930        if matches!(&entry.event_type, AuditEventType::Unknown(_)) && !seal {
931            self.unknown += 1;
932        }
933        self.sealed = if seal {
934            Some(segments::sealed_seq(entry.action.as_deref()).ok_or(())?)
935        } else {
936            None
937        };
938        let signature_required = seal
939            || matches!(
940                &entry.event_type,
941                AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
942            );
943        if signature_required && entry.signature.is_none() {
944            return Err(());
945        }
946        if let Some(signature) = entry.signature.as_deref() {
947            let signature = crate::core::agent_identity::hex_decode(signature).map_err(|_| ())?;
948            let Ok(public_key) = key
949                .get_or_insert_with(|| {
950                    crate::core::agent_identity::load_public_key("lean-ctx").map_err(|_| ())
951                })
952                .as_ref()
953            else {
954                return Err(());
955            };
956            if !crate::core::agent_identity::verify_signature(
957                public_key.as_bytes(),
958                entry.entry_hash.as_bytes(),
959                &signature,
960            ) {
961                return Err(());
962            }
963        }
964        if let Some((kind, n, _)) = entry
965            .action
966            .as_deref()
967            .and_then(crate::core::security_events::parse_action)
968        {
969            self.security.record(kind, n, &entry.entry_hash);
970            if kind.in_signed_tally() {
971                self.tally.record(kind, n, &entry.entry_hash);
972            }
973        }
974        match entry.event_type {
975            AuditEventType::AgentDriftDetected => {
976                if let Some(evidence) = entry.action {
977                    self.pending.insert(
978                        entry.agent_id,
979                        (
980                            entry.timestamp,
981                            format!("{evidence} observation={}", entry.entry_hash),
982                        ),
983                    );
984                }
985            }
986            AuditEventType::AgentDriftAcknowledged
987                if self
988                    .pending
989                    .get(&entry.agent_id)
990                    .is_some_and(|(_, evidence)| entry.action.as_ref() == Some(evidence)) =>
991            {
992                self.pending.remove(&entry.agent_id);
993            }
994            _ => {}
995        }
996        self.prev_hash = entry_hash;
997        self.total += 1;
998        Ok(())
999    }
1000
1001    /// Verify every line of `reader` (the same line rules as
1002    /// `BufRead::lines`). Returns the bytes consumed and whether the input
1003    /// ended on a line boundary (empty input counts as one).
1004    fn verify_stream(&mut self, reader: &mut impl BufRead) -> Result<(u64, bool), ()> {
1005        let mut consumed = 0u64;
1006        let mut ends_on_newline = true;
1007        let mut buf = Vec::new();
1008        let mut key = None;
1009        loop {
1010            buf.clear();
1011            let read = reader.read_until(b'\n', &mut buf).map_err(|_| ())?;
1012            if read == 0 {
1013                return Ok((consumed, ends_on_newline));
1014            }
1015            consumed += read as u64;
1016            ends_on_newline = buf.last() == Some(&b'\n');
1017            if ends_on_newline {
1018                buf.pop();
1019                if buf.last() == Some(&b'\r') {
1020                    buf.pop();
1021                }
1022            }
1023            let line = std::str::from_utf8(&buf).map_err(|_| ())?;
1024            let mut saw_value = false;
1025            for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>()
1026            {
1027                saw_value = true;
1028                self.advance(parsed.map_err(|_| ())?, &mut key)?;
1029            }
1030            if !saw_value {
1031                return Err(());
1032            }
1033        }
1034    }
1035}
1036
1037/// Verify the trail at `path`. With `force_full == false` a signed,
1038/// identity-bound checkpoint of the last verified prefix (see
1039/// [`checkpoint`]) lets the verifier read only the appended suffix; any
1040/// doubt about the prefix falls back to a full pass. A full pass refreshes
1041/// the checkpoint.
1042fn verify_chain_mode(
1043    path: &Path,
1044    agent_id: Option<&str>,
1045    force_full: bool,
1046) -> (ChainVerifyResult, Option<(String, String)>) {
1047    verify_chain_mode_at(path, agent_id, force_full, chrono::Utc::now().timestamp())
1048}
1049
1050/// [`verify_chain_mode`] at an explicit wall-clock `now` (Unix seconds).
1051fn verify_chain_mode_at(
1052    path: &Path,
1053    agent_id: Option<&str>,
1054    force_full: bool,
1055    now: i64,
1056) -> (ChainVerifyResult, Option<(String, String)>) {
1057    match verify_state_at(path, force_full, now) {
1058        Verified::Missing => (
1059            ChainVerifyResult {
1060                total_entries: 0,
1061                valid: true,
1062                first_invalid_at: None,
1063                source_present: false,
1064                unknown_event_types: 0,
1065            },
1066            None,
1067        ),
1068        Verified::Unavailable => (unavailable(), None),
1069        Verified::Invalid(state) => state.invalid(),
1070        Verified::Valid(state) => state.valid(agent_id),
1071    }
1072}
1073
1074enum Verified {
1075    /// No trail file: a valid empty history.
1076    Missing,
1077    /// Unreadable or lock contention: fail closed, but not corruption.
1078    Unavailable,
1079    /// Broken at `state.total`.
1080    Invalid(ChainState),
1081    Valid(ChainState),
1082}
1083
1084/// Verify the trail at `path` through its sealed segments and the active
1085/// file, in chain order. The segment list is read before and after the
1086/// active file is opened, so a seal that lands in between is retried
1087/// instead of read as a gap.
1088fn verify_state_at(path: &Path, force_full: bool, now: i64) -> Verified {
1089    for _ in 0..SNAPSHOT_ATTEMPTS {
1090        let sealed = segments::sealed(path);
1091        let active = match fs::File::open(path) {
1092            Ok(file) => Some(file),
1093            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1094            Err(_) => return Verified::Unavailable,
1095        };
1096        if segments::sealed(path) == sealed {
1097            return verify_snapshot(path, &sealed, active, force_full, now);
1098        }
1099    }
1100    Verified::Unavailable
1101}
1102
1103fn verify_snapshot(
1104    path: &Path,
1105    sealed: &[(u64, PathBuf)],
1106    active: Option<fs::File>,
1107    force_full: bool,
1108    now: i64,
1109) -> Verified {
1110    if sealed.is_empty() && active.is_none() {
1111        return Verified::Missing;
1112    }
1113    // A sealed segment is never written again, so its checkpoint is trusted
1114    // past the 24 h full pass, except for one segment per day in rotation.
1115    let day = usize::try_from(now.div_euclid(24 * 60 * 60)).unwrap_or(0);
1116    let sample = day.checked_rem(sealed.len());
1117    let mut state = ChainState::genesis();
1118    for (index, (seq, segment)) in sealed.iter().enumerate() {
1119        let Ok(file) = fs::File::open(segment) else {
1120            return Verified::Unavailable;
1121        };
1122        let max_age = (Some(index) == sample).then_some(checkpoint::FULL_REVERIFY_SECS);
1123        state = match verify_file(segment, &file, &state, force_full, max_age, now) {
1124            // Each segment ends with the seal that names it.
1125            Verified::Valid(state) if state.sealed == Some(*seq) => state,
1126            Verified::Valid(state) => return Verified::Invalid(state),
1127            other => return other,
1128        };
1129    }
1130    match active {
1131        Some(file) => verify_file(
1132            path,
1133            &file,
1134            &state,
1135            force_full,
1136            Some(checkpoint::FULL_REVERIFY_SECS),
1137            now,
1138        ),
1139        None => Verified::Valid(state),
1140    }
1141}
1142
1143/// Verify one file of the trail, continuing from `start`: from its
1144/// checkpoint when one resumes (see [`checkpoint::Store::resume`]), else in
1145/// full.
1146fn verify_file(
1147    path: &Path,
1148    file: &fs::File,
1149    start: &ChainState,
1150    force_full: bool,
1151    max_age: Option<i64>,
1152    now: i64,
1153) -> Verified {
1154    use std::io::{Seek, SeekFrom};
1155
1156    let mut cursor = file;
1157    // Contention is not corruption: a reader that cannot get the lock in time
1158    // reports the trail unavailable (fail-closed), never invalid. Appends need
1159    // the exclusive lock, so the stamp taken under it describes exactly the
1160    // prefix `file[..len]` read below, which no append changes afterwards (see
1161    // `release_read_lock`); the pass itself runs without the lock.
1162    if lock_trail_shared(file).is_err() {
1163        return Verified::Unavailable;
1164    }
1165    let store = checkpoint::Store::for_trail(path);
1166    let stamp = checkpoint::FileStamp::of(file);
1167    let Some(len) = stamp
1168        .as_ref()
1169        .map(|stamp| stamp.size)
1170        .or_else(|| file.metadata().ok().map(|meta| meta.len()))
1171    else {
1172        return Verified::Unavailable;
1173    };
1174    release_read_lock(file);
1175
1176    if !force_full {
1177        if let (Some(store), Some(stamp)) = (&store, &stamp) {
1178            if let Some(resumed) = store.resume(file, stamp, start, max_age, now) {
1179                let mut state = resumed.state;
1180                if resumed.offset == stamp.size {
1181                    return Verified::Valid(state);
1182                }
1183                if cursor.seek(SeekFrom::Start(resumed.offset)).is_ok() {
1184                    let suffix = state.verify_stream(&mut std::io::BufReader::new(
1185                        file.take(len - resumed.offset),
1186                    ));
1187                    if !still_holds_prefix(file, len) {
1188                        return Verified::Unavailable;
1189                    }
1190                    if let Ok((read, clean)) = suffix {
1191                        if clean && resumed.offset + read == stamp.size {
1192                            store.save(file, stamp, start, &state, resumed.full_verified_at);
1193                        }
1194                        return Verified::Valid(state);
1195                    }
1196                }
1197                // A broken suffix is reported by a full pass, so the index
1198                // is the true first invalid entry even if the prefix changed.
1199            }
1200        }
1201    }
1202
1203    if cursor.seek(SeekFrom::Start(0)).is_err() {
1204        return Verified::Unavailable;
1205    }
1206    let mut state = start.clone();
1207    let pass = state.verify_stream(&mut std::io::BufReader::new(file.take(len)));
1208    // Cut in place while it was read: what the pass saw is no verdict.
1209    if !still_holds_prefix(file, len) {
1210        return Verified::Unavailable;
1211    }
1212    let Ok((read, clean)) = pass else {
1213        if let Some(store) = &store {
1214            store.discard();
1215        }
1216        return Verified::Invalid(state);
1217    };
1218    if let (Some(store), Some(stamp)) = (&store, &stamp) {
1219        if clean && read == stamp.size {
1220            store.save(file, stamp, start, &state, now);
1221        }
1222    }
1223    Verified::Valid(state)
1224}
1225
1226/// Security evidence a signed savings-batch tally rests on, aggregated while
1227/// the chain is verified: the counts of the kinds the tally carries and the
1228/// entry hashes of the entries they come from.
1229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1230pub struct SignedTallyEvidence {
1231    pub counts: crate::core::security_events::SecurityCounts,
1232    pub entries: u64,
1233    pub first_hash: Option<String>,
1234    pub last_hash: Option<String>,
1235}
1236
1237impl SignedTallyEvidence {
1238    fn record(&mut self, kind: crate::core::security_events::SecurityKind, n: u64, hash: &str) {
1239        self.counts.add(kind, n);
1240        self.entries += 1;
1241        self.first_hash.get_or_insert_with(|| hash.to_string());
1242        self.last_hash = Some(hash.to_string());
1243    }
1244}
1245
1246/// The signed-tally evidence of the trail at `path`, from the same
1247/// (incremental) pass that verifies the chain. `None` when the trail fails
1248/// verification or is unavailable; a missing trail is an empty tally.
1249pub fn verified_signed_tally_at(path: &Path) -> Option<SignedTallyEvidence> {
1250    match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
1251        Verified::Missing => Some(SignedTallyEvidence::default()),
1252        Verified::Valid(state) => Some(state.tally),
1253        Verified::Unavailable | Verified::Invalid(_) => None,
1254    }
1255}
1256
1257/// The chain check of the trail at `path` and the security evidence of
1258/// every kind it holds, from one (incremental) verification: what the
1259/// lifetime value proof shows, without reading the trail a second time.
1260/// The evidence is `None` unless the chain verifies; a missing trail is an
1261/// empty, intact chain.
1262pub fn verified_security_at(path: &Path) -> (ChainVerifyResult, Option<SignedTallyEvidence>) {
1263    match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
1264        Verified::Missing => (
1265            ChainVerifyResult {
1266                total_entries: 0,
1267                valid: true,
1268                first_invalid_at: None,
1269                source_present: false,
1270                unknown_event_types: 0,
1271            },
1272            Some(SignedTallyEvidence::default()),
1273        ),
1274        Verified::Unavailable => (unavailable(), None),
1275        Verified::Invalid(state) => (state.invalid().0, None),
1276        Verified::Valid(state) => (state.valid(None).0, Some(state.security)),
1277    }
1278}
1279
1280/// Verify the default trail, resuming from the verified checkpoint when it
1281/// is still trustworthy (hot path: billing, dashboard, drift reconstruction).
1282pub fn verify_chain() -> ChainVerifyResult {
1283    verify_chain_with_drift(None).0
1284}
1285
1286/// Verify the default trail with a full pass from genesis, ignoring any
1287/// checkpoint. For explicit audit commands and signed compliance reports.
1288pub fn verify_chain_full() -> ChainVerifyResult {
1289    let Some(path) = trail_path() else {
1290        return unavailable();
1291    };
1292    verify_chain_mode(&path, None, true).0
1293}
1294
1295/// Verify the hash chain of the trail at an explicit `path` (incremental,
1296/// like [`verify_chain`]).
1297pub fn verify_chain_at(path: &Path) -> ChainVerifyResult {
1298    verify_chain_with_drift_at(path, None).0
1299}
1300
1301pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
1302    let serialized = serde_json::to_string(args).unwrap_or_default();
1303    let mut hasher = Sha256::new();
1304    hasher.update(serialized.as_bytes());
1305    crate::core::agent_identity::hex_encode(&hasher.finalize())
1306}
1307
1308#[cfg(test)]
1309#[path = "audit_trail_tests.rs"]
1310mod tests;