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/// RAII guard that resets [`HISTORY_INTEGRITY`] and [`ANCHOR_STORE`] to `None` on drop
1179/// (issue #6686).
1180///
1181/// Both are process-wide statics reconfigured per-test; a manual reset call at the end of a
1182/// test body is skipped by a panic or early return, leaking a foreign key ring / anchor store
1183/// into whichever test runs next in the same process — including tests in `fork.rs` and
1184/// `replay.rs` that never call [`configure_history_integrity`]/[`configure_anchor_store`]
1185/// themselves but still open a [`SessionEventLog`]. Every test across this crate that
1186/// configures either static, or that opens a [`SessionEventLog`] while one could be configured,
1187/// must both construct this guard and carry `#[serial_test::serial(session_history_integrity)]`
1188/// so no two such tests run concurrently.
1189#[cfg(test)]
1190pub(crate) struct IntegrityConfigGuard(());
1191
1192#[cfg(test)]
1193impl IntegrityConfigGuard {
1194    pub(crate) fn new() -> Self {
1195        Self(())
1196    }
1197}
1198
1199#[cfg(test)]
1200impl Drop for IntegrityConfigGuard {
1201    fn drop(&mut self) {
1202        configure_history_integrity(None);
1203        configure_anchor_store(None);
1204    }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use std::future::Future;
1210    use std::pin::Pin;
1211
1212    use super::*;
1213
1214    #[tokio::test]
1215    #[serial_test::serial(session_history_integrity)]
1216    async fn test_append_and_read_roundtrip() {
1217        let dir = tempfile::tempdir().unwrap();
1218        let log = SessionEventLog::open(dir.path()).await.unwrap();
1219
1220        for i in 0..5u64 {
1221            log.append(
1222                Some(i),
1223                None,
1224                SessionEvent::UserMessage {
1225                    text: format!("msg-{i}"),
1226                    image_refs: vec![],
1227                },
1228            )
1229            .await
1230            .unwrap();
1231        }
1232
1233        assert_eq!(log.last_seq(), Some(4));
1234        let events = log.read_all().await.unwrap();
1235        assert_eq!(events.len(), 5);
1236        for (i, envelope) in events.iter().enumerate() {
1237            assert_eq!(envelope.seq, i as u64);
1238        }
1239    }
1240
1241    #[tokio::test]
1242    #[serial_test::serial(session_history_integrity)]
1243    async fn test_reopen_resumes_seq() {
1244        let dir = tempfile::tempdir().unwrap();
1245        {
1246            let log = SessionEventLog::open(dir.path()).await.unwrap();
1247            log.append(
1248                None,
1249                None,
1250                SessionEvent::SessionEnded { reason: "x".into() },
1251            )
1252            .await
1253            .unwrap();
1254        }
1255        let log = SessionEventLog::open(dir.path()).await.unwrap();
1256        assert_eq!(log.last_seq(), Some(0));
1257        let appended = log
1258            .append(
1259                None,
1260                None,
1261                SessionEvent::SessionEnded { reason: "y".into() },
1262            )
1263            .await
1264            .unwrap();
1265        assert_eq!(appended.seq, 1);
1266    }
1267
1268    #[tokio::test]
1269    #[serial_test::serial(session_history_integrity)]
1270    async fn test_torn_write_truncation() {
1271        let dir = tempfile::tempdir().unwrap();
1272        let path;
1273        {
1274            let log = SessionEventLog::open(dir.path()).await.unwrap();
1275            for i in 0..3u64 {
1276                log.append(
1277                    None,
1278                    None,
1279                    SessionEvent::UserMessage {
1280                        text: format!("msg-{i}"),
1281                        image_refs: vec![],
1282                    },
1283                )
1284                .await
1285                .unwrap();
1286            }
1287            path = log.path().to_path_buf();
1288        }
1289
1290        // Simulate a torn write: truncate the file mid-way through the last line.
1291        let full = tokio::fs::read(&path).await.unwrap();
1292        let cut = full.len() - 5;
1293        tokio::fs::write(&path, &full[..cut]).await.unwrap();
1294
1295        let log = SessionEventLog::open(dir.path()).await.unwrap();
1296        assert_eq!(
1297            log.last_seq(),
1298            Some(1),
1299            "torn last line must be dropped cleanly"
1300        );
1301        let events = log.read_all().await.unwrap();
1302        assert_eq!(events.len(), 2);
1303    }
1304
1305    /// Regression test for #5487 Finding B: a lockless `open()`/`read_all()` must never
1306    /// physically truncate a torn tail — it cannot distinguish a genuinely torn line from a
1307    /// live writer's in-flight, not-yet-fsynced append, so mutating the file could destroy that
1308    /// writer's data out from under it. Only `open_exclusive()` may repair.
1309    #[cfg(unix)]
1310    #[tokio::test]
1311    #[serial_test::serial(session_history_integrity)]
1312    async fn test_open_does_not_physically_truncate_torn_tail() {
1313        let dir = tempfile::tempdir().unwrap();
1314        let path;
1315        {
1316            let log = SessionEventLog::open(dir.path()).await.unwrap();
1317            for i in 0..3u64 {
1318                log.append(
1319                    None,
1320                    None,
1321                    SessionEvent::UserMessage {
1322                        text: format!("msg-{i}"),
1323                        image_refs: vec![],
1324                    },
1325                )
1326                .await
1327                .unwrap();
1328            }
1329            path = log.path().to_path_buf();
1330        }
1331
1332        let full = tokio::fs::read(&path).await.unwrap();
1333        let cut = full.len() - 5;
1334        tokio::fs::write(&path, &full[..cut]).await.unwrap();
1335        let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
1336
1337        // Lockless open()/read_all(): in-memory result drops the torn line, but the file on
1338        // disk must be untouched.
1339        let log = SessionEventLog::open(dir.path()).await.unwrap();
1340        assert_eq!(log.last_seq(), Some(1));
1341        let events = log.read_all().await.unwrap();
1342        assert_eq!(events.len(), 2);
1343        assert_eq!(
1344            tokio::fs::metadata(&path).await.unwrap().len(),
1345            torn_len,
1346            "open()/read_all() must never physically truncate the file"
1347        );
1348        drop(log);
1349
1350        // open_exclusive(): now physically repairs the file.
1351        let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1352        assert_eq!(log.last_seq(), Some(1));
1353        let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
1354        assert!(
1355            repaired_len < torn_len,
1356            "open_exclusive() must physically truncate the torn tail"
1357        );
1358    }
1359
1360    #[tokio::test]
1361    #[serial_test::serial(session_history_integrity)]
1362    async fn test_torn_write_truncation_various_offsets() {
1363        for cut_from_end in [1usize, 3, 10, 20] {
1364            let dir = tempfile::tempdir().unwrap();
1365            let path;
1366            {
1367                let log = SessionEventLog::open(dir.path()).await.unwrap();
1368                for i in 0..4u64 {
1369                    log.append(
1370                        None,
1371                        None,
1372                        SessionEvent::UserMessage {
1373                            text: format!("event-number-{i}"),
1374                            image_refs: vec![],
1375                        },
1376                    )
1377                    .await
1378                    .unwrap();
1379                }
1380                path = log.path().to_path_buf();
1381            }
1382            let full = tokio::fs::read(&path).await.unwrap();
1383            let cut = full.len().saturating_sub(cut_from_end);
1384            tokio::fs::write(&path, &full[..cut]).await.unwrap();
1385
1386            // Must not panic and must never see more than the 4 originally-committed events.
1387            let log = SessionEventLog::open(dir.path()).await.unwrap();
1388            let events = log.read_all().await.unwrap();
1389            assert!(events.len() <= 4);
1390        }
1391    }
1392
1393    #[tokio::test]
1394    #[serial_test::serial(session_history_integrity)]
1395    async fn test_empty_log_read_all() {
1396        let dir = tempfile::tempdir().unwrap();
1397        let log = SessionEventLog::open(dir.path()).await.unwrap();
1398        assert_eq!(log.last_seq(), None);
1399        assert!(log.read_all().await.unwrap().is_empty());
1400    }
1401
1402    #[cfg(unix)]
1403    #[tokio::test]
1404    #[serial_test::serial(session_history_integrity)]
1405    async fn test_file_permissions_are_0600() {
1406        use std::os::unix::fs::PermissionsExt;
1407        let dir = tempfile::tempdir().unwrap();
1408        let log = SessionEventLog::open(dir.path()).await.unwrap();
1409        let meta = tokio::fs::metadata(log.path()).await.unwrap();
1410        assert_eq!(meta.permissions().mode() & 0o777, 0o600);
1411    }
1412
1413    /// Regression test for #5487 bug B: `read_events` must compute the true running
1414    /// maximum `seq`, not just take the last physical line's value. Simulates the on-disk
1415    /// shape a pre-fix concurrent-append race could produce: seq 7 written physically before
1416    /// seq 6.
1417    #[tokio::test]
1418    #[serial_test::serial(session_history_integrity)]
1419    async fn test_max_seq_survives_out_of_order_physical_lines() {
1420        let dir = tempfile::tempdir().unwrap();
1421        let path = dir.path().join(EVENTS_FILE_NAME);
1422
1423        let make_line = |seq: u64| {
1424            let envelope = SessionEventEnvelope::new(
1425                seq,
1426                None,
1427                None,
1428                SessionEvent::SessionEnded { reason: "x".into() },
1429            );
1430            let mut line = serde_json::to_vec(&envelope).unwrap();
1431            line.push(b'\n');
1432            line
1433        };
1434
1435        // Physical order is seq=7 then seq=6 — out of seq order, as a pre-fix race could
1436        // produce, but every line individually well-formed and fsynced.
1437        let mut contents = make_line(7);
1438        contents.extend(make_line(6));
1439        tokio::fs::write(&path, &contents).await.unwrap();
1440
1441        let log = SessionEventLog::open(dir.path()).await.unwrap();
1442        assert_eq!(
1443            log.last_seq(),
1444            Some(7),
1445            "next_seq must be derived from the true max seq, not the last physical line"
1446        );
1447        let appended = log
1448            .append(
1449                None,
1450                None,
1451                SessionEvent::SessionEnded { reason: "z".into() },
1452            )
1453            .await
1454            .unwrap();
1455        assert_eq!(
1456            appended.seq, 8,
1457            "must not reuse a seq already present earlier in the file"
1458        );
1459    }
1460
1461    /// Regression test for #6378: `AdvisoryLock::acquire` must record the holder's own PID
1462    /// into the lock file's contents so a contending `acquire` can diagnose who holds it.
1463    /// Before the fix the lock file was permanently empty.
1464    #[cfg(unix)]
1465    #[tokio::test]
1466    #[serial_test::serial(session_history_integrity)]
1467    async fn test_open_exclusive_writes_own_pid_into_lock_file() {
1468        let dir = tempfile::tempdir().unwrap();
1469        let _log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1470
1471        let lock_path = dir.path().join(LOCK_FILE_NAME);
1472        let contents = tokio::fs::read_to_string(&lock_path).await.unwrap();
1473        let pid: u32 = contents.trim().parse().unwrap_or_else(|e| {
1474            panic!("lock file contents {contents:?} did not parse as a PID: {e}")
1475        });
1476        assert_eq!(pid, std::process::id());
1477    }
1478
1479    /// Regression test for #6378: on contention, `SessionError::AlreadyLocked` must carry the
1480    /// holder's PID (read back from the lock file) and a liveness verdict, not just the lock
1481    /// path. This is a same-process test, so both the holder and the contender are the current
1482    /// process — a genuinely alive PID is exactly what `pid_alive` must report.
1483    #[cfg(unix)]
1484    #[tokio::test]
1485    #[serial_test::serial(session_history_integrity)]
1486    async fn test_open_exclusive_rejects_second_writer() {
1487        let dir = tempfile::tempdir().unwrap();
1488        let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1489        match SessionEventLog::open_exclusive(dir.path()).await {
1490            Err(SessionError::AlreadyLocked { pid, pid_alive, .. }) => {
1491                assert_eq!(pid, Some(std::process::id()));
1492                assert_eq!(pid_alive, Some(true));
1493            }
1494            Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
1495            Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
1496        }
1497    }
1498
1499    #[cfg(unix)]
1500    #[tokio::test]
1501    #[serial_test::serial(session_history_integrity)]
1502    async fn test_open_exclusive_allows_reacquire_after_drop() {
1503        let dir = tempfile::tempdir().unwrap();
1504        {
1505            let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1506        }
1507        // Lock released when the first handle dropped — must not still be held.
1508        let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1509    }
1510
1511    #[cfg(unix)]
1512    #[tokio::test]
1513    #[serial_test::serial(session_history_integrity)]
1514    async fn test_open_is_not_blocked_by_open_exclusive() {
1515        let dir = tempfile::tempdir().unwrap();
1516        let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1517        // Read-only `open()` must still succeed while a writer holds the exclusive lock.
1518        let _reader = SessionEventLog::open(dir.path()).await.unwrap();
1519    }
1520
1521    /// Regression test for #5487 bug A: drives genuine concurrent `append()` calls (on real
1522    /// OS threads, not just cooperative interleaving) against one shared `SessionEventLog` and
1523    /// asserts seq assignment and physical write order never diverge. Before the fix, `seq`
1524    /// was assigned via `fetch_add` before acquiring the writer lock, so a task could win a
1525    /// low seq but lose the race for the lock, landing its line after a higher-seq task's line.
1526    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1527    #[serial_test::serial(session_history_integrity)]
1528    async fn test_concurrent_append_preserves_seq_order() {
1529        const N: u64 = 100;
1530
1531        let dir = tempfile::tempdir().unwrap();
1532        let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1533
1534        let mut tasks = tokio::task::JoinSet::new();
1535        for i in 0..N {
1536            let log = log.clone();
1537            tasks.spawn(async move {
1538                log.append(
1539                    None,
1540                    None,
1541                    SessionEvent::UserMessage {
1542                        text: format!("msg-{i}"),
1543                        image_refs: vec![],
1544                    },
1545                )
1546                .await
1547                .unwrap()
1548                .seq
1549            });
1550        }
1551
1552        let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
1553        assigned_seqs.sort_unstable();
1554        assert_eq!(
1555            assigned_seqs,
1556            (0..N).collect::<Vec<_>>(),
1557            "every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
1558        );
1559
1560        // Physical order on disk must match seq order: seq assignment and the write it
1561        // guards must never diverge under contention (#5487 fix 2).
1562        let events = log.read_all().await.unwrap();
1563        assert_eq!(events.len(), usize::try_from(N).unwrap());
1564        for (i, envelope) in events.iter().enumerate() {
1565            assert_eq!(
1566                envelope.seq, i as u64,
1567                "physical line {i} must carry seq {i}; seq and write order diverged"
1568            );
1569        }
1570    }
1571
1572    /// Regression test for #5445 Finding 3: `read_events_chunked` must never hold more than
1573    /// [`REPLAY_CHUNK_SIZE`] raw envelopes at once, and the concatenation of all chunks must
1574    /// exactly reproduce what `read_events` (the whole-file `Vec` path) returns, in order.
1575    #[tokio::test]
1576    #[serial_test::serial(session_history_integrity)]
1577    async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
1578        const N: u64 = 733; // comfortably > REPLAY_CHUNK_SIZE, not an exact multiple of it
1579
1580        let dir = tempfile::tempdir().unwrap();
1581        let log = SessionEventLog::open(dir.path()).await.unwrap();
1582        for i in 0..N {
1583            log.append(
1584                None,
1585                None,
1586                SessionEvent::UserMessage {
1587                    text: format!("msg-{i}"),
1588                    image_refs: vec![],
1589                },
1590            )
1591            .await
1592            .unwrap();
1593        }
1594
1595        let (whole_file_events, _, _) =
1596            read_events(log.path(), false, None, b"test-session", false, None)
1597                .await
1598                .unwrap();
1599        assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
1600
1601        let mut chunked_events = Vec::new();
1602        let mut chunk_sizes = Vec::new();
1603        read_events_chunked(log.path(), false, None, b"test-session", false, None, |chunk| {
1604            assert!(
1605                chunk.len() <= REPLAY_CHUNK_SIZE,
1606                "a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
1607                chunk.len()
1608            );
1609            chunk_sizes.push(chunk.len());
1610            chunked_events.extend(chunk);
1611            ControlFlow::Continue(())
1612        })
1613        .await
1614        .unwrap();
1615
1616        assert_eq!(
1617            chunked_events.len(),
1618            whole_file_events.len(),
1619            "chunked read must yield the same total event count as the whole-file read"
1620        );
1621        for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
1622            assert_eq!(whole.seq, chunked.seq);
1623        }
1624        assert!(
1625            chunk_sizes.len() > 1,
1626            "expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
1627        );
1628    }
1629
1630    // --- Hash-chain integrity tests (issue #6360) ---
1631    //
1632    // `configure_history_integrity` mutates process-global state, so these tests rely on
1633    // `cargo nextest`'s one-process-per-test model for isolation (never run this module with
1634    // plain `cargo test`, which shares one process across tests in a binary and could race).
1635
1636    fn test_ring(epoch: u32, byte: u8) -> Arc<ChainKeyRing> {
1637        Arc::new(ChainKeyRing::new(
1638            epoch,
1639            zeph_common::hash_chain::ChainKey::new([byte; 32]),
1640        ))
1641    }
1642
1643    #[tokio::test]
1644    #[serial_test::serial(session_history_integrity)]
1645    async fn chained_log_roundtrip() {
1646        let _guard = IntegrityConfigGuard::new();
1647        configure_history_integrity(Some(test_ring(0, 20)));
1648        let dir = tempfile::tempdir().unwrap();
1649        let log = SessionEventLog::open(dir.path()).await.unwrap();
1650        log.append(
1651            None,
1652            None,
1653            SessionEvent::UserMessage {
1654                text: "hello".to_owned(),
1655                image_refs: vec![],
1656            },
1657        )
1658        .await
1659        .unwrap();
1660        log.append(
1661            None,
1662            None,
1663            SessionEvent::SessionEnded { reason: "x".into() },
1664        )
1665        .await
1666        .unwrap();
1667        drop(log);
1668
1669        let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1670            .await
1671            .unwrap();
1672        assert!(
1673            raw.lines().all(|l| l.contains("\"chain\":")),
1674            "every line must carry a chain field once integrity is configured"
1675        );
1676
1677        let log = SessionEventLog::open(dir.path()).await.unwrap();
1678        let events = log.read_all().await.unwrap();
1679        assert_eq!(events.len(), 2);
1680    }
1681
1682    #[tokio::test]
1683    #[serial_test::serial(session_history_integrity)]
1684    async fn tamper_in_place_edit_is_detected() {
1685        let _guard = IntegrityConfigGuard::new();
1686        configure_history_integrity(Some(test_ring(0, 21)));
1687        let dir = tempfile::tempdir().unwrap();
1688        let log = SessionEventLog::open(dir.path()).await.unwrap();
1689        // A first, untouched entry so the key epoch resolves cleanly there; tampering the
1690        // *second* entry below then produces a definite Mismatch (not an ambiguous
1691        // Unverifiable, which is what tampering the very first chained entry would produce —
1692        // that case is covered separately by the epoch-resolution tests).
1693        log.append(
1694            None,
1695            None,
1696            SessionEvent::SessionEnded {
1697                reason: "untouched".into(),
1698            },
1699        )
1700        .await
1701        .unwrap();
1702        log.append(
1703            None,
1704            None,
1705            SessionEvent::UserMessage {
1706                text: "original".to_owned(),
1707                image_refs: vec![],
1708            },
1709        )
1710        .await
1711        .unwrap();
1712        drop(log);
1713
1714        let path = dir.path().join(EVENTS_FILE_NAME);
1715        let raw = tokio::fs::read_to_string(&path).await.unwrap();
1716        let tampered = raw.replace("original", "forged-approval");
1717        assert_ne!(raw, tampered);
1718        tokio::fs::write(&path, tampered).await.unwrap();
1719
1720        let result = SessionEventLog::open(dir.path()).await;
1721        assert!(matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("TAMPER")));
1722    }
1723
1724    #[tokio::test]
1725    #[serial_test::serial(session_history_integrity)]
1726    async fn legacy_log_is_auto_trusted_once_when_integrity_configured_later() {
1727        let _guard = IntegrityConfigGuard::new();
1728        configure_history_integrity(None);
1729        let dir = tempfile::tempdir().unwrap();
1730        let log = SessionEventLog::open(dir.path()).await.unwrap();
1731        log.append(
1732            None,
1733            None,
1734            SessionEvent::UserMessage {
1735                text: "pre-feature message".to_owned(),
1736                image_refs: vec![],
1737            },
1738        )
1739        .await
1740        .unwrap();
1741        drop(log);
1742
1743        let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1744            .await
1745            .unwrap();
1746        assert!(!raw.contains("\"chain\":"));
1747
1748        configure_history_integrity(Some(test_ring(0, 22)));
1749        let log = SessionEventLog::open(dir.path()).await.unwrap();
1750        let events = log.read_all().await.unwrap();
1751        assert_eq!(
1752            events.len(),
1753            1,
1754            "legacy content must be auto-trusted, not rejected"
1755        );
1756
1757        // A legacy log read while a key IS configured must be flagged exactly once per path
1758        // (security review B2 condition (c)) — repeat reads must not re-warn.
1759        let events_path = dir.path().join(EVENTS_FILE_NAME);
1760        assert!(
1761            WARNED_LEGACY_UNDER_KEY
1762                .read()
1763                .unwrap()
1764                .contains(&events_path),
1765            "path must be recorded as warned after the first legacy-under-active-key read"
1766        );
1767        let warned_count_before = WARNED_LEGACY_UNDER_KEY.read().unwrap().len();
1768        let _ = log.read_all().await.unwrap();
1769        assert_eq!(
1770            WARNED_LEGACY_UNDER_KEY.read().unwrap().len(),
1771            warned_count_before,
1772            "a second read of the same path must not add a second warned-set entry"
1773        );
1774    }
1775
1776    #[tokio::test]
1777    #[serial_test::serial(session_history_integrity)]
1778    async fn partial_strip_of_chain_field_is_detected_as_tamper() {
1779        let _guard = IntegrityConfigGuard::new();
1780        configure_history_integrity(Some(test_ring(0, 23)));
1781        let dir = tempfile::tempdir().unwrap();
1782        let log = SessionEventLog::open(dir.path()).await.unwrap();
1783        log.append(
1784            None,
1785            None,
1786            SessionEvent::UserMessage {
1787                text: "one".to_owned(),
1788                image_refs: vec![],
1789            },
1790        )
1791        .await
1792        .unwrap();
1793        log.append(
1794            None,
1795            None,
1796            SessionEvent::UserMessage {
1797                text: "two".to_owned(),
1798                image_refs: vec![],
1799            },
1800        )
1801        .await
1802        .unwrap();
1803        drop(log);
1804
1805        let path = dir.path().join(EVENTS_FILE_NAME);
1806        let raw = tokio::fs::read_to_string(&path).await.unwrap();
1807        let lines: Vec<&str> = raw.lines().collect();
1808        assert_eq!(lines.len(), 2);
1809        let mut second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1810        second.as_object_mut().unwrap().remove("chain");
1811        let stripped = format!("{}\n{}\n", lines[0], second);
1812        tokio::fs::write(&path, stripped).await.unwrap();
1813
1814        let result = SessionEventLog::open(dir.path()).await;
1815        assert!(
1816            matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("partial strip"))
1817        );
1818    }
1819
1820    #[tokio::test]
1821    #[serial_test::serial(session_history_integrity)]
1822    async fn key_unavailable_on_chained_log_fails_closed_not_legacy() {
1823        let _guard = IntegrityConfigGuard::new();
1824        configure_history_integrity(Some(test_ring(0, 24)));
1825        let dir = tempfile::tempdir().unwrap();
1826        let log = SessionEventLog::open(dir.path()).await.unwrap();
1827        log.append(
1828            None,
1829            None,
1830            SessionEvent::SessionEnded { reason: "x".into() },
1831        )
1832        .await
1833        .unwrap();
1834        drop(log);
1835
1836        configure_history_integrity(None);
1837        let result = SessionEventLog::open(dir.path()).await;
1838        assert!(matches!(result, Err(SessionError::Integrity(_))));
1839    }
1840
1841    /// `--allow-unverified` override: a tampered chained log must still open and read via
1842    /// `open_exclusive_allow_unverified`, and the bypass must persist across `read_all` calls
1843    /// on the same handle, not just the initial open.
1844    #[tokio::test]
1845    #[serial_test::serial(session_history_integrity)]
1846    async fn allow_unverified_bypasses_tamper_detection_for_the_whole_handle() {
1847        let _guard = IntegrityConfigGuard::new();
1848        configure_history_integrity(Some(test_ring(0, 40)));
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 {
1855                reason: "untouched".into(),
1856            },
1857        )
1858        .await
1859        .unwrap();
1860        log.append(
1861            None,
1862            None,
1863            SessionEvent::UserMessage {
1864                text: "original".to_owned(),
1865                image_refs: vec![],
1866            },
1867        )
1868        .await
1869        .unwrap();
1870        drop(log);
1871
1872        let path = dir.path().join(EVENTS_FILE_NAME);
1873        let raw = tokio::fs::read_to_string(&path).await.unwrap();
1874        let tampered = raw.replace("original", "forged-approval");
1875        assert_ne!(raw, tampered);
1876        tokio::fs::write(&path, tampered).await.unwrap();
1877
1878        // The normal path still fails closed.
1879        let result = SessionEventLog::open_exclusive(dir.path()).await;
1880        assert!(matches!(result, Err(SessionError::Integrity(_))));
1881
1882        // The deliberate override succeeds, both at open and on a subsequent read_all.
1883        let log = SessionEventLog::open_exclusive_allow_unverified(dir.path())
1884            .await
1885            .unwrap();
1886        let events = log.read_all().await.unwrap();
1887        assert_eq!(events.len(), 2);
1888    }
1889
1890    #[tokio::test]
1891    #[serial_test::serial(session_history_integrity)]
1892    async fn rotated_key_epoch_verifies_as_rekeyed_not_tampered() {
1893        let _guard = IntegrityConfigGuard::new();
1894        let old_key_byte = 25u8;
1895        configure_history_integrity(Some(test_ring(0, old_key_byte)));
1896        let dir = tempfile::tempdir().unwrap();
1897        let log = SessionEventLog::open(dir.path()).await.unwrap();
1898        log.append(
1899            None,
1900            None,
1901            SessionEvent::SessionEnded { reason: "x".into() },
1902        )
1903        .await
1904        .unwrap();
1905        drop(log);
1906
1907        let ring = Arc::new(
1908            ChainKeyRing::new(1, zeph_common::hash_chain::ChainKey::new([30u8; 32])).with_previous(
1909                0,
1910                zeph_common::hash_chain::ChainKey::new([old_key_byte; 32]),
1911            ),
1912        );
1913        configure_history_integrity(Some(ring));
1914
1915        let log = SessionEventLog::open(dir.path()).await.unwrap();
1916        let events = log.read_all().await.unwrap();
1917        assert_eq!(events.len(), 1);
1918    }
1919
1920    /// S1 regression: a mid-file (non-trailing) malformed line must never be silently
1921    /// auto-truncated by `open_exclusive`'s torn-tail repair — it must surface as an integrity
1922    /// error instead. This is a correctness bug independent of chaining (it corrupts crash
1923    /// recovery itself), reproduced here without configuring any key ring.
1924    #[tokio::test]
1925    #[serial_test::serial(session_history_integrity)]
1926    async fn internal_malformed_line_is_never_treated_as_torn_tail() {
1927        configure_history_integrity(None);
1928        let dir = tempfile::tempdir().unwrap();
1929        let path;
1930        {
1931            let log = SessionEventLog::open(dir.path()).await.unwrap();
1932            for i in 0..3u64 {
1933                log.append(
1934                    None,
1935                    None,
1936                    SessionEvent::UserMessage {
1937                        text: format!("msg-{i}"),
1938                        image_refs: vec![],
1939                    },
1940                )
1941                .await
1942                .unwrap();
1943            }
1944            path = log.path().to_path_buf();
1945        }
1946
1947        // Corrupt the *middle* line (not the last) so it fails to parse as JSON, followed by
1948        // legitimate content — simulates a tamper that overwrites one line in place with
1949        // garbage, as opposed to a genuine crash mid-append (which can only corrupt the tail).
1950        let content = tokio::fs::read_to_string(&path).await.unwrap();
1951        let lines: Vec<&str> = content.lines().collect();
1952        assert_eq!(lines.len(), 3);
1953        let corrupted = format!("{}\nnot valid json at all\n{}\n", lines[0], lines[2]);
1954        tokio::fs::write(&path, corrupted).await.unwrap();
1955
1956        // Under the pre-S1 bug, `open_exclusive` would silently truncate everything from the
1957        // corrupted line onward, treating it as an ordinary torn crash-recovery tail. It must
1958        // instead fail closed.
1959        let result = SessionEventLog::open_exclusive(dir.path()).await;
1960        assert!(matches!(result, Err(SessionError::Integrity(_))));
1961
1962        // And the file on disk must be untouched — no silent repair happened.
1963        let after = tokio::fs::read_to_string(&path).await.unwrap();
1964        assert_eq!(
1965            after.lines().count(),
1966            3,
1967            "file must not have been truncated"
1968        );
1969
1970        configure_history_integrity(None);
1971    }
1972
1973    /// S2 regression: concurrent `append` calls must never desynchronize on-disk physical order
1974    /// from chain-link order.
1975    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1976    #[serial_test::serial(session_history_integrity)]
1977    async fn concurrent_append_preserves_chain_order() {
1978        const N: u64 = 60;
1979        let _guard = IntegrityConfigGuard::new();
1980        configure_history_integrity(Some(test_ring(0, 26)));
1981        let dir = tempfile::tempdir().unwrap();
1982        let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1983
1984        let mut tasks = tokio::task::JoinSet::new();
1985        for i in 0..N {
1986            let log = log.clone();
1987            tasks.spawn(async move {
1988                log.append(
1989                    None,
1990                    None,
1991                    SessionEvent::UserMessage {
1992                        text: format!("msg-{i}"),
1993                        image_refs: vec![],
1994                    },
1995                )
1996                .await
1997                .unwrap();
1998            });
1999        }
2000        while tasks.join_next().await.is_some() {}
2001        drop(log);
2002
2003        // If chain order had diverged from physical write order, this would fail with a
2004        // definite Mismatch tamper verdict even though nothing was actually tampered with.
2005        let log = SessionEventLog::open(dir.path()).await.unwrap();
2006        let events = log.read_all().await.unwrap();
2007        assert_eq!(events.len(), usize::try_from(N).unwrap());
2008    }
2009
2010    /// Chunked reads (used by replay) must verify the chain identically to the whole-file read.
2011    #[tokio::test]
2012    #[serial_test::serial(session_history_integrity)]
2013    async fn chunked_read_verifies_chain_and_matches_whole_file_read() {
2014        const N: u64 = 250; // > REPLAY_CHUNK_SIZE, exercises the chunk-boundary epoch resolution
2015        let _guard = IntegrityConfigGuard::new();
2016        configure_history_integrity(Some(test_ring(0, 27)));
2017        let dir = tempfile::tempdir().unwrap();
2018        let log = SessionEventLog::open(dir.path()).await.unwrap();
2019        for i in 0..N {
2020            log.append(
2021                None,
2022                None,
2023                SessionEvent::UserMessage {
2024                    text: format!("msg-{i}"),
2025                    image_refs: vec![],
2026                },
2027            )
2028            .await
2029            .unwrap();
2030        }
2031
2032        let whole = log.read_all().await.unwrap();
2033        assert_eq!(whole.len(), usize::try_from(N).unwrap());
2034
2035        let mut chunked = Vec::new();
2036        log.read_chunked(|chunk| {
2037            chunked.extend(chunk);
2038            ControlFlow::Continue(())
2039        })
2040        .await
2041        .unwrap();
2042        assert_eq!(chunked.len(), whole.len());
2043    }
2044
2045    /// Chunked reads must also detect tamper, not just the whole-file read — a tampered event
2046    /// deep enough to land in a later chunk must abort before ever reaching `on_chunk`.
2047    #[tokio::test]
2048    #[serial_test::serial(session_history_integrity)]
2049    async fn chunked_read_detects_tamper_in_a_later_chunk() {
2050        const N: u64 = 150;
2051        let _guard = IntegrityConfigGuard::new();
2052        configure_history_integrity(Some(test_ring(0, 28)));
2053        let dir = tempfile::tempdir().unwrap();
2054        let log = SessionEventLog::open(dir.path()).await.unwrap();
2055        for i in 0..N {
2056            log.append(
2057                None,
2058                None,
2059                SessionEvent::UserMessage {
2060                    text: format!("msg-{i}"),
2061                    image_refs: vec![],
2062                },
2063            )
2064            .await
2065            .unwrap();
2066        }
2067        let path = log.path().to_path_buf();
2068        drop(log);
2069
2070        // Tamper an event past the first REPLAY_CHUNK_SIZE (100) events.
2071        let raw = tokio::fs::read_to_string(&path).await.unwrap();
2072        let tampered = raw.replacen("msg-120", "forged-120", 1);
2073        assert_ne!(raw, tampered);
2074        tokio::fs::write(&path, tampered).await.unwrap();
2075
2076        configure_history_integrity(Some(test_ring(0, 28)));
2077        let log = SessionEventLog::open(dir.path()).await;
2078        // Depending on where tamper lands relative to open-time seeding, this may fail at
2079        // `open` (M3 tail verify covers the whole file) — assert the failure is an Integrity
2080        // error either at open or at an explicit chunked read.
2081        match log {
2082            Err(SessionError::Integrity(_)) => {}
2083            Ok(log) => {
2084                let mut seen = Vec::new();
2085                let result = log
2086                    .read_chunked(|chunk| {
2087                        seen.extend(chunk);
2088                        ControlFlow::Continue(())
2089                    })
2090                    .await;
2091                assert!(matches!(result, Err(SessionError::Integrity(_))));
2092            }
2093            Err(other) => panic!("expected Integrity error, got {other:?}"),
2094        }
2095    }
2096
2097    // --- Vault-anchor downgrade-resistance tests (issue #6449) ---
2098
2099    /// In-memory [`AnchorStore`] mock for tests, mirroring the identical mock in
2100    /// `zeph_subagent::transcript`'s test module.
2101    #[derive(Default)]
2102    struct MockAnchorStore {
2103        map: std::sync::Mutex<std::collections::HashMap<String, Anchor>>,
2104    }
2105
2106    impl AnchorStore for MockAnchorStore {
2107        fn get(
2108            &self,
2109            subsystem: AnchorSubsystem,
2110            file_id: &[u8],
2111        ) -> Pin<
2112            Box<
2113                dyn Future<Output = Result<Option<Anchor>, zeph_common::anchor::AnchorError>>
2114                    + Send
2115                    + '_,
2116            >,
2117        > {
2118            let result = self.get_sync(subsystem, file_id);
2119            Box::pin(async move { result })
2120        }
2121
2122        fn get_sync(
2123            &self,
2124            subsystem: AnchorSubsystem,
2125            file_id: &[u8],
2126        ) -> Result<Option<Anchor>, zeph_common::anchor::AnchorError> {
2127            let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2128            Ok(self.map.lock().unwrap().get(&key).cloned())
2129        }
2130
2131        fn put(
2132            &self,
2133            subsystem: AnchorSubsystem,
2134            file_id: &[u8],
2135            anchor: Anchor,
2136        ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2137        {
2138            let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2139            self.map.lock().unwrap().insert(key, anchor);
2140            Box::pin(async { Ok(()) })
2141        }
2142
2143        fn delete(
2144            &self,
2145            subsystem: AnchorSubsystem,
2146            file_id: &[u8],
2147        ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2148        {
2149            let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2150            self.map.lock().unwrap().remove(&key);
2151            Box::pin(async { Ok(()) })
2152        }
2153    }
2154
2155    /// FINDING B regression: a session log chained before any anchor store existed must still
2156    /// open normally once one comes online — an absent anchor is never a tamper signature.
2157    #[tokio::test]
2158    #[serial_test::serial(session_history_integrity)]
2159    async fn pre_anchor_chained_log_still_opens_with_anchor_store_online() {
2160        let _guard = IntegrityConfigGuard::new();
2161        configure_history_integrity(Some(test_ring(0, 40)));
2162        let dir = tempfile::tempdir().unwrap();
2163        let log = SessionEventLog::open(dir.path()).await.unwrap();
2164        log.append(
2165            None,
2166            None,
2167            SessionEvent::UserMessage {
2168                text: "pre-anchor".to_owned(),
2169                image_refs: vec![],
2170            },
2171        )
2172        .await
2173        .unwrap();
2174        drop(log);
2175
2176        configure_anchor_store(Some(Arc::new(MockAnchorStore::default())));
2177        let log = SessionEventLog::open(dir.path()).await.unwrap();
2178        let events = log.read_all().await.unwrap();
2179        assert_eq!(
2180            events.len(),
2181            1,
2182            "absent anchor must never brick a legacy-chained log"
2183        );
2184    }
2185
2186    #[tokio::test]
2187    #[serial_test::serial(session_history_integrity)]
2188    async fn whole_strip_of_anchored_session_is_tamper() {
2189        let _guard = IntegrityConfigGuard::new();
2190        configure_history_integrity(Some(test_ring(0, 41)));
2191        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2192        configure_anchor_store(Some(Arc::clone(&store)));
2193
2194        let dir = tempfile::tempdir().unwrap();
2195        let log = SessionEventLog::open(dir.path()).await.unwrap();
2196        log.append(
2197            None,
2198            None,
2199            SessionEvent::UserMessage {
2200                text: "one".to_owned(),
2201                image_refs: vec![],
2202            },
2203        )
2204        .await
2205        .unwrap();
2206        log.append(
2207            None,
2208            None,
2209            SessionEvent::SessionEnded { reason: "x".into() },
2210        )
2211        .await
2212        .unwrap();
2213        log.finalize().await.unwrap();
2214        drop(log);
2215
2216        // Sanity: anchored and untouched, the log still opens.
2217        assert!(SessionEventLog::open(dir.path()).await.is_ok());
2218
2219        let path = dir.path().join(EVENTS_FILE_NAME);
2220        let raw = tokio::fs::read_to_string(&path).await.unwrap();
2221        let stripped: String = raw
2222            .lines()
2223            .map(|line| {
2224                let mut value: serde_json::Value = serde_json::from_str(line).unwrap();
2225                value.as_object_mut().unwrap().remove("chain");
2226                value.to_string()
2227            })
2228            .collect::<Vec<_>>()
2229            .join("\n")
2230            + "\n";
2231        tokio::fs::write(&path, stripped).await.unwrap();
2232
2233        match SessionEventLog::open(dir.path()).await {
2234            Err(SessionError::Integrity(m)) => {
2235                assert!(m.contains("TAMPER") && m.contains("vault anchor"), "{m}");
2236            }
2237            other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2238        }
2239    }
2240
2241    #[tokio::test]
2242    #[serial_test::serial(session_history_integrity)]
2243    async fn truncation_below_anchored_session_count_is_tamper() {
2244        let _guard = IntegrityConfigGuard::new();
2245        configure_history_integrity(Some(test_ring(0, 42)));
2246        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2247        configure_anchor_store(Some(Arc::clone(&store)));
2248
2249        let dir = tempfile::tempdir().unwrap();
2250        let log = SessionEventLog::open(dir.path()).await.unwrap();
2251        log.append(
2252            None,
2253            None,
2254            SessionEvent::UserMessage {
2255                text: "one".to_owned(),
2256                image_refs: vec![],
2257            },
2258        )
2259        .await
2260        .unwrap();
2261        log.append(
2262            None,
2263            None,
2264            SessionEvent::SessionEnded { reason: "x".into() },
2265        )
2266        .await
2267        .unwrap();
2268        log.finalize().await.unwrap();
2269        drop(log);
2270
2271        let path = dir.path().join(EVENTS_FILE_NAME);
2272        let raw = tokio::fs::read_to_string(&path).await.unwrap();
2273        let first_line = raw.lines().next().unwrap();
2274        tokio::fs::write(&path, format!("{first_line}\n"))
2275            .await
2276            .unwrap();
2277
2278        match SessionEventLog::open(dir.path()).await {
2279            Err(SessionError::Integrity(m)) => {
2280                assert!(m.contains("TAMPER") && m.contains("truncated"), "{m}");
2281            }
2282            other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2283        }
2284    }
2285
2286    /// Legitimate post-close growth (on-disk count > anchor.count, prefix matches) must open OK
2287    /// — the anchor is a prefix commitment, not an exact-count requirement, for sessions.
2288    #[tokio::test]
2289    #[serial_test::serial(session_history_integrity)]
2290    async fn growth_after_anchor_with_matching_prefix_is_ok() {
2291        let _guard = IntegrityConfigGuard::new();
2292        configure_history_integrity(Some(test_ring(0, 43)));
2293        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2294        configure_anchor_store(Some(Arc::clone(&store)));
2295
2296        let dir = tempfile::tempdir().unwrap();
2297        let log = SessionEventLog::open(dir.path()).await.unwrap();
2298        log.append(
2299            None,
2300            None,
2301            SessionEvent::UserMessage {
2302                text: "one".to_owned(),
2303                image_refs: vec![],
2304            },
2305        )
2306        .await
2307        .unwrap();
2308        log.finalize().await.unwrap();
2309
2310        // More appended after the anchor was written (no new finalize) — a legitimate
2311        // still-open session continuing to grow.
2312        log.append(
2313            None,
2314            None,
2315            SessionEvent::SessionEnded { reason: "x".into() },
2316        )
2317        .await
2318        .unwrap();
2319        drop(log);
2320
2321        let log = SessionEventLog::open(dir.path()).await.unwrap();
2322        let events = log.read_all().await.unwrap();
2323        assert_eq!(
2324            events.len(),
2325            2,
2326            "post-anchor growth with a matching prefix must open OK"
2327        );
2328    }
2329
2330    #[tokio::test]
2331    #[serial_test::serial(session_history_integrity)]
2332    async fn finalize_is_noop_without_anchor_store_or_without_chaining() {
2333        let _guard = IntegrityConfigGuard::new();
2334        configure_history_integrity(Some(test_ring(0, 44)));
2335        let dir = tempfile::tempdir().unwrap();
2336        let log = SessionEventLog::open(dir.path()).await.unwrap();
2337        log.append(
2338            None,
2339            None,
2340            SessionEvent::SessionEnded { reason: "x".into() },
2341        )
2342        .await
2343        .unwrap();
2344        log.finalize().await.unwrap();
2345        configure_history_integrity(None);
2346
2347        let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2348        configure_anchor_store(Some(Arc::clone(&store)));
2349        let dir2 = tempfile::tempdir().unwrap();
2350        let log2 = SessionEventLog::open(dir2.path()).await.unwrap();
2351        log2.append(
2352            None,
2353            None,
2354            SessionEvent::SessionEnded {
2355                reason: "legacy".into(),
2356            },
2357        )
2358        .await
2359        .unwrap();
2360        log2.finalize().await.unwrap();
2361        let identity = file_identity(dir2.path());
2362        assert!(
2363            store
2364                .get_sync(AnchorSubsystem::SessionLog, &identity)
2365                .unwrap()
2366                .is_none(),
2367            "no anchor should be written for an unchained handle"
2368        );
2369    }
2370}