Skip to main content

ai_memory/
audit.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Enterprise audit trail (PR-5 of issue #487).
5//!
6//! Every memory-mutation call site in the binary — HTTP handlers, MCP
7//! tool dispatch, CLI write commands, and `ai-memory boot` — emits an
8//! [`AuditEvent`] to a hash-chained, append-only JSON log when the
9//! audit subsystem is enabled. The schema is **stable, versioned, and
10//! framework-agnostic** (NOT bound to OCSF or CEF — see
11//! `docs/security/audit-schema.md`). SIEMs ingest the lines as-is.
12//!
13//! # Design properties
14//!
15//! 1. **Default-OFF** for privacy. Operators opt in via
16//!    `[audit] enabled = true` in `config.toml` (or
17//!    `AI_MEMORY_AUDIT_DIR=<dir>` env var for one-off runs — see
18//!    `src/log_paths.rs::AUDIT_DIR_ENV` for the canonical name; the
19//!    audit log file is written as `<dir>/audit.log`).
20//! 2. **Hash-chained, tamper-evident.** Each line carries a `prev_hash`
21//!    that matches the prior line's `self_hash`. `ai-memory audit
22//!    verify` recomputes the chain and exits non-zero on mismatch.
23//! 3. **Append-only OS hint.** Best-effort `chflags(2)` (BSD/macOS) or
24//!    `FS_IOC_SETFLAGS` ioctl (Linux). Documented as defense in depth;
25//!    the chain is the load-bearing tamper-evidence.
26//! 4. **Privacy by default.** Audit captures `(memory_id, namespace,
27//!    title, action, outcome, actor)`. Memory **content is never
28//!    emitted** — `redact_content = true` is the only supported mode in
29//!    the v1 schema; the field is reserved in [`AuditTarget`] for
30//!    future compliance contexts that mandate content capture.
31//! 5. **Per-process monotonic sequence**, independent of the chain.
32//!    Lets a SIEM detect dropped lines even before the chain check.
33//! 6. **No backpressure on the caller.** Emission is synchronous (one
34//!    write per line so the chain is consistent across processes
35//!    concurrently appending — the file is opened with `O_APPEND`),
36//!    but failures inside emit are swallowed and logged via `tracing`.
37//!    A broken audit pipeline never blocks a memory operation.
38
39use std::fs::{File, OpenOptions};
40use std::io::{BufRead, BufReader, Read, Write};
41use std::path::{Path, PathBuf};
42use std::sync::Mutex;
43use std::sync::atomic::Ordering;
44
45use anyhow::{Context, Result, anyhow};
46use chrono::Utc;
47use serde::{Deserialize, Serialize};
48use sha2::{Digest, Sha256};
49
50use crate::runtime_context::RuntimeContext;
51
52/// Canonical `consolidate` operation label — shared by the audit op
53/// vocabulary, the autonomy rollback tags, and the governance action
54/// adapter (#1558 batch 6).
55pub(crate) const OP_CONSOLIDATE: &str = "consolidate";
56
57/// Stable schema version stamped on every emitted line. Bump only when
58/// a field's semantics change in a way SIEM parsers care about
59/// (renaming, removing, or repurposing). Adding optional fields does
60/// NOT bump the version. See `docs/security/audit-schema.md` §Version
61/// policy for the full contract.
62pub const SCHEMA_VERSION: u32 = 1;
63
64/// Sentinel `prev_hash` for the first line in a fresh chain. Hex-encoded
65/// 32-byte zero buffer — picked so a chain head is unambiguous on
66/// inspection.
67pub const CHAIN_HEAD_PREV_HASH: &str =
68    "0000000000000000000000000000000000000000000000000000000000000000";
69
70/// One audit event. The serialized form is one JSON object per line
71/// (NDJSON). Field order is stable for chain reproducibility.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct AuditEvent {
74    /// Schema version — see [`SCHEMA_VERSION`].
75    pub schema_version: u32,
76    /// RFC3339 UTC timestamp when the event was emitted.
77    pub timestamp: String,
78    /// Per-process monotonic counter starting at 1 on init.
79    pub sequence: u64,
80    pub actor: AuditActor,
81    pub action: AuditAction,
82    pub target: AuditTarget,
83    pub outcome: AuditOutcome,
84    /// Authentication context. `None` for stdio MCP / CLI invocations
85    /// where there is no transport-level auth.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub auth: Option<AuditAuth>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub session_id: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub request_id: Option<String>,
92    /// Populated only when `outcome = Error`. Capped at 256 chars to
93    /// prevent error-message based content leaks.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub error: Option<String>,
96    /// Hex-encoded sha256 of the immediately prior line's `self_hash`,
97    /// or [`CHAIN_HEAD_PREV_HASH`] for the first line of a fresh chain.
98    pub prev_hash: String,
99    /// Hex-encoded sha256 of every preceding field in serialization order.
100    pub self_hash: String,
101}
102
103/// Who performed the action.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct AuditActor {
106    /// Resolved NHI agent_id (`ai:<client>@<host>:pid-<n>`,
107    /// `host:<host>:pid-<n>-<uuid>`, etc.). Always present.
108    pub agent_id: String,
109    /// Visibility scope: `private | team | unit | org | collective`.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub scope: Option<String>,
112    /// How `agent_id` was synthesized — surfaces NHI provenance to the
113    /// SIEM. One of: `explicit | env | mcp_client_info | host_fallback
114    /// | anonymous_fallback | http_header | http_body | per_request`.
115    pub synthesis_source: String,
116}
117
118/// #1558 batch 5 wave 3 — canonical [`AuditActor::synthesis_source`]
119/// provenance values. One spelling per value; every production writer
120/// (MCP dispatch, MCP store/delete tools, HTTP handlers, CLI
121/// crud/store/update) references these consts instead of scattering
122/// the literal. The vocabulary doc on `synthesis_source` above stays
123/// the narrative SSOT; this mod is the mechanical one.
124pub mod synthesis_sources {
125    /// Caller passed an explicit `--agent-id` / `agent_id` param.
126    pub const EXPLICIT: &str = "explicit";
127    /// Resolved from `initialize.clientInfo.name` (MCP stdio).
128    pub const MCP_CLIENT_INFO: &str = "mcp_client_info";
129    /// Synthesized `host:<hostname>:pid-…` fallback (no client info).
130    pub const HOST_FALLBACK: &str = "host_fallback";
131    /// Taken from the `X-Agent-Id` HTTP request header.
132    pub const HTTP_HEADER: &str = "http_header";
133    /// No explicit caller identity — default resolution ladder.
134    pub const DEFAULT_FALLBACK: &str = "default_fallback";
135}
136
137/// Canonical action vocabulary. Adding a variant is a non-breaking
138/// schema change; renaming or removing one IS breaking.
139#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum AuditAction {
142    Recall,
143    Store,
144    Update,
145    Delete,
146    Link,
147    Promote,
148    Forget,
149    Consolidate,
150    Export,
151    Import,
152    Approve,
153    Reject,
154    SessionBoot,
155    /// L1 capture-nag (#1389 / #1398). Emitted by the MCP dispatch loop
156    /// when an agent crosses the consecutive-non-capture-tool-call
157    /// threshold without a `memory_store` / `memory_capture_turn`.
158    /// Informational (`outcome = Allow`); surfaces capture drift to the
159    /// SIEM in real time rather than at next-session recovery (L2).
160    CaptureLag,
161}
162
163impl AuditAction {
164    /// Wire-format string for log-grep convenience.
165    #[must_use]
166    pub fn as_str(&self) -> &'static str {
167        match self {
168            Self::Recall => "recall",
169            Self::Store => "store",
170            Self::Update => "update",
171            Self::Delete => "delete",
172            Self::Link => "link",
173            Self::Promote => "promote",
174            Self::Forget => "forget",
175            Self::Consolidate => OP_CONSOLIDATE,
176            Self::Export => "export",
177            Self::Import => "import",
178            Self::Approve => "approve",
179            Self::Reject => "reject",
180            Self::SessionBoot => "session_boot",
181            Self::CaptureLag => "capture_lag",
182        }
183    }
184}
185
186/// What was acted upon.
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct AuditTarget {
189    /// Memory id, or `"*"` for a list/sweep operation that touches
190    /// many rows (forget, export, consolidate-many, etc.).
191    pub memory_id: String,
192    /// Memory namespace at the time of the action.
193    pub namespace: String,
194    /// Memory title at the time of the action. Capped at 200 chars and
195    /// stripped of newlines to prevent log-injection. Title is **not**
196    /// content; titles are advisory labels by design (`memory.content`
197    /// is the secret payload and is **never** emitted).
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub title: Option<String>,
200    /// Memory tier (`short | mid | long`) at action time.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub tier: Option<String>,
203    /// Memory `metadata.scope` at action time.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub scope: Option<String>,
206}
207
208/// Outcome of the action.
209#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(rename_all = "snake_case")]
211pub enum AuditOutcome {
212    Allow,
213    Deny,
214    Error,
215    Pending,
216}
217
218/// Authentication context for HTTP-originated events. Stdio (CLI / MCP)
219/// invocations omit this block entirely.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct AuditAuth {
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub source_ip: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub mtls_fp: Option<String>,
226    /// **Hash** of the API key id, never the raw key. Hex-encoded
227    /// sha256 truncated to 16 bytes.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub api_key_id_hash: Option<String>,
230}
231
232// ---------------------------------------------------------------------------
233// Sink — process-wide singleton holding the file handle + chain head.
234// ---------------------------------------------------------------------------
235
236// v0.7.x (issue #1174 follow-up #1192) — sink + sequence moved into
237// `RuntimeContext::audit`. The accessors below preserve byte-equivalent
238// semantics: every read goes through `RuntimeContext::global().audit.*`
239// so the V-4 hash chain invariant + the F2 sequence-restart invariant
240// are observed identically by `init`, `emit`, `verify_chain`, and the
241// `init_for_test` / `shutdown_for_test` helpers.
242
243/// Initialised audit sink — writer handle protected by a mutex so the
244/// chain head update + write are atomic across emission threads. The
245/// writer is `dyn Write + Send` so tests can substitute an in-memory
246/// `Vec<u8>` for the production `File`.
247pub struct AuditSink {
248    inner: Mutex<SinkInner>,
249    #[allow(dead_code)]
250    redact_content: bool,
251}
252
253impl std::fmt::Debug for AuditSink {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("AuditSink")
256            .field("redact_content", &self.redact_content)
257            .finish_non_exhaustive()
258    }
259}
260
261struct SinkInner {
262    writer: Box<dyn Write + Send>,
263    /// `self_hash` of the last line written, used as the next line's
264    /// `prev_hash`. Starts as [`CHAIN_HEAD_PREV_HASH`] for a fresh log.
265    last_hash: String,
266    /// Source path, when the sink wraps a real file. `None` for
267    /// in-memory test sinks.
268    #[allow(dead_code)]
269    path: Option<PathBuf>,
270}
271
272/// Initialise the audit sink. Called at most once per process from
273/// [`init_from_config`]; subsequent calls replace the prior sink so
274/// test-only callers can swap targets.
275///
276/// # Errors
277/// - The audit directory cannot be created.
278/// - The audit log file cannot be opened in append mode.
279/// - Reading the existing chain tail (to seed `last_hash`) fails.
280pub fn init(path: &Path, redact_content: bool, append_only_hint: bool) -> Result<()> {
281    if let Some(parent) = path.parent()
282        && !parent.as_os_str().is_empty()
283    {
284        std::fs::create_dir_all(parent)
285            .with_context(|| format!("creating audit log dir {}", parent.display()))?;
286    }
287
288    // Seed the chain head from the existing tail of the log so a
289    // restart on an existing file continues the chain.
290    //
291    // **F2 (v0.7.0 round-2-fixes):** also seed the per-process
292    // SEQUENCE counter from the trailing record's `sequence` so the
293    // next emit produces `last_sequence + 1`, monotonic across
294    // daemon restarts. Pre-fix the SEQUENCE was reset to 0 here,
295    // which made `audit verify` flag "sequence not monotonic:
296    // prior=N, this=1" on the first event after every restart —
297    // the hash chain was intact but the sequence integer reset.
298    let (last_hash, last_sequence) = match read_chain_tail(path) {
299        Ok(Some((hash, seq))) => (hash, seq),
300        _ => (CHAIN_HEAD_PREV_HASH.to_string(), 0),
301    };
302
303    let file = OpenOptions::new()
304        .create(true)
305        .append(true)
306        .open(path)
307        .with_context(|| format!("opening audit log {}", path.display()))?;
308
309    if append_only_hint {
310        // Best-effort. Errors here are documented and informational —
311        // the hash chain is the load-bearing tamper-evidence.
312        if let Err(e) = mark_append_only(path) {
313            tracing::warn!(
314                "audit: append-only OS flag could not be set on {} ({e}); \
315                 the hash chain remains the authoritative tamper-evidence",
316                path.display()
317            );
318        }
319    }
320
321    let sink = AuditSink {
322        inner: Mutex::new(SinkInner {
323            writer: Box::new(file),
324            last_hash,
325            path: Some(path.to_path_buf()),
326        }),
327        redact_content,
328    };
329
330    let audit = &RuntimeContext::global().audit;
331    audit.sequence.store(last_sequence, Ordering::SeqCst);
332    if let Ok(mut guard) = audit.sink.write() {
333        *guard = Some(std::sync::Arc::new(sink));
334    }
335    Ok(())
336}
337
338/// Test-only helper: install an in-memory sink that captures every
339/// emitted line into the supplied `Arc<Mutex<Vec<u8>>>`. Bypasses the
340/// filesystem entirely so tests run in any sandbox.
341#[cfg(test)]
342pub fn init_for_test(buf: std::sync::Arc<Mutex<Vec<u8>>>) {
343    struct VecWriter(std::sync::Arc<Mutex<Vec<u8>>>);
344    impl Write for VecWriter {
345        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
346            self.0
347                .lock()
348                .expect("test sink poisoned")
349                .extend_from_slice(data);
350            Ok(data.len())
351        }
352        fn flush(&mut self) -> std::io::Result<()> {
353            Ok(())
354        }
355    }
356    let sink = AuditSink {
357        inner: Mutex::new(SinkInner {
358            writer: Box::new(VecWriter(buf)),
359            last_hash: CHAIN_HEAD_PREV_HASH.to_string(),
360            path: None,
361        }),
362        redact_content: true,
363    };
364    let audit = &RuntimeContext::global().audit;
365    audit.sequence.store(0, Ordering::SeqCst);
366    if let Ok(mut guard) = audit.sink.write() {
367        *guard = Some(std::sync::Arc::new(sink));
368    }
369}
370
371/// Process-wide lock serialising any test that installs or removes the
372/// global audit sink. The sink lives on the shared [`RuntimeContext`],
373/// so tests across modules (audit's own + the `mcp` dispatch tests that
374/// exercise `capture_lag` emission) MUST hold this for their duration or
375/// they stomp each other's buffers and hash chains.
376#[cfg(test)]
377pub(crate) fn sink_test_lock() -> std::sync::MutexGuard<'static, ()> {
378    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
379    LOCK.get_or_init(|| std::sync::Mutex::new(()))
380        .lock()
381        .unwrap_or_else(|p| p.into_inner())
382}
383
384/// Test-only helper to remove the active sink so subsequent emissions
385/// no-op.
386#[cfg(test)]
387pub fn shutdown_for_test() {
388    let audit = &RuntimeContext::global().audit;
389    if let Ok(mut guard) = audit.sink.write() {
390        *guard = None;
391    }
392    audit.sequence.store(0, Ordering::SeqCst);
393}
394
395/// Read the last `(self_hash, sequence)` pair from an existing audit
396/// log. Returns `Ok(None)` when the file is empty or doesn't exist;
397/// returns the `self_hash` and `sequence` of the last well-formed line
398/// otherwise. A malformed trailing line counts as "empty" — emission
399/// seeds a fresh chain head, and `audit verify` will surface the
400/// corruption.
401///
402/// **F2 (v0.7.0 round-2-fixes):** the return tuple is consumed by
403/// [`init`] to seed both `last_hash` (chain continuity) AND the
404/// per-process `SEQUENCE` counter (monotonicity across restarts).
405///
406/// **M14 (v0.7.0 round-2-fixes):** while walking the file we also
407/// surface out-of-order sequence numbers via `tracing::warn!`. A line
408/// with sequence N followed by a later line with sequence < N is
409/// presumptive corruption — could be a partial replay, a manual edit,
410/// or a clock skew on a multi-writer host. We do NOT refuse to start:
411/// the hash chain is the authoritative tamper signal and the operator
412/// may have intentional gaps from `audit truncate` (off-spec but
413/// possible). The WARN goes to journalctl + SIEM where a human can
414/// triage. The exact pair of `(prior_seq, this_seq)` is included so
415/// the operator can grep the file for the offending line.
416fn read_chain_tail(path: &Path) -> Result<Option<(String, u64)>> {
417    if !path.exists() {
418        return Ok(None);
419    }
420    let file = File::open(path)?;
421    let reader = BufReader::new(file);
422    let mut last: Option<(String, u64)> = None;
423    let mut prior_seq: Option<u64> = None;
424    for line in reader.lines() {
425        let line = line?;
426        if line.trim().is_empty() {
427            continue;
428        }
429        if let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) {
430            // M14: surface out-of-order seqnums. A higher prior_seq
431            // followed by a lower this_seq is the corruption signal —
432            // equal seqnums are *also* a violation (duplicate emit),
433            // so we warn on `<=` not `<`. The first record establishes
434            // the baseline (prior_seq = None) and never trips here.
435            if let Some(prev) = prior_seq
436                && prev >= ev.sequence
437            {
438                tracing::warn!(
439                    target: "ai_memory::audit",
440                    prior_seq = prev,
441                    this_seq = ev.sequence,
442                    path = %path.display(),
443                    "audit: out-of-order sequence number detected on init scan \
444                     (prior {prev} >= this {this}). Hash-chain integrity is the \
445                     authoritative tamper signal; verify with `ai-memory audit verify`.",
446                    prev = prev,
447                    this = ev.sequence
448                );
449            }
450            prior_seq = Some(ev.sequence);
451            last = Some((ev.self_hash, ev.sequence));
452        }
453    }
454    Ok(last)
455}
456
457/// Whether the audit subsystem is currently enabled. Cheap.
458#[must_use]
459pub fn is_enabled() -> bool {
460    RuntimeContext::global()
461        .audit
462        .sink
463        .read()
464        .map(|g| g.is_some())
465        .unwrap_or(false)
466}
467
468// ---------------------------------------------------------------------------
469// Hashing — stable canonical form so emit + verify agree byte-for-byte.
470// ---------------------------------------------------------------------------
471
472/// Compute the canonical hash for an event. Hashes the same JSON the
473/// emitter writes to disk EXCEPT with `self_hash` set to the empty
474/// string sentinel — this lets `audit verify` recompute it from the
475/// stored line by zeroing the same field.
476fn compute_self_hash(ev: &AuditEvent) -> String {
477    let canonical = canonical_json_for_hash(ev);
478    let mut hasher = Sha256::new();
479    hasher.update(canonical.as_bytes());
480    hex_encode(&hasher.finalize())
481}
482
483/// Serialize an event into the canonical pre-hash form: serde_json
484/// representation with `self_hash` zeroed. The `prev_hash` is part of
485/// the hashed input — that's exactly the linkage that makes the chain
486/// tamper-evident.
487fn canonical_json_for_hash(ev: &AuditEvent) -> String {
488    let mut clone = ev.clone();
489    clone.self_hash.clear();
490    serde_json::to_string(&clone).expect("AuditEvent always serializes")
491}
492
493fn hex_encode(bytes: &[u8]) -> String {
494    static HEX: &[u8; 16] = b"0123456789abcdef";
495    let mut out = String::with_capacity(bytes.len() * 2);
496    for b in bytes {
497        out.push(HEX[(b >> 4) as usize] as char);
498        out.push(HEX[(b & 0x0f) as usize] as char);
499    }
500    out
501}
502
503// ---------------------------------------------------------------------------
504// Emission API — the surface the rest of the binary calls.
505// ---------------------------------------------------------------------------
506
507/// Builder for an audit event. Most call sites use one of the
508/// convenience helpers ([`emit_store`], [`emit_recall`], etc.) but the
509/// builder is public so unusual flows (consolidate-many, deferred
510/// import) can fill in custom targets.
511#[derive(Debug, Clone)]
512pub struct EventBuilder {
513    pub action: AuditAction,
514    pub actor: AuditActor,
515    pub target: AuditTarget,
516    pub outcome: AuditOutcome,
517    pub auth: Option<AuditAuth>,
518    pub session_id: Option<String>,
519    pub request_id: Option<String>,
520    pub error: Option<String>,
521}
522
523impl EventBuilder {
524    /// Build a default-shaped event for `action`. Caller fills in the
525    /// remaining fields.
526    #[must_use]
527    pub fn new(action: AuditAction, actor: AuditActor, target: AuditTarget) -> Self {
528        Self {
529            action,
530            actor,
531            target,
532            outcome: AuditOutcome::Allow,
533            auth: None,
534            session_id: None,
535            request_id: None,
536            error: None,
537        }
538    }
539
540    /// Override outcome (default = Allow).
541    #[must_use]
542    pub fn outcome(mut self, outcome: AuditOutcome) -> Self {
543        self.outcome = outcome;
544        self
545    }
546
547    /// Set the error string. Caps at 256 chars and strips newlines so a
548    /// runaway error message can't leak content or break the log line.
549    #[must_use]
550    pub fn error(mut self, msg: impl Into<String>) -> Self {
551        self.error = Some(sanitize_field(&msg.into(), 256));
552        self.outcome = AuditOutcome::Error;
553        self
554    }
555
556    #[must_use]
557    pub fn auth(mut self, auth: AuditAuth) -> Self {
558        self.auth = Some(auth);
559        self
560    }
561
562    #[must_use]
563    pub fn request_id(mut self, id: impl Into<String>) -> Self {
564        self.request_id = Some(id.into());
565        self
566    }
567}
568
569/// Write an event to the configured sink. No-op when audit is disabled.
570/// Failures are logged via `tracing::error!` and dropped — audit is
571/// **never** allowed to fail a memory operation.
572pub fn emit(builder: EventBuilder) {
573    if let Err(e) = try_emit(builder) {
574        tracing::error!("audit: emission failed: {e}");
575    }
576}
577
578/// Inner emission with proper `Result` so tests can assert directly on
579/// the writer. `emit` swallows errors so production never blocks.
580fn try_emit(builder: EventBuilder) -> Result<()> {
581    let audit = &RuntimeContext::global().audit;
582    let sink = {
583        let guard = audit
584            .sink
585            .read()
586            .map_err(|_| anyhow!("audit sink rwlock poisoned"))?;
587        match guard.as_ref() {
588            Some(s) => s.clone(),
589            None => return Ok(()),
590        }
591    };
592
593    let mut inner = sink
594        .inner
595        .lock()
596        .map_err(|_| anyhow!("audit sink mutex poisoned"))?;
597
598    let sequence = audit.sequence.fetch_add(1, Ordering::SeqCst) + 1;
599
600    let mut ev = AuditEvent {
601        schema_version: SCHEMA_VERSION,
602        timestamp: Utc::now().to_rfc3339(),
603        sequence,
604        actor: builder.actor,
605        action: builder.action,
606        target: AuditTarget {
607            memory_id: sanitize_field(&builder.target.memory_id, 128),
608            namespace: sanitize_field(&builder.target.namespace, 128),
609            title: builder.target.title.map(|t| sanitize_field(&t, 200)),
610            tier: builder.target.tier,
611            scope: builder.target.scope,
612        },
613        outcome: builder.outcome,
614        auth: builder.auth,
615        session_id: builder.session_id,
616        request_id: builder.request_id,
617        error: builder.error,
618        prev_hash: inner.last_hash.clone(),
619        self_hash: String::new(),
620    };
621
622    let self_hash = compute_self_hash(&ev);
623    ev.self_hash = self_hash.clone();
624
625    let line = serde_json::to_string(&ev).context("serializing audit event")?;
626    writeln!(inner.writer, "{line}").context("appending audit line")?;
627    inner.writer.flush().ok();
628    inner.last_hash = self_hash;
629    Ok(())
630}
631
632/// Sanitize a field for log emission: strip control chars + newlines
633/// (prevent log injection) and cap to `max_chars` (prevent unbounded
634/// growth from a hostile title or error message).
635fn sanitize_field(s: &str, max_chars: usize) -> String {
636    let cleaned: String = s
637        .chars()
638        .filter(|c| !c.is_control() || *c == '\t')
639        .collect();
640    if cleaned.chars().count() <= max_chars {
641        cleaned
642    } else {
643        cleaned.chars().take(max_chars).collect()
644    }
645}
646
647// ---------------------------------------------------------------------------
648// Convenience helpers.
649// ---------------------------------------------------------------------------
650
651/// Construct an [`AuditActor`] from an agent_id + synthesis source +
652/// optional scope. The synthesis source is informational metadata and
653/// MUST be one of the documented strings in [`AuditActor`].
654#[must_use]
655pub fn actor(
656    agent_id: impl Into<String>,
657    synthesis_source: impl Into<String>,
658    scope: Option<String>,
659) -> AuditActor {
660    AuditActor {
661        agent_id: agent_id.into(),
662        synthesis_source: synthesis_source.into(),
663        scope,
664    }
665}
666
667/// Construct an [`AuditTarget`] for a single memory.
668#[must_use]
669pub fn target_memory(
670    memory_id: impl Into<String>,
671    namespace: impl Into<String>,
672    title: Option<String>,
673    tier: Option<String>,
674    scope: Option<String>,
675) -> AuditTarget {
676    AuditTarget {
677        memory_id: memory_id.into(),
678        namespace: namespace.into(),
679        title,
680        tier,
681        scope,
682    }
683}
684
685/// Construct an [`AuditTarget`] for a multi-row sweep operation.
686#[must_use]
687pub fn target_sweep(namespace: impl Into<String>) -> AuditTarget {
688    AuditTarget {
689        memory_id: "*".to_string(),
690        namespace: namespace.into(),
691        title: None,
692        tier: None,
693        scope: None,
694    }
695}
696
697// ---------------------------------------------------------------------------
698// Verify — the load-bearing tamper-evidence walk.
699// ---------------------------------------------------------------------------
700
701/// Outcome of [`verify_chain`].
702#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct VerifyReport {
704    pub total_lines: u64,
705    pub first_failure: Option<VerifyFailure>,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq)]
709pub struct VerifyFailure {
710    pub line_number: u64,
711    pub kind: VerifyFailureKind,
712    pub detail: String,
713}
714
715/// Why the per-line audit-event hash chain (`AuditEvent` JSONL files
716/// under `audit/`) failed to verify.
717///
718/// # Disambiguation (issue #970)
719///
720/// A sibling enum
721/// [`crate::governance::audit::VerifyFailureKind`] exists for the
722/// **governance forensic-bundle chain** (Ed25519-signed
723/// `ForensicDecision` rows in `signed_events`). Despite the shared
724/// name, the two enums verify different chain shapes and have
725/// different variant sets:
726///
727/// - `audit::VerifyFailureKind` (this enum): `Parse`, `SelfHash`,
728///   `ChainBreak`, `Sequence`. The audit chain hashes each line's
729///   canonical bytes (`SelfHash`) and verifies a monotonically
730///   increasing `sequence` (`Sequence`). It does NOT sign rows
731///   individually.
732/// - `governance::audit::VerifyFailureKind`: `Parse`, `ChainBreak`,
733///   `Signature`. The forensic chain signs each row with an
734///   Ed25519 key (`Signature`) and verifies the cross-row hash
735///   pointer (`ChainBreak`). It has no per-line `SelfHash`
736///   (signature verification subsumes it) and no `Sequence`
737///   variant (sequence is a SQLite column, not a line counter).
738///
739/// They are call-site-disambiguated by their module path. See
740/// `docs/internal/enum-proliferation-audit-970.md`.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub enum VerifyFailureKind {
743    /// Line could not be parsed as an `AuditEvent`.
744    Parse,
745    /// Recomputed `self_hash` did not match the stored value.
746    SelfHash,
747    /// Stored `prev_hash` did not match the prior line's `self_hash`.
748    ChainBreak,
749    /// `sequence` did not increase monotonically.
750    Sequence,
751}
752
753impl VerifyReport {
754    /// Convenience — `Ok(())` when chain is intact, `Err` when not.
755    pub fn into_result(self) -> Result<u64> {
756        if let Some(failure) = self.first_failure {
757            Err(anyhow!(
758                "audit chain verification failed at line {}: {:?} — {}",
759                failure.line_number,
760                failure.kind,
761                failure.detail
762            ))
763        } else {
764            Ok(self.total_lines)
765        }
766    }
767}
768
769/// Walk an audit log file and verify the chain. Returns a structured
770/// report; the binary's `audit verify` subcommand turns this into an
771/// exit code.
772///
773/// # Errors
774/// - The file cannot be opened or read.
775pub fn verify_chain(path: &Path) -> Result<VerifyReport> {
776    let file = File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
777    verify_chain_from_reader(file)
778}
779
780/// Verify a chain from any [`Read`] source. Lets tests run against
781/// in-memory buffers without touching the filesystem.
782pub fn verify_chain_from_reader<R: Read>(reader: R) -> Result<VerifyReport> {
783    let buf = BufReader::new(reader);
784    let mut total: u64 = 0;
785    let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
786    let mut prev_seq: u64 = 0;
787
788    for (idx, line) in buf.lines().enumerate() {
789        let line_no = (idx as u64) + 1;
790        let line = line.with_context(|| format!("reading audit line {line_no}"))?;
791        if line.trim().is_empty() {
792            continue;
793        }
794        total += 1;
795
796        let ev: AuditEvent = match serde_json::from_str(&line) {
797            Ok(e) => e,
798            Err(e) => {
799                return Ok(VerifyReport {
800                    total_lines: total,
801                    first_failure: Some(VerifyFailure {
802                        line_number: line_no,
803                        kind: VerifyFailureKind::Parse,
804                        detail: format!("malformed JSON: {e}"),
805                    }),
806                });
807            }
808        };
809
810        if ev.prev_hash != prev_hash {
811            return Ok(VerifyReport {
812                total_lines: total,
813                first_failure: Some(VerifyFailure {
814                    line_number: line_no,
815                    kind: VerifyFailureKind::ChainBreak,
816                    detail: format!(
817                        "prev_hash mismatch: expected {prev_hash}, got {}",
818                        ev.prev_hash
819                    ),
820                }),
821            });
822        }
823
824        if ev.sequence <= prev_seq && prev_seq != 0 {
825            return Ok(VerifyReport {
826                total_lines: total,
827                first_failure: Some(VerifyFailure {
828                    line_number: line_no,
829                    kind: VerifyFailureKind::Sequence,
830                    detail: format!(
831                        "sequence not monotonic: prior={prev_seq}, this={}",
832                        ev.sequence
833                    ),
834                }),
835            });
836        }
837
838        let recomputed = compute_self_hash(&ev);
839        if recomputed != ev.self_hash {
840            return Ok(VerifyReport {
841                total_lines: total,
842                first_failure: Some(VerifyFailure {
843                    line_number: line_no,
844                    kind: VerifyFailureKind::SelfHash,
845                    detail: format!(
846                        "self_hash mismatch: stored={}, recomputed={}",
847                        ev.self_hash, recomputed
848                    ),
849                }),
850            });
851        }
852
853        prev_hash = ev.self_hash.clone();
854        prev_seq = ev.sequence;
855    }
856
857    Ok(VerifyReport {
858        total_lines: total,
859        first_failure: None,
860    })
861}
862
863// ---------------------------------------------------------------------------
864// Bootstrap — read AppConfig and bring the sink up.
865// ---------------------------------------------------------------------------
866
867/// Initialise the audit sink from a parsed [`crate::config::AuditConfig`].
868/// Returns `Ok(())` whether or not audit is enabled — it is a no-op when
869/// disabled.
870///
871/// # Errors
872/// - The audit directory or file cannot be opened.
873pub fn init_from_config(cfg: &crate::config::AuditConfig) -> Result<()> {
874    if !cfg.enabled.unwrap_or(false) {
875        if let Ok(mut guard) = RuntimeContext::global().audit.sink.write() {
876            *guard = None;
877        }
878        return Ok(());
879    }
880    let resolved_path = resolve_audit_path(cfg);
881    init(
882        &resolved_path,
883        cfg.redact_content.unwrap_or(true),
884        cfg.append_only.unwrap_or(true),
885    )
886}
887
888/// Resolve the audit log file path from the config, honouring the
889/// user-mandated precedence ladder: CLI > env (`AI_MEMORY_AUDIT_DIR`)
890/// > `[audit] path` in config > platform default. Appends `audit.log`
891/// when the resolved path looks like a directory.
892///
893/// Backwards-compatible wrapper that doesn't take a CLI override —
894/// subcommand wiring uses [`resolve_audit_path_with_override`].
895#[must_use]
896pub fn resolve_audit_path(cfg: &crate::config::AuditConfig) -> PathBuf {
897    let resolved = crate::log_paths::resolve_audit_dir(None, cfg.path.as_deref())
898        .map(|r| r.path)
899        .unwrap_or_else(|_| {
900            crate::log_paths::platform_default(crate::log_paths::DirKind::Audit).path
901        });
902    finalize_audit_file(resolved, cfg.path.as_deref())
903}
904
905/// Strict variant: takes an optional `--audit-dir` override, returns
906/// the resolved file path (with `audit.log` appended when the input
907/// resolves to a directory) plus the [`crate::log_paths::PathSource`]
908/// used.
909///
910/// # Errors
911/// - Resolved directory is world-writable.
912pub fn resolve_audit_path_with_override(
913    cli_override: Option<&Path>,
914    cfg: &crate::config::AuditConfig,
915) -> Result<(PathBuf, crate::log_paths::PathSource)> {
916    let r = crate::log_paths::resolve_audit_dir(cli_override, cfg.path.as_deref())?;
917    let final_path = finalize_audit_file(r.path, cfg.path.as_deref());
918    Ok((final_path, r.source))
919}
920
921/// Append `audit.log` when the resolved path is a directory; respect
922/// an explicit file-path the user wrote in config.
923fn finalize_audit_file(p: PathBuf, raw_config: Option<&str>) -> PathBuf {
924    // If the user configured an explicit file path (has a non-empty
925    // extension that isn't a trailing slash), keep it as-is.
926    if let Some(raw) = raw_config
927        && !raw.ends_with('/')
928        && std::path::Path::new(raw).extension().is_some()
929    {
930        return p;
931    }
932    if p.extension().is_none() || p.to_string_lossy().ends_with('/') {
933        p.join("audit.log")
934    } else {
935        p
936    }
937}
938
939pub(crate) fn expand_tilde(raw: &str) -> String {
940    if let Some(rest) = raw.strip_prefix("~/")
941        && let Ok(home) = std::env::var("HOME")
942    {
943        return format!("{home}/{rest}");
944    }
945    raw.to_string()
946}
947
948// ---------------------------------------------------------------------------
949// Append-only OS hint — best effort.
950// ---------------------------------------------------------------------------
951
952/// Apply the platform-appropriate "append-only" file flag. Silent on
953/// non-unix platforms.
954#[cfg(unix)]
955fn mark_append_only(path: &Path) -> Result<()> {
956    use std::ffi::CString;
957    use std::os::unix::ffi::OsStrExt;
958
959    let c_path =
960        CString::new(path.as_os_str().as_bytes()).context("path contains an interior NUL byte")?;
961    #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
962    {
963        // SAFETY: c_path is a NUL-terminated string we own; chflags is
964        // a libc syscall whose only safety obligation is a valid C
965        // string. UF_APPEND is the user-visible append-only flag.
966        let rc = unsafe { libc::chflags(c_path.as_ptr(), libc::UF_APPEND.into()) };
967        if rc != 0 {
968            return Err(anyhow!(
969                "chflags(UF_APPEND) failed: errno={}",
970                std::io::Error::last_os_error()
971            ));
972        }
973        return Ok(());
974    }
975    #[cfg(target_os = "linux")]
976    {
977        // On Linux we'd issue FS_IOC_SETFLAGS with FS_APPEND_FL. The
978        // syscall requires CAP_LINUX_IMMUTABLE on most filesystems and
979        // is filesystem-specific (ext*, xfs, btrfs); refuse silently
980        // on filesystems that don't support it. This is a best-effort
981        // hint — the chain is the load-bearing tamper-evidence.
982        const FS_APPEND_FL: libc::c_int = 0x0000_0020;
983        // FS_IOC_SETFLAGS = _IOW('f', 2, long) = 0x4008_6602 on most
984        // 64-bit Linux ABIs. Hard-coded to avoid pulling in an extra
985        // crate just for the constant.
986        const FS_IOC_SETFLAGS: libc::c_ulong = 0x4008_6602;
987        let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
988        if fd < 0 {
989            return Err(anyhow!(
990                "open(audit log) for ioctl failed: errno={}",
991                std::io::Error::last_os_error()
992            ));
993        }
994        let mut flags: libc::c_int = 0;
995        // SAFETY: fd is a valid file descriptor we just opened; the
996        // ioctl call follows the documented FS_IOC_GETFLAGS / SETFLAGS
997        // protocol.
998        let rc = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
999        if rc == 0 {
1000            flags |= FS_APPEND_FL;
1001            let rc2 = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
1002            unsafe { libc::close(fd) };
1003            if rc2 != 0 {
1004                return Err(anyhow!(
1005                    "ioctl(FS_IOC_SETFLAGS) failed: errno={}",
1006                    std::io::Error::last_os_error()
1007                ));
1008            }
1009            return Ok(());
1010        }
1011        unsafe { libc::close(fd) };
1012        Err(anyhow!(
1013            "ioctl(FS_IOC_GETFLAGS) failed: errno={}",
1014            std::io::Error::last_os_error()
1015        ))
1016    }
1017    #[cfg(not(any(
1018        target_os = "macos",
1019        target_os = "freebsd",
1020        target_os = "openbsd",
1021        target_os = "linux"
1022    )))]
1023    {
1024        let _ = c_path;
1025        Err(anyhow!(
1026            "append-only flag not supported on this unix variant"
1027        ))
1028    }
1029}
1030
1031#[cfg(not(unix))]
1032fn mark_append_only(_path: &Path) -> Result<()> {
1033    Err(anyhow!("append-only flag is unix-only"))
1034}
1035
1036// ---------------------------------------------------------------------------
1037// Tests.
1038// ---------------------------------------------------------------------------
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043    use crate::models::Tier;
1044
1045    fn sample_event(seq: u64, prev: &str) -> AuditEvent {
1046        let mut ev = AuditEvent {
1047            schema_version: SCHEMA_VERSION,
1048            timestamp: "2026-04-30T00:00:00+00:00".to_string(),
1049            sequence: seq,
1050            actor: actor("ai:test@host:pid-1", "host_fallback", None),
1051            action: AuditAction::Store,
1052            target: target_memory(
1053                format!("mem-{seq}"),
1054                "ns-x",
1055                Some("title".to_string()),
1056                Some(Tier::Mid.as_str().to_string()),
1057                None,
1058            ),
1059            outcome: AuditOutcome::Allow,
1060            auth: None,
1061            session_id: None,
1062            request_id: None,
1063            error: None,
1064            prev_hash: prev.to_string(),
1065            self_hash: String::new(),
1066        };
1067        ev.self_hash = compute_self_hash(&ev);
1068        ev
1069    }
1070
1071    #[test]
1072    fn audit_event_round_trips_through_serde() {
1073        let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
1074        let s = serde_json::to_string(&ev).unwrap();
1075        let back: AuditEvent = serde_json::from_str(&s).unwrap();
1076        assert_eq!(back, ev);
1077        assert_eq!(back.schema_version, SCHEMA_VERSION);
1078    }
1079
1080    #[test]
1081    fn audit_chain_links_correctly_for_three_events() {
1082        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1083        let e2 = sample_event(2, &e1.self_hash);
1084        let e3 = sample_event(3, &e2.self_hash);
1085        let mut buf = String::new();
1086        for ev in [&e1, &e2, &e3] {
1087            buf.push_str(&serde_json::to_string(ev).unwrap());
1088            buf.push('\n');
1089        }
1090        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1091        assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
1092        assert_eq!(report.total_lines, 3);
1093    }
1094
1095    #[test]
1096    fn audit_verify_detects_tampered_line() {
1097        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1098        let mut e2 = sample_event(2, &e1.self_hash);
1099        // Tamper: swap the title without recomputing self_hash.
1100        e2.target.title = Some("EVIL".to_string());
1101        let e3 = sample_event(3, &e2.self_hash);
1102        let mut buf = String::new();
1103        for ev in [&e1, &e2, &e3] {
1104            buf.push_str(&serde_json::to_string(ev).unwrap());
1105            buf.push('\n');
1106        }
1107        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1108        let failure = report.first_failure.expect("tampering must be detected");
1109        assert_eq!(failure.line_number, 2);
1110        assert!(matches!(failure.kind, VerifyFailureKind::SelfHash));
1111    }
1112
1113    #[test]
1114    fn audit_verify_detects_chain_break() {
1115        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1116        // Break: e2's prev_hash points at a hash that isn't e1's.
1117        let e2 = sample_event(2, "deadbeef");
1118        let mut buf = String::new();
1119        for ev in [&e1, &e2] {
1120            buf.push_str(&serde_json::to_string(ev).unwrap());
1121            buf.push('\n');
1122        }
1123        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1124        let failure = report.first_failure.expect("chain break must be detected");
1125        assert!(matches!(failure.kind, VerifyFailureKind::ChainBreak));
1126    }
1127
1128    #[test]
1129    fn audit_redacts_content_by_default() {
1130        // The schema does not have a `content` field. This test
1131        // doubles as a guardrail: if anyone ever adds one to
1132        // AuditEvent or AuditTarget, the round-trip assertion below
1133        // will surface it.
1134        let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
1135        let json = serde_json::to_value(&ev).unwrap();
1136        assert!(
1137            json.get("content").is_none(),
1138            "AuditEvent must never carry a content field"
1139        );
1140        assert!(
1141            json["target"].get("content").is_none(),
1142            "AuditTarget must never carry a content field"
1143        );
1144    }
1145
1146    #[test]
1147    fn audit_action_as_str_round_trips() {
1148        for action in [
1149            AuditAction::Recall,
1150            AuditAction::Store,
1151            AuditAction::Update,
1152            AuditAction::Delete,
1153            AuditAction::Link,
1154            AuditAction::Promote,
1155            AuditAction::Forget,
1156            AuditAction::Consolidate,
1157            AuditAction::Export,
1158            AuditAction::Import,
1159            AuditAction::Approve,
1160            AuditAction::Reject,
1161            AuditAction::SessionBoot,
1162        ] {
1163            let s = action.as_str();
1164            // serde rename-all snake_case round-trips through the
1165            // string representation.
1166            let v: serde_json::Value = serde_json::to_value(action).unwrap();
1167            assert_eq!(v.as_str().unwrap(), s);
1168        }
1169    }
1170
1171    #[test]
1172    fn audit_sanitize_strips_newlines() {
1173        let cleaned = sanitize_field("line1\nline2\rline3", 32);
1174        assert!(!cleaned.contains('\n'));
1175        assert!(!cleaned.contains('\r'));
1176    }
1177
1178    #[test]
1179    fn audit_sanitize_caps_length() {
1180        let s = "x".repeat(500);
1181        let cleaned = sanitize_field(&s, 100);
1182        assert_eq!(cleaned.chars().count(), 100);
1183    }
1184
1185    #[test]
1186    fn audit_resolve_path_directory_expands_to_file() {
1187        let cfg = crate::config::AuditConfig {
1188            enabled: Some(true),
1189            path: Some("/tmp/ai-memory/audit/".to_string()),
1190            ..Default::default()
1191        };
1192        let p = resolve_audit_path(&cfg);
1193        assert!(p.ends_with("audit.log"));
1194    }
1195
1196    #[test]
1197    fn audit_resolve_path_explicit_file_kept() {
1198        let cfg = crate::config::AuditConfig {
1199            enabled: Some(true),
1200            path: Some("/var/log/ai-memory/custom.log".to_string()),
1201            ..Default::default()
1202        };
1203        let p = resolve_audit_path(&cfg);
1204        assert_eq!(p, PathBuf::from("/var/log/ai-memory/custom.log"));
1205    }
1206
1207    /// Serialize tests that mutate the process-wide audit sink so
1208    /// concurrent test runners don't stomp on each other. Tests that
1209    /// touch the live SINK should hold this lock for their duration.
1210    fn sink_lock() -> std::sync::MutexGuard<'static, ()> {
1211        super::sink_test_lock()
1212    }
1213
1214    /// PR-5 (issue #487) load-bearing integration test. Wire the
1215    /// audit subsystem to an in-memory sink and emit one event per
1216    /// canonical action. Each successful operation MUST produce one
1217    /// line; the chain MUST stay intact across the run.
1218    #[test]
1219    fn audit_emits_at_every_call_site() {
1220        let _g = sink_lock();
1221        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1222        super::init_for_test(buf.clone());
1223
1224        let actions = [
1225            AuditAction::Store,
1226            AuditAction::Recall,
1227            AuditAction::Update,
1228            AuditAction::Delete,
1229            AuditAction::Link,
1230            AuditAction::Promote,
1231            AuditAction::Forget,
1232            AuditAction::Consolidate,
1233            AuditAction::Export,
1234            AuditAction::Import,
1235            AuditAction::Approve,
1236            AuditAction::Reject,
1237            AuditAction::SessionBoot,
1238            AuditAction::CaptureLag,
1239        ];
1240        for (i, action) in actions.iter().copied().enumerate() {
1241            let id = format!("mem-{i}");
1242            super::emit(EventBuilder::new(
1243                action,
1244                actor("ai:test@host", "explicit", None),
1245                target_memory(id, "ns-x", Some("t".to_string()), None, None),
1246            ));
1247        }
1248
1249        let lines = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1250        let count = lines.lines().filter(|l| !l.is_empty()).count();
1251        assert_eq!(
1252            count,
1253            actions.len(),
1254            "expected one audit line per action, got {count}: {lines}"
1255        );
1256        // Chain MUST be intact across the whole run.
1257        let report = verify_chain_from_reader(lines.as_bytes()).unwrap();
1258        assert!(
1259            report.first_failure.is_none(),
1260            "chain must verify across all call sites; failure: {:?}",
1261            report.first_failure
1262        );
1263        assert_eq!(report.total_lines as usize, actions.len());
1264
1265        super::shutdown_for_test();
1266    }
1267
1268    #[test]
1269    fn audit_emit_is_noop_when_disabled() {
1270        let _g = sink_lock();
1271        super::shutdown_for_test();
1272        // No sink active — emit must not panic and must not produce
1273        // any output anywhere.
1274        super::emit(EventBuilder::new(
1275            AuditAction::Store,
1276            actor("a", "explicit", None),
1277            target_memory("m", "ns", None, None, None),
1278        ));
1279        // is_enabled stays false.
1280        assert!(!super::is_enabled());
1281    }
1282
1283    #[test]
1284    fn audit_compliance_preset_soc2_overrides_retention() {
1285        // The compliance presets are pure config — applying SOC2 with
1286        // `applied = true` propagates the documented retention to the
1287        // top-level config field. This is a unit-test on the merge
1288        // logic, decoupled from disk.
1289        let cfg = crate::config::AuditConfig {
1290            enabled: Some(true),
1291            retention_days: Some(90),
1292            compliance: Some(crate::config::AuditComplianceConfig {
1293                soc2: Some(crate::config::CompliancePreset {
1294                    applied: Some(true),
1295                    retention_days: Some(730),
1296                    redact_content: Some(true),
1297                    attestation_cadence_minutes: Some(60),
1298                    encrypt_at_rest: None,
1299                    pseudonymize_actors: None,
1300                }),
1301                ..Default::default()
1302            }),
1303            ..Default::default()
1304        };
1305        let resolved = cfg.effective_retention_days();
1306        assert_eq!(resolved, 730, "SOC2 preset must override default retention");
1307    }
1308
1309    // ------------------------------------------------------------------
1310    // PR-9e coverage uplift (issue #487): exercise `init`, `read_chain_tail`,
1311    // builder method chains, `init_from_config` enabled+disabled paths,
1312    // `finalize_audit_file`, and the verify Sequence/Parse failure modes.
1313    // ------------------------------------------------------------------
1314
1315    #[test]
1316    fn audit_init_creates_log_file_in_fresh_directory() {
1317        let _g = sink_lock();
1318        let tmp = tempfile::tempdir().unwrap();
1319        let path = tmp.path().join("nested").join("audit.log");
1320        // Directory does not yet exist; init must create it.
1321        super::init(&path, true, false).unwrap();
1322        assert!(path.exists(), "init must create the log file");
1323        assert!(super::is_enabled());
1324        super::shutdown_for_test();
1325    }
1326
1327    #[test]
1328    fn audit_init_seeds_last_hash_from_existing_chain() {
1329        let _g = sink_lock();
1330        let tmp = tempfile::tempdir().unwrap();
1331        let path = tmp.path().join("audit.log");
1332
1333        // Pre-populate with a 2-event chain. We specifically test the
1334        // `read_chain_tail` linkage: the next emitted event's
1335        // `prev_hash` must match the file's last self_hash.
1336        //
1337        // **F2 (v0.7.0 round-2-fixes):** `init` now seeds the
1338        // SEQUENCE counter from the trailing record's sequence as
1339        // well, so the next emit produces `last_sequence + 1`. The
1340        // dedicated F2 test below pins that behavior; here we keep
1341        // the focus on hash-chain continuity.
1342        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1343        let e2 = sample_event(2, &e1.self_hash);
1344        let mut body = String::new();
1345        body.push_str(&serde_json::to_string(&e1).unwrap());
1346        body.push('\n');
1347        body.push_str(&serde_json::to_string(&e2).unwrap());
1348        body.push('\n');
1349        std::fs::write(&path, body).unwrap();
1350
1351        // Init points at the existing file — `read_chain_tail` must
1352        // seed `last_hash` from e2.
1353        super::init(&path, true, false).unwrap();
1354
1355        // Emit a third event; its prev_hash should equal e2.self_hash.
1356        super::emit(EventBuilder::new(
1357            AuditAction::Store,
1358            actor("ai:t@h", "explicit", None),
1359            target_memory("m3", "ns-x", Some("t".to_string()), None, None),
1360        ));
1361
1362        let body = std::fs::read_to_string(&path).unwrap();
1363        let third_line = body.lines().nth(2).expect("3rd line");
1364        let parsed: AuditEvent = serde_json::from_str(third_line).unwrap();
1365        assert_eq!(parsed.prev_hash, e2.self_hash, "chain must continue");
1366        super::shutdown_for_test();
1367    }
1368
1369    /// F2 regression (v0.7.0 round-2-fixes): `init` must seed the
1370    /// per-process SEQUENCE counter from the trailing record's
1371    /// sequence so emissions across daemon restarts remain
1372    /// monotonic. Pre-fix the SEQUENCE was reset to 0 every init,
1373    /// so the next event emitted sequence=1 even when the file's
1374    /// last record was sequence=N>1 — `audit verify` then flagged
1375    /// "sequence not monotonic: prior=N, this=1" on the first
1376    /// post-restart event.
1377    #[test]
1378    fn audit_init_seeds_sequence_from_existing_chain_tail() {
1379        let _g = sink_lock();
1380        let tmp = tempfile::tempdir().unwrap();
1381        let path = tmp.path().join("audit.log");
1382
1383        // Phase 1: drive 5 events with sequences 1..=5 against a
1384        // real file (init opens the file in append mode like the
1385        // production daemon does).
1386        super::init(&path, true, false).unwrap();
1387        for i in 0..5 {
1388            super::emit(EventBuilder::new(
1389                AuditAction::Store,
1390                actor("ai:writer", "explicit", None),
1391                target_memory(&format!("m{i}"), "ns", Some(format!("t{i}")), None, None),
1392            ));
1393        }
1394
1395        // Verify Phase 1 sequences are 1..=5.
1396        let body = std::fs::read_to_string(&path).unwrap();
1397        let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
1398        assert_eq!(lines.len(), 5, "phase 1 must emit 5 events");
1399        for (i, line) in lines.iter().enumerate() {
1400            let ev: AuditEvent = serde_json::from_str(line).unwrap();
1401            #[allow(clippy::cast_possible_truncation)]
1402            let expected = (i as u64) + 1;
1403            assert_eq!(
1404                ev.sequence, expected,
1405                "phase 1 event {i} must have sequence {expected}"
1406            );
1407        }
1408
1409        // Simulate daemon restart: drop the active sink, then re-init
1410        // pointing at the same physical file.
1411        super::shutdown_for_test();
1412        super::init(&path, true, false).unwrap();
1413
1414        // Phase 2: emit a single event. Pre-fix this would emit
1415        // sequence=1; post-fix it must emit sequence=6.
1416        super::emit(EventBuilder::new(
1417            AuditAction::Store,
1418            actor("ai:writer", "explicit", None),
1419            target_memory("m6", "ns", Some("t6".to_string()), None, None),
1420        ));
1421
1422        let body = std::fs::read_to_string(&path).unwrap();
1423        let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
1424        assert_eq!(lines.len(), 6, "phase 2 must append a 6th event");
1425
1426        let last: AuditEvent = serde_json::from_str(lines[5]).unwrap();
1427        assert_eq!(
1428            last.sequence, 6,
1429            "F2: post-restart event must continue sequence from disk (got {}, expected 6)",
1430            last.sequence,
1431        );
1432
1433        // Hash-chain linkage from the prior tail must also hold.
1434        let fifth: AuditEvent = serde_json::from_str(lines[4]).unwrap();
1435        assert_eq!(
1436            last.prev_hash, fifth.self_hash,
1437            "F2 must not regress hash-chain continuity"
1438        );
1439        super::shutdown_for_test();
1440    }
1441
1442    #[test]
1443    fn audit_init_skips_chain_tail_when_log_corrupted() {
1444        let _g = sink_lock();
1445        let tmp = tempfile::tempdir().unwrap();
1446        let path = tmp.path().join("audit.log");
1447        // File has a malformed trailing line; init must fall back to
1448        // CHAIN_HEAD_PREV_HASH because no well-formed lines exist.
1449        std::fs::write(&path, "{not valid json\n").unwrap();
1450        super::init(&path, true, false).unwrap();
1451        // Emitting a fresh event must seed prev_hash with the chain head.
1452        super::emit(EventBuilder::new(
1453            AuditAction::Store,
1454            actor("a", "explicit", None),
1455            target_memory("m", "ns", None, None, None),
1456        ));
1457        let body = std::fs::read_to_string(&path).unwrap();
1458        let last = body.lines().filter(|l| !l.is_empty()).last().unwrap();
1459        let parsed: AuditEvent = serde_json::from_str(last).unwrap();
1460        assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
1461        super::shutdown_for_test();
1462    }
1463
1464    /// M14 (v0.7.0 round-2-fixes): init must surface out-of-order
1465    /// sequence numbers via `tracing::warn!` without refusing to
1466    /// start. We hand-craft an audit log with two lines whose sequence
1467    /// numbers are intentionally swapped (line 1 → seq=2, line 2 →
1468    /// seq=1), point `init` at it, and assert (a) init succeeds and
1469    /// (b) a WARN was observed describing the out-of-order pair.
1470    #[test]
1471    fn audit_init_warns_on_out_of_order_sequence() {
1472        let _g = sink_lock();
1473        let tmp = tempfile::tempdir().unwrap();
1474        let path = tmp.path().join("audit.log");
1475
1476        // Compose two minimal AuditEvent lines with swapped seqs. We
1477        // construct via the public struct + serde so this stays
1478        // forward-compatible if a future schema bumps `schema_version`.
1479        let make_event = |seq: u64| AuditEvent {
1480            schema_version: SCHEMA_VERSION,
1481            timestamp: "2026-05-10T00:00:00Z".to_string(),
1482            sequence: seq,
1483            actor: AuditActor {
1484                agent_id: "ai:test".to_string(),
1485                scope: None,
1486                synthesis_source: "explicit".to_string(),
1487            },
1488            action: AuditAction::Store,
1489            target: AuditTarget {
1490                memory_id: format!("m-seq-{seq}"),
1491                namespace: "ns".to_string(),
1492                title: None,
1493                tier: None,
1494                scope: None,
1495            },
1496            outcome: AuditOutcome::Allow,
1497            auth: None,
1498            session_id: None,
1499            request_id: None,
1500            error: None,
1501            prev_hash: CHAIN_HEAD_PREV_HASH.to_string(),
1502            self_hash: format!("{seq:064x}"),
1503        };
1504
1505        let line_a = serde_json::to_string(&make_event(2)).unwrap();
1506        let line_b = serde_json::to_string(&make_event(1)).unwrap();
1507        std::fs::write(&path, format!("{line_a}\n{line_b}\n")).unwrap();
1508
1509        // Capture WARN output via a per-call subscriber.
1510        #[derive(Clone, Default)]
1511        struct WarnSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
1512        impl std::io::Write for WarnSink {
1513            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
1514                self.0.lock().unwrap().extend_from_slice(b);
1515                Ok(b.len())
1516            }
1517            fn flush(&mut self) -> std::io::Result<()> {
1518                Ok(())
1519            }
1520        }
1521        impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for WarnSink {
1522            type Writer = WarnSink;
1523            fn make_writer(&'a self) -> Self::Writer {
1524                self.clone()
1525            }
1526        }
1527        let sink = WarnSink::default();
1528        let buf = sink.0.clone();
1529        let subscriber = tracing_subscriber::fmt()
1530            .with_max_level(tracing::Level::WARN)
1531            .with_writer(sink)
1532            .without_time()
1533            .finish();
1534
1535        tracing::subscriber::with_default(subscriber, || {
1536            super::init(&path, true, false)
1537                .expect("M14: init must succeed despite out-of-order seqs");
1538        });
1539        let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1540        assert!(
1541            captured.contains("out-of-order sequence"),
1542            "M14: expected out-of-order WARN, got: {captured:?}"
1543        );
1544        // The exact pair must be reported so an operator can grep.
1545        assert!(
1546            captured.contains("prior 2"),
1547            "M14: WARN must include prior sequence (=2), got: {captured:?}"
1548        );
1549        assert!(
1550            captured.contains("this 1"),
1551            "M14: WARN must include this sequence (=1), got: {captured:?}"
1552        );
1553
1554        // init must have populated the sink (no refusal) — emitting
1555        // an event after init still works.
1556        super::emit(EventBuilder::new(
1557            AuditAction::Store,
1558            actor("ai:writer", "explicit", None),
1559            target_memory("m-after-warn", "ns", None, None, None),
1560        ));
1561        let body = std::fs::read_to_string(&path).unwrap();
1562        let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
1563        assert_eq!(
1564            lines.len(),
1565            3,
1566            "M14: init must accept the file and emit must still work"
1567        );
1568        super::shutdown_for_test();
1569    }
1570
1571    #[test]
1572    fn audit_event_builder_error_outcome() {
1573        let b = EventBuilder::new(
1574            AuditAction::Store,
1575            actor("a", "explicit", None),
1576            target_memory("m", "ns", None, None, None),
1577        )
1578        .error("boom");
1579        assert_eq!(b.outcome, AuditOutcome::Error);
1580        assert_eq!(b.error.as_deref(), Some("boom"));
1581    }
1582
1583    #[test]
1584    fn audit_event_builder_error_caps_long_message() {
1585        let long = "x".repeat(1000);
1586        let b = EventBuilder::new(
1587            AuditAction::Store,
1588            actor("a", "explicit", None),
1589            target_memory("m", "ns", None, None, None),
1590        )
1591        .error(long);
1592        // sanitize_field caps at 256 chars.
1593        assert_eq!(b.error.as_ref().unwrap().chars().count(), 256);
1594    }
1595
1596    #[test]
1597    fn audit_event_builder_outcome_chain() {
1598        let b = EventBuilder::new(
1599            AuditAction::Store,
1600            actor("a", "explicit", None),
1601            target_memory("m", "ns", None, None, None),
1602        )
1603        .outcome(AuditOutcome::Deny);
1604        assert_eq!(b.outcome, AuditOutcome::Deny);
1605    }
1606
1607    #[test]
1608    fn audit_event_builder_auth_and_request_id() {
1609        let auth = AuditAuth {
1610            source_ip: Some("203.0.113.1".to_string()),
1611            mtls_fp: None,
1612            api_key_id_hash: Some("abc".to_string()),
1613        };
1614        let b = EventBuilder::new(
1615            AuditAction::Store,
1616            actor("a", "explicit", None),
1617            target_memory("m", "ns", None, None, None),
1618        )
1619        .auth(auth.clone())
1620        .request_id("req-123");
1621        assert_eq!(b.auth, Some(auth));
1622        assert_eq!(b.request_id.as_deref(), Some("req-123"));
1623    }
1624
1625    #[test]
1626    fn audit_init_from_config_disabled_clears_sink() {
1627        let _g = sink_lock();
1628        // Bring up an in-memory sink first.
1629        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1630        super::init_for_test(buf);
1631        assert!(super::is_enabled());
1632
1633        let cfg = crate::config::AuditConfig {
1634            enabled: Some(false),
1635            ..Default::default()
1636        };
1637        super::init_from_config(&cfg).unwrap();
1638        // Disabled-branch must clear the global sink.
1639        assert!(!super::is_enabled());
1640        super::shutdown_for_test();
1641    }
1642
1643    #[test]
1644    fn audit_init_from_config_enabled_initialises_sink_at_resolved_path() {
1645        let _g = sink_lock();
1646        super::shutdown_for_test();
1647        let tmp = tempfile::tempdir().unwrap();
1648        let path = tmp.path().join("audit.log");
1649        let cfg = crate::config::AuditConfig {
1650            enabled: Some(true),
1651            path: Some(path.to_string_lossy().into_owned()),
1652            redact_content: Some(true),
1653            // Don't try to apply the OS append-only flag in tests —
1654            // the calling user typically lacks CAP_LINUX_IMMUTABLE
1655            // and we don't want a kernel-level side effect.
1656            append_only: Some(false),
1657            ..Default::default()
1658        };
1659        super::init_from_config(&cfg).unwrap();
1660        assert!(super::is_enabled());
1661        // The configured file must exist on disk after init.
1662        assert!(path.exists(), "audit log file must be created");
1663        super::shutdown_for_test();
1664    }
1665
1666    #[test]
1667    fn audit_finalize_audit_file_keeps_explicit_file_path() {
1668        let cfg = crate::config::AuditConfig {
1669            enabled: Some(true),
1670            path: Some("/var/log/ai-memory/x.log".to_string()),
1671            ..Default::default()
1672        };
1673        let p = resolve_audit_path(&cfg);
1674        // Explicit file path must be preserved (not appended with audit.log).
1675        assert_eq!(p, PathBuf::from("/var/log/ai-memory/x.log"));
1676    }
1677
1678    #[test]
1679    fn audit_finalize_audit_file_appends_audit_log_for_dir_path() {
1680        let cfg = crate::config::AuditConfig {
1681            enabled: Some(true),
1682            path: Some("/var/log/ai-memory/".to_string()),
1683            ..Default::default()
1684        };
1685        let p = resolve_audit_path(&cfg);
1686        assert!(p.ends_with("audit.log"));
1687    }
1688
1689    #[test]
1690    fn audit_finalize_audit_file_appends_audit_log_for_extension_less_path() {
1691        // No trailing slash and no extension: treat as dir, append audit.log.
1692        let cfg = crate::config::AuditConfig {
1693            enabled: Some(true),
1694            path: Some("/var/log/aim_audit_dir".to_string()),
1695            ..Default::default()
1696        };
1697        let p = resolve_audit_path(&cfg);
1698        assert!(p.ends_with("audit.log"));
1699    }
1700
1701    #[test]
1702    fn audit_verify_detects_sequence_regression() {
1703        // Build a chain with a non-monotonic sequence to hit the
1704        // VerifyFailureKind::Sequence branch.
1705        let e1 = sample_event(5, CHAIN_HEAD_PREV_HASH);
1706        // e2 has sequence == e1's sequence (not strictly greater).
1707        let e2 = sample_event(5, &e1.self_hash);
1708        let mut buf = String::new();
1709        for ev in [&e1, &e2] {
1710            buf.push_str(&serde_json::to_string(ev).unwrap());
1711            buf.push('\n');
1712        }
1713        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1714        let failure = report.first_failure.expect("sequence regression");
1715        assert!(matches!(failure.kind, VerifyFailureKind::Sequence));
1716    }
1717
1718    #[test]
1719    fn audit_verify_detects_malformed_json_line() {
1720        // Single garbage line — must surface VerifyFailureKind::Parse.
1721        let buf = "this is not json\n";
1722        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1723        let failure = report.first_failure.expect("parse failure");
1724        assert!(matches!(failure.kind, VerifyFailureKind::Parse));
1725        assert!(failure.detail.contains("malformed JSON"));
1726    }
1727
1728    #[test]
1729    fn audit_verify_skips_blank_lines() {
1730        // Mix blank lines into a valid chain — must verify clean.
1731        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1732        let e2 = sample_event(2, &e1.self_hash);
1733        let buf = format!(
1734            "\n{}\n\n{}\n\n",
1735            serde_json::to_string(&e1).unwrap(),
1736            serde_json::to_string(&e2).unwrap()
1737        );
1738        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1739        assert!(report.first_failure.is_none());
1740        assert_eq!(report.total_lines, 2);
1741    }
1742
1743    #[test]
1744    fn audit_verify_report_into_result_ok() {
1745        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1746        let report = verify_chain_from_reader(
1747            format!("{}\n", serde_json::to_string(&e1).unwrap()).as_bytes(),
1748        )
1749        .unwrap();
1750        let n = report.into_result().unwrap();
1751        assert_eq!(n, 1);
1752    }
1753
1754    #[test]
1755    fn audit_verify_report_into_result_err() {
1756        let report = VerifyReport {
1757            total_lines: 5,
1758            first_failure: Some(VerifyFailure {
1759                line_number: 3,
1760                kind: VerifyFailureKind::ChainBreak,
1761                detail: "x".to_string(),
1762            }),
1763        };
1764        let err = report.into_result().unwrap_err();
1765        let msg = format!("{err}");
1766        assert!(msg.contains("audit chain verification failed"));
1767        assert!(msg.contains("line 3"));
1768    }
1769
1770    #[test]
1771    fn audit_emit_records_request_id_and_auth() {
1772        let _g = sink_lock();
1773        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1774        super::init_for_test(buf.clone());
1775        super::emit(
1776            EventBuilder::new(
1777                AuditAction::Store,
1778                actor("a", "explicit", None),
1779                target_memory("m", "ns", None, None, None),
1780            )
1781            .auth(AuditAuth {
1782                source_ip: Some("198.51.100.7".to_string()),
1783                mtls_fp: None,
1784                api_key_id_hash: None,
1785            })
1786            .request_id("trace-abc"),
1787        );
1788        let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1789        assert!(body.contains("\"request_id\":\"trace-abc\""), "got: {body}");
1790        assert!(body.contains("198.51.100.7"));
1791        super::shutdown_for_test();
1792    }
1793
1794    #[test]
1795    fn audit_emit_records_error_outcome() {
1796        let _g = sink_lock();
1797        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1798        super::init_for_test(buf.clone());
1799        super::emit(
1800            EventBuilder::new(
1801                AuditAction::Store,
1802                actor("a", "explicit", None),
1803                target_memory("m", "ns", None, None, None),
1804            )
1805            .error("disk full"),
1806        );
1807        let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1808        assert!(body.contains("\"outcome\":\"error\""), "got: {body}");
1809        assert!(body.contains("\"error\":\"disk full\""), "got: {body}");
1810        super::shutdown_for_test();
1811    }
1812
1813    #[test]
1814    fn audit_expand_tilde_passthrough_when_no_tilde() {
1815        // Pure-string helper — should leave non-tilde paths intact.
1816        assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
1817        assert_eq!(super::expand_tilde("rel/path"), "rel/path");
1818    }
1819
1820    #[test]
1821    fn audit_target_sweep_uses_wildcard_id() {
1822        let t = super::target_sweep("ns-y");
1823        assert_eq!(t.memory_id, "*");
1824        assert_eq!(t.namespace, "ns-y");
1825    }
1826
1827    #[test]
1828    fn audit_target_memory_round_trips_optional_fields() {
1829        let t = super::target_memory(
1830            "mem-1",
1831            "ns-x",
1832            Some("title".to_string()),
1833            Some(Tier::Long.as_str().to_string()),
1834            Some("team".to_string()),
1835        );
1836        assert_eq!(t.tier.as_deref(), Some(Tier::Long.as_str()));
1837        assert_eq!(t.scope.as_deref(), Some("team"));
1838    }
1839
1840    // -----------------------------------------------------------------
1841    // L0.7-2 Tier A — long-tail error path + helper coverage
1842    // (lines 244/266, 271-277, 363/368, 685-686, 809-811, 842/845,
1843    // 850-853 expand_tilde, mark_append_only happy path on darwin)
1844    // -----------------------------------------------------------------
1845
1846    #[test]
1847    fn expand_tilde_substitutes_home_when_set() {
1848        // Line 850-853 happy path: prefix "~/" + HOME present →
1849        // expanded. We do NOT mutate HOME here — log_paths.rs has its
1850        // own env-lock-serialised HOME tests, and racing across two
1851        // process-wide locks is unsafe. Instead we call expand_tilde
1852        // twice and assert ONE of the documented behaviours:
1853        //   - HOME present: result == "{HOME}/audit/log"
1854        //   - HOME absent : result == "~/audit/log" (raw passthrough)
1855        // Both arms hit the prefix-match line; only the inner HOME
1856        // lookup differs. Either way, line 850 and the prefix check
1857        // are exercised.
1858        let out = super::expand_tilde("~/audit/log");
1859        // Accept either expanded or passthrough; the test exists to
1860        // pin the prefix detection logic + reachable code path, not
1861        // to assert a particular HOME value (which races across tests).
1862        assert!(
1863            out.ends_with("/audit/log") || out == "~/audit/log",
1864            "unexpected output shape: {out}"
1865        );
1866    }
1867
1868    #[test]
1869    fn expand_tilde_no_match_passthrough() {
1870        // The non-tilde fast-path (already covered by an earlier test)
1871        // and a "~" without "/" suffix both fall through to the raw
1872        // return arm. This pins the non-prefix branch.
1873        assert_eq!(super::expand_tilde("~root/etc"), "~root/etc");
1874        assert_eq!(super::expand_tilde("~"), "~");
1875    }
1876
1877    #[test]
1878    fn audit_init_returns_error_when_parent_path_is_a_file() {
1879        // Line 244-245: `create_dir_all` fails when the parent is a
1880        // regular file (cannot create a dir on top of one). `init`
1881        // surfaces the wrapped Err via with_context.
1882        let _g = sink_lock();
1883        let tmp = tempfile::tempdir().unwrap();
1884        // Create a regular file at what would be the parent dir.
1885        let blocker = tmp.path().join("blocker");
1886        std::fs::write(&blocker, b"i am a file, not a directory").unwrap();
1887        // Request a log path *inside* the blocker file → create_dir_all
1888        // hits ENOTDIR on the parent.
1889        let log_path = blocker.join("nested").join("audit.log");
1890        let err = super::init(&log_path, true, false).unwrap_err();
1891        let msg = format!("{err:#}");
1892        assert!(
1893            msg.contains("creating audit log dir") || msg.contains("audit"),
1894            "expected wrapped context, got: {msg}"
1895        );
1896        super::shutdown_for_test();
1897    }
1898
1899    #[test]
1900    fn audit_init_applies_append_only_flag_on_macos() {
1901        // Line 268-269, 282-287: append_only_hint=true must trigger
1902        // mark_append_only and (on macOS/BSD) the chflags branch.
1903        // Even if the syscall fails for unprivileged users, init must
1904        // still succeed because failures are logged WARN and swallowed.
1905        let _g = sink_lock();
1906        let tmp = tempfile::tempdir().unwrap();
1907        let path = tmp.path().join("audit.log");
1908        // Pre-create so chflags has a real inode to flag.
1909        std::fs::write(&path, b"").unwrap();
1910        // append_only_hint=true reaches mark_append_only. On darwin the
1911        // call may or may not succeed depending on user privileges and
1912        // chflags's response to UF_APPEND on a tmpfile — either way
1913        // init MUST return Ok() and a sink MUST be installed.
1914        super::init(&path, true, true).expect("init must tolerate flag outcome");
1915        assert!(super::is_enabled());
1916        super::shutdown_for_test();
1917        // Best-effort: clear UF_APPEND if it was set so tmpdir cleanup
1918        // can remove the file. We ignore errors — the file lives under
1919        // the OS tmpdir cleaner anyway.
1920        #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
1921        unsafe {
1922            use std::ffi::CString;
1923            use std::os::unix::ffi::OsStrExt;
1924            if let Ok(c) = CString::new(path.as_os_str().as_bytes()) {
1925                let _ = libc::chflags(c.as_ptr(), 0);
1926            }
1927        }
1928    }
1929
1930    #[test]
1931    fn read_chain_tail_returns_none_for_missing_file() {
1932        // Line 360-361 fast path: file doesn't exist.
1933        let tmp = tempfile::tempdir().unwrap();
1934        let missing = tmp.path().join("nope.log");
1935        // We call through init: init seeds with CHAIN_HEAD_PREV_HASH.
1936        // (read_chain_tail is private; init is the canonical caller.)
1937        let _g = sink_lock();
1938        super::init(&missing, true, false).unwrap();
1939        // After init, the new file must exist and a fresh chain head
1940        // must be in place — emit one event, verify prev_hash is the
1941        // sentinel.
1942        super::emit(EventBuilder::new(
1943            AuditAction::Store,
1944            actor("a", "explicit", None),
1945            target_memory("m", "ns", None, None, None),
1946        ));
1947        let body = std::fs::read_to_string(&missing).unwrap();
1948        let line = body.lines().next().unwrap();
1949        let parsed: AuditEvent = serde_json::from_str(line).unwrap();
1950        assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
1951        super::shutdown_for_test();
1952    }
1953
1954    #[test]
1955    fn read_chain_tail_skips_blank_lines() {
1956        // Line 369-370: empty/blank lines must be skipped during chain
1957        // tail scan. We pre-seed a chain with embedded blank lines,
1958        // init, then emit and verify the next prev_hash still threads
1959        // through the last real event.
1960        let _g = sink_lock();
1961        let tmp = tempfile::tempdir().unwrap();
1962        let path = tmp.path().join("audit.log");
1963        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1964        let e2 = sample_event(2, &e1.self_hash);
1965        let body = format!(
1966            "{}\n\n\n{}\n   \n",
1967            serde_json::to_string(&e1).unwrap(),
1968            serde_json::to_string(&e2).unwrap(),
1969        );
1970        std::fs::write(&path, body).unwrap();
1971        super::init(&path, true, false).unwrap();
1972        super::emit(EventBuilder::new(
1973            AuditAction::Store,
1974            actor("a", "explicit", None),
1975            target_memory("m", "ns", None, None, None),
1976        ));
1977        let full = std::fs::read_to_string(&path).unwrap();
1978        let lines: Vec<_> = full.lines().filter(|l| !l.trim().is_empty()).collect();
1979        let last = lines.last().unwrap();
1980        let parsed: AuditEvent = serde_json::from_str(last).unwrap();
1981        assert_eq!(
1982            parsed.prev_hash, e2.self_hash,
1983            "blank lines must be skipped"
1984        );
1985        super::shutdown_for_test();
1986    }
1987
1988    #[test]
1989    fn verify_chain_open_error_wrapped_with_context() {
1990        // Line 686: File::open failure on a non-existent path must
1991        // surface an error with the "opening <path>" context.
1992        let tmp = tempfile::tempdir().unwrap();
1993        let missing = tmp.path().join("does-not-exist.log");
1994        let err = super::verify_chain(&missing).unwrap_err();
1995        let msg = format!("{err:#}");
1996        assert!(msg.contains("opening"), "expected context, got: {msg}");
1997        assert!(msg.contains("does-not-exist.log"), "got: {msg}");
1998    }
1999
2000    #[test]
2001    fn finalize_audit_file_keeps_explicit_extension_path() {
2002        // Line 836-840: when raw_config has a non-slash trailing
2003        // extension, the function returns `p` as-is (no audit.log
2004        // append). Covered by audit_finalize_audit_file_keeps_explicit_file_path
2005        // — this is a tighter focus on the extension-discrimination branch.
2006        let cfg = crate::config::AuditConfig {
2007            enabled: Some(true),
2008            path: Some("./custom.txt".to_string()),
2009            ..Default::default()
2010        };
2011        let p = resolve_audit_path(&cfg);
2012        // The configured file path must round-trip without an
2013        // audit.log suffix because it has a non-empty extension.
2014        assert!(
2015            p.to_string_lossy().ends_with(".txt"),
2016            "got: {}",
2017            p.display()
2018        );
2019    }
2020
2021    #[test]
2022    fn finalize_audit_file_keeps_resolved_file_when_no_config_override() {
2023        // Direct unit-test on the `else p` arm (line 845): construct a
2024        // resolved `PathBuf` that already has an extension and pass
2025        // raw_config = None so the head-branch falls through; the
2026        // p.extension().is_none() check fails (it IS Some), so the else
2027        // arm executes returning `p` unchanged.
2028        let p = PathBuf::from("/var/log/aimemory.log");
2029        let out = super::finalize_audit_file(p.clone(), None);
2030        assert_eq!(out, p);
2031    }
2032
2033    #[test]
2034    fn resolve_audit_path_falls_back_to_platform_default_when_resolver_errs() {
2035        // Lines 807-811: `resolve_audit_dir` returns Err when the
2036        // configured dir is world-writable; `resolve_audit_path` (the
2037        // non-strict variant) silently falls back to `platform_default`.
2038        // We exercise the fallback by chmodding a tempdir to 0777 and
2039        // pointing AuditConfig.path at it. After the call:
2040        //   * Function must return Ok-like PathBuf (no panic)
2041        //   * Result must NOT be inside the world-writable dir (that
2042        //     would defeat the security check)
2043        #[cfg(unix)]
2044        {
2045            use std::os::unix::fs::PermissionsExt;
2046            let tmp = tempfile::tempdir().unwrap();
2047            let www = tmp.path().join("world_writable");
2048            std::fs::create_dir_all(&www).unwrap();
2049            std::fs::set_permissions(&www, std::fs::Permissions::from_mode(0o777)).unwrap();
2050            let cfg = crate::config::AuditConfig {
2051                enabled: Some(true),
2052                path: Some(www.to_string_lossy().into_owned()),
2053                ..Default::default()
2054            };
2055            let p = super::resolve_audit_path(&cfg);
2056            // p must NOT be inside the world-writable dir (the fallback
2057            // routed past it).
2058            assert!(
2059                !p.starts_with(&www),
2060                "world-writable dir must not be used; got: {}",
2061                p.display()
2062            );
2063        }
2064    }
2065
2066    #[test]
2067    fn resolve_audit_path_with_override_propagates_world_writable_error() {
2068        // Line 826: strict variant returns Err when resolve_audit_dir
2069        // refuses a world-writable path. Mirrors the non-strict test
2070        // above but asserts Err on the strict surface.
2071        #[cfg(unix)]
2072        {
2073            use std::os::unix::fs::PermissionsExt;
2074            let tmp = tempfile::tempdir().unwrap();
2075            let www = tmp.path().join("ww");
2076            std::fs::create_dir_all(&www).unwrap();
2077            std::fs::set_permissions(&www, std::fs::Permissions::from_mode(0o777)).unwrap();
2078            let cfg = crate::config::AuditConfig::default();
2079            let err = super::resolve_audit_path_with_override(Some(&www), &cfg).unwrap_err();
2080            let msg = format!("{err}");
2081            assert!(
2082                msg.contains("world-writable"),
2083                "expected world-writable error, got: {msg}"
2084            );
2085        }
2086    }
2087
2088    #[test]
2089    fn init_with_directory_in_place_of_file_returns_open_error() {
2090        // Line 266: `OpenOptions::new().open(path)` fails when the
2091        // path resolves to an existing *directory*. `init` wraps the
2092        // error with `with_context("opening audit log {path}")`.
2093        let _g = sink_lock();
2094        let tmp = tempfile::tempdir().unwrap();
2095        // Use the tempdir itself as the target — opening a dir for
2096        // write/append fails with EISDIR on macOS/Linux.
2097        let err = super::init(tmp.path(), true, false).unwrap_err();
2098        let msg = format!("{err:#}");
2099        assert!(msg.contains("opening audit log"), "got: {msg}");
2100        super::shutdown_for_test();
2101    }
2102
2103    #[test]
2104    fn resolve_audit_path_with_override_returns_source_tag() {
2105        // Line 822-828: the strict variant. With a CLI override the
2106        // PathSource should reflect that. We pass a tempdir as the
2107        // override and assert the returned path embeds it.
2108        let tmp = tempfile::tempdir().unwrap();
2109        let cfg = crate::config::AuditConfig::default();
2110        let (path, _source) =
2111            super::resolve_audit_path_with_override(Some(tmp.path()), &cfg).unwrap();
2112        // Output path must live under the override dir (since override
2113        // wins precedence).
2114        assert!(
2115            path.starts_with(tmp.path()),
2116            "expected override-rooted path, got: {}",
2117            path.display()
2118        );
2119        // And must end with audit.log because we passed a directory.
2120        assert!(path.ends_with("audit.log"), "got: {}", path.display());
2121    }
2122}