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