Skip to main content

zeph_session/
log.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The append-only JSONL event log: [`SessionEventLog`].
5//!
6//! Mirrors the append + fsync pattern of `zeph-durable`'s `JournalWriter`
7//! (`crates/zeph-durable/src/writer.rs`) at the conversation-semantics level, but persists to a
8//! plain JSONL file rather than a `SQLite`-backed journal (spec-068 §3, §14).
9//!
10//! # Invariants
11//!
12//! - INV-SP-1 (log-first ordering): callers must append to this log before updating any
13//!   downstream projection (`SQLite` `messages`, `acp_sessions.last_seq`).
14//! - INV-SP-2 (torn-append truncation): every read validates each line and drops a garbled/
15//!   incomplete trailing line from the in-memory result, which can only occur as the very last
16//!   line because appends are serialized through a single writer (INV-D2). Only
17//!   [`SessionEventLog::open_exclusive`] additionally repairs the torn tail physically on disk —
18//!   a lockless [`SessionEventLog::open`]/[`SessionEventLog::read_all`] cannot prove a "torn"
19//!   line isn't a live writer's in-flight, not-yet-fsynced append, so it must never mutate the
20//!   file (#5487 Finding B).
21//! - INV-D2 (single writer): only the session's owning actor/agent process may hold a
22//!   `SessionEventLog` for a given session directory at a time. [`SessionEventLog::open`]
23//!   does not itself enforce cross-process exclusion — it is also used by read-only
24//!   tooling (session export/inspection) that may legitimately run alongside a live
25//!   writer. The session's owning actor/agent process should instead use
26//!   [`SessionEventLog::open_exclusive`], which takes a non-blocking `flock(2)` advisory
27//!   lock (Unix only) and fails with [`SessionError::AlreadyLocked`] if another writer
28//!   already holds the session directory.
29
30use std::ops::ControlFlow;
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::{Arc, RwLock as StdRwLock};
34
35use tokio::fs::{self, File, OpenOptions};
36use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
37use tokio::sync::Mutex;
38use zeph_common::anchor::{Anchor, AnchorStore, AnchorSubsystem};
39use zeph_common::hash_chain::{
40    ChainError, ChainHash, ChainKeyRing, ChainStreamVerifier, KeyResolution, chain_next, genesis,
41};
42
43use crate::error::SessionError;
44use crate::event::{SessionEvent, SessionEventEnvelope};
45
46const EVENTS_FILE_NAME: &str = "events.jsonl";
47#[cfg(unix)]
48const LOCK_FILE_NAME: &str = "events.jsonl.lock";
49
50/// Domain-separation tag for this subsystem's hash chain (issue #6360) — distinct from
51/// `zeph-subagent`'s so a chain from one subsystem can never verify against the other.
52pub const CHAIN_DOMAIN: &str = "zeph-session log v1";
53
54/// Process-wide history-chain key ring, configured once at bootstrap by resolving
55/// `ZEPH_HISTORY_KEY` from the vault (see `zeph_core::history_integrity`).
56///
57/// See `zeph_subagent::transcript`'s identical registry for the full rationale (a `RwLock`, not
58/// `OnceLock`, so tests can reconfigure it, and process-global rather than a constructor
59/// parameter because `SessionEventLog::open`/`open_exclusive` have 40+ call sites across crates
60/// outside this feature's ownership).
61static HISTORY_INTEGRITY: StdRwLock<Option<Arc<ChainKeyRing>>> = StdRwLock::new(None);
62
63/// Configure (or disable, with `None`) history-chain verification for every
64/// [`SessionEventLog`] operation in this process from this point forward. See
65/// `zeph_subagent::transcript::configure_history_integrity`'s doc for the full contract — this
66/// mirrors it exactly.
67///
68/// # Invariant: single-set-at-startup
69///
70/// This is `pub` (not `pub(crate)`) specifically so `src/runner.rs` — a different crate from
71/// this one — can call it once during CLI bootstrap, before any `SessionEventLog` is opened
72/// (see `configure_history_integrity_from_default_vault` in `src/runner.rs`). It is **not**
73/// meant to be called again later by production code: reconfiguring mid-process cannot make an
74/// already-open handle less safe (each handle captures `ring` at construction and is immune to
75/// later reconfiguration, and setting `ring = None` only ever makes *subsequent* opens
76/// fail-closed, never trust-bypassing), but a caller reconfiguring after bootstrap without a
77/// clear reason is almost certainly a bug, not an intended feature — no production code path
78/// does this today, and none should be added without updating this doc. Tests are the one
79/// legitimate exception, calling this per-test under `cargo nextest`'s one-process-per-test
80/// isolation.
81pub fn configure_history_integrity(ring: Option<Arc<ChainKeyRing>>) {
82    if let Ok(mut guard) = HISTORY_INTEGRITY.write() {
83        *guard = ring;
84    }
85}
86
87fn history_integrity() -> Option<Arc<ChainKeyRing>> {
88    HISTORY_INTEGRITY.read().ok().and_then(|g| g.clone())
89}
90
91/// Process-wide vault-anchor store (issue #6449). See
92/// `zeph_subagent::transcript::configure_anchor_store`'s identical registry for the full
93/// rationale — this mirrors it exactly. `None` (the default) disables anchor writes/checks
94/// entirely: sessions behave exactly as they did under #6453.
95static ANCHOR_STORE: StdRwLock<Option<Arc<dyn AnchorStore>>> = StdRwLock::new(None);
96
97/// Configure (or disable, with `None`) the vault-anchor store for every [`SessionEventLog`]
98/// operation in this process from this point forward.
99pub fn configure_anchor_store(store: Option<Arc<dyn AnchorStore>>) {
100    if let Ok(mut guard) = ANCHOR_STORE.write() {
101        *guard = store;
102    }
103}
104
105fn anchor_store() -> Option<Arc<dyn AnchorStore>> {
106    ANCHOR_STORE.read().ok().and_then(|g| g.clone())
107}
108
109/// Chunk size for [`SessionEventLog::read_chunked`] (spec §6.2 step 3: "bounded buffer, ≤ 100
110/// events in memory at once").
111const REPLAY_CHUNK_SIZE: usize = 100;
112
113/// Bound on the single async vault-anchor `get` performed at open time (issue #6449). A vault
114/// stall must fail deterministically rather than hang an unattended caller (durable resume,
115/// scheduler restore, ACP resume, fork pre-copy) — this timeout applies uniformly regardless of
116/// caller, since `open`/`open_exclusive` cannot distinguish attended from unattended callers
117/// itself.
118const ANCHOR_GET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
119
120/// Append-only JSONL log for one conversation-session's `events.jsonl`.
121///
122/// # Examples
123///
124/// ```
125/// use tempfile::tempdir;
126/// use zeph_session::event::SessionEvent;
127/// use zeph_session::log::SessionEventLog;
128///
129/// # #[tokio::main]
130/// # async fn main() {
131/// let dir = tempdir().unwrap();
132/// let log = SessionEventLog::open(dir.path()).await.unwrap();
133/// log.append(None, None, SessionEvent::SessionEnded { reason: "user_quit".to_owned() })
134///     .await
135///     .unwrap();
136/// assert_eq!(log.last_seq(), Some(0));
137/// # }
138/// ```
139struct SessionWriteState {
140    file: File,
141    /// Running chain head; `None` until either the first chained append in this handle's
142    /// lifetime (fresh chaining start on a legacy or empty log) or seeded from the log's
143    /// existing chained tail at open time (M3).
144    prev: Option<ChainHash>,
145    /// Total on-disk event count (seeded from any pre-existing content at open time,
146    /// incremented on every successful append) — the `count` half of the vault anchor written
147    /// by [`SessionEventLog::finalize`] (issue #6449).
148    count: u64,
149}
150
151pub struct SessionEventLog {
152    events_path: PathBuf,
153    /// `file` and the running chain state share one lock so the chain-link read-modify-write is
154    /// always atomic with the physical write and `sync_all` (S2, issue #6360 critic rev2) —
155    /// matches `seq` assignment, which already had to be under this same lock for INV-SP-2's
156    /// ascending-seq-order guarantee (#5487); folding the chain link in adds no new await inside
157    /// the guarded section (BLAKE3 is CPU-only).
158    writer: Mutex<SessionWriteState>,
159    next_seq: AtomicU64,
160    file_identity: Vec<u8>,
161    /// Captured once at open time so every `append` on this handle uses one consistent key
162    /// ring, even if `configure_history_integrity` is called again concurrently.
163    ring: Option<Arc<ChainKeyRing>>,
164    /// Set only by [`SessionEventLog::open_exclusive_allow_unverified`] — every subsequent
165    /// [`Self::read_all`]/[`Self::read_chunked`] call on this handle also skips chain
166    /// verification, not just the initial open, so the deliberate operator override applies
167    /// for this handle's whole lifetime rather than just its construction.
168    allow_unverified: bool,
169    /// Captured once at open time, like `ring` (issue #6449) — `None` if no anchor store is
170    /// configured, or none is on file for this session yet.
171    anchor: Option<Anchor>,
172    #[allow(dead_code)] // held only for its Drop (releases the flock, if taken)
173    lock: Option<AdvisoryLock>,
174}
175
176/// Derive a session log's chain identity from its directory (the `session_id`) — binds the
177/// chain to this one session so a whole-log substitution (swapping in another session's
178/// `events.jsonl`) breaks at the genesis hash.
179fn file_identity(session_dir: &Path) -> Vec<u8> {
180    session_dir
181        .file_name()
182        .map(|s| s.to_string_lossy().into_owned())
183        .unwrap_or_default()
184        .into_bytes()
185}
186
187impl SessionEventLog {
188    /// Open (creating if absent) the `events.jsonl` log under `session_dir`.
189    ///
190    /// Validates the existing file per INV-SP-2, dropping a torn trailing line from the
191    /// in-memory result, then opens the file in append mode for subsequent writes. Sets
192    /// file/directory permissions to `0o700`/`0o600` on Unix (spec §4.1); a no-op on other
193    /// platforms.
194    ///
195    /// Does not take the cross-process advisory lock, and never physically truncates the file
196    /// (even if a torn tail is found) — safe for read-only tooling that may run alongside a live
197    /// writer whose in-flight, not-yet-fsynced line could otherwise be mistaken for "torn" and
198    /// destroyed (#5487 Finding B). The session's owning actor/agent process should use
199    /// [`Self::open_exclusive`] instead, which does perform the physical repair.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`SessionError::Io`] if the directory or file cannot be created, or
204    /// [`SessionError::Serde`] surfaces only via [`Self::read_all`], never here (torn lines are
205    /// discarded, not treated as fatal).
206    pub async fn open(session_dir: &Path) -> Result<Self, SessionError> {
207        Self::open_with_lock(session_dir, None, false).await
208    }
209
210    /// Open the `events.jsonl` log under `session_dir` like [`Self::open`], but **skip
211    /// hash-chain verification** for this handle's whole lifetime — see
212    /// [`Self::open_exclusive_allow_unverified`]'s doc for the full contract (this is its
213    /// lockless counterpart, for read-only tooling such as `sessions resume --print
214    /// --allow-unverified`).
215    ///
216    /// # Errors
217    ///
218    /// Returns any error [`Self::open`] can return other than [`SessionError::Integrity`]
219    /// (which this method exists specifically to bypass).
220    pub async fn open_allow_unverified(session_dir: &Path) -> Result<Self, SessionError> {
221        Self::open_with_lock(session_dir, None, true).await
222    }
223
224    /// Open the `events.jsonl` log under `session_dir` like [`Self::open`], but additionally
225    /// take a non-blocking, exclusive advisory lock (`flock(2)` on Unix, mirroring
226    /// `zeph-scheduler`'s `PidFile`) enforcing INV-D2's single-writer invariant.
227    ///
228    /// Intended for the session's owning actor/agent process. On non-Unix targets the lock
229    /// is a no-op (the workspace has no vetted cross-platform advisory-locking primitive), so
230    /// this degrades to [`Self::open`]'s behavior there.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`SessionError::AlreadyLocked`] if another process already holds the session's
235    /// write lock, or any error [`Self::open`] can return.
236    pub async fn open_exclusive(session_dir: &Path) -> Result<Self, SessionError> {
237        fs::create_dir_all(session_dir).await?;
238        let lock = AdvisoryLock::acquire(session_dir)?;
239        Self::open_with_lock(session_dir, Some(lock), false).await
240    }
241
242    /// Open the `events.jsonl` log under `session_dir` like [`Self::open_exclusive`], but
243    /// **skip hash-chain verification** for this one open.
244    ///
245    /// This is the deliberate, logged override an operator invokes explicitly (e.g. `zeph
246    /// sessions resume <id> --allow-unverified`) after being shown a detected chain-integrity
247    /// failure — never a silent fallback. Per spec-069 FR-004's fail-closed-by-default posture:
248    /// callers on an **unattended** path (durable resume, the crash-orphan sweep, automatic
249    /// sub-agent transcript reload) must never call this — only a human-attended path with an
250    /// explicit, deliberate opt-in may bypass verification. `read_all`/`read_chunked` on the
251    /// returned handle also skip chain verification (a dedicated `allow_unverified` flag carried
252    /// on `Self`, threaded through every subsequent read — **not** implemented by nulling the
253    /// key ring, which would instead re-trigger the normal "no key configured" fail-closed path
254    /// and make this override indistinguishable from a plain hard failure), so the whole session
255    /// is treated as best-effort-trusted, matching the legacy posture, for as long as this
256    /// handle is held.
257    ///
258    /// **Scope of the bypass**: this skips cryptographic chain verification only. It does
259    /// **not** bypass the structural torn-tail/internal-malformed-line check (S1, the private
260    /// `peek_confirms_trailing_torn` helper) — a line that fails to parse as JSON is still a
261    /// hard error even with this override, since that is a distinct failure class (structural
262    /// corruption, not a cryptographic tamper verdict) that this override was never meant to
263    /// paper over. An operator with a genuinely corrupt (non-tamper) internal-malformed-line
264    /// session cannot recover it via `--allow-unverified`.
265    ///
266    /// # Errors
267    ///
268    /// Returns any error [`Self::open_exclusive`] can return other than
269    /// [`SessionError::Integrity`] (which this method exists specifically to bypass).
270    pub async fn open_exclusive_allow_unverified(session_dir: &Path) -> Result<Self, SessionError> {
271        fs::create_dir_all(session_dir).await?;
272        let lock = AdvisoryLock::acquire(session_dir)?;
273        Self::open_with_lock(session_dir, Some(lock), true).await
274    }
275
276    async fn open_with_lock(
277        session_dir: &Path,
278        lock: Option<AdvisoryLock>,
279        allow_unverified: bool,
280    ) -> Result<Self, SessionError> {
281        fs::create_dir_all(session_dir).await?;
282        set_permissions(session_dir, 0o700).await?;
283
284        let events_path = session_dir.join(EVENTS_FILE_NAME);
285        let ring = history_integrity();
286        let identity = file_identity(session_dir);
287
288        // Resolve the vault anchor once, bounded by a timeout (issue #6449) so a vault stall
289        // fails deterministically rather than hanging an unattended caller (durable resume,
290        // scheduler restore, ACP resume, fork pre-copy — none of which can offer an interactive
291        // retry).
292        let anchor = match anchor_store() {
293            Some(store) => tokio::time::timeout(
294                ANCHOR_GET_TIMEOUT,
295                store.get(AnchorSubsystem::SessionLog, &identity),
296            )
297            .await
298            .map_err(|_| {
299                SessionError::Integrity(format!(
300                    "vault anchor lookup for session '{}' timed out after {:?} — failing \
301                         closed rather than opening unverified",
302                    session_dir.display(),
303                    ANCHOR_GET_TIMEOUT
304                ))
305            })?
306            .map_err(|e| SessionError::Integrity(format!("anchor lookup failed: {e}")))?,
307            None => None,
308        };
309
310        // Only the exclusive-lock holder may physically repair a torn tail (see
311        // `read_events`'s doc comment) — a lockless `open()` cannot prove the "torn" line
312        // isn't a live writer's in-flight, not-yet-fsynced append. Chain verification (S1)
313        // always runs regardless of lock status — only the physical *repair* is gated, never
314        // the integrity check itself; a failed check here means `open`/`open_exclusive` fails
315        // outright rather than opening atop unverified content (M3 open-time tail verify/seed)
316        // — unless `allow_unverified` is set (the deliberate `--allow-unverified` operator
317        // override), in which case verification is skipped entirely for this open, distinct
318        // from `ring = None` (which still fail-closes a chained file per NFR-004).
319        let (_, max_seq, chain_head) = read_events(
320            &events_path,
321            lock.is_some(),
322            ring.as_deref(),
323            &identity,
324            allow_unverified,
325            anchor.as_ref(),
326        )
327        .await?;
328
329        let file = OpenOptions::new()
330            .create(true)
331            .append(true)
332            .open(&events_path)
333            .await?;
334        set_permissions(&events_path, 0o600).await?;
335
336        let next_seq = max_seq.map_or(0, |seq| seq + 1);
337        let count = max_seq.map_or(0, |seq| seq + 1);
338        Ok(Self {
339            events_path,
340            writer: Mutex::new(SessionWriteState {
341                file,
342                prev: chain_head,
343                count,
344            }),
345            next_seq: AtomicU64::new(next_seq),
346            file_identity: identity,
347            ring,
348            allow_unverified,
349            anchor,
350            lock,
351        })
352    }
353
354    /// The path to this session's `events.jsonl` file.
355    #[must_use]
356    pub fn path(&self) -> &Path {
357        &self.events_path
358    }
359
360    /// The highest `seq` durably appended so far, or `None` if the log is empty.
361    #[must_use]
362    pub fn last_seq(&self) -> Option<u64> {
363        let next = self.next_seq.load(Ordering::SeqCst);
364        next.checked_sub(1)
365    }
366
367    /// Append one event, assigning it the next monotonic `seq`, and `fsync` before returning.
368    ///
369    /// The single `write_all` + `sync_all` pair is the atomicity boundary INV-SP-2 relies on: a
370    /// crash mid-write can only ever corrupt this one trailing line.
371    ///
372    /// When history-chain verification is configured, the chain-link read-modify-write
373    /// (canonicalize with `chain: None`, hash, then serialize again with the computed hash) is
374    /// folded into the same critical section as `seq` assignment and the physical write/fsync
375    /// (S2) — on-disk order always matches chain order, exactly as it already had to for `seq`
376    /// (#5487).
377    ///
378    /// # Errors
379    ///
380    /// Returns [`SessionError::Serde`] if the event cannot be JSON-encoded, or
381    /// [`SessionError::Io`] if the write or fsync fails.
382    #[tracing::instrument(name = "session.log.append", skip_all, level = "debug")]
383    pub async fn append(
384        &self,
385        turn_id: Option<u64>,
386        parent_seq: Option<u64>,
387        kind: SessionEvent,
388    ) -> Result<SessionEventEnvelope, SessionError> {
389        let mut state = self.writer.lock().await;
390
391        // seq assignment MUST happen while holding the writer lock: two concurrent
392        // callers assigned seq N and N+1 before the lock could still race for the
393        // lock and land their physical writes in the opposite order, breaking
394        // INV-SP-2's ascending-seq-order assumption (#5487).
395        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
396        let mut envelope = SessionEventEnvelope::new(seq, turn_id, parent_seq, kind);
397
398        let new_head = if let Some(ring) = self.ring.as_deref() {
399            let content = serde_json::to_vec(&envelope)?;
400            let base = state.prev.unwrap_or_else(|| {
401                genesis(
402                    &ring.current_key(),
403                    CHAIN_DOMAIN,
404                    &self.file_identity,
405                    ring.current_epoch(),
406                )
407            });
408            let h = chain_next(&ring.current_key(), &base, &content);
409            envelope.chain = Some(h.to_hex());
410            Some(h)
411        } else {
412            None
413        };
414
415        let mut line = serde_json::to_vec(&envelope)?;
416        line.push(b'\n');
417
418        state.file.write_all(&line).await?;
419        state.file.sync_all().await?;
420
421        // Only advance the running chain state after the write+fsync succeeded — a failed
422        // write must not desynchronize `prev` from what is actually durable on disk.
423        if let Some(h) = new_head {
424            state.prev = Some(h);
425        }
426        state.count += 1;
427
428        Ok(envelope)
429    }
430
431    /// Finalize this handle: if a vault-anchor store is configured (issue #6449) and this
432    /// handle's lifetime saw at least one chained append, persist an [`Anchor`] recording the
433    /// current `(epoch, count, head)` — a *prefix commitment* as of this clean close, not a
434    /// guarantee against every possible future truncation (see the module docs' session prefix
435    /// residual note).
436    ///
437    /// Written **last**, after every append is durably fsynced, so a crash before this point
438    /// leaves the log present with no anchor, which is always benign (never a false tamper
439    /// signature).
440    ///
441    /// A no-op, not an error, when no anchor store is configured or this handle never chained.
442    ///
443    /// # Errors
444    ///
445    /// Returns [`SessionError::Integrity`] if the configured anchor store's `put` fails. Callers
446    /// should treat this as best-effort and log rather than fail the whole close/shutdown flow —
447    /// the session log itself is already safely written.
448    pub async fn finalize(&self) -> Result<(), SessionError> {
449        let Some(store) = anchor_store() else {
450            return Ok(());
451        };
452        let (head, count) = {
453            let state = self.writer.lock().await;
454            let Some(head) = state.prev else {
455                return Ok(());
456            };
457            (head, state.count)
458        };
459        let epoch = self.ring.as_ref().map_or(0, |r| r.current_epoch());
460        let anchor = Anchor::new(epoch, count, head);
461        store
462            .put(AnchorSubsystem::SessionLog, &self.file_identity, anchor)
463            .await
464            .map_err(|e| SessionError::Integrity(format!("anchor put failed: {e}")))
465    }
466
467    /// Read and validate every event currently in the log, dropping a torn trailing line from
468    /// the result (INV-SP-2). Only physically repairs the file if this handle was opened via
469    /// [`Self::open_exclusive`] — see that method's doc comment.
470    ///
471    /// # Errors
472    ///
473    /// Returns [`SessionError::Io`] if the file cannot be read, or [`SessionError::Integrity`]
474    /// if hash-chain verification fails (S1: this check always runs before any torn-tail
475    /// repair).
476    #[tracing::instrument(name = "session.log.read_all", skip_all, level = "debug")]
477    pub async fn read_all(&self) -> Result<Vec<SessionEventEnvelope>, SessionError> {
478        // Same repair gating as `open_with_lock`: only repair the physical file when this
479        // handle holds the exclusive lock (i.e. is the session's owning writer). A read-only
480        // handle (`open()`) calling `read_all()` — e.g. `sessions show --events`, the ACP HTTP
481        // inspection endpoint — must never truncate a live writer's in-flight tail out from
482        // under it (#5487 Finding B).
483        let (events, _, _) = read_events(
484            &self.events_path,
485            self.lock.is_some(),
486            self.ring.as_deref(),
487            &self.file_identity,
488            self.allow_unverified,
489            self.anchor.as_ref(),
490        )
491        .await?;
492        Ok(events)
493    }
494
495    /// Read this log's events in bounded chunks of at most [`REPLAY_CHUNK_SIZE`], invoking
496    /// `on_chunk` per chunk instead of materializing the whole file's parsed events into one
497    /// `Vec` the way [`Self::read_all`] does (spec §6.2 step 3). Used by
498    /// [`crate::replay::ReplayEngine::replay`] to keep peak memory bounded when replaying large
499    /// session logs.
500    ///
501    /// `on_chunk` returns [`ControlFlow::Break`] to stop reading early (e.g. once a replay
502    /// `up_to` bound is reached) — remaining lines, including any torn tail beyond the stop
503    /// point, are then left uninspected.
504    ///
505    /// Note the over-read this implies: when `up_to` falls inside a chunk still being
506    /// accumulated, that entire chunk (up to [`REPLAY_CHUNK_SIZE`] events) is read and parsed
507    /// from disk before `on_chunk` gets a chance to evaluate the break — this never exceeds the
508    /// ≤ [`REPLAY_CHUNK_SIZE`]-in-memory bound, but a future refactor must not assume the read
509    /// stops the instant the `up_to` seq is reached.
510    ///
511    /// Same torn-tail detection/repair gating as [`Self::read_all`]: only physically repairs
512    /// the file when this handle was opened via [`Self::open_exclusive`]. Chain verification
513    /// (S1) runs incrementally as each event is parsed — before it is ever handed to
514    /// `on_chunk` — so a tampered event is never exposed to the caller even transiently, and
515    /// the bounded-memory guarantee this method exists for is preserved (verification state is
516    /// O(1): at most two in-flight [`zeph_common::hash_chain::ChainStreamVerifier`] candidates
517    /// until the key epoch resolves, then one).
518    ///
519    /// # Errors
520    ///
521    /// Returns [`SessionError::Io`] if the file cannot be read, or [`SessionError::Integrity`]
522    /// if hash-chain verification fails.
523    #[tracing::instrument(name = "session.log.read_chunked", skip_all, level = "debug")]
524    pub(crate) async fn read_chunked(
525        &self,
526        on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
527    ) -> Result<(), SessionError> {
528        read_events_chunked(
529            &self.events_path,
530            self.lock.is_some(),
531            self.ring.as_deref(),
532            &self.file_identity,
533            self.allow_unverified,
534            self.anchor.as_ref(),
535            on_chunk,
536        )
537        .await
538    }
539}
540
541/// Incremental chain-tracking state shared by [`read_events`]'s whole-file loop and
542/// [`read_events_chunked`]'s bounded-chunk loop, so both apply identical legacy-prefix /
543/// partial-strip / verification logic to each event as it is parsed (S1: this runs strictly
544/// before any torn-tail repair in both callers, and strictly before an event is exposed to a
545/// `read_chunked` caller via `on_chunk`).
546struct SessionChainTracker<'a> {
547    path: &'a Path,
548    ring: Option<&'a ChainKeyRing>,
549    file_identity: &'a [u8],
550    verifier: Option<ChainStreamVerifier>,
551    chain_started: bool,
552    /// Set only by [`SessionEventLog::open_exclusive_allow_unverified`]'s deliberate operator
553    /// override (spec-069 FR-004): when `true`, [`Self::feed`] is a no-op for every event,
554    /// chained or not — this is NOT the same as `ring = None` (which still fail-closes a
555    /// chained file per NFR-004; the override must actually bypass verification, not just
556    /// simulate a missing key, or `--allow-unverified` would be indistinguishable from a
557    /// plain hard failure).
558    allow_unverified: bool,
559    /// Vault anchor for this session, if configured and present (issue #6449). Also bypassed
560    /// entirely when `allow_unverified` is set, consistent with that override treating the
561    /// whole session as best-effort-trusted.
562    anchor: Option<&'a Anchor>,
563    /// Total physical event count fed so far (including any legacy prefix) — used to locate the
564    /// entry at `anchor.count` and capture the chain head immediately after it.
565    physical_index: u64,
566    /// The chain head immediately after the `anchor.count`-th event was fed, if reached.
567    anchor_checkpoint_head: Option<ChainHash>,
568}
569
570impl<'a> SessionChainTracker<'a> {
571    fn new(
572        path: &'a Path,
573        ring: Option<&'a ChainKeyRing>,
574        file_identity: &'a [u8],
575        allow_unverified: bool,
576        anchor: Option<&'a Anchor>,
577    ) -> Self {
578        Self {
579            path,
580            ring,
581            file_identity,
582            verifier: None,
583            chain_started: false,
584            allow_unverified,
585            anchor,
586            physical_index: 0,
587            anchor_checkpoint_head: None,
588        }
589    }
590
591    /// Feed one parsed event in on-disk order. Must be called for every event, in order,
592    /// including the legacy prefix (a no-op for those).
593    ///
594    /// # Errors
595    ///
596    /// Returns [`SessionError::Integrity`] on a partial strip (a `chain`-less event after
597    /// chaining has started), a chained log with no key ring configured (NFR-004), or a
598    /// definite/ambiguous chain-verification failure. Always `Ok` when this tracker was
599    /// constructed with `allow_unverified = true`.
600    fn feed(&mut self, event: &SessionEventEnvelope) -> Result<(), SessionError> {
601        if self.allow_unverified {
602            return Ok(());
603        }
604        let Some(hex) = event.chain.as_deref() else {
605            return if self.chain_started {
606                Err(SessionError::Integrity(format!(
607                    "session log '{}' has an event missing its chain field while earlier \
608                     events in this log are chained — partial strip detected, TAMPER DETECTED",
609                    self.path.display()
610                )))
611            } else {
612                // legacy prefix, no-op — but still advance the physical index (issue #6449:
613                // the anchor's `count` is a total physical count including any legacy prefix).
614                self.physical_index += 1;
615                Ok(())
616            };
617        };
618        self.chain_started = true;
619
620        let stored = ChainHash::from_hex(hex).map_err(|_| {
621            SessionError::Integrity(format!(
622                "session log '{}' has a malformed chain hash",
623                self.path.display()
624            ))
625        })?;
626
627        if self.verifier.is_none() {
628            let ring = self.ring.ok_or_else(|| {
629                SessionError::Integrity(format!(
630                    "session log '{}' carries chain metadata but no history-integrity key is \
631                     configured for this process — refusing to trust it unverified (NFR-004)",
632                    self.path.display()
633                ))
634            })?;
635            self.verifier = Some(ChainStreamVerifier::new(
636                ring,
637                CHAIN_DOMAIN,
638                self.file_identity.to_vec(),
639            ));
640        }
641
642        let mut stripped = event.clone();
643        stripped.chain = None;
644        let content = serde_json::to_vec(&stripped)?;
645        // `verifier` was just ensured `Some` above.
646        self.verifier
647            .as_mut()
648            .expect("verifier initialized above")
649            .verify_next(&content, &stored)
650            .map_err(|e| describe_chain_error(self.path, &e))?;
651
652        self.physical_index += 1;
653        if let Some(anchor) = self.anchor
654            && self.physical_index == anchor.count
655        {
656            self.anchor_checkpoint_head =
657                self.verifier.as_ref().and_then(ChainStreamVerifier::head);
658        }
659        Ok(())
660    }
661
662    /// Finalize: logs a re-keyed note if applicable, enforces the anchor decision table (issue
663    /// #6449), and returns the verified head hash (`None` if the log was pure legacy — chaining
664    /// never started).
665    ///
666    /// # Errors
667    ///
668    /// Returns [`SessionError::Integrity`] if an anchor is configured and: this log is
669    /// legacy-looking (no chain field ever fed) despite the anchor existing — a whole-strip
670    /// downgrade signature; the on-disk event count is below the anchor's recorded count
671    /// (truncation); or the chain head at the anchor's recorded count disagrees with the stored
672    /// anchor. A no-op when `allow_unverified` was set (mirrors [`Self::feed`]'s bypass).
673    fn finish(self) -> Result<Option<ChainHash>, SessionError> {
674        if self.allow_unverified {
675            return Ok(None);
676        }
677        if let Some(KeyResolution::Rekeyed(epoch)) = self
678            .verifier
679            .as_ref()
680            .and_then(ChainStreamVerifier::resolution)
681        {
682            tracing::info!(
683                path = %self.path.display(),
684                epoch,
685                "session log verified under a previous key epoch (re-keyed, not tampered)"
686            );
687        }
688        // Pure legacy (chaining never started) while a key IS configured is anomalous: every
689        // legitimately-written log since this process started should carry a chain field.
690        // Auto-trusted per FR-006 (unless an anchor proves otherwise, checked below), but must
691        // be observable, not silent (security review B2 condition, NFR-005).
692        if !self.chain_started && self.ring.is_some() {
693            warn_legacy_under_active_key_once(self.path);
694        }
695
696        if let Some(anchor) = self.anchor {
697            if !self.chain_started {
698                // Legacy-looking (no chain field anywhere), but a vault anchor exists for this
699                // session's identity: a file-write-only attacker cannot delete a vault entry, so
700                // this can only mean every chain field was deliberately stripped.
701                tracing::error!(
702                    audit_event = "history_integrity_tamper",
703                    subsystem = "session_log",
704                    reason = "whole_strip_legacy_with_anchor",
705                    path = %self.path.display(),
706                    anchored_count = anchor.count,
707                    "TAMPER DETECTED: session log is legacy-looking but a vault anchor exists for \
708                     it (issue #6449)"
709                );
710                return Err(SessionError::Integrity(format!(
711                    "TAMPER DETECTED in session log '{}': log has no chain metadata \
712                     (legacy-looking) but a vault anchor exists for it (anchored at count={}) — \
713                     this log was previously chained and its chain fields have been stripped",
714                    self.path.display(),
715                    anchor.count
716                )));
717            }
718            if self.physical_index < anchor.count {
719                tracing::error!(
720                    audit_event = "history_integrity_tamper",
721                    subsystem = "session_log",
722                    reason = "truncated_below_anchor_count",
723                    path = %self.path.display(),
724                    on_disk_count = self.physical_index,
725                    anchored_count = anchor.count,
726                    "TAMPER DETECTED: session log truncated below its anchored count (issue #6449)"
727                );
728                return Err(SessionError::Integrity(format!(
729                    "TAMPER DETECTED in session log '{}': on-disk event count ({}) is below the \
730                     anchored count ({}) — the log was truncated after being anchored",
731                    self.path.display(),
732                    self.physical_index,
733                    anchor.count
734                )));
735            }
736            let anchor_head = anchor.head().map_err(|e| {
737                SessionError::Integrity(format!(
738                    "session log '{}' anchor is malformed: {e}",
739                    self.path.display()
740                ))
741            })?;
742            match self.anchor_checkpoint_head {
743                Some(h) if h == anchor_head => {}
744                _ => {
745                    tracing::error!(
746                        audit_event = "history_integrity_tamper",
747                        subsystem = "session_log",
748                        reason = "anchor_head_mismatch",
749                        path = %self.path.display(),
750                        anchored_count = anchor.count,
751                        "TAMPER DETECTED: session log chain head at the anchored count does not \
752                         match the stored vault anchor (issue #6449)"
753                    );
754                    return Err(SessionError::Integrity(format!(
755                        "TAMPER DETECTED in session log '{}': chain head at the anchored count \
756                         ({}) does not match the stored vault anchor",
757                        self.path.display(),
758                        anchor.count
759                    )));
760                }
761            }
762        }
763
764        Ok(self.verifier.and_then(|v| v.head()))
765    }
766}
767
768/// Paths already warned about via [`warn_legacy_under_active_key_once`] this process — kept
769/// small (one entry per distinct session path actually read while chaining-disabled, not
770/// per-read) so a session's history isn't re-warned every time it's reloaded.
771static WARNED_LEGACY_UNDER_KEY: std::sync::LazyLock<StdRwLock<std::collections::HashSet<PathBuf>>> =
772    std::sync::LazyLock::new(|| StdRwLock::new(std::collections::HashSet::new()));
773
774/// Log a structured `WARN` the first time a given path is found to be pure-legacy (no `chain`
775/// field anywhere) while a history-integrity key ring IS configured (issue #6360, security
776/// review B2 condition (c)). See `zeph_subagent::transcript`'s identical helper for the full
777/// rationale — this mirrors it exactly.
778fn warn_legacy_under_active_key_once(path: &Path) {
779    let already_warned = WARNED_LEGACY_UNDER_KEY
780        .read()
781        .is_ok_and(|set| set.contains(path));
782    if already_warned {
783        return;
784    }
785    if let Ok(mut set) = WARNED_LEGACY_UNDER_KEY.write()
786        && !set.insert(path.to_path_buf())
787    {
788        return; // another thread warned first between the read and write locks
789    }
790    tracing::warn!(
791        path = %path.display(),
792        "history-chain integrity: session log classifies as legacy (no chain field anywhere) \
793         while a history-integrity key IS configured for this process — this is expected for \
794         genuine pre-upgrade content, but is also the signature of a full chain-strip downgrade \
795         attack (issue #6449, the vault-anchor gap); accepted per FR-006, flagged for operator \
796         visibility"
797    );
798}
799
800/// Render a [`ChainError`] as a [`SessionError::Integrity`] with operator-actionable wording
801/// that distinguishes a definite tamper verdict from an ambiguous/possibly-re-keyed one (FR-008
802/// — an operator must not be misled into believing a re-keyed log was tampered with).
803fn describe_chain_error(path: &Path, err: &ChainError) -> SessionError {
804    match err {
805        ChainError::Unverifiable => SessionError::Integrity(format!(
806            "session log '{}' is unverifiable: no known key epoch (current or previous \
807             rotation window) produces a valid chain — possibly re-keyed past the rotation \
808             window, or tampered; this is fail-closed by design (NFR-004) and cannot be \
809             auto-recovered",
810            path.display()
811        )),
812        ChainError::Mismatch { index } => SessionError::Integrity(format!(
813            "TAMPER DETECTED in session log '{}': chain hash mismatch at chained-entry index \
814             {index} — content was modified, reordered, or deleted after being written",
815            path.display()
816        )),
817        other => SessionError::Integrity(format!(
818            "session log '{}' failed chain verification: {other}",
819            path.display()
820        )),
821    }
822}
823
824/// The outcome of parsing one physical line from an `events.jsonl` file.
825enum LineOutcome {
826    /// End of file reached (0 bytes read).
827    Eof,
828    /// A blank line (allowed, e.g. trailing newline) — no envelope produced.
829    Blank,
830    /// A well-formed, newline-terminated envelope. Boxed: adding the `chain` field (issue
831    /// #6360) grew `SessionEventEnvelope` past clippy's `large_enum_variant` threshold relative
832    /// to this enum's other all-unit variants.
833    Event(Box<SessionEventEnvelope>),
834    /// A garbled or unterminated line — the torn tail (INV-SP-2). Can only be the final line
835    /// because appends are serialized through a single writer (INV-D2).
836    Torn,
837}
838
839/// Line-oriented cursor over an `events.jsonl` file, shared by [`read_events`] (whole-file,
840/// `Vec`-accumulating) and [`read_events_chunked`] (bounded-chunk streaming) so both read paths
841/// apply identical per-line validation (INV-SP-2).
842struct EventLineReader {
843    reader: BufReader<File>,
844    line: String,
845    offset: u64,
846    valid_len: u64,
847}
848
849impl EventLineReader {
850    /// Opens `path`, returning `None` if the file does not exist (an empty/absent log).
851    async fn open(path: &Path) -> Result<Option<Self>, SessionError> {
852        let file = match File::open(path).await {
853            Ok(file) => file,
854            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
855            Err(e) => return Err(e.into()),
856        };
857        Ok(Some(Self {
858            reader: BufReader::new(file),
859            line: String::new(),
860            offset: 0,
861            valid_len: 0,
862        }))
863    }
864
865    async fn next_line(&mut self) -> Result<LineOutcome, SessionError> {
866        self.line.clear();
867        let bytes_read = self.reader.read_line(&mut self.line).await? as u64;
868        if bytes_read == 0 {
869            return Ok(LineOutcome::Eof);
870        }
871
872        let is_terminated = self.line.ends_with('\n');
873        let trimmed = self.line.trim_end_matches(['\n', '\r']);
874        if trimmed.is_empty() {
875            self.offset += bytes_read;
876            if is_terminated {
877                self.valid_len = self.offset;
878            }
879            return Ok(LineOutcome::Blank);
880        }
881
882        match serde_json::from_str::<SessionEventEnvelope>(trimmed) {
883            Ok(envelope) if is_terminated => {
884                self.offset += bytes_read;
885                self.valid_len = self.offset;
886                Ok(LineOutcome::Event(Box::new(envelope)))
887            }
888            _ => Ok(LineOutcome::Torn),
889        }
890    }
891}
892
893/// Physically truncates `path` to `valid_len` if it is shorter than the file's actual length,
894/// repairing a torn tail on disk (INV-SP-2). Only called when `repair` gating (see
895/// [`SessionEventLog::open_exclusive`]) has already authorized it.
896async fn repair_torn_tail(path: &Path, valid_len: u64) -> Result<(), SessionError> {
897    let actual_len = fs::metadata(path).await?.len();
898    if valid_len < actual_len {
899        let file = OpenOptions::new().write(true).open(path).await?;
900        file.set_len(valid_len).await?;
901    }
902    Ok(())
903}
904
905/// Shared epilogue for [`read_events`] and [`read_events_chunked`]: warns once if a torn tail was
906/// detected (INV-SP-2), then physically repairs it when `repair` gating authorizes it.
907async fn finish_torn_tail(
908    path: &Path,
909    valid_len: u64,
910    repair: bool,
911    torn: bool,
912) -> Result<(), SessionError> {
913    if torn {
914        tracing::warn!(
915            path = %path.display(),
916            valid_len,
917            repair,
918            "dropped torn tail in session event log (INV-SP-2)"
919        );
920    }
921
922    if repair {
923        repair_torn_tail(path, valid_len).await?;
924    }
925
926    Ok(())
927}
928
929/// Read every valid line of `path`, dropping a garbled/incomplete trailing line from the
930/// in-memory result (INV-SP-2).
931///
932/// When `repair` is `true`, additionally truncates that torn tail physically on disk. Only the
933/// session's exclusive-lock holder (see [`SessionEventLog::open_exclusive`]) may pass `true`: it
934/// is the only caller that can prove a "torn" trailing line isn't actually a live writer's
935/// in-flight, not-yet-fsynced append (#5487 Finding B) — a lockless reader physically truncating
936/// the file could destroy a concurrent writer's tail out from under it.
937///
938/// Returns the validated events, the maximum `seq` seen (`None` for an empty/absent log), and
939/// the verified chain head hash (`None` if the log is pure legacy — no chain metadata anywhere).
940///
941/// # Errors
942///
943/// Returns [`SessionError::Integrity`] (S1) if an internal (non-trailing) line is malformed —
944/// which must never be treated as a repairable torn tail — or if hash-chain verification fails.
945/// This check always completes, and any resulting error is returned, **before** the torn-tail
946/// repair below ever runs: a chain-verifiable-but-corrupted internal line must never be silently
947/// truncated away as ordinary crash recovery.
948async fn read_events(
949    path: &Path,
950    repair: bool,
951    ring: Option<&ChainKeyRing>,
952    file_identity: &[u8],
953    allow_unverified: bool,
954    anchor: Option<&Anchor>,
955) -> Result<(Vec<SessionEventEnvelope>, Option<u64>, Option<ChainHash>), SessionError> {
956    let Some(mut lines) = EventLineReader::open(path).await? else {
957        return Ok((Vec::new(), None, None));
958    };
959
960    let mut events = Vec::new();
961    let mut max_seq = None;
962    let mut torn = false;
963    let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor);
964
965    loop {
966        match lines.next_line().await? {
967            LineOutcome::Eof => break,
968            LineOutcome::Blank => {}
969            LineOutcome::Event(envelope) => {
970                chain.feed(&envelope)?;
971                // Track the true running maximum, not just the last line's value: a
972                // file whose physical order doesn't match seq order (e.g. from a
973                // pre-fix #5487 race) must still yield the correct next seq.
974                max_seq = Some(max_seq.map_or(envelope.seq, |m: u64| m.max(envelope.seq)));
975                events.push(*envelope);
976            }
977            LineOutcome::Torn => {
978                torn = peek_confirms_trailing_torn(&mut lines, path).await?;
979                break;
980            }
981        }
982    }
983    let valid_len = lines.valid_len;
984    drop(lines);
985
986    // S1: chain verification has already run above, per event, as it was parsed — any failure
987    // already returned via `chain.feed`'s `?` before this point, so `finish_torn_tail`'s
988    // physical repair below is only ever reached once the whole read is chain-verified clean.
989    let chain_head = chain.finish()?;
990
991    finish_torn_tail(path, valid_len, repair, torn).await?;
992
993    Ok((events, max_seq, chain_head))
994}
995
996/// Read `path`'s events in bounded chunks of at most [`REPLAY_CHUNK_SIZE`], invoking `on_chunk`
997/// for each chunk instead of materializing the whole file into one `Vec` (spec §6.2 step 3).
998/// Torn-tail detection/repair semantics match [`read_events`] exactly — the torn check happens
999/// once, when EOF is reached (or not at all, if `on_chunk` breaks early). Chain verification
1000/// (S1) runs per event as it is parsed, strictly before that event is added to a chunk that
1001/// might be handed to `on_chunk`, so a tampered event is never exposed to the caller.
1002async fn read_events_chunked(
1003    path: &Path,
1004    repair: bool,
1005    ring: Option<&ChainKeyRing>,
1006    file_identity: &[u8],
1007    allow_unverified: bool,
1008    anchor: Option<&Anchor>,
1009    mut on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
1010) -> Result<(), SessionError> {
1011    let Some(mut lines) = EventLineReader::open(path).await? else {
1012        return Ok(());
1013    };
1014
1015    let mut chunk = Vec::with_capacity(REPLAY_CHUNK_SIZE);
1016    let mut torn = false;
1017    let mut broke_early = false;
1018    let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor);
1019
1020    loop {
1021        match lines.next_line().await? {
1022            LineOutcome::Eof => break,
1023            LineOutcome::Blank => {}
1024            LineOutcome::Event(envelope) => {
1025                chain.feed(&envelope)?;
1026                chunk.push(*envelope);
1027                if chunk.len() >= REPLAY_CHUNK_SIZE {
1028                    let flushed =
1029                        std::mem::replace(&mut chunk, Vec::with_capacity(REPLAY_CHUNK_SIZE));
1030                    if on_chunk(flushed).is_break() {
1031                        broke_early = true;
1032                        break;
1033                    }
1034                }
1035            }
1036            LineOutcome::Torn => {
1037                torn = peek_confirms_trailing_torn(&mut lines, path).await?;
1038                break;
1039            }
1040        }
1041    }
1042
1043    if !broke_early && !chunk.is_empty() && on_chunk(chunk).is_break() {
1044        broke_early = true;
1045    }
1046
1047    // An early `Break` means the caller (e.g. replay's `up_to` bound) stopped before EOF —
1048    // whatever lies beyond that point, torn or not, is irrelevant to this read.
1049    if broke_early {
1050        return Ok(());
1051    }
1052
1053    let valid_len = lines.valid_len;
1054    drop(lines);
1055    let _chain_head = chain.finish()?;
1056
1057    finish_torn_tail(path, valid_len, repair, torn).await?;
1058
1059    Ok(())
1060}
1061
1062/// S1 guard: a genuinely crash-torn tail (INV-D2, single serialized writer) can only be the
1063/// file's physical last line. Called immediately after [`LineOutcome::Torn`], this peeks one
1064/// more line to confirm nothing follows — if more content does follow, the "torn" line was not
1065/// a crash artifact (it is either mid-file corruption or a deliberately tampered line hiding
1066/// further tampering), and must never be treated as a repairable trailing tail.
1067///
1068/// Returns `Ok(true)` (genuine trailing torn tail, eligible for [`repair_torn_tail`]) only when
1069/// EOF immediately follows.
1070///
1071/// # Errors
1072///
1073/// Returns [`SessionError::Integrity`] if anything other than EOF follows the torn line.
1074async fn peek_confirms_trailing_torn(
1075    lines: &mut EventLineReader,
1076    path: &Path,
1077) -> Result<bool, SessionError> {
1078    match lines.next_line().await? {
1079        LineOutcome::Eof => Ok(true),
1080        _ => Err(SessionError::Integrity(format!(
1081            "internal malformed line in '{}' is not the file's physical last line — refusing \
1082             to treat it as a torn crash-recovery tail (TAMPER DETECTED or mid-file corruption)",
1083            path.display()
1084        ))),
1085    }
1086}
1087
1088/// Sets Unix permission bits on `path` (e.g. `0o700` for a directory, `0o600` for a file); a
1089/// no-op on non-Unix targets. `pub(crate)` so other modules (e.g. [`crate::fork`]) can apply the
1090/// same permission convention to directories/files they create outside this module.
1091#[cfg(unix)]
1092pub(crate) async fn set_permissions(path: &Path, mode: u32) -> Result<(), SessionError> {
1093    use std::os::unix::fs::PermissionsExt;
1094    fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await?;
1095    Ok(())
1096}
1097
1098/// Cross-process advisory lock enforcing INV-D2's single-writer invariant, held for the
1099/// lifetime of a [`SessionEventLog`] opened via [`SessionEventLog::open_exclusive`].
1100///
1101/// Backed by `flock(2)` on a sibling lock file (`events.jsonl.lock`) rather than
1102/// `events.jsonl` itself, so the lock is independent of the append-mode file handle already
1103/// held for writing. Mirrors `zeph-scheduler`'s `PidFile`: the holder's PID is written into the
1104/// file once the lock is acquired, so a contending `acquire` can read it back to tell an
1105/// operator (or [`SessionError::AlreadyLocked`]'s caller) which process to check — unlike a pid
1106/// file, though, the lock file is never unlinked on drop: it is a permanent sentinel, not
1107/// ephemeral process identity, and unlinking it would reopen an unlink/re-create race between
1108/// the releasing and the next acquiring process.
1109///
1110/// **Invariant**: `session_dir` MUST reside on a local filesystem. NFS/network mounts do not
1111/// guarantee reliable exclusive locking with `flock(2)` (#6378).
1112#[cfg(unix)]
1113struct AdvisoryLock(#[allow(dead_code)] rustix::fd::OwnedFd);
1114
1115#[cfg(unix)]
1116impl AdvisoryLock {
1117    fn acquire(session_dir: &Path) -> Result<Self, SessionError> {
1118        use rustix::fs::{FlockOperation, Mode, OFlags};
1119
1120        let lock_path = session_dir.join(LOCK_FILE_NAME);
1121        let fd = rustix::fs::open(
1122            &lock_path,
1123            OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
1124            Mode::from_raw_mode(0o600),
1125        )
1126        .map_err(std::io::Error::from)?;
1127
1128        rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
1129            if e == rustix::io::Errno::WOULDBLOCK {
1130                // The lock file is a permanent sentinel (never unlinked, never cleared on
1131                // `Drop`), so between a new holder's successful `flock` above and its
1132                // `ftruncate`+write below, this read can still observe the *previous* holder's
1133                // PID — a dead PID here is a snapshot, not proof the current holder is gone
1134                // (#6378, see `describe_already_locked`'s hedged wording).
1135                let pid = zeph_common::pidfile::read_pid_lenient(&lock_path);
1136                let pid_alive = pid.map(zeph_common::pidfile::is_process_alive);
1137                SessionError::AlreadyLocked {
1138                    path: lock_path.display().to_string(),
1139                    pid,
1140                    pid_alive,
1141                }
1142            } else {
1143                SessionError::Io(e.into())
1144            }
1145        })?;
1146
1147        // We hold the lock — record our own PID so a future contending `acquire` can diagnose
1148        // us (#6378: previously the lock file was permanently empty, giving an operator nothing
1149        // to verify a contended lock against). Mirrors `PidLockGuard::acquire`.
1150        rustix::fs::ftruncate(&fd, 0).map_err(std::io::Error::from)?;
1151        // A `u32` PID renders to at most 10 bytes — a single `write(2)` to a local regular file
1152        // for a buffer this small does not return a short count in practice, so no `write_all`
1153        // retry loop is needed here.
1154        rustix::io::write(&fd, std::process::id().to_string().as_bytes())
1155            .map_err(std::io::Error::from)?;
1156
1157        Ok(Self(fd))
1158    }
1159}
1160
1161/// No vetted cross-platform advisory-locking primitive exists in this workspace, so
1162/// [`SessionEventLog::open_exclusive`] does not enforce INV-D2 on non-Unix targets.
1163#[cfg(not(unix))]
1164struct AdvisoryLock;
1165
1166#[cfg(not(unix))]
1167impl AdvisoryLock {
1168    fn acquire(_session_dir: &Path) -> Result<Self, SessionError> {
1169        Ok(Self)
1170    }
1171}
1172
1173#[cfg(not(unix))]
1174pub(crate) async fn set_permissions(_path: &Path, _mode: u32) -> Result<(), SessionError> {
1175    Ok(())
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use std::future::Future;
1181    use std::pin::Pin;
1182
1183    use super::*;
1184
1185    #[tokio::test]
1186    async fn test_append_and_read_roundtrip() {
1187        let dir = tempfile::tempdir().unwrap();
1188        let log = SessionEventLog::open(dir.path()).await.unwrap();
1189
1190        for i in 0..5u64 {
1191            log.append(
1192                Some(i),
1193                None,
1194                SessionEvent::UserMessage {
1195                    text: format!("msg-{i}"),
1196                    image_refs: vec![],
1197                },
1198            )
1199            .await
1200            .unwrap();
1201        }
1202
1203        assert_eq!(log.last_seq(), Some(4));
1204        let events = log.read_all().await.unwrap();
1205        assert_eq!(events.len(), 5);
1206        for (i, envelope) in events.iter().enumerate() {
1207            assert_eq!(envelope.seq, i as u64);
1208        }
1209    }
1210
1211    #[tokio::test]
1212    async fn test_reopen_resumes_seq() {
1213        let dir = tempfile::tempdir().unwrap();
1214        {
1215            let log = SessionEventLog::open(dir.path()).await.unwrap();
1216            log.append(
1217                None,
1218                None,
1219                SessionEvent::SessionEnded { reason: "x".into() },
1220            )
1221            .await
1222            .unwrap();
1223        }
1224        let log = SessionEventLog::open(dir.path()).await.unwrap();
1225        assert_eq!(log.last_seq(), Some(0));
1226        let appended = log
1227            .append(
1228                None,
1229                None,
1230                SessionEvent::SessionEnded { reason: "y".into() },
1231            )
1232            .await
1233            .unwrap();
1234        assert_eq!(appended.seq, 1);
1235    }
1236
1237    #[tokio::test]
1238    async fn test_torn_write_truncation() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let path;
1241        {
1242            let log = SessionEventLog::open(dir.path()).await.unwrap();
1243            for i in 0..3u64 {
1244                log.append(
1245                    None,
1246                    None,
1247                    SessionEvent::UserMessage {
1248                        text: format!("msg-{i}"),
1249                        image_refs: vec![],
1250                    },
1251                )
1252                .await
1253                .unwrap();
1254            }
1255            path = log.path().to_path_buf();
1256        }
1257
1258        // Simulate a torn write: truncate the file mid-way through the last line.
1259        let full = tokio::fs::read(&path).await.unwrap();
1260        let cut = full.len() - 5;
1261        tokio::fs::write(&path, &full[..cut]).await.unwrap();
1262
1263        let log = SessionEventLog::open(dir.path()).await.unwrap();
1264        assert_eq!(
1265            log.last_seq(),
1266            Some(1),
1267            "torn last line must be dropped cleanly"
1268        );
1269        let events = log.read_all().await.unwrap();
1270        assert_eq!(events.len(), 2);
1271    }
1272
1273    /// Regression test for #5487 Finding B: a lockless `open()`/`read_all()` must never
1274    /// physically truncate a torn tail — it cannot distinguish a genuinely torn line from a
1275    /// live writer's in-flight, not-yet-fsynced append, so mutating the file could destroy that
1276    /// writer's data out from under it. Only `open_exclusive()` may repair.
1277    #[cfg(unix)]
1278    #[tokio::test]
1279    async fn test_open_does_not_physically_truncate_torn_tail() {
1280        let dir = tempfile::tempdir().unwrap();
1281        let path;
1282        {
1283            let log = SessionEventLog::open(dir.path()).await.unwrap();
1284            for i in 0..3u64 {
1285                log.append(
1286                    None,
1287                    None,
1288                    SessionEvent::UserMessage {
1289                        text: format!("msg-{i}"),
1290                        image_refs: vec![],
1291                    },
1292                )
1293                .await
1294                .unwrap();
1295            }
1296            path = log.path().to_path_buf();
1297        }
1298
1299        let full = tokio::fs::read(&path).await.unwrap();
1300        let cut = full.len() - 5;
1301        tokio::fs::write(&path, &full[..cut]).await.unwrap();
1302        let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
1303
1304        // Lockless open()/read_all(): in-memory result drops the torn line, but the file on
1305        // disk must be untouched.
1306        let log = SessionEventLog::open(dir.path()).await.unwrap();
1307        assert_eq!(log.last_seq(), Some(1));
1308        let events = log.read_all().await.unwrap();
1309        assert_eq!(events.len(), 2);
1310        assert_eq!(
1311            tokio::fs::metadata(&path).await.unwrap().len(),
1312            torn_len,
1313            "open()/read_all() must never physically truncate the file"
1314        );
1315        drop(log);
1316
1317        // open_exclusive(): now physically repairs the file.
1318        let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1319        assert_eq!(log.last_seq(), Some(1));
1320        let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
1321        assert!(
1322            repaired_len < torn_len,
1323            "open_exclusive() must physically truncate the torn tail"
1324        );
1325    }
1326
1327    #[tokio::test]
1328    async fn test_torn_write_truncation_various_offsets() {
1329        for cut_from_end in [1usize, 3, 10, 20] {
1330            let dir = tempfile::tempdir().unwrap();
1331            let path;
1332            {
1333                let log = SessionEventLog::open(dir.path()).await.unwrap();
1334                for i in 0..4u64 {
1335                    log.append(
1336                        None,
1337                        None,
1338                        SessionEvent::UserMessage {
1339                            text: format!("event-number-{i}"),
1340                            image_refs: vec![],
1341                        },
1342                    )
1343                    .await
1344                    .unwrap();
1345                }
1346                path = log.path().to_path_buf();
1347            }
1348            let full = tokio::fs::read(&path).await.unwrap();
1349            let cut = full.len().saturating_sub(cut_from_end);
1350            tokio::fs::write(&path, &full[..cut]).await.unwrap();
1351
1352            // Must not panic and must never see more than the 4 originally-committed events.
1353            let log = SessionEventLog::open(dir.path()).await.unwrap();
1354            let events = log.read_all().await.unwrap();
1355            assert!(events.len() <= 4);
1356        }
1357    }
1358
1359    #[tokio::test]
1360    async fn test_empty_log_read_all() {
1361        let dir = tempfile::tempdir().unwrap();
1362        let log = SessionEventLog::open(dir.path()).await.unwrap();
1363        assert_eq!(log.last_seq(), None);
1364        assert!(log.read_all().await.unwrap().is_empty());
1365    }
1366
1367    #[cfg(unix)]
1368    #[tokio::test]
1369    async fn test_file_permissions_are_0600() {
1370        use std::os::unix::fs::PermissionsExt;
1371        let dir = tempfile::tempdir().unwrap();
1372        let log = SessionEventLog::open(dir.path()).await.unwrap();
1373        let meta = tokio::fs::metadata(log.path()).await.unwrap();
1374        assert_eq!(meta.permissions().mode() & 0o777, 0o600);
1375    }
1376
1377    /// Regression test for #5487 bug B: `read_events` must compute the true running
1378    /// maximum `seq`, not just take the last physical line's value. Simulates the on-disk
1379    /// shape a pre-fix concurrent-append race could produce: seq 7 written physically before
1380    /// seq 6.
1381    #[tokio::test]
1382    async fn test_max_seq_survives_out_of_order_physical_lines() {
1383        let dir = tempfile::tempdir().unwrap();
1384        let path = dir.path().join(EVENTS_FILE_NAME);
1385
1386        let make_line = |seq: u64| {
1387            let envelope = SessionEventEnvelope::new(
1388                seq,
1389                None,
1390                None,
1391                SessionEvent::SessionEnded { reason: "x".into() },
1392            );
1393            let mut line = serde_json::to_vec(&envelope).unwrap();
1394            line.push(b'\n');
1395            line
1396        };
1397
1398        // Physical order is seq=7 then seq=6 — out of seq order, as a pre-fix race could
1399        // produce, but every line individually well-formed and fsynced.
1400        let mut contents = make_line(7);
1401        contents.extend(make_line(6));
1402        tokio::fs::write(&path, &contents).await.unwrap();
1403
1404        let log = SessionEventLog::open(dir.path()).await.unwrap();
1405        assert_eq!(
1406            log.last_seq(),
1407            Some(7),
1408            "next_seq must be derived from the true max seq, not the last physical line"
1409        );
1410        let appended = log
1411            .append(
1412                None,
1413                None,
1414                SessionEvent::SessionEnded { reason: "z".into() },
1415            )
1416            .await
1417            .unwrap();
1418        assert_eq!(
1419            appended.seq, 8,
1420            "must not reuse a seq already present earlier in the file"
1421        );
1422    }
1423
1424    /// Regression test for #6378: `AdvisoryLock::acquire` must record the holder's own PID
1425    /// into the lock file's contents so a contending `acquire` can diagnose who holds it.
1426    /// Before the fix the lock file was permanently empty.
1427    #[cfg(unix)]
1428    #[tokio::test]
1429    async fn test_open_exclusive_writes_own_pid_into_lock_file() {
1430        let dir = tempfile::tempdir().unwrap();
1431        let _log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1432
1433        let lock_path = dir.path().join(LOCK_FILE_NAME);
1434        let contents = tokio::fs::read_to_string(&lock_path).await.unwrap();
1435        let pid: u32 = contents.trim().parse().unwrap_or_else(|e| {
1436            panic!("lock file contents {contents:?} did not parse as a PID: {e}")
1437        });
1438        assert_eq!(pid, std::process::id());
1439    }
1440
1441    /// Regression test for #6378: on contention, `SessionError::AlreadyLocked` must carry the
1442    /// holder's PID (read back from the lock file) and a liveness verdict, not just the lock
1443    /// path. This is a same-process test, so both the holder and the contender are the current
1444    /// process — a genuinely alive PID is exactly what `pid_alive` must report.
1445    #[cfg(unix)]
1446    #[tokio::test]
1447    async fn test_open_exclusive_rejects_second_writer() {
1448        let dir = tempfile::tempdir().unwrap();
1449        let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1450        match SessionEventLog::open_exclusive(dir.path()).await {
1451            Err(SessionError::AlreadyLocked { pid, pid_alive, .. }) => {
1452                assert_eq!(pid, Some(std::process::id()));
1453                assert_eq!(pid_alive, Some(true));
1454            }
1455            Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
1456            Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
1457        }
1458    }
1459
1460    #[cfg(unix)]
1461    #[tokio::test]
1462    async fn test_open_exclusive_allows_reacquire_after_drop() {
1463        let dir = tempfile::tempdir().unwrap();
1464        {
1465            let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1466        }
1467        // Lock released when the first handle dropped — must not still be held.
1468        let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1469    }
1470
1471    #[cfg(unix)]
1472    #[tokio::test]
1473    async fn test_open_is_not_blocked_by_open_exclusive() {
1474        let dir = tempfile::tempdir().unwrap();
1475        let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1476        // Read-only `open()` must still succeed while a writer holds the exclusive lock.
1477        let _reader = SessionEventLog::open(dir.path()).await.unwrap();
1478    }
1479
1480    /// Regression test for #5487 bug A: drives genuine concurrent `append()` calls (on real
1481    /// OS threads, not just cooperative interleaving) against one shared `SessionEventLog` and
1482    /// asserts seq assignment and physical write order never diverge. Before the fix, `seq`
1483    /// was assigned via `fetch_add` before acquiring the writer lock, so a task could win a
1484    /// low seq but lose the race for the lock, landing its line after a higher-seq task's line.
1485    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1486    async fn test_concurrent_append_preserves_seq_order() {
1487        const N: u64 = 100;
1488
1489        let dir = tempfile::tempdir().unwrap();
1490        let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1491
1492        let mut tasks = tokio::task::JoinSet::new();
1493        for i in 0..N {
1494            let log = log.clone();
1495            tasks.spawn(async move {
1496                log.append(
1497                    None,
1498                    None,
1499                    SessionEvent::UserMessage {
1500                        text: format!("msg-{i}"),
1501                        image_refs: vec![],
1502                    },
1503                )
1504                .await
1505                .unwrap()
1506                .seq
1507            });
1508        }
1509
1510        let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
1511        assigned_seqs.sort_unstable();
1512        assert_eq!(
1513            assigned_seqs,
1514            (0..N).collect::<Vec<_>>(),
1515            "every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
1516        );
1517
1518        // Physical order on disk must match seq order: seq assignment and the write it
1519        // guards must never diverge under contention (#5487 fix 2).
1520        let events = log.read_all().await.unwrap();
1521        assert_eq!(events.len(), usize::try_from(N).unwrap());
1522        for (i, envelope) in events.iter().enumerate() {
1523            assert_eq!(
1524                envelope.seq, i as u64,
1525                "physical line {i} must carry seq {i}; seq and write order diverged"
1526            );
1527        }
1528    }
1529
1530    /// Regression test for #5445 Finding 3: `read_events_chunked` must never hold more than
1531    /// [`REPLAY_CHUNK_SIZE`] raw envelopes at once, and the concatenation of all chunks must
1532    /// exactly reproduce what `read_events` (the whole-file `Vec` path) returns, in order.
1533    #[tokio::test]
1534    async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
1535        const N: u64 = 733; // comfortably > REPLAY_CHUNK_SIZE, not an exact multiple of it
1536
1537        let dir = tempfile::tempdir().unwrap();
1538        let log = SessionEventLog::open(dir.path()).await.unwrap();
1539        for i in 0..N {
1540            log.append(
1541                None,
1542                None,
1543                SessionEvent::UserMessage {
1544                    text: format!("msg-{i}"),
1545                    image_refs: vec![],
1546                },
1547            )
1548            .await
1549            .unwrap();
1550        }
1551
1552        let (whole_file_events, _, _) =
1553            read_events(log.path(), false, None, b"test-session", false, None)
1554                .await
1555                .unwrap();
1556        assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
1557
1558        let mut chunked_events = Vec::new();
1559        let mut chunk_sizes = Vec::new();
1560        read_events_chunked(log.path(), false, None, b"test-session", false, None, |chunk| {
1561            assert!(
1562                chunk.len() <= REPLAY_CHUNK_SIZE,
1563                "a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
1564                chunk.len()
1565            );
1566            chunk_sizes.push(chunk.len());
1567            chunked_events.extend(chunk);
1568            ControlFlow::Continue(())
1569        })
1570        .await
1571        .unwrap();
1572
1573        assert_eq!(
1574            chunked_events.len(),
1575            whole_file_events.len(),
1576            "chunked read must yield the same total event count as the whole-file read"
1577        );
1578        for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
1579            assert_eq!(whole.seq, chunked.seq);
1580        }
1581        assert!(
1582            chunk_sizes.len() > 1,
1583            "expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
1584        );
1585    }
1586
1587    // --- Hash-chain integrity tests (issue #6360) ---
1588    //
1589    // `configure_history_integrity` mutates process-global state, so these tests rely on
1590    // `cargo nextest`'s one-process-per-test model for isolation (never run this module with
1591    // plain `cargo test`, which shares one process across tests in a binary and could race).
1592
1593    fn test_ring(epoch: u32, byte: u8) -> Arc<ChainKeyRing> {
1594        Arc::new(ChainKeyRing::new(
1595            epoch,
1596            zeph_common::hash_chain::ChainKey::new([byte; 32]),
1597        ))
1598    }
1599
1600    #[tokio::test]
1601    async fn chained_log_roundtrip() {
1602        configure_history_integrity(Some(test_ring(0, 20)));
1603        let dir = tempfile::tempdir().unwrap();
1604        let log = SessionEventLog::open(dir.path()).await.unwrap();
1605        log.append(
1606            None,
1607            None,
1608            SessionEvent::UserMessage {
1609                text: "hello".to_owned(),
1610                image_refs: vec![],
1611            },
1612        )
1613        .await
1614        .unwrap();
1615        log.append(
1616            None,
1617            None,
1618            SessionEvent::SessionEnded { reason: "x".into() },
1619        )
1620        .await
1621        .unwrap();
1622        drop(log);
1623
1624        let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1625            .await
1626            .unwrap();
1627        assert!(
1628            raw.lines().all(|l| l.contains("\"chain\":")),
1629            "every line must carry a chain field once integrity is configured"
1630        );
1631
1632        let log = SessionEventLog::open(dir.path()).await.unwrap();
1633        let events = log.read_all().await.unwrap();
1634        assert_eq!(events.len(), 2);
1635
1636        configure_history_integrity(None);
1637    }
1638
1639    #[tokio::test]
1640    async fn tamper_in_place_edit_is_detected() {
1641        configure_history_integrity(Some(test_ring(0, 21)));
1642        let dir = tempfile::tempdir().unwrap();
1643        let log = SessionEventLog::open(dir.path()).await.unwrap();
1644        // A first, untouched entry so the key epoch resolves cleanly there; tampering the
1645        // *second* entry below then produces a definite Mismatch (not an ambiguous
1646        // Unverifiable, which is what tampering the very first chained entry would produce —
1647        // that case is covered separately by the epoch-resolution tests).
1648        log.append(
1649            None,
1650            None,
1651            SessionEvent::SessionEnded {
1652                reason: "untouched".into(),
1653            },
1654        )
1655        .await
1656        .unwrap();
1657        log.append(
1658            None,
1659            None,
1660            SessionEvent::UserMessage {
1661                text: "original".to_owned(),
1662                image_refs: vec![],
1663            },
1664        )
1665        .await
1666        .unwrap();
1667        drop(log);
1668
1669        let path = dir.path().join(EVENTS_FILE_NAME);
1670        let raw = tokio::fs::read_to_string(&path).await.unwrap();
1671        let tampered = raw.replace("original", "forged-approval");
1672        assert_ne!(raw, tampered);
1673        tokio::fs::write(&path, tampered).await.unwrap();
1674
1675        let result = SessionEventLog::open(dir.path()).await;
1676        assert!(matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("TAMPER")));
1677
1678        configure_history_integrity(None);
1679    }
1680
1681    #[tokio::test]
1682    async fn legacy_log_is_auto_trusted_once_when_integrity_configured_later() {
1683        configure_history_integrity(None);
1684        let dir = tempfile::tempdir().unwrap();
1685        let log = SessionEventLog::open(dir.path()).await.unwrap();
1686        log.append(
1687            None,
1688            None,
1689            SessionEvent::UserMessage {
1690                text: "pre-feature message".to_owned(),
1691                image_refs: vec![],
1692            },
1693        )
1694        .await
1695        .unwrap();
1696        drop(log);
1697
1698        let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1699            .await
1700            .unwrap();
1701        assert!(!raw.contains("\"chain\":"));
1702
1703        configure_history_integrity(Some(test_ring(0, 22)));
1704        let log = SessionEventLog::open(dir.path()).await.unwrap();
1705        let events = log.read_all().await.unwrap();
1706        assert_eq!(
1707            events.len(),
1708            1,
1709            "legacy content must be auto-trusted, not rejected"
1710        );
1711
1712        // A legacy log read while a key IS configured must be flagged exactly once per path
1713        // (security review B2 condition (c)) — repeat reads must not re-warn.
1714        let events_path = dir.path().join(EVENTS_FILE_NAME);
1715        assert!(
1716            WARNED_LEGACY_UNDER_KEY
1717                .read()
1718                .unwrap()
1719                .contains(&events_path),
1720            "path must be recorded as warned after the first legacy-under-active-key read"
1721        );
1722        let warned_count_before = WARNED_LEGACY_UNDER_KEY.read().unwrap().len();
1723        let _ = log.read_all().await.unwrap();
1724        assert_eq!(
1725            WARNED_LEGACY_UNDER_KEY.read().unwrap().len(),
1726            warned_count_before,
1727            "a second read of the same path must not add a second warned-set entry"
1728        );
1729
1730        configure_history_integrity(None);
1731    }
1732
1733    #[tokio::test]
1734    async fn partial_strip_of_chain_field_is_detected_as_tamper() {
1735        configure_history_integrity(Some(test_ring(0, 23)));
1736        let dir = tempfile::tempdir().unwrap();
1737        let log = SessionEventLog::open(dir.path()).await.unwrap();
1738        log.append(
1739            None,
1740            None,
1741            SessionEvent::UserMessage {
1742                text: "one".to_owned(),
1743                image_refs: vec![],
1744            },
1745        )
1746        .await
1747        .unwrap();
1748        log.append(
1749            None,
1750            None,
1751            SessionEvent::UserMessage {
1752                text: "two".to_owned(),
1753                image_refs: vec![],
1754            },
1755        )
1756        .await
1757        .unwrap();
1758        drop(log);
1759
1760        let path = dir.path().join(EVENTS_FILE_NAME);
1761        let raw = tokio::fs::read_to_string(&path).await.unwrap();
1762        let lines: Vec<&str> = raw.lines().collect();
1763        assert_eq!(lines.len(), 2);
1764        let mut second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1765        second.as_object_mut().unwrap().remove("chain");
1766        let stripped = format!("{}\n{}\n", lines[0], second);
1767        tokio::fs::write(&path, stripped).await.unwrap();
1768
1769        let result = SessionEventLog::open(dir.path()).await;
1770        assert!(
1771            matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("partial strip"))
1772        );
1773
1774        configure_history_integrity(None);
1775    }
1776
1777    #[tokio::test]
1778    async fn key_unavailable_on_chained_log_fails_closed_not_legacy() {
1779        configure_history_integrity(Some(test_ring(0, 24)));
1780        let dir = tempfile::tempdir().unwrap();
1781        let log = SessionEventLog::open(dir.path()).await.unwrap();
1782        log.append(
1783            None,
1784            None,
1785            SessionEvent::SessionEnded { reason: "x".into() },
1786        )
1787        .await
1788        .unwrap();
1789        drop(log);
1790
1791        configure_history_integrity(None);
1792        let result = SessionEventLog::open(dir.path()).await;
1793        assert!(matches!(result, Err(SessionError::Integrity(_))));
1794    }
1795
1796    /// `--allow-unverified` override: a tampered chained log must still open and read via
1797    /// `open_exclusive_allow_unverified`, and the bypass must persist across `read_all` calls
1798    /// on the same handle, not just the initial open.
1799    #[tokio::test]
1800    async fn allow_unverified_bypasses_tamper_detection_for_the_whole_handle() {
1801        configure_history_integrity(Some(test_ring(0, 40)));
1802        let dir = tempfile::tempdir().unwrap();
1803        let log = SessionEventLog::open(dir.path()).await.unwrap();
1804        log.append(
1805            None,
1806            None,
1807            SessionEvent::SessionEnded {
1808                reason: "untouched".into(),
1809            },
1810        )
1811        .await
1812        .unwrap();
1813        log.append(
1814            None,
1815            None,
1816            SessionEvent::UserMessage {
1817                text: "original".to_owned(),
1818                image_refs: vec![],
1819            },
1820        )
1821        .await
1822        .unwrap();
1823        drop(log);
1824
1825        let path = dir.path().join(EVENTS_FILE_NAME);
1826        let raw = tokio::fs::read_to_string(&path).await.unwrap();
1827        let tampered = raw.replace("original", "forged-approval");
1828        assert_ne!(raw, tampered);
1829        tokio::fs::write(&path, tampered).await.unwrap();
1830
1831        // The normal path still fails closed.
1832        let result = SessionEventLog::open_exclusive(dir.path()).await;
1833        assert!(matches!(result, Err(SessionError::Integrity(_))));
1834
1835        // The deliberate override succeeds, both at open and on a subsequent read_all.
1836        let log = SessionEventLog::open_exclusive_allow_unverified(dir.path())
1837            .await
1838            .unwrap();
1839        let events = log.read_all().await.unwrap();
1840        assert_eq!(events.len(), 2);
1841
1842        configure_history_integrity(None);
1843    }
1844
1845    #[tokio::test]
1846    async fn rotated_key_epoch_verifies_as_rekeyed_not_tampered() {
1847        let old_key_byte = 25u8;
1848        configure_history_integrity(Some(test_ring(0, old_key_byte)));
1849        let dir = tempfile::tempdir().unwrap();
1850        let log = SessionEventLog::open(dir.path()).await.unwrap();
1851        log.append(
1852            None,
1853            None,
1854            SessionEvent::SessionEnded { reason: "x".into() },
1855        )
1856        .await
1857        .unwrap();
1858        drop(log);
1859
1860        let ring = Arc::new(
1861            ChainKeyRing::new(1, zeph_common::hash_chain::ChainKey::new([30u8; 32])).with_previous(
1862                0,
1863                zeph_common::hash_chain::ChainKey::new([old_key_byte; 32]),
1864            ),
1865        );
1866        configure_history_integrity(Some(ring));
1867
1868        let log = SessionEventLog::open(dir.path()).await.unwrap();
1869        let events = log.read_all().await.unwrap();
1870        assert_eq!(events.len(), 1);
1871
1872        configure_history_integrity(None);
1873    }
1874
1875    /// S1 regression: a mid-file (non-trailing) malformed line must never be silently
1876    /// auto-truncated by `open_exclusive`'s torn-tail repair — it must surface as an integrity
1877    /// error instead. This is a correctness bug independent of chaining (it corrupts crash
1878    /// recovery itself), reproduced here without configuring any key ring.
1879    #[tokio::test]
1880    async fn internal_malformed_line_is_never_treated_as_torn_tail() {
1881        configure_history_integrity(None);
1882        let dir = tempfile::tempdir().unwrap();
1883        let path;
1884        {
1885            let log = SessionEventLog::open(dir.path()).await.unwrap();
1886            for i in 0..3u64 {
1887                log.append(
1888                    None,
1889                    None,
1890                    SessionEvent::UserMessage {
1891                        text: format!("msg-{i}"),
1892                        image_refs: vec![],
1893                    },
1894                )
1895                .await
1896                .unwrap();
1897            }
1898            path = log.path().to_path_buf();
1899        }
1900
1901        // Corrupt the *middle* line (not the last) so it fails to parse as JSON, followed by
1902        // legitimate content — simulates a tamper that overwrites one line in place with
1903        // garbage, as opposed to a genuine crash mid-append (which can only corrupt the tail).
1904        let content = tokio::fs::read_to_string(&path).await.unwrap();
1905        let lines: Vec<&str> = content.lines().collect();
1906        assert_eq!(lines.len(), 3);
1907        let corrupted = format!("{}\nnot valid json at all\n{}\n", lines[0], lines[2]);
1908        tokio::fs::write(&path, corrupted).await.unwrap();
1909
1910        // Under the pre-S1 bug, `open_exclusive` would silently truncate everything from the
1911        // corrupted line onward, treating it as an ordinary torn crash-recovery tail. It must
1912        // instead fail closed.
1913        let result = SessionEventLog::open_exclusive(dir.path()).await;
1914        assert!(matches!(result, Err(SessionError::Integrity(_))));
1915
1916        // And the file on disk must be untouched — no silent repair happened.
1917        let after = tokio::fs::read_to_string(&path).await.unwrap();
1918        assert_eq!(
1919            after.lines().count(),
1920            3,
1921            "file must not have been truncated"
1922        );
1923
1924        configure_history_integrity(None);
1925    }
1926
1927    /// S2 regression: concurrent `append` calls must never desynchronize on-disk physical order
1928    /// from chain-link order.
1929    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1930    async fn concurrent_append_preserves_chain_order() {
1931        const N: u64 = 60;
1932        configure_history_integrity(Some(test_ring(0, 26)));
1933        let dir = tempfile::tempdir().unwrap();
1934        let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1935
1936        let mut tasks = tokio::task::JoinSet::new();
1937        for i in 0..N {
1938            let log = log.clone();
1939            tasks.spawn(async move {
1940                log.append(
1941                    None,
1942                    None,
1943                    SessionEvent::UserMessage {
1944                        text: format!("msg-{i}"),
1945                        image_refs: vec![],
1946                    },
1947                )
1948                .await
1949                .unwrap();
1950            });
1951        }
1952        while tasks.join_next().await.is_some() {}
1953        drop(log);
1954
1955        // If chain order had diverged from physical write order, this would fail with a
1956        // definite Mismatch tamper verdict even though nothing was actually tampered with.
1957        let log = SessionEventLog::open(dir.path()).await.unwrap();
1958        let events = log.read_all().await.unwrap();
1959        assert_eq!(events.len(), usize::try_from(N).unwrap());
1960
1961        configure_history_integrity(None);
1962    }
1963
1964    /// Chunked reads (used by replay) must verify the chain identically to the whole-file read.
1965    #[tokio::test]
1966    async fn chunked_read_verifies_chain_and_matches_whole_file_read() {
1967        const N: u64 = 250; // > REPLAY_CHUNK_SIZE, exercises the chunk-boundary epoch resolution
1968        configure_history_integrity(Some(test_ring(0, 27)));
1969        let dir = tempfile::tempdir().unwrap();
1970        let log = SessionEventLog::open(dir.path()).await.unwrap();
1971        for i in 0..N {
1972            log.append(
1973                None,
1974                None,
1975                SessionEvent::UserMessage {
1976                    text: format!("msg-{i}"),
1977                    image_refs: vec![],
1978                },
1979            )
1980            .await
1981            .unwrap();
1982        }
1983
1984        let whole = log.read_all().await.unwrap();
1985        assert_eq!(whole.len(), usize::try_from(N).unwrap());
1986
1987        let mut chunked = Vec::new();
1988        log.read_chunked(|chunk| {
1989            chunked.extend(chunk);
1990            ControlFlow::Continue(())
1991        })
1992        .await
1993        .unwrap();
1994        assert_eq!(chunked.len(), whole.len());
1995
1996        configure_history_integrity(None);
1997    }
1998
1999    /// Chunked reads must also detect tamper, not just the whole-file read — a tampered event
2000    /// deep enough to land in a later chunk must abort before ever reaching `on_chunk`.
2001    #[tokio::test]
2002    async fn chunked_read_detects_tamper_in_a_later_chunk() {
2003        const N: u64 = 150;
2004        configure_history_integrity(Some(test_ring(0, 28)));
2005        let dir = tempfile::tempdir().unwrap();
2006        let log = SessionEventLog::open(dir.path()).await.unwrap();
2007        for i in 0..N {
2008            log.append(
2009                None,
2010                None,
2011                SessionEvent::UserMessage {
2012                    text: format!("msg-{i}"),
2013                    image_refs: vec![],
2014                },
2015            )
2016            .await
2017            .unwrap();
2018        }
2019        let path = log.path().to_path_buf();
2020        drop(log);
2021
2022        // Tamper an event past the first REPLAY_CHUNK_SIZE (100) events.
2023        let raw = tokio::fs::read_to_string(&path).await.unwrap();
2024        let tampered = raw.replacen("msg-120", "forged-120", 1);
2025        assert_ne!(raw, tampered);
2026        tokio::fs::write(&path, tampered).await.unwrap();
2027
2028        configure_history_integrity(Some(test_ring(0, 28)));
2029        let log = SessionEventLog::open(dir.path()).await;
2030        // Depending on where tamper lands relative to open-time seeding, this may fail at
2031        // `open` (M3 tail verify covers the whole file) — assert the failure is an Integrity
2032        // error either at open or at an explicit chunked read.
2033        match log {
2034            Err(SessionError::Integrity(_)) => {}
2035            Ok(log) => {
2036                let mut seen = Vec::new();
2037                let result = log
2038                    .read_chunked(|chunk| {
2039                        seen.extend(chunk);
2040                        ControlFlow::Continue(())
2041                    })
2042                    .await;
2043                assert!(matches!(result, Err(SessionError::Integrity(_))));
2044            }
2045            Err(other) => panic!("expected Integrity error, got {other:?}"),
2046        }
2047
2048        configure_history_integrity(None);
2049    }
2050
2051    // --- Vault-anchor downgrade-resistance tests (issue #6449) ---
2052
2053    /// In-memory [`AnchorStore`] mock for tests, mirroring the identical mock in
2054    /// `zeph_subagent::transcript`'s test module.
2055    #[derive(Default)]
2056    struct MockAnchorStore {
2057        map: std::sync::Mutex<std::collections::HashMap<String, Anchor>>,
2058    }
2059
2060    impl AnchorStore for MockAnchorStore {
2061        fn get(
2062            &self,
2063            subsystem: AnchorSubsystem,
2064            file_id: &[u8],
2065        ) -> Pin<
2066            Box<
2067                dyn Future<Output = Result<Option<Anchor>, zeph_common::anchor::AnchorError>>
2068                    + Send
2069                    + '_,
2070            >,
2071        > {
2072            let result = self.get_sync(subsystem, file_id);
2073            Box::pin(async move { result })
2074        }
2075
2076        fn get_sync(
2077            &self,
2078            subsystem: AnchorSubsystem,
2079            file_id: &[u8],
2080        ) -> Result<Option<Anchor>, zeph_common::anchor::AnchorError> {
2081            let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2082            Ok(self.map.lock().unwrap().get(&key).cloned())
2083        }
2084
2085        fn put(
2086            &self,
2087            subsystem: AnchorSubsystem,
2088            file_id: &[u8],
2089            anchor: Anchor,
2090        ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2091        {
2092            let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2093            self.map.lock().unwrap().insert(key, anchor);
2094            Box::pin(async { Ok(()) })
2095        }
2096
2097        fn delete(
2098            &self,
2099            subsystem: AnchorSubsystem,
2100            file_id: &[u8],
2101        ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2102        {
2103            let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2104            self.map.lock().unwrap().remove(&key);
2105            Box::pin(async { Ok(()) })
2106        }
2107    }
2108
2109    /// FINDING B regression: a session log chained before any anchor store existed must still
2110    /// open normally once one comes online — an absent anchor is never a tamper signature.
2111    #[tokio::test]
2112    async fn pre_anchor_chained_log_still_opens_with_anchor_store_online() {
2113        configure_history_integrity(Some(test_ring(0, 40)));
2114        let dir = tempfile::tempdir().unwrap();
2115        let log = SessionEventLog::open(dir.path()).await.unwrap();
2116        log.append(
2117            None,
2118            None,
2119            SessionEvent::UserMessage {
2120                text: "pre-anchor".to_owned(),
2121                image_refs: vec![],
2122            },
2123        )
2124        .await
2125        .unwrap();
2126        drop(log);
2127
2128        configure_anchor_store(Some(Arc::new(MockAnchorStore::default())));
2129        let log = SessionEventLog::open(dir.path()).await.unwrap();
2130        let events = log.read_all().await.unwrap();
2131        assert_eq!(
2132            events.len(),
2133            1,
2134            "absent anchor must never brick a legacy-chained log"
2135        );
2136
2137        configure_anchor_store(None);
2138        configure_history_integrity(None);
2139    }
2140
2141    #[tokio::test]
2142    async fn whole_strip_of_anchored_session_is_tamper() {
2143        configure_history_integrity(Some(test_ring(0, 41)));
2144        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2145        configure_anchor_store(Some(Arc::clone(&store)));
2146
2147        let dir = tempfile::tempdir().unwrap();
2148        let log = SessionEventLog::open(dir.path()).await.unwrap();
2149        log.append(
2150            None,
2151            None,
2152            SessionEvent::UserMessage {
2153                text: "one".to_owned(),
2154                image_refs: vec![],
2155            },
2156        )
2157        .await
2158        .unwrap();
2159        log.append(
2160            None,
2161            None,
2162            SessionEvent::SessionEnded { reason: "x".into() },
2163        )
2164        .await
2165        .unwrap();
2166        log.finalize().await.unwrap();
2167        drop(log);
2168
2169        // Sanity: anchored and untouched, the log still opens.
2170        assert!(SessionEventLog::open(dir.path()).await.is_ok());
2171
2172        let path = dir.path().join(EVENTS_FILE_NAME);
2173        let raw = tokio::fs::read_to_string(&path).await.unwrap();
2174        let stripped: String = raw
2175            .lines()
2176            .map(|line| {
2177                let mut value: serde_json::Value = serde_json::from_str(line).unwrap();
2178                value.as_object_mut().unwrap().remove("chain");
2179                value.to_string()
2180            })
2181            .collect::<Vec<_>>()
2182            .join("\n")
2183            + "\n";
2184        tokio::fs::write(&path, stripped).await.unwrap();
2185
2186        match SessionEventLog::open(dir.path()).await {
2187            Err(SessionError::Integrity(m)) => {
2188                assert!(m.contains("TAMPER") && m.contains("vault anchor"), "{m}");
2189            }
2190            other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2191        }
2192
2193        configure_anchor_store(None);
2194        configure_history_integrity(None);
2195    }
2196
2197    #[tokio::test]
2198    async fn truncation_below_anchored_session_count_is_tamper() {
2199        configure_history_integrity(Some(test_ring(0, 42)));
2200        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2201        configure_anchor_store(Some(Arc::clone(&store)));
2202
2203        let dir = tempfile::tempdir().unwrap();
2204        let log = SessionEventLog::open(dir.path()).await.unwrap();
2205        log.append(
2206            None,
2207            None,
2208            SessionEvent::UserMessage {
2209                text: "one".to_owned(),
2210                image_refs: vec![],
2211            },
2212        )
2213        .await
2214        .unwrap();
2215        log.append(
2216            None,
2217            None,
2218            SessionEvent::SessionEnded { reason: "x".into() },
2219        )
2220        .await
2221        .unwrap();
2222        log.finalize().await.unwrap();
2223        drop(log);
2224
2225        let path = dir.path().join(EVENTS_FILE_NAME);
2226        let raw = tokio::fs::read_to_string(&path).await.unwrap();
2227        let first_line = raw.lines().next().unwrap();
2228        tokio::fs::write(&path, format!("{first_line}\n"))
2229            .await
2230            .unwrap();
2231
2232        match SessionEventLog::open(dir.path()).await {
2233            Err(SessionError::Integrity(m)) => {
2234                assert!(m.contains("TAMPER") && m.contains("truncated"), "{m}");
2235            }
2236            other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2237        }
2238
2239        configure_anchor_store(None);
2240        configure_history_integrity(None);
2241    }
2242
2243    /// Legitimate post-close growth (on-disk count > anchor.count, prefix matches) must open OK
2244    /// — the anchor is a prefix commitment, not an exact-count requirement, for sessions.
2245    #[tokio::test]
2246    async fn growth_after_anchor_with_matching_prefix_is_ok() {
2247        configure_history_integrity(Some(test_ring(0, 43)));
2248        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2249        configure_anchor_store(Some(Arc::clone(&store)));
2250
2251        let dir = tempfile::tempdir().unwrap();
2252        let log = SessionEventLog::open(dir.path()).await.unwrap();
2253        log.append(
2254            None,
2255            None,
2256            SessionEvent::UserMessage {
2257                text: "one".to_owned(),
2258                image_refs: vec![],
2259            },
2260        )
2261        .await
2262        .unwrap();
2263        log.finalize().await.unwrap();
2264
2265        // More appended after the anchor was written (no new finalize) — a legitimate
2266        // still-open session continuing to grow.
2267        log.append(
2268            None,
2269            None,
2270            SessionEvent::SessionEnded { reason: "x".into() },
2271        )
2272        .await
2273        .unwrap();
2274        drop(log);
2275
2276        let log = SessionEventLog::open(dir.path()).await.unwrap();
2277        let events = log.read_all().await.unwrap();
2278        assert_eq!(
2279            events.len(),
2280            2,
2281            "post-anchor growth with a matching prefix must open OK"
2282        );
2283
2284        configure_anchor_store(None);
2285        configure_history_integrity(None);
2286    }
2287
2288    #[tokio::test]
2289    async fn finalize_is_noop_without_anchor_store_or_without_chaining() {
2290        configure_history_integrity(Some(test_ring(0, 44)));
2291        let dir = tempfile::tempdir().unwrap();
2292        let log = SessionEventLog::open(dir.path()).await.unwrap();
2293        log.append(
2294            None,
2295            None,
2296            SessionEvent::SessionEnded { reason: "x".into() },
2297        )
2298        .await
2299        .unwrap();
2300        log.finalize().await.unwrap();
2301        configure_history_integrity(None);
2302
2303        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2304        configure_anchor_store(Some(Arc::clone(&store)));
2305        let dir2 = tempfile::tempdir().unwrap();
2306        let log2 = SessionEventLog::open(dir2.path()).await.unwrap();
2307        log2.append(
2308            None,
2309            None,
2310            SessionEvent::SessionEnded {
2311                reason: "legacy".into(),
2312            },
2313        )
2314        .await
2315        .unwrap();
2316        log2.finalize().await.unwrap();
2317        let identity = file_identity(dir2.path());
2318        assert!(
2319            store
2320                .get_sync(AnchorSubsystem::SessionLog, &identity)
2321                .unwrap()
2322                .is_none(),
2323            "no anchor should be written for an unchained handle"
2324        );
2325
2326        configure_anchor_store(None);
2327    }
2328}