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/// The files of the trail at `trail` in chain order: its sealed segments,
772/// oldest first, then the active trail if it exists. With `since`, a sealed
773/// segment whose last entry (its seal) is older than `since` is left out;
774/// that takes one tail read per segment. Lines keep the trail's format and
775/// run in time order across the files.
776pub fn trail_files_since(
777    trail: &Path,
778    since: Option<chrono::DateTime<chrono::Utc>>,
779) -> Vec<PathBuf> {
780    let mut files: Vec<PathBuf> = segments::sealed(trail)
781        .into_iter()
782        .map(|(_, path)| path)
783        .filter(|path| {
784            since.is_none_or(|since| {
785                fs::File::open(path)
786                    .ok()
787                    .and_then(|file| read_tail_entry(&file).ok().flatten())
788                    .and_then(|last| entry_time(&last))
789                    .is_none_or(|sealed_at| sealed_at >= since)
790            })
791        })
792        .collect();
793    if trail.exists() {
794        files.push(trail.to_path_buf());
795    }
796    files
797}
798
799/// The text of [`trail_files_since`] joined in order; `None` when the trail
800/// has no file at all. A seal that lands while the files are read moves the
801/// active trail away, so the read is repeated until the segment list held
802/// still across it.
803pub fn read_trail_since(
804    trail: &Path,
805    since: Option<chrono::DateTime<chrono::Utc>>,
806) -> std::io::Result<Option<String>> {
807    for _ in 0..SNAPSHOT_ATTEMPTS {
808        let before = segments::sealed(trail);
809        let files = trail_files_since(trail, since);
810        if files.is_empty() {
811            return Ok(None);
812        }
813        let mut text = String::new();
814        for file in files {
815            match fs::read_to_string(&file) {
816                Ok(part) => {
817                    text.push_str(&part);
818                    if !text.is_empty() && !text.ends_with('\n') {
819                        text.push('\n');
820                    }
821                }
822                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
823                Err(error) => return Err(error),
824            }
825        }
826        if segments::sealed(trail) == before {
827            return Ok(Some(text));
828        }
829    }
830    Err(std::io::Error::other(
831        "the audit trail kept being sealed while it was read",
832    ))
833}
834
835/// How often a reader retries when a seal moved the active trail under it.
836const SNAPSHOT_ATTEMPTS: usize = 3;
837
838fn entry_time(entry: &serde_json::Value) -> Option<chrono::DateTime<chrono::Utc>> {
839    let stamp = entry.get("timestamp")?.as_str()?;
840    Some(chrono::DateTime::parse_from_rfc3339(stamp).ok()?.to_utc())
841}
842
843/// Open the trail at `path` for a listing, with its length taken under the
844/// shared lock (see [`release_read_lock`]).
845fn open_for_listing(path: &Path) -> Option<(fs::File, u64)> {
846    let file = fs::File::open(path).ok()?;
847    // Best effort: without the shared lock a Windows read can stop at an
848    // in-progress append (see `lock_trail_shared`); the listing then ends early.
849    let locked = lock_trail_shared(&file);
850    if let Err(error) = &locked {
851        tracing::debug!("audit listing reads unlocked and may end early: {error}");
852    }
853    let len = file.metadata().ok()?.len();
854    if locked.is_ok() {
855        release_read_lock(&file);
856    }
857    Some((file, len))
858}
859
860fn parse_entries(reader: impl BufRead) -> Vec<AuditEntry> {
861    let mut entries = Vec::new();
862    for line in reader.lines().map_while(Result::ok) {
863        entries.extend(
864            serde_json::Deserializer::from_str(&line)
865                .into_iter::<AuditEntry>()
866                .map_while(Result::ok),
867        );
868    }
869    entries
870}
871
872/// Reconstruct the latest unacknowledged drift from the append-only trail.
873/// This is the downgrade-safe source of truth: an older binary may rewrite an
874/// `Attestation` without its newer `drift` field, but it cannot synthesize the
875/// explicit acknowledgement event required to clear this history.
876pub fn latest_unacknowledged_drift(agent_id: &str) -> Result<Option<(String, String)>, String> {
877    let (chain, pending) = verify_chain_with_drift(Some(agent_id));
878    if !chain.source_present {
879        return Err("audit trail unavailable".to_string());
880    }
881    if !chain.valid {
882        return Err(format!(
883            "audit chain invalid at entry {}",
884            chain.first_invalid_at.unwrap_or(chain.total_entries)
885        ));
886    }
887    if chain.total_entries == 0 {
888        // An existing zero-byte trail is evidence loss for any identity that
889        // has already reached an attested state. Keep the generic verifier's
890        // fresh-install view (`valid=true`, `source_present=true`) compatible,
891        // but do not let a caller that reconstructs durable identity state
892        // interpret an emptied trail as a clean history.
893        return Err("audit trail unavailable".to_string());
894    }
895
896    Ok(pending)
897}
898
899/// The hashed field set of an entry, kept as raw JSON values.
900///
901/// The chain hash covers exactly these fields in exactly this order, so a
902/// reader can re-derive it WITHOUT understanding `event_type` — that is what
903/// makes an additive event type verifiable by an older reader (OCP Part 4).
904/// A missing field is a structural defect and yields `None`.
905fn hashed_fields(value: &serde_json::Value) -> Option<serde_json::Value> {
906    let get = |key: &str| value.get(key).cloned();
907    Some(serde_json::json!({
908        "agent_id": get("agent_id")?,
909        "tool": get("tool")?,
910        "action": value.get("action").cloned().unwrap_or(serde_json::Value::Null),
911        "input_hash": get("input_hash")?,
912        "output_tokens": get("output_tokens")?,
913        "role": get("role")?,
914        "event_type": get("event_type")?,
915    }))
916}
917
918fn invalid_at(total: usize, unknown: usize) -> ChainVerifyResult {
919    ChainVerifyResult {
920        total_entries: total,
921        valid: false,
922        first_invalid_at: Some(total),
923        source_present: true,
924        unknown_event_types: unknown,
925    }
926}
927
928fn unavailable() -> ChainVerifyResult {
929    ChainVerifyResult {
930        total_entries: 0,
931        valid: false,
932        first_invalid_at: Some(0),
933        source_present: false,
934        unknown_event_types: 0,
935    }
936}
937
938/// Verify the hash chain over the whole trail.
939///
940/// A namespaced extension entry whose `event_type` this build does not know is counted in
941/// `unknown_event_types` and its links are still verified from the raw
942/// fields — a reader older than the writer must not report a forward-
943/// compatible `x-` event as tampering. Anything else (unparseable line,
944/// missing hashed field, broken `prev_hash` link, wrong `entry_hash`) is
945/// still reported as invalid at its index; corruption is never hidden.
946fn verify_chain_with_drift(
947    agent_id: Option<&str>,
948) -> (ChainVerifyResult, Option<(String, String)>) {
949    let Some(path) = trail_path() else {
950        return (unavailable(), None);
951    };
952    verify_chain_with_drift_at(&path, agent_id)
953}
954
955fn verify_chain_with_drift_at(
956    path: &Path,
957    agent_id: Option<&str>,
958) -> (ChainVerifyResult, Option<(String, String)>) {
959    verify_chain_mode(path, agent_id, false)
960}
961
962/// Chain state after a verified prefix: everything a verifier needs to
963/// continue with the next entry exactly as a full pass would.
964#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
965pub(super) struct ChainState {
966    prev_hash: String,
967    /// The v2 chain head when it differs from `prev_hash` (the last entry
968    /// was a v2 entry).
969    head: Option<String>,
970    total: usize,
971    unknown: usize,
972    /// Latest unacknowledged drift per agent id, with the same transition
973    /// rule a single-agent pass applies (an agent's state depends only on
974    /// its own entries, so tracking all agents is exactly equivalent).
975    pending: std::collections::BTreeMap<String, (String, String)>,
976    /// Signed-tally security evidence of the verified entries.
977    tally: SignedTallyEvidence,
978    /// Security evidence of every kind (the lifetime value proof), so the
979    /// proof needs no second read of the trail.
980    security: SignedTallyEvidence,
981    /// The segment the last entry sealed, if it was a seal.
982    sealed: Option<u64>,
983}
984
985impl ChainState {
986    fn genesis() -> Self {
987        Self {
988            prev_hash: "genesis".to_string(),
989            head: None,
990            total: 0,
991            unknown: 0,
992            pending: std::collections::BTreeMap::new(),
993            tally: SignedTallyEvidence::default(),
994            security: SignedTallyEvidence::default(),
995            sealed: None,
996        }
997    }
998
999    fn invalid(&self) -> (ChainVerifyResult, Option<(String, String)>) {
1000        (invalid_at(self.total, self.unknown), None)
1001    }
1002
1003    fn valid(&self, agent_id: Option<&str>) -> (ChainVerifyResult, Option<(String, String)>) {
1004        (
1005            ChainVerifyResult {
1006                total_entries: self.total,
1007                valid: true,
1008                first_invalid_at: None,
1009                source_present: true,
1010                unknown_event_types: self.unknown,
1011            },
1012            agent_id.and_then(|id| self.pending.get(id).cloned()),
1013        )
1014    }
1015
1016    /// Verify one parsed entry and advance. `Err` leaves `total`/`unknown`
1017    /// at the values a report of the failure must carry.
1018    ///
1019    /// Every signature present is verified against the local `lean-ctx`
1020    /// key, not only the ones drift and seal entries must carry: a forged
1021    /// or foreign signature on an ordinary entry is tampering too. The key
1022    /// is loaded once per pass into `key`.
1023    fn advance(
1024        &mut self,
1025        value: serde_json::Value,
1026        key: &mut Option<Result<ed25519_dalek::VerifyingKey, ()>>,
1027    ) -> Result<(), ()> {
1028        let (Some(entry_prev), Some(entry_hash)) = (
1029            value.get("prev_hash").and_then(serde_json::Value::as_str),
1030            value.get("entry_hash").and_then(serde_json::Value::as_str),
1031        ) else {
1032            return Err(());
1033        };
1034        let partial = hashed_fields(&value).ok_or(())?;
1035        if entry_prev != self.prev_hash {
1036            return Err(());
1037        }
1038        let data_json = serde_json::to_string(&partial).unwrap_or_default();
1039        if entry_hash != compute_entry_hash(&self.prev_hash, &data_json) {
1040            return Err(());
1041        }
1042        let entry_hash = entry_hash.to_string();
1043        // v2 binds the timestamp and the session facts in `v2_hash`. A later
1044        // version is forward-compatible: its v1 link is verified above, the
1045        // rest is counted like an unknown event, never reported as tampering.
1046        let version = v2::version_of(&value).ok_or(())?;
1047        let newer_version = match version {
1048            0 => return Err(()),
1049            1 => false,
1050            v2::ENTRY_VERSION => {
1051                let fields = v2::v2_fields(&value).ok_or(())?;
1052                let claimed = value.get("v2_hash").and_then(serde_json::Value::as_str);
1053                if claimed != Some(v2::compute_v2_hash(self.head(), &fields).as_str()) {
1054                    return Err(());
1055                }
1056                false
1057            }
1058            _ => true,
1059        };
1060        let head = v2::head_of(&value, version).ok_or(())?;
1061        let entry = serde_json::from_value::<AuditEntry>(value).map_err(|_| ())?;
1062        let seal = is_seal(&entry.event_type);
1063        if (matches!(&entry.event_type, AuditEventType::Unknown(_)) && !seal) || newer_version {
1064            self.unknown += 1;
1065        }
1066        self.sealed = if seal {
1067            Some(segments::sealed_seq(entry.action.as_deref()).ok_or(())?)
1068        } else {
1069            None
1070        };
1071        let signature_required = seal
1072            || matches!(
1073                &entry.event_type,
1074                AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
1075            );
1076        if signature_required && entry.signature.is_none() {
1077            return Err(());
1078        }
1079        if let Some(signature) = entry.signature.as_deref() {
1080            let signature = crate::core::agent_identity::hex_decode(signature).map_err(|_| ())?;
1081            let Ok(public_key) = key
1082                .get_or_insert_with(|| {
1083                    crate::core::agent_identity::load_public_key("lean-ctx").map_err(|_| ())
1084                })
1085                .as_ref()
1086            else {
1087                return Err(());
1088            };
1089            if !crate::core::agent_identity::verify_signature(
1090                public_key.as_bytes(),
1091                entry.entry_hash.as_bytes(),
1092                &signature,
1093            ) {
1094                return Err(());
1095            }
1096        }
1097        if let Some((kind, n, _)) = entry
1098            .action
1099            .as_deref()
1100            .and_then(crate::core::security_events::parse_action)
1101        {
1102            self.security.record(kind, n, &entry.entry_hash);
1103            if kind.in_signed_tally() {
1104                self.tally.record(kind, n, &entry.entry_hash);
1105            }
1106        }
1107        match entry.event_type {
1108            AuditEventType::AgentDriftDetected => {
1109                if let Some(evidence) = entry.action {
1110                    self.pending.insert(
1111                        entry.agent_id,
1112                        (
1113                            entry.timestamp,
1114                            format!("{evidence} observation={}", entry.entry_hash),
1115                        ),
1116                    );
1117                }
1118            }
1119            AuditEventType::AgentDriftAcknowledged
1120                if self
1121                    .pending
1122                    .get(&entry.agent_id)
1123                    .is_some_and(|(_, evidence)| entry.action.as_ref() == Some(evidence)) =>
1124            {
1125                self.pending.remove(&entry.agent_id);
1126            }
1127            _ => {}
1128        }
1129        self.head = (head != entry_hash).then_some(head);
1130        self.prev_hash = entry_hash;
1131        self.total += 1;
1132        Ok(())
1133    }
1134
1135    /// The v2 chain head the next entry hashes after.
1136    fn head(&self) -> &str {
1137        self.head.as_deref().unwrap_or(&self.prev_hash)
1138    }
1139
1140    /// Verify every line of `reader` (the same line rules as
1141    /// `BufRead::lines`). Returns the bytes consumed and whether the input
1142    /// ended on a line boundary (empty input counts as one).
1143    fn verify_stream(&mut self, reader: &mut impl BufRead) -> Result<(u64, bool), ()> {
1144        let mut consumed = 0u64;
1145        let mut ends_on_newline = true;
1146        let mut buf = Vec::new();
1147        let mut key = None;
1148        loop {
1149            buf.clear();
1150            let read = reader.read_until(b'\n', &mut buf).map_err(|_| ())?;
1151            if read == 0 {
1152                return Ok((consumed, ends_on_newline));
1153            }
1154            consumed += read as u64;
1155            ends_on_newline = buf.last() == Some(&b'\n');
1156            if ends_on_newline {
1157                buf.pop();
1158                if buf.last() == Some(&b'\r') {
1159                    buf.pop();
1160                }
1161            }
1162            let line = std::str::from_utf8(&buf).map_err(|_| ())?;
1163            let mut saw_value = false;
1164            for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>()
1165            {
1166                saw_value = true;
1167                self.advance(parsed.map_err(|_| ())?, &mut key)?;
1168            }
1169            if !saw_value {
1170                return Err(());
1171            }
1172        }
1173    }
1174}
1175
1176/// Verify the trail at `path`. With `force_full == false` a signed,
1177/// identity-bound checkpoint of the last verified prefix (see
1178/// [`checkpoint`]) lets the verifier read only the appended suffix; any
1179/// doubt about the prefix falls back to a full pass. A full pass refreshes
1180/// the checkpoint.
1181fn verify_chain_mode(
1182    path: &Path,
1183    agent_id: Option<&str>,
1184    force_full: bool,
1185) -> (ChainVerifyResult, Option<(String, String)>) {
1186    verify_chain_mode_at(path, agent_id, force_full, chrono::Utc::now().timestamp())
1187}
1188
1189/// [`verify_chain_mode`] at an explicit wall-clock `now` (Unix seconds).
1190fn verify_chain_mode_at(
1191    path: &Path,
1192    agent_id: Option<&str>,
1193    force_full: bool,
1194    now: i64,
1195) -> (ChainVerifyResult, Option<(String, String)>) {
1196    match verify_state_at(path, force_full, now) {
1197        Verified::Missing => (
1198            ChainVerifyResult {
1199                total_entries: 0,
1200                valid: true,
1201                first_invalid_at: None,
1202                source_present: false,
1203                unknown_event_types: 0,
1204            },
1205            None,
1206        ),
1207        Verified::Unavailable => (unavailable(), None),
1208        Verified::Invalid(state) => state.invalid(),
1209        Verified::Valid(state) => state.valid(agent_id),
1210    }
1211}
1212
1213enum Verified {
1214    /// No trail file: a valid empty history.
1215    Missing,
1216    /// Unreadable or lock contention: fail closed, but not corruption.
1217    Unavailable,
1218    /// Broken at `state.total`.
1219    Invalid(ChainState),
1220    Valid(ChainState),
1221}
1222
1223/// Verify the trail at `path` through its sealed segments and the active
1224/// file, in chain order. The segment list is read before and after the
1225/// active file is opened, so a seal that lands in between is retried
1226/// instead of read as a gap.
1227fn verify_state_at(path: &Path, force_full: bool, now: i64) -> Verified {
1228    for _ in 0..SNAPSHOT_ATTEMPTS {
1229        let sealed = segments::sealed(path);
1230        let active = match fs::File::open(path) {
1231            Ok(file) => Some(file),
1232            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1233            Err(_) => return Verified::Unavailable,
1234        };
1235        if segments::sealed(path) == sealed {
1236            return verify_snapshot(path, &sealed, active, force_full, now);
1237        }
1238    }
1239    Verified::Unavailable
1240}
1241
1242fn verify_snapshot(
1243    path: &Path,
1244    sealed: &[(u64, PathBuf)],
1245    active: Option<fs::File>,
1246    force_full: bool,
1247    now: i64,
1248) -> Verified {
1249    if sealed.is_empty() && active.is_none() {
1250        return Verified::Missing;
1251    }
1252    // A sealed segment is never written again, so its checkpoint is trusted
1253    // past the 24 h full pass, except for one segment per day in rotation.
1254    let day = usize::try_from(now.div_euclid(24 * 60 * 60)).unwrap_or(0);
1255    let sample = day.checked_rem(sealed.len());
1256    let mut state = ChainState::genesis();
1257    for (index, (seq, segment)) in sealed.iter().enumerate() {
1258        let Ok(file) = fs::File::open(segment) else {
1259            return Verified::Unavailable;
1260        };
1261        let max_age = (Some(index) == sample).then_some(checkpoint::FULL_REVERIFY_SECS);
1262        state = match verify_file(segment, &file, &state, force_full, max_age, now) {
1263            // Each segment ends with the seal that names it.
1264            Verified::Valid(state) if state.sealed == Some(*seq) => state,
1265            Verified::Valid(state) => return Verified::Invalid(state),
1266            other => return other,
1267        };
1268    }
1269    match active {
1270        Some(file) => verify_file(
1271            path,
1272            &file,
1273            &state,
1274            force_full,
1275            Some(checkpoint::FULL_REVERIFY_SECS),
1276            now,
1277        ),
1278        None => Verified::Valid(state),
1279    }
1280}
1281
1282/// Verify one file of the trail, continuing from `start`: from its
1283/// checkpoint when one resumes (see [`checkpoint::Store::resume`]), else in
1284/// full.
1285fn verify_file(
1286    path: &Path,
1287    file: &fs::File,
1288    start: &ChainState,
1289    force_full: bool,
1290    max_age: Option<i64>,
1291    now: i64,
1292) -> Verified {
1293    use std::io::{Seek, SeekFrom};
1294
1295    let mut cursor = file;
1296    // Contention is not corruption: a reader that cannot get the lock in time
1297    // reports the trail unavailable (fail-closed), never invalid. Appends need
1298    // the exclusive lock, so the stamp taken under it describes exactly the
1299    // prefix `file[..len]` read below, which no append changes afterwards (see
1300    // `release_read_lock`); the pass itself runs without the lock.
1301    if lock_trail_shared(file).is_err() {
1302        return Verified::Unavailable;
1303    }
1304    let store = checkpoint::Store::for_trail(path);
1305    let stamp = checkpoint::FileStamp::of(file);
1306    let Some(len) = stamp
1307        .as_ref()
1308        .map(|stamp| stamp.size)
1309        .or_else(|| file.metadata().ok().map(|meta| meta.len()))
1310    else {
1311        return Verified::Unavailable;
1312    };
1313    release_read_lock(file);
1314
1315    if !force_full {
1316        if let (Some(store), Some(stamp)) = (&store, &stamp) {
1317            if let Some(resumed) = store.resume(file, stamp, start, max_age, now) {
1318                let mut state = resumed.state;
1319                if resumed.offset == stamp.size {
1320                    return Verified::Valid(state);
1321                }
1322                if cursor.seek(SeekFrom::Start(resumed.offset)).is_ok() {
1323                    let suffix = state.verify_stream(&mut std::io::BufReader::new(
1324                        file.take(len - resumed.offset),
1325                    ));
1326                    if !still_holds_prefix(file, len) {
1327                        return Verified::Unavailable;
1328                    }
1329                    if let Ok((read, clean)) = suffix {
1330                        if clean && resumed.offset + read == stamp.size {
1331                            store.save(file, stamp, start, &state, resumed.full_verified_at);
1332                        }
1333                        return Verified::Valid(state);
1334                    }
1335                }
1336                // A broken suffix is reported by a full pass, so the index
1337                // is the true first invalid entry even if the prefix changed.
1338            }
1339        }
1340    }
1341
1342    if cursor.seek(SeekFrom::Start(0)).is_err() {
1343        return Verified::Unavailable;
1344    }
1345    let mut state = start.clone();
1346    let pass = state.verify_stream(&mut std::io::BufReader::new(file.take(len)));
1347    // Cut in place while it was read: what the pass saw is no verdict.
1348    if !still_holds_prefix(file, len) {
1349        return Verified::Unavailable;
1350    }
1351    let Ok((read, clean)) = pass else {
1352        if let Some(store) = &store {
1353            store.discard();
1354        }
1355        return Verified::Invalid(state);
1356    };
1357    if let (Some(store), Some(stamp)) = (&store, &stamp) {
1358        if clean && read == stamp.size {
1359            store.save(file, stamp, start, &state, now);
1360        }
1361    }
1362    Verified::Valid(state)
1363}
1364
1365/// Security evidence a signed savings-batch tally rests on, aggregated while
1366/// the chain is verified: the counts of the kinds the tally carries and the
1367/// entry hashes of the entries they come from.
1368#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1369pub struct SignedTallyEvidence {
1370    pub counts: crate::core::security_events::SecurityCounts,
1371    pub entries: u64,
1372    pub first_hash: Option<String>,
1373    pub last_hash: Option<String>,
1374}
1375
1376impl SignedTallyEvidence {
1377    fn record(&mut self, kind: crate::core::security_events::SecurityKind, n: u64, hash: &str) {
1378        self.counts.add(kind, n);
1379        self.entries += 1;
1380        self.first_hash.get_or_insert_with(|| hash.to_string());
1381        self.last_hash = Some(hash.to_string());
1382    }
1383}
1384
1385/// The signed-tally evidence of the trail at `path`, from the same
1386/// (incremental) pass that verifies the chain. `None` when the trail fails
1387/// verification or is unavailable; a missing trail is an empty tally.
1388pub fn verified_signed_tally_at(path: &Path) -> Option<SignedTallyEvidence> {
1389    match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
1390        Verified::Missing => Some(SignedTallyEvidence::default()),
1391        Verified::Valid(state) => Some(state.tally),
1392        Verified::Unavailable | Verified::Invalid(_) => None,
1393    }
1394}
1395
1396/// The chain check of the trail at `path` and the security evidence of
1397/// every kind it holds, from one (incremental) verification: what the
1398/// lifetime value proof shows, without reading the trail a second time.
1399/// The evidence is `None` unless the chain verifies; a missing trail is an
1400/// empty, intact chain.
1401pub fn verified_security_at(path: &Path) -> (ChainVerifyResult, Option<SignedTallyEvidence>) {
1402    match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
1403        Verified::Missing => (
1404            ChainVerifyResult {
1405                total_entries: 0,
1406                valid: true,
1407                first_invalid_at: None,
1408                source_present: false,
1409                unknown_event_types: 0,
1410            },
1411            Some(SignedTallyEvidence::default()),
1412        ),
1413        Verified::Unavailable => (unavailable(), None),
1414        Verified::Invalid(state) => (state.invalid().0, None),
1415        Verified::Valid(state) => (state.valid(None).0, Some(state.security)),
1416    }
1417}
1418
1419/// Verify the default trail, resuming from the verified checkpoint when it
1420/// is still trustworthy (hot path: billing, dashboard, drift reconstruction).
1421pub fn verify_chain() -> ChainVerifyResult {
1422    verify_chain_with_drift(None).0
1423}
1424
1425/// Verify the default trail with a full pass from genesis, ignoring any
1426/// checkpoint. For explicit audit commands and signed compliance reports.
1427pub fn verify_chain_full() -> ChainVerifyResult {
1428    let Some(path) = trail_path() else {
1429        return unavailable();
1430    };
1431    verify_chain_mode(&path, None, true).0
1432}
1433
1434/// Verify the hash chain of the trail at an explicit `path` (incremental,
1435/// like [`verify_chain`]).
1436pub fn verify_chain_at(path: &Path) -> ChainVerifyResult {
1437    verify_chain_with_drift_at(path, None).0
1438}
1439
1440pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
1441    let serialized = serde_json::to_string(args).unwrap_or_default();
1442    let mut hasher = Sha256::new();
1443    hasher.update(serialized.as_bytes());
1444    crate::core::agent_identity::hex_encode(&hasher.finalize())
1445}
1446
1447#[cfg(test)]
1448#[path = "audit_trail_tests.rs"]
1449mod tests;