Skip to main content

octl_core/
events.rs

1//! Event append primitive + `seq` recovery (design.md §1.4, §4).
2
3use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
4use std::path::{Path, PathBuf};
5
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::atomic::{open_events_append, write_atomic};
11use crate::error::{Error, Result};
12use crate::lock::{LockedRun, RunLock};
13use crate::paths::RunPaths;
14use crate::projections::{derive_counters, read_manifest_opt, write_manifest};
15use crate::reducer::{commit_ops, reduce_event_to_ops};
16use crate::schema::{Event, NodeId};
17
18/// Backward-scan chunk size when looking for the previous newline.
19const SCAN_CHUNK: u64 = 64 * 1024;
20
21/// Read the last `seq` from `events.jsonl`, or `0` if empty/missing.
22///
23/// Tolerates:
24/// - lines larger than any fixed buffer (`node.report` payloads can be 10s of KB
25///   per `design.md` §1.4) — we scan backwards in chunks for the previous `\n`.
26/// - a crash-truncated final line lacking a trailing `\n` — that partial tail
27///   is discarded and recovery uses the last complete record.
28///
29/// Caller must already hold the run's [`RunLock`] for correctness against
30/// concurrent appenders.
31pub fn recover_last_seq(events_path: &Path) -> Result<u64> {
32    let mut f = match std::fs::File::open(events_path) {
33        Ok(f) => f,
34        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
35        Err(e) => return Err(Error::io(events_path, e)),
36    };
37    let len = f.metadata().map_err(|e| Error::io(events_path, e))?.len();
38    if len == 0 {
39        return Ok(0);
40    }
41
42    // Require a newline-terminated final line; otherwise treat the last
43    // partial chunk as torn and recover from the previous complete line.
44    let mut tail_byte = [0u8; 1];
45    f.seek(SeekFrom::End(-1))
46        .map_err(|e| Error::io(events_path, e))?;
47    f.read_exact(&mut tail_byte)
48        .map_err(|e| Error::io(events_path, e))?;
49    let mut end = if tail_byte[0] == b'\n' {
50        len - 1
51    } else {
52        match find_prev_newline(&mut f, len, events_path)? {
53            Some(p) => p,
54            None => return Ok(0),
55        }
56    };
57
58    // `end` is the byte index of the trailing `\n` of the last complete
59    // record. Walk backward over complete lines, skipping any that are empty
60    // or whitespace-only — consecutive newlines or blank/whitespace lines (e.g.
61    // from external editing) shouldn't fool recovery into reading the wrong
62    // last record — and recover the seq from the last line bearing real bytes.
63    loop {
64        let line_start = match find_prev_newline(&mut f, end, events_path)? {
65            Some(p) => p + 1,
66            None => 0,
67        };
68        let line_len = end - line_start;
69        f.seek(SeekFrom::Start(line_start))
70            .map_err(|e| Error::io(events_path, e))?;
71        let mut line = vec![0u8; line_len as usize];
72        f.read_exact(&mut line)
73            .map_err(|e| Error::io(events_path, e))?;
74        // Any non-whitespace byte means a real record — parse it. Lines that
75        // are empty or hold only ASCII whitespace (a stray `\r`, `\t`, or
76        // spaces left by external editing) carry no record, so skip them and
77        // keep scanning back; serde tolerates whitespace surrounding a real
78        // envelope, so a genuine record with trailing spaces still parses.
79        if line.iter().any(|b| !b.is_ascii_whitespace()) {
80            return parse_seq(&line, events_path);
81        }
82        // Whitespace-only line: no record here. Step to the newline before it
83        // and keep scanning; reaching the start means the log holds no event.
84        if line_start == 0 {
85            return Ok(0);
86        }
87        end = line_start - 1;
88    }
89}
90
91/// The envelope fields recovered from the last complete line. Required fields
92/// mirror [`Event`]'s required shape, so `recover_last_seq` accepts a last line
93/// iff [`read_all_events`] would — the two readers agree on what the last
94/// record is. `data` / `idempotency_key` are skipped (serde ignores unknown
95/// fields) so a multi-KB `node.report` payload isn't re-materialized on the
96/// hot append path just to read `seq`.
97#[derive(Deserialize)]
98#[allow(dead_code)] // fields exist to force serde validation, not to be read
99struct SeqLine {
100    seq: u64,
101    ts: chrono::DateTime<chrono::Utc>,
102    kind: String,
103    run_id: crate::schema::RunId,
104    #[serde(default)]
105    node_id: Option<NodeId>,
106}
107
108fn parse_seq(line: &[u8], events_path: &Path) -> Result<u64> {
109    // The last complete line must be a full, valid event envelope — the same
110    // bar `read_all_events` applies to every line — so a `\n`-terminated line
111    // that parses as JSON but isn't a valid event (e.g. `{"seq":1}` missing
112    // `ts`/`run_id`) is event-log corruption, not a usable seq source. This
113    // keeps the three readers aligned on the last record.
114    let hdr: SeqLine = serde_json::from_slice(line).map_err(|e| Error::CorruptEventLog {
115        path: events_path.to_path_buf(),
116        reason: format!(
117            "last complete line is not a valid event: {} [{e}]",
118            excerpt(line)
119        ),
120    })?;
121    Ok(hdr.seq)
122}
123
124/// Find the byte offset of the last `\n` strictly before `before`. Returns
125/// `None` if no newline exists in `[0, before)`.
126fn find_prev_newline(
127    f: &mut std::fs::File,
128    before: u64,
129    events_path: &Path,
130) -> Result<Option<u64>> {
131    if before == 0 {
132        return Ok(None);
133    }
134    let mut pos = before;
135    loop {
136        let start = pos.saturating_sub(SCAN_CHUNK);
137        let len = pos - start;
138        f.seek(SeekFrom::Start(start))
139            .map_err(|e| Error::io(events_path, e))?;
140        let mut buf = vec![0u8; len as usize];
141        f.read_exact(&mut buf)
142            .map_err(|e| Error::io(events_path, e))?;
143        if let Some(i) = buf.iter().rposition(|b| *b == b'\n') {
144            return Ok(Some(start + i as u64));
145        }
146        if start == 0 {
147            return Ok(None);
148        }
149        pos = start;
150    }
151}
152
153/// Truncate a torn (newline-less) final line off `events.jsonl` so the next
154/// append never concatenates onto a partial record.
155///
156/// `recover_last_seq` only *ignores* a torn tail for seq purposes — it never
157/// removes the bytes. Without this, an append after a crash-truncated write
158/// would write its `\n`-terminated line directly onto the partial bytes,
159/// producing one malformed `…torn…{"seq":…}` line that every later reader
160/// (now sharing a strict torn-tail policy) hard-errors on. Cutting back to
161/// the last complete record here guarantees the file is always empty or
162/// `\n`-terminated before we append.
163///
164/// Caller must hold the run's [`RunLock`]. No-op when the file is absent,
165/// empty, or already `\n`-terminated (the common, clean case — one `stat` +
166/// one-byte read, no rewrite).
167fn truncate_torn_tail(events_path: &Path) -> Result<()> {
168    let mut opts = std::fs::OpenOptions::new();
169    opts.read(true).write(true);
170    // `O_NOFOLLOW`: refuse to rewrite the tail through a symlinked event log.
171    crate::paths::nofollow(&mut opts);
172    let mut f = match opts.open(events_path) {
173        Ok(f) => f,
174        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
175        Err(e) => return Err(Error::io(events_path, e)),
176    };
177    let len = f.metadata().map_err(|e| Error::io(events_path, e))?.len();
178    if len == 0 {
179        return Ok(());
180    }
181    let mut tail = [0u8; 1];
182    f.seek(SeekFrom::End(-1))
183        .map_err(|e| Error::io(events_path, e))?;
184    f.read_exact(&mut tail)
185        .map_err(|e| Error::io(events_path, e))?;
186    if tail[0] == b'\n' {
187        return Ok(());
188    }
189    // Torn final line: cut back to just past the last complete record's
190    // trailing newline, or to empty when no complete record exists.
191    let keep = match find_prev_newline(&mut f, len, events_path)? {
192        Some(nl) => nl + 1,
193        None => 0,
194    };
195    f.set_len(keep).map_err(|e| Error::io(events_path, e))?;
196    f.sync_all().map_err(|e| Error::io(events_path, e))?;
197    // Surface the recovery so an operator inspecting the run knows a
198    // crash-torn tail was discarded (and how many bytes), rather than the
199    // truncation happening invisibly under the lock.
200    tracing::warn!(
201        target: "octl_core::events",
202        path = %events_path.display(),
203        discarded_bytes = len - keep,
204        kept_bytes = keep,
205        "truncated crash-torn final line off events.jsonl before append"
206    );
207    Ok(())
208}
209
210/// Append one event with a caller-supplied `seq`. The `_witness: &LockedRun`
211/// is compile-time proof the caller holds the run's exclusive [`RunLock`] for
212/// the duration of this call; the caller is still responsible for ensuring
213/// `seq` is monotonic. Misuse can corrupt the event log.
214///
215/// Test-only (`#[cfg(test)]`): a raw, no-reducer, caller-managed-`seq`
216/// primitive used by the crate's fixtures and the flock stress test to craft
217/// event logs with explicit seqs. Production mutation goes through
218/// [`append_and_apply_event`]; projection rebuild (future) replays via
219/// [`crate::reducer`], so neither needs this.
220#[cfg(test)]
221pub(crate) fn append_event_with_seq(
222    _witness: &LockedRun<'_>,
223    paths: &RunPaths,
224    seq: u64,
225    kind: &str,
226    node_id: Option<&NodeId>,
227    idempotency_key: Option<&str>,
228    data: Value,
229) -> Result<()> {
230    write_event_line(paths, seq, kind, node_id, idempotency_key, data)
231}
232
233#[cfg(test)]
234fn write_event_line(
235    paths: &RunPaths,
236    seq: u64,
237    kind: &str,
238    node_id: Option<&NodeId>,
239    idempotency_key: Option<&str>,
240    data: Value,
241) -> Result<()> {
242    let ev = Event {
243        ts: Utc::now(),
244        seq,
245        kind: kind.to_string(),
246        run_id: paths.run_id.clone(),
247        node_id: node_id.cloned(),
248        idempotency_key: idempotency_key.map(str::to_string),
249        data,
250    };
251    let events_path = paths.events();
252    let mut line = serde_json::to_vec(&ev).map_err(|e| Error::json(events_path.clone(), e))?;
253    line.push(b'\n');
254    let mut f = open_events_append(&events_path)?;
255    f.write_all(&line)
256        .map_err(|e| Error::io(events_path.clone(), e))?;
257    f.sync_all().map_err(|e| Error::io(events_path, e))?;
258    Ok(())
259}
260
261/// Outcome of an [`append_and_apply_event`] call.
262///
263/// `seq` is the value a caller surfaces to a user: the freshly appended
264/// event's `seq`, or — on an idempotent replay — the `seq` of the
265/// pre-existing matching event. A reducer no-op (e.g. an event dropped by
266/// the terminal-state guard) is still a success at this layer: `seq` names
267/// the appended event regardless of whether the reducer changed anything.
268///
269/// There is intentionally no `derived_event_ids` field. This API mutates
270/// exactly one event; the supervisor's report consumption, which emits a
271/// *batch* of derived discussion/spinoff events under one held lock, uses
272/// [`append_and_apply_unlocked`] instead (the sanctioned lock-held
273/// composition path) and tracks its own emitted ids.
274#[derive(Debug, Serialize)]
275pub struct AppendResult {
276    /// `seq` of the appended event, or of the prior event on an idempotent
277    /// replay.
278    pub seq: u64,
279    /// True when `idempotency_key` matched a prior event so nothing new was
280    /// appended or applied; `seq`/`prior` then describe that prior event.
281    pub idempotent_replay: bool,
282    /// True when the reducer produced at least one projection write for THIS
283    /// append — i.e. the event actually changed state, rather than folding to a
284    /// no-op (an unknown/audit kind, or an event dropped by a `*.created` /
285    /// terminal-state guard). Lets a caller distinguish "the reducer applied my
286    /// event" from "it was a dead event" WITHOUT re-reading the projection and
287    /// pattern-matching a field (issue `reducer-adopt-explicit-merge`).
288    ///
289    /// This is a report of what the reducer did on THIS call, NOT a durable
290    /// "is teardown pending?" signal: it is `false` both on an idempotent replay
291    /// AND on a fresh append the reducer no-op'd (e.g. re-submitting the exact
292    /// report already adopted). Callers making a DURABLE decision (does the run
293    /// still need a teardown actor?) must read projection state, not this flag —
294    /// see `run merge`'s `ensure_report_consumer`, which deliberately does NOT gate
295    /// its reattach on `applied` (that was a crash-retry leak caught in review).
296    pub applied: bool,
297    /// On an idempotent replay, the prior event's recorded `node_id` and
298    /// `data`, so a caller can reject a key reused with a conflicting
299    /// request (Stripe-style). `None` on a fresh append.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub prior: Option<PriorEvent>,
302}
303
304/// The one canonical mutation entry point: append a single event to
305/// `events.jsonl` *and* fold it into the projection files via the reducer,
306/// all under the run's `flock`, with idempotency-key dedup.
307///
308/// On success, every `events.jsonl` line is folded into `manifest.json` /
309/// `nodes/*.json` / `discussions/*.json` / `spinoffs/*.json` before the lock
310/// is released, so a read CLI run a millisecond later never sees a stale
311/// projection. This is *not* a crash-atomic transaction: the event is fsynced
312/// before the reducer runs, so a crash (or an I/O error from `apply_event`)
313/// after the append but before the projection write leaves the log ahead of
314/// the projections — recoverable only by a future `rebuild_projections`. The
315/// log is the source of truth; projections are a derived cache.
316///
317/// The append is transactional against reducer *validation*: the event is
318/// first reduced through [`reduce_event_to_ops`](crate::reducer) under the
319/// lock — the single plan-then-commit path that both validates and computes
320/// the projection writes — and only a validating event is appended (and
321/// fsynced) and then committed by the reducer. A reducer-rejected event (a
322/// `CorruptEventLog` for a malformed payload) errors *before* any bytes are
323/// written, so the log never gains a poison line that a future replay /
324/// `rebuild_projections` would choke on.
325/// (A pre-existing torn tail may still be truncated before validation runs —
326/// those bytes are uncommitted by definition; see [`recover_last_seq`].)
327///
328/// When `idempotency_key` is `Some` and a prior event with the same `kind` +
329/// key already exists ([`find_prior_with_key`](crate::events)), nothing is appended or
330/// applied: the result carries the prior event's `seq`, `idempotent_replay:
331/// true`, and `prior: Some(..)` so the caller can detect a key reused with a
332/// conflicting payload. With `idempotency_key: None` no scan runs.
333///
334/// Callers that must compose several writes — or a read-modify-write
335/// transaction (read a projection, decide, then append) — under one lock
336/// window hold the lock themselves and use [`append_and_apply_unlocked`],
337/// the sanctioned lock-held composition path. Re-entering this function
338/// while already holding the lock would deadlock: `flock` blocks when a
339/// second open of the lock file from the same process tries `LOCK_EX`.
340pub fn append_and_apply_event(
341    paths: &RunPaths,
342    kind: &str,
343    node_id: Option<&NodeId>,
344    idempotency_key: Option<&str>,
345    data: Value,
346) -> Result<AppendResult> {
347    RunLock::with_lock(paths, |lock| {
348        // Catch the projections up to the event log before either the
349        // idempotency lookup or a fresh append. This is the recovery half of
350        // append+apply atomicity: any unapplied tail left by a prior crash is
351        // folded here, under the same lock, so an idempotent replay returns
352        // only once the prior event's projection is durably committed
353        // (`applied_seq >= prior.seq`) — never a stale "found, but not applied"
354        // result. A clean run with no tail makes this a cheap no-op.
355        let events_path = paths.checked_events()?;
356        truncate_torn_tail(&events_path)?;
357        replay_unapplied(paths, &events_path)?;
358        // Idempotency lookup + append share this one lock window so a
359        // concurrent retry can't see "no prior event" and double-append.
360        if let Some(key) = idempotency_key {
361            if let Some(prior) = find_prior_with_key(lock, paths, kind, key)? {
362                return Ok(AppendResult {
363                    seq: prior.seq,
364                    idempotent_replay: true,
365                    // Nothing was applied by THIS call — the prior event (already
366                    // folded) carried any state change.
367                    applied: false,
368                    prior: Some(prior),
369                });
370            }
371        }
372        let (seq, applied) =
373            append_and_apply_reporting(lock, paths, kind, node_id, idempotency_key, data)?;
374        Ok(AppendResult {
375            seq,
376            idempotent_replay: false,
377            applied,
378            prior: None,
379        })
380    })
381}
382
383/// Append one event and fold it into projections. The `_witness: &LockedRun`
384/// is compile-time proof the caller already holds the run's exclusive
385/// [`RunLock`] — obtained from [`RunLock::with_lock`] or [`RunLock::witness`],
386/// so this entry point cannot be reached without the lock. The **sanctioned
387/// lock-held composition path**: use it to fold extra logic (an idempotency-key
388/// lookup, a status precondition) or several writes (the supervisor's
389/// derived discussion/spinoff batch) into one locked critical section.
390/// Calling [`append_and_apply_event`] from within a held lock would
391/// deadlock because `flock` blocks when a second open of the lock file from
392/// the same process tries to acquire `LOCK_EX`.
393///
394/// # The witness is mandatory
395///
396/// Without a `&LockedRun` proof the lock is held, this does not compile — there
397/// is no way to skip the parameter, and [`LockedRun`] cannot be constructed
398/// outside this crate (its field is private), so the only source is a held
399/// [`RunLock`]:
400///
401/// ```compile_fail
402/// use octl_core::{append_and_apply_unlocked, RunPaths};
403/// # fn demo(paths: &RunPaths) {
404/// // No witness passed — the first argument must be a `&LockedRun`, which a
405/// // caller can only obtain by actually holding the run's exclusive lock.
406/// let _ = append_and_apply_unlocked(paths, "run.status", None, None, serde_json::json!({}));
407/// # }
408/// ```
409pub fn append_and_apply_unlocked(
410    witness: &LockedRun<'_>,
411    paths: &RunPaths,
412    kind: &str,
413    node_id: Option<&NodeId>,
414    idempotency_key: Option<&str>,
415    data: Value,
416) -> Result<u64> {
417    append_and_apply_reporting(witness, paths, kind, node_id, idempotency_key, data)
418        .map(|(seq, _)| seq)
419}
420
421/// As [`append_and_apply_unlocked`], but also reports whether the reducer APPLIED
422/// (produced ≥1 projection op) vs folded to a no-op — the `bool` feeding
423/// [`AppendResult::applied`]. Kept private so the public composition primitive
424/// stays `-> u64` for its 15+ callers (none of which need the applied bit); only
425/// [`append_and_apply_event`] threads it out. See [`AppendResult::applied`] for
426/// why callers want it (issue `reducer-adopt-explicit-merge`).
427fn append_and_apply_reporting(
428    _witness: &LockedRun<'_>,
429    paths: &RunPaths,
430    kind: &str,
431    node_id: Option<&NodeId>,
432    idempotency_key: Option<&str>,
433    data: Value,
434) -> Result<(u64, bool)> {
435    // Symlink containment runs once here, before truncate/recover/open all
436    // reuse this path — guarding the run root and the event log itself so a
437    // swapped `events.jsonl` can't redirect the run's source-of-truth write
438    // outside the run tree.
439    let events_path = paths.checked_events()?;
440    // Remove any crash-torn final line BEFORE recovering the seq or
441    // appending, so the new record is never concatenated onto a partial one
442    // and `seq` is recovered from a clean, `\n`-terminated file.
443    truncate_torn_tail(&events_path)?;
444    // Replay any unapplied tail (`seq > applied_seq`) before appending, so this
445    // append never stacks onto a projection that is behind the log. When called
446    // from `append_and_apply_event` the tail was already drained a moment ago,
447    // so this is a no-op; direct lock-held callers (supervisor batch, cancel,
448    // discussion/spinoff resolution) get the same recovery for free.
449    replay_unapplied(paths, &events_path)?;
450    let last = recover_last_seq(&events_path)?;
451    let seq = last + 1;
452    let ev = Event {
453        ts: Utc::now(),
454        seq,
455        kind: kind.to_string(),
456        run_id: paths.run_id.clone(),
457        node_id: node_id.cloned(),
458        idempotency_key: idempotency_key.map(str::to_string),
459        data,
460    };
461    // Transactional gate, plan-then-commit: reduce the event against current
462    // projection state BEFORE the durable append. `reduce_event_to_ops` both
463    // validates and computes the exact projection writes to make; a reducer-
464    // rejected event errors here and is never written, so a later replay /
465    // rebuild can't trip on a poison line. The planned ops are then committed
466    // *after* the fsynced append — nothing mutates the projections between the
467    // plan and the commit (the append only touches `events.jsonl`), so the
468    // planned writes are still valid. One reduce pass serves both the gate and
469    // the apply, so there is no validate/apply branch pair to drift apart.
470    let ops = reduce_event_to_ops(paths, &ev)?;
471    // Whether the reducer changed state for this event — reported to the caller
472    // via `AppendResult::applied`. Captured before `commit_ops` consumes `ops`.
473    let applied = !ops.is_empty();
474    let mut line = serde_json::to_vec(&ev).map_err(|e| Error::json(events_path.clone(), e))?;
475    line.push(b'\n');
476    let mut f = open_events_append(&events_path)?;
477    f.write_all(&line)
478        .map_err(|e| Error::io(events_path.clone(), e))?;
479    f.sync_all().map_err(|e| Error::io(events_path, e))?;
480    commit_ops(paths, ops)?;
481    // Advance the watermark only after every projection this event touched is
482    // durably committed. A crash before this point leaves `applied_seq < seq`,
483    // and the next lock acquisition replays the event (idempotently — the
484    // reducer's existence/terminal guards make a re-fold a no-op) before
485    // advancing. So the watermark can only ever lag the projections, never lead
486    // them — the projection a reader sees is always at least as new as
487    // `applied_seq` claims.
488    advance_applied_seq(paths, seq)?;
489    Ok((seq, applied))
490}
491
492/// The three observable outcomes of an [`append_and_apply_idempotent`] call —
493/// the shared `--idempotency-key` contract that `event create`, `discussion
494/// resolve`, and future keyed verbs (`spinoff approve|reject`, `run create`,
495/// `node report`) all answer to, lifted out of each CLI's private log scan.
496///
497/// The discriminator is whether a prior event with the same `kind` + key
498/// already exists, and — if so — whether the call's `(node_id, data)` identity
499/// matches that prior event:
500///
501/// - [`AppendOutcome::Appended`] — no prior event carried this key: a fresh
502///   event was appended and folded into the projections. `seq` is its sequence.
503/// - [`AppendOutcome::IdempotentReplay`] — a prior event carried this key **and**
504///   the same `node_id` + `data`: a true retry. Nothing was appended; the
505///   `prior` event (its `seq` / `node_id` / `data`) is returned so the caller
506///   can surface the original sequence.
507/// - [`AppendOutcome::Conflict`] — a prior event carried this key but with a
508///   **different** `node_id` or `data`: the key was reused for a different
509///   request (a client bug, Stripe-style). Nothing was appended; `prior` is
510///   returned so the caller can build a precise conflict error (e.g. diff the
511///   payload vs. the node id).
512#[derive(Debug)]
513pub enum AppendOutcome {
514    /// A fresh event was appended and applied; `seq` is its sequence number.
515    Appended {
516        /// The appended event's `seq`.
517        seq: u64,
518    },
519    /// The key matched a prior event with identical `node_id` + `data`. No new
520    /// event was written; `prior.seq` is the original sequence to surface.
521    IdempotentReplay {
522        /// The pre-existing matching event (its `seq`, `node_id`, and `data`).
523        prior: PriorEvent,
524    },
525    /// The key matched a prior event whose `node_id` or `data` differs from this
526    /// request. No new event was written; the caller should reject the reuse.
527    Conflict {
528        /// The pre-existing event recorded under the same key, for the caller's
529        /// conflict diagnostics (`prior.seq` is the original sequence).
530        prior: PriorEvent,
531    },
532}
533
534/// Append one keyed event idempotently: scan for a prior event with the same
535/// `kind` + `key`, and either replay it, reject a conflicting reuse, or append
536/// fresh — the centralized `--idempotency-key` primitive (issue
537/// `core-idempotency-api`).
538///
539/// This is the **sanctioned lock-held composition path** for keyed appends: the
540/// `_witness: &LockedRun` proves the caller already holds the run's exclusive
541/// [`RunLock`] (from [`RunLock::with_lock`] or [`RunLock::witness`]), so the
542/// scan and the append share one lock window and a concurrent retry can never
543/// see "no prior event" and double-append. Calling it composes with the
544/// applied-seq watermark and the path-traversal defense exactly as
545/// [`append_and_apply_unlocked`] does — it catches the projections up to the log
546/// (`truncate_torn_tail` + `replay_unapplied`) before scanning, guards the run
547/// root + event log via `RunPaths::checked_events`, and routes the fresh
548/// append through `append_and_apply_unlocked`.
549///
550/// `build` lazily produces the event's `data` payload given the sequence the
551/// fresh event *would* receive. It is a **pure** constructor: it is invoked once
552/// to materialize the candidate payload (to compare against a prior event, or to
553/// write a fresh one) and must not encode caller-side domain preconditions — a
554/// verb whose append is gated on projection state (e.g. `discussion resolve`'s
555/// already-resolved / no-op decision) keeps that logic in its own locked body
556/// and uses [`find_prior_with_key`] directly. The `u64` lets a payload embed its
557/// own `seq`; a payload that does so is not replay-stable and should not be used
558/// with idempotency.
559///
560/// The key must be non-empty: an empty key is rejected with
561/// [`Error::EmptyIdempotencyKey`] before any scan, since `""` would collapse
562/// every keyless append into one dedup slot.
563///
564/// # Examples
565///
566/// ```no_run
567/// use octl_core::{append_and_apply_idempotent, AppendOutcome, RunLock, RunPaths};
568/// use serde_json::json;
569///
570/// # fn demo(paths: &RunPaths) -> octl_core::Result<()> {
571/// let outcome = RunLock::with_lock(paths, |lock| {
572///     append_and_apply_idempotent(
573///         paths,
574///         lock,
575///         "node.status",
576///         None,            // no target node
577///         "retry-key-42",  // the caller's idempotency key (non-empty)
578///         |_seq| Ok(json!({ "status": "running" })),
579///     )
580/// })?;
581/// match outcome {
582///     AppendOutcome::Appended { seq } => println!("appended at seq {seq}"),
583///     AppendOutcome::IdempotentReplay { prior } => println!("replayed seq {}", prior.seq),
584///     AppendOutcome::Conflict { prior } => println!("key reused; prior seq {}", prior.seq),
585/// }
586/// # Ok(())
587/// # }
588/// ```
589pub fn append_and_apply_idempotent<F>(
590    paths: &RunPaths,
591    witness: &LockedRun<'_>,
592    kind: &str,
593    node_id: Option<&NodeId>,
594    key: &str,
595    build: F,
596) -> Result<AppendOutcome>
597where
598    F: FnOnce(u64) -> Result<Value>,
599{
600    if key.is_empty() {
601        return Err(Error::EmptyIdempotencyKey);
602    }
603    // Catch the projections up to the log before scanning, mirroring
604    // `append_and_apply_unlocked`'s recovery half: an idempotent replay must
605    // only report once the prior event's projection is durably committed, never
606    // a stale "found, but not applied" result. A clean run makes this a no-op.
607    let events_path = paths.checked_events()?;
608    truncate_torn_tail(&events_path)?;
609    replay_unapplied(paths, &events_path)?;
610
611    // The sequence a fresh append *would* take. Computed once, after catch-up,
612    // so `build`'s payload sees the same seq `append_and_apply_unlocked` will
613    // assign under this still-held lock.
614    let next_seq = recover_last_seq(&events_path)? + 1;
615    let data = build(next_seq)?;
616
617    if let Some(prior) = find_prior_with_key(witness, paths, kind, key)? {
618        // A prior event carries this key. It is a true replay only when the
619        // full request identity — the envelope `node_id` *and* the `data`
620        // payload — matches; any divergence is a key reused for a different
621        // request and must surface as a conflict, never a silent no-op.
622        let same_node = prior.node_id.as_deref() == node_id.map(NodeId::as_str);
623        if same_node && prior.data == data {
624            return Ok(AppendOutcome::IdempotentReplay { prior });
625        }
626        return Ok(AppendOutcome::Conflict { prior });
627    }
628
629    let seq = append_and_apply_unlocked(witness, paths, kind, node_id, Some(key), data)?;
630    Ok(AppendOutcome::Appended { seq })
631}
632
633/// Replay every unapplied tail event — those with `seq > manifest.applied_seq`
634/// — into the projections, advancing the watermark after each, so the
635/// projection cache is caught up to `events.jsonl` before any new append.
636///
637/// This is the recovery half of the append+apply atomicity guarantee. A writer
638/// that crashed after fsyncing an event row but before fsyncing its projection
639/// (or before advancing `applied_seq`) leaves `applied_seq < last_seq`; the
640/// next lock acquisition heals it here. The reducer is idempotent — every
641/// `*.created` reducer short-circuits when its projection already exists, and
642/// every status/report reducer is a no-op once the target is terminal — so
643/// re-folding an event whose projection *did* land changes nothing. The
644/// manifest's denormalized counters can't desync across this replay either:
645/// they are not folded incrementally but re-derived from projection state by
646/// [`advance_applied_seq`] after each event, so a re-fold simply recomputes the
647/// same totals.
648///
649/// No manifest yet (pre-`run.created`) means there is no watermark to anchor
650/// and nothing durable to catch up, so this returns immediately until the
651/// manifest exists. A legacy manifest reads as `applied_seq = 0` (serde
652/// default), so the first call re-folds the entire log; that is intentional
653/// and safe — see [`crate::schema::Manifest::applied_seq`].
654///
655/// # Corrupt-line tolerance
656///
657/// A line that does not parse as an [`Event`] is skipped, not hard-errored —
658/// the same definition of "corrupt" the quarantine path uses, and the same
659/// tolerance the pre-watermark append path had (it only ever parsed the *last*
660/// line via [`recover_last_seq`]). Bricking every append on an interior poison
661/// line would, among other things, make it impossible to even *record* the
662/// supervisor's `event_log_skipped_line` diagnostic about that very line.
663/// Healing such a line is the supervisor's quarantine job, not the writer's.
664///
665/// A *parse-valid* event whose payload is semantically corrupt is skipped the
666/// same way (with a `warn`), rather than hard-erroring. The dangerous subclass
667/// is an event carrying an embedded id (`discussion_id`, `proposal_id`,
668/// `child_run_id`, `child_node_id`) that fails its strict `parse_str` and would
669/// otherwise be joined onto a path — the reducer's independent second line of
670/// defense against a corrupt log, a restored backup, or a future writer that
671/// bypasses the CLI validators (issue `reducer-path-traversal-defense`). Such
672/// an event is a *valid `Event` envelope* (only its `data` is bad), so the
673/// supervisor's [`quarantine_corrupt_lines`] — which only excises lines that
674/// fail the strict envelope parse — can never heal it; hard-erroring here would
675/// brick every future append on that line with no automated recovery path.
676/// Skipping it converges the projection to the largest safe subset and never
677/// joins a tainted id onto a path (the typed-id constructors already make
678/// traversal structurally impossible — a `"../escape"` id never parses into a
679/// [`DiscussionId`], so it can never reach `discussions/<id>.json`). The append
680/// *gate* stays fail-closed: [`reduce_event_to_ops`] rejects such an event
681/// before it is ever written, so a sanctioned log never reaches this branch and
682/// re-reducing real events on replay is a clean idempotent no-op. A genuine I/O
683/// fault (from the commit or watermark write) still propagates.
684///
685/// Because a sanctioned log is appended in `seq` order under the lock, file
686/// order equals `seq` order for real events; the only out-of-order bytes are
687/// skipped junk, so advancing the watermark to each applied event's `seq` never
688/// jumps over an unfolded real event.
689///
690/// Caller must hold the run's [`RunLock`] and must have already truncated any
691/// torn tail, so the final line is either complete or absent.
692fn replay_unapplied(paths: &RunPaths, events_path: &Path) -> Result<()> {
693    let applied = match read_manifest_opt(paths)? {
694        Some(m) => m.applied_seq,
695        None => return Ok(()),
696    };
697    // Cheap fast path for the overwhelmingly common clean case: the watermark
698    // already covers the log, so there is nothing to replay and no full scan.
699    if applied >= recover_last_seq(events_path)? {
700        return Ok(());
701    }
702    let f = match std::fs::File::open(events_path) {
703        Ok(f) => f,
704        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
705        Err(e) => return Err(Error::io(events_path, e)),
706    };
707    let mut reader = PhysicalLineReader::new(BufReader::new(f));
708    while let Some(line) = reader.next_line().map_err(|e| Error::io(events_path, e))? {
709        // A torn final line is an uncommitted partial write — stop, exactly as
710        // every other reader does.
711        if !line.complete {
712            break;
713        }
714        if line.content.is_empty() {
715            continue;
716        }
717        // Skip a parse-failing line (external junk by the quarantine
718        // definition); apply every event past the watermark in order.
719        let ev: Event = match serde_json::from_slice(line.content) {
720            Ok(ev) => ev,
721            Err(_) => continue,
722        };
723        if ev.seq <= applied {
724            continue;
725        }
726        // Plan the projection writes. A parse-valid but domain-corrupt event —
727        // most dangerously one whose embedded id fails its strict `parse_str`
728        // and would otherwise be joined onto a path — surfaces here as
729        // `CorruptEventLog`. Quarantine cannot excise it (it is a valid
730        // envelope), so we skip it with a warn rather than aborting the whole
731        // catch-up replay; the watermark is not advanced for a skipped event.
732        // See this function's "Corrupt-line tolerance" doc. I/O faults from the
733        // commit/watermark write below still propagate.
734        let ops = match reduce_event_to_ops(paths, &ev) {
735            Ok(ops) => ops,
736            Err(Error::CorruptEventLog { reason, .. }) => {
737                tracing::warn!(
738                    target: "octl_core::events",
739                    path = %events_path.display(),
740                    seq = ev.seq,
741                    kind = %ev.kind,
742                    reason = %reason,
743                    "skipping corrupt event during replay (unsafe id or malformed payload); projection not advanced for it"
744                );
745                continue;
746            }
747            Err(e) => return Err(e),
748        };
749        commit_ops(paths, ops)?;
750        advance_applied_seq(paths, ev.seq)?;
751    }
752    Ok(())
753}
754
755/// Advance `manifest.applied_seq` to `seq` and fsync the manifest (atomic
756/// temp-file + rename), recording that every projection touched by event `seq`
757/// is durably committed.
758///
759/// A no-op when no manifest exists yet, or when the watermark already covers
760/// `seq` — so re-folding an already-applied event (during replay) doesn't churn
761/// the manifest. The reducer for the event may itself have just rewritten the
762/// manifest (e.g. a status transition); reading it back here preserves those
763/// fields while moving only the watermark forward. Caller holds the [`RunLock`].
764///
765/// This is also the single point that persists the manifest's denormalized
766/// counters (`node_count`, `open_discussions`, `pending_spinoffs`). They are
767/// **derived**, not incremented: [`derive_counters`] recomputes them from the
768/// projection directories — which, because the caller commits an event's
769/// projection ops *before* calling this, already reflect event `seq`. Pinning
770/// the counters to the watermark advance is what makes them undriftable: even
771/// when a crash-replay re-folds an event whose reducer short-circuits to zero
772/// ops (its projection already landed before the crash), this still runs and
773/// re-derives the true counts, healing any counter the old incremental path
774/// would have stranded. See [`derive_counters`] and issue
775/// `manifest-counter-desync`.
776fn advance_applied_seq(paths: &RunPaths, seq: u64) -> Result<()> {
777    if let Some(mut m) = read_manifest_opt(paths)? {
778        if m.applied_seq < seq {
779            let counters = derive_counters(paths)?;
780            m.node_count = counters.node_count;
781            m.open_discussions = counters.open_discussions;
782            m.pending_spinoffs = counters.pending_spinoffs;
783            m.applied_seq = seq;
784            write_manifest(paths, &m)?;
785        }
786    }
787    Ok(())
788}
789
790/// One physical line surfaced by [`PhysicalLineReader`]: its content with
791/// any trailing terminator stripped, plus enough framing for the torn-tail
792/// policy (whether it was newline-terminated) and for error context (byte
793/// offset + 1-based line number).
794struct PhysicalLine<'a> {
795    /// Line content with a single trailing terminator (`\n`, optionally
796    /// preceded by `\r`) removed. Interior/leading bytes are untouched.
797    content: &'a [u8],
798    /// `false` only for a final line lacking a trailing `\n` — a torn,
799    /// in-flight append. `true` for every newline-terminated line. Because a
800    /// non-terminated line can only be the last bytes in the file, this is
801    /// `false` for at most one line, and only ever the last one.
802    complete: bool,
803    /// 1-based line number, for `CorruptEventLog` context.
804    lineno: u64,
805}
806
807/// The single physical-line reader behind both [`read_all_events`] and
808/// [`find_prior_with_key`], so the read paths can never disagree about the
809/// torn-tail policy (design.md §1.4; torn-line-policy-consistency).
810///
811/// Bytes are read with [`BufRead::read_until`] (not `read_line`/`lines()`)
812/// for two reasons: it keeps the trailing `\n` so a torn final line is
813/// distinguishable from a newline-terminated interior one, and it reads raw
814/// bytes so a torn tail that cuts a multi-byte UTF-8 sequence is tolerated as
815/// a partial write rather than surfacing as an I/O error. A *newline-
816/// terminated* line with invalid UTF-8 still reaches the caller's parse,
817/// which classifies it as `CorruptEventLog`.
818///
819/// `next_line` lends a slice into an internal buffer, so a caller holds at
820/// most one line at a time — the streaming (lending-iterator) pattern, which
821/// keeps the per-line allocation cost to a single reused buffer.
822struct PhysicalLineReader<R: BufRead> {
823    reader: R,
824    buf: Vec<u8>,
825    lineno: u64,
826    done: bool,
827}
828
829impl<R: BufRead> PhysicalLineReader<R> {
830    fn new(reader: R) -> Self {
831        Self {
832            reader,
833            buf: Vec::new(),
834            lineno: 0,
835            done: false,
836        }
837    }
838
839    /// Yield the next physical line, or `None` at end of file. I/O errors are
840    /// surfaced raw so the caller can attach the log path.
841    fn next_line(&mut self) -> std::io::Result<Option<PhysicalLine<'_>>> {
842        if self.done {
843            return Ok(None);
844        }
845        self.buf.clear();
846        let n = self.reader.read_until(b'\n', &mut self.buf)?;
847        if n == 0 {
848            self.done = true;
849            return Ok(None);
850        }
851        self.lineno += 1;
852        let complete = self.buf.last() == Some(&b'\n');
853        // A non-terminated line is necessarily the final bytes of the file;
854        // stop after handing it back so the torn-tail policy only ever sees
855        // it last.
856        if !complete {
857            self.done = true;
858        }
859        let len = trim_line_end(&self.buf).len();
860        Ok(Some(PhysicalLine {
861            content: &self.buf[..len],
862            complete,
863            lineno: self.lineno,
864        }))
865    }
866}
867
868/// Stream `events.jsonl` line by line, deserializing each complete line into a
869/// caller-chosen envelope probe `T` and invoking `visit(probe, raw_line)`.
870///
871/// This is the streaming counterpart to [`read_all_events`]: it shares the exact
872/// [`PhysicalLineReader`] torn-tail / [`Error::CorruptEventLog`] policy (a torn
873/// final line lacking a trailing `\n` is dropped *without* parsing even if its
874/// bytes are valid JSON; any newline-terminated unparseable line is interior
875/// corruption surfaced as [`Error::CorruptEventLog`]) but never materializes the
876/// whole log — the caller accumulates only what it needs into its own state.
877///
878/// `T` deserializes only the envelope fields it declares; serde ignores the
879/// rest, so a multi-KB `node.report` `data` payload is scanned but never
880/// allocated. The raw line bytes are *lent* to `visit` (a streaming
881/// lending-iterator borrow into the reader's reused buffer), so the closure can
882/// re-parse the full payload for the rare line it must materialize without the
883/// reader holding more than one line at a time.
884///
885/// A missing log is an empty stream (`Ok(())` with no calls). Caller must hold
886/// the run's [`RunLock`]; the scan is read-only over an append-only file.
887pub(crate) fn for_each_event_probe<T, F>(events_path: &Path, mut visit: F) -> Result<()>
888where
889    T: serde::de::DeserializeOwned,
890    F: FnMut(T, &[u8]) -> Result<()>,
891{
892    let f = match std::fs::File::open(events_path) {
893        Ok(f) => f,
894        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
895        Err(e) => return Err(Error::io(events_path, e)),
896    };
897    let mut reader = PhysicalLineReader::new(BufReader::new(f));
898    while let Some(line) = reader.next_line().map_err(|e| Error::io(events_path, e))? {
899        // Torn final line (no trailing newline): uncommitted partial write,
900        // discarded without parsing — mirrors `recover_last_seq`.
901        if !line.complete {
902            break;
903        }
904        if line.content.is_empty() {
905            continue;
906        }
907        let probe: T =
908            serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
909                path: events_path.to_path_buf(),
910                reason: format!(
911                    "line {} is not a valid event: {} [{e}]",
912                    line.lineno,
913                    excerpt(line.content)
914                ),
915            })?;
916        visit(probe, line.content)?;
917    }
918    Ok(())
919}
920
921/// Read every event from `events.jsonl`. Used by tests and reducer replays.
922///
923/// # Torn-line policy
924///
925/// Built on the shared [`for_each_event_probe`](crate::events) (hence
926/// [`PhysicalLineReader`](crate::events)), so it matches
927/// [`find_prior_with_key`](crate::events) and [`recover_last_seq`] exactly: a
928/// torn final line lacking a trailing `\n` is an in-flight partial write,
929/// dropped *without* parsing even if its bytes happen to be valid JSON. Any
930/// newline-terminated line that fails to parse is interior corruption and
931/// surfaces as [`Error::CorruptEventLog`] — not a transient JSON fault — so a
932/// replay rejects a poisoned log loudly instead of silently dropping a line.
933pub fn read_all_events(events_path: &Path) -> Result<Vec<Event>> {
934    let mut out = Vec::new();
935    for_each_event_probe::<Event, _>(events_path, |ev, _raw| {
936        out.push(ev);
937        Ok(())
938    })?;
939    Ok(out)
940}
941
942/// Outcome of a [`quarantine_corrupt_lines`] call that removed at least one
943/// poison line. `backup_path` is the renamed copy of the original log (kept
944/// verbatim for operator forensics / hand-repair); `removed_byte_offsets`
945/// are the start offsets, in that original, of every newline-terminated line
946/// that failed to parse as an [`Event`] and was excised from the recovered
947/// `events.jsonl`.
948#[derive(Debug, Clone, Serialize)]
949pub struct Quarantine {
950    /// Path to the timestamped `.bak` holding the original poisoned log.
951    pub backup_path: PathBuf,
952    /// Byte offsets (in the original log) of every excised corrupt line.
953    pub removed_byte_offsets: Vec<u64>,
954}
955
956/// Heal a poisoned `events.jsonl` by excising its corrupt physical lines.
957///
958/// P2 made the supervisor *skip* a corrupt JSONL line in memory and keep
959/// tailing, but the bytes stayed on disk forever — so every fresh strict
960/// reader ([`read_all_events`] / a future `rebuild_projections`) still
961/// hard-errors on them, and the skip diagnostic is unreachable to a strict
962/// replay (the corrupt line aborts the read before it). This is the durable
963/// repair: under the run's [`RunLock`], the original log is renamed to
964/// `events.jsonl.corrupt-<ts>.bak` and a recovered `events.jsonl` is written
965/// in its place containing every line *except* the corrupt ones.
966///
967/// "Corrupt" means exactly what the strict readers reject: a
968/// newline-terminated, non-empty line that does not parse as a full [`Event`]
969/// envelope. Empty lines and a torn (newline-less) final line are retained
970/// verbatim — the readers already tolerate both, so excising them would be a
971/// behavior change, not a repair.
972///
973/// Returns `Ok(None)` when the log is missing or already clean (no rename, no
974/// rewrite — the common case is cheap: one read, no corrupt line found).
975/// Returns `Ok(Some(_))` with the backup path and removed offsets when at
976/// least one line was excised. Caller is expected to surface the outcome
977/// (e.g. a `supervisor.event_log_quarantined` diagnostic) and, for a live
978/// tail, restart its read cursor at offset 0 since every byte offset shifts.
979///
980/// `backup_ts` is supplied by the caller (kept out of core so the rename is
981/// deterministic in tests); a filename-safe basic-ISO stamp like
982/// `20260628T120000Z` is the intended form.
983///
984/// # Operator recovery
985///
986/// The excised bytes are never destroyed — they survive verbatim in the
987/// `events.jsonl.corrupt-<ts>.bak` sibling (named by the emitted
988/// `supervisor.event_log_quarantined { backup_path }` diagnostic). To recover
989/// a line the automated repair dropped: open the `.bak`, inspect the line(s)
990/// at the reported `removed_byte_offsets`, hand-fix any salvageable JSON, and —
991/// if you want the record back — stop the run's supervisor, append the
992/// corrected line to the live `events.jsonl` (or replace the file wholesale
993/// from a fixed copy of the backup), then restart the supervisor. The healed
994/// log is the source of truth; projections rebuild from it.
995pub fn quarantine_corrupt_lines(paths: &RunPaths, backup_ts: &str) -> Result<Option<Quarantine>> {
996    RunLock::with_lock(paths, |lock| {
997        quarantine_corrupt_lines_unlocked(lock, paths, backup_ts)
998    })
999}
1000
1001/// As [`quarantine_corrupt_lines`] but takes a `&LockedRun` witness proving the
1002/// caller already holds the run's exclusive [`RunLock`] — the sanctioned
1003/// lock-held composition path, mirroring [`append_and_apply_unlocked`].
1004/// Re-entering [`quarantine_corrupt_lines`] under a held lock would deadlock on
1005/// the second `flock` open.
1006pub fn quarantine_corrupt_lines_unlocked(
1007    _witness: &LockedRun<'_>,
1008    paths: &RunPaths,
1009    backup_ts: &str,
1010) -> Result<Option<Quarantine>> {
1011    // Guard the run root + event log against symlink redirection before the
1012    // rename/rewrite, exactly as the append path does.
1013    let events_path = paths.checked_events()?;
1014    let raw = match std::fs::read(&events_path) {
1015        Ok(b) => b,
1016        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1017        Err(e) => return Err(Error::io(&events_path, e)),
1018    };
1019
1020    // Walk physical lines, keeping the raw bytes (terminator included) of every
1021    // retained line so the recovered file is byte-identical save for the
1022    // excised corruption. A line is corrupt iff it is newline-terminated,
1023    // non-empty, and fails the same strict `Event` parse `read_all_events`
1024    // applies — so the recovered log is guaranteed to pass a strict replay.
1025    let mut recovered: Vec<u8> = Vec::with_capacity(raw.len());
1026    let mut removed_byte_offsets: Vec<u64> = Vec::new();
1027    let mut offset: u64 = 0;
1028    let mut i = 0usize;
1029    while i < raw.len() {
1030        let (line_end, complete) = match raw[i..].iter().position(|b| *b == b'\n') {
1031            Some(p) => (i + p + 1, true), // include the trailing '\n'
1032            None => (raw.len(), false),   // torn final line, no '\n'
1033        };
1034        let raw_line = &raw[i..line_end];
1035        let content = trim_line_end(raw_line);
1036        let corrupt =
1037            complete && !content.is_empty() && serde_json::from_slice::<Event>(content).is_err();
1038        if corrupt {
1039            removed_byte_offsets.push(offset);
1040        } else {
1041            recovered.extend_from_slice(raw_line);
1042        }
1043        offset += raw_line.len() as u64;
1044        i = line_end;
1045    }
1046
1047    if removed_byte_offsets.is_empty() {
1048        return Ok(None);
1049    }
1050
1051    // Rename the poisoned log aside (forensics), then atomically drop the
1052    // recovered log in its place. Order matters: the rename frees the path for
1053    // `write_atomic`'s tempfile+rename and preserves the original even if the
1054    // rewrite then fails.
1055    let backup_path = backup_path_for(&events_path, backup_ts);
1056    std::fs::rename(&events_path, &backup_path).map_err(|e| Error::io(&backup_path, e))?;
1057    write_atomic(&events_path, &recovered)?;
1058    Ok(Some(Quarantine {
1059        backup_path,
1060        removed_byte_offsets,
1061    }))
1062}
1063
1064/// Build the `events.jsonl.corrupt-<ts>.bak` sibling path for a quarantine
1065/// backup, preserving the original file name as a prefix.
1066fn backup_path_for(events_path: &Path, ts: &str) -> PathBuf {
1067    let mut name = events_path
1068        .file_name()
1069        .map(std::ffi::OsStr::to_os_string)
1070        .unwrap_or_default();
1071    name.push(format!(".corrupt-{ts}.bak"));
1072    events_path.with_file_name(name)
1073}
1074
1075/// A prior event located by [`find_prior_with_key`](crate::events). Carries enough to let
1076/// an idempotent-retry caller both return the recorded `seq` and verify the
1077/// retry payload matches what was originally written.
1078#[derive(Debug, Clone, PartialEq, Serialize)]
1079pub struct PriorEvent {
1080    /// The recorded `seq` of the matching event.
1081    pub seq: u64,
1082    /// The event's top-level `node_id`, if any.
1083    pub node_id: Option<String>,
1084    /// The event's `data` payload.
1085    pub data: Value,
1086}
1087
1088/// Fields skimmed from every line to test for a match without ever
1089/// allocating the (potentially large) `data` payload. `seq` is optional and
1090/// used only for best-effort error context — it is never a match key, so a
1091/// line missing it must not change whether a `kind` + `idempotency_key`
1092/// match is found.
1093#[derive(Deserialize)]
1094struct ProbeFields {
1095    #[serde(default)]
1096    seq: Option<u64>,
1097    kind: String,
1098    idempotency_key: Option<String>,
1099}
1100
1101/// Fields pulled from the one matching line, including the full payload.
1102#[derive(Deserialize)]
1103struct FullEventForReplay {
1104    seq: u64,
1105    node_id: Option<String>,
1106    data: Value,
1107}
1108
1109/// Maximum number of bytes from a malformed line to surface (escaped) in an
1110/// [`Error::CorruptEventLog`] reason.
1111const CORRUPT_LINE_EXCERPT_BYTES: usize = 100;
1112
1113/// Stream-scan `events.jsonl` for the first event with matching `kind` and
1114/// `idempotency_key`, returning a typed [`PriorEvent`] (or `None` when the
1115/// log is missing or holds no such event).
1116///
1117/// The skim parses each line's envelope (`kind` / `idempotency_key` / `seq`)
1118/// but never materializes `data` for non-matching lines; the full payload
1119/// (`node_id` plus `data`) is deserialized only for the one matching line.
1120/// JSON parsing still scans every byte of every line, so the scan is linear
1121/// in total log bytes under the lock — there is no payload-skipping shortcut.
1122///
1123/// # Torn-line policy
1124///
1125/// [`recover_last_seq`] tolerates a crash-truncated *final* line that lacks
1126/// a trailing newline and discards it regardless of whether its bytes
1127/// happen to form valid JSON. This scanner mirrors that exactly: a final
1128/// line with no trailing `\n` is treated as an in-flight partial write and
1129/// ignored — *before* any parse attempt — so the read (dedup) and write
1130/// (recovery) paths never disagree about whether that tail is committed.
1131///
1132/// Any *interior* line that fails to parse (it is newline-terminated, so a
1133/// later line follows) is a data-integrity fault, so it returns
1134/// [`Error::CorruptEventLog`] rather than silently skipping a line that
1135/// might carry the very key being looked up, which would let the caller
1136/// double-append. This is strictly *more* conservative than
1137/// `recover_last_seq` (which only inspects the last complete line) — a
1138/// deliberate choice for the dedup read.
1139///
1140/// Bytes are read with [`std::io::BufRead::read_until`] rather than
1141/// `read_line` so a torn tail that cuts a multi-byte UTF-8 sequence is
1142/// tolerated as a partial write (matching `recover_last_seq`) instead of
1143/// surfacing as an I/O error; a *newline-terminated* line containing
1144/// invalid UTF-8 is reported as `CorruptEventLog`, not I/O.
1145///
1146/// The `_witness: &LockedRun` is compile-time proof the caller holds the run's
1147/// exclusive [`RunLock`] — the scan is read-only, but it is only meaningful
1148/// fused with an append under one lock window (otherwise a concurrent retry can
1149/// see "no prior event" and double-append). The witness gates the public surface
1150/// so a caller cannot run the scan-then-append race: it must already hold the
1151/// lock to scan, and the same held lock covers the append it threads into
1152/// [`append_and_apply_unlocked`]. [`append_and_apply_idempotent`] fuses the two
1153/// for the common case; a caller that must interleave domain logic between the
1154/// scan and the append (e.g. `discussion resolve`'s already-resolved / no-op
1155/// precedence) calls this primitive directly under its own held lock.
1156pub fn find_prior_with_key(
1157    _witness: &LockedRun<'_>,
1158    paths: &RunPaths,
1159    kind: &str,
1160    idempotency_key: &str,
1161) -> Result<Option<PriorEvent>> {
1162    // Guard the run root + event log before reading: the idempotency scan
1163    // opens `events.jsonl` ahead of the append, so it must refuse a symlinked
1164    // log too rather than read through it.
1165    let events_path = paths.checked_events()?;
1166    let f = match std::fs::File::open(&events_path) {
1167        Ok(f) => f,
1168        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1169        Err(e) => return Err(Error::io(&events_path, e)),
1170    };
1171    let mut reader = PhysicalLineReader::new(BufReader::new(f));
1172    // `seq` of the last successfully-parsed line, for best-effort error
1173    // context pointing at where corruption begins.
1174    let mut last_good_seq: u64 = 0;
1175    while let Some(line) = reader.next_line().map_err(|e| Error::io(&events_path, e))? {
1176        // Mirror `recover_last_seq`: a final line lacking a trailing newline
1177        // is an uncommitted partial write, discarded WITHOUT parsing — even
1178        // if its bytes form valid JSON. Parsing it could otherwise return a
1179        // "match" for an event recovery considers unwritten, double-counting
1180        // the seq or skipping a real append.
1181        if !line.complete {
1182            break;
1183        }
1184        if line.content.is_empty() {
1185            continue;
1186        }
1187        let probe: ProbeFields =
1188            serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
1189                path: events_path.clone(),
1190                reason: format!(
1191                    "line {} is not a valid event envelope (last good seq {last_good_seq}): \
1192                 {} [{e}]",
1193                    line.lineno,
1194                    excerpt(line.content),
1195                ),
1196            })?;
1197        if let Some(seq) = probe.seq {
1198            last_good_seq = seq;
1199        }
1200        if probe.kind != kind || probe.idempotency_key.as_deref() != Some(idempotency_key) {
1201            continue;
1202        }
1203        let full: FullEventForReplay =
1204            serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
1205                path: events_path.clone(),
1206                reason: format!(
1207                    "line {} matched idempotency key but is not a replayable event: {} [{e}]",
1208                    line.lineno,
1209                    excerpt(line.content),
1210                ),
1211            })?;
1212        return Ok(Some(PriorEvent {
1213            seq: full.seq,
1214            node_id: full.node_id,
1215            data: full.data,
1216        }));
1217    }
1218    Ok(None)
1219}
1220
1221/// Strip a single trailing line terminator (`\n`, optionally preceded by
1222/// `\r`) from a raw line. Unlike `trim_end_matches`, this removes exactly
1223/// one terminator so interior/leading bytes are never altered.
1224fn trim_line_end(buf: &[u8]) -> &[u8] {
1225    let mut end = buf.len();
1226    if end > 0 && buf[end - 1] == b'\n' {
1227        end -= 1;
1228        if end > 0 && buf[end - 1] == b'\r' {
1229            end -= 1;
1230        }
1231    }
1232    &buf[..end]
1233}
1234
1235/// Render a bounded, escaped prefix of a malformed log line for inclusion
1236/// in an error message. Bytes are lossily decoded (a torn multi-byte tail
1237/// becomes the replacement char) and control characters are escaped so an
1238/// excerpt can't inject newlines or ANSI sequences into CLI output.
1239pub(crate) fn excerpt(line: &[u8]) -> String {
1240    let shown = &line[..line.len().min(CORRUPT_LINE_EXCERPT_BYTES)];
1241    let mut out: String = String::from_utf8_lossy(shown).escape_debug().to_string();
1242    if line.len() > CORRUPT_LINE_EXCERPT_BYTES {
1243        out.push('…');
1244    }
1245    out
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use crate::RunPaths;
1252    use serde_json::json;
1253    use tempfile::TempDir;
1254
1255    #[test]
1256    fn envelope_run_id_comes_from_paths_not_directory_basename() {
1257        // The whole point of storing run_id: even when the on-disk directory
1258        // name disagrees with the run id (symlinked/non-canonical root, the
1259        // original `root.file_name()` bug), the envelope must carry the stored
1260        // run_id verbatim — never the basename.
1261        let tmp = TempDir::new().unwrap();
1262        let dir = tmp.path().join("not-a-ulid-basename");
1263        std::fs::create_dir_all(&dir).unwrap();
1264        let run_id = "01jxsnap000000000000000000";
1265        let paths = RunPaths::new(dir, run_id).unwrap();
1266
1267        let r = append_and_apply_event(&paths, "run.status", None, None, serde_json::json!({}))
1268            .unwrap();
1269        assert_eq!(r.seq, 1);
1270
1271        let events = read_all_events(&paths.events()).unwrap();
1272        assert_eq!(events.len(), 1);
1273        assert_eq!(events[0].run_id.as_str(), run_id);
1274    }
1275
1276    #[cfg(unix)]
1277    #[test]
1278    fn append_rejects_a_symlinked_event_log() {
1279        // `events.jsonl` is the run's source of truth and highest-leverage
1280        // write — a symlinked log must be refused, not appended through.
1281        use crate::Error;
1282        use std::os::unix::fs::symlink;
1283        let tmp = TempDir::new().unwrap();
1284        let paths = fresh_run(&tmp);
1285        let target = tmp.path().join("evil-events.jsonl");
1286        symlink(&target, paths.events()).unwrap();
1287        let err = append_and_apply_event(&paths, "run.status", None, None, json!({})).unwrap_err();
1288        assert!(
1289            matches!(err, Error::SymlinkStateFile { name: "events", .. }),
1290            "got {err:?}"
1291        );
1292        // The forged append never reached the symlink target.
1293        assert!(!target.exists());
1294    }
1295
1296    /// Build a fresh, empty run directory with a valid `RunPaths` whose
1297    /// `run_id` matches the envelope the reducer will fold.
1298    fn fresh_run(tmp: &TempDir) -> RunPaths {
1299        let run_id = "01jxsnap000000000000000000";
1300        let dir = tmp.path().join(run_id);
1301        std::fs::create_dir_all(&dir).unwrap();
1302        RunPaths::new(dir, run_id).unwrap()
1303    }
1304
1305    /// Parse a `NodeId` for a test append call (the typed envelope id).
1306    fn nid(s: &str) -> NodeId {
1307        NodeId::parse_str(s).unwrap()
1308    }
1309
1310    /// Drive a run to a live node so reducer-affecting events have a target.
1311    fn bootstrap_live_node(paths: &RunPaths) {
1312        append_and_apply_event(
1313            paths,
1314            "run.created",
1315            None,
1316            None,
1317            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "fix" }),
1318        )
1319        .unwrap();
1320        append_and_apply_event(
1321            paths,
1322            "node.created",
1323            Some(&nid("n-0001")),
1324            None,
1325            serde_json::json!({ "kind": "spinoff" }),
1326        )
1327        .unwrap();
1328    }
1329
1330    #[test]
1331    fn append_and_apply_event_success_path_appends_and_folds() {
1332        let tmp = TempDir::new().unwrap();
1333        let paths = fresh_run(&tmp);
1334
1335        let r = append_and_apply_event(
1336            &paths,
1337            "run.created",
1338            None,
1339            None,
1340            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1341        )
1342        .unwrap();
1343        assert_eq!(r.seq, 1);
1344        assert!(!r.idempotent_replay);
1345        assert!(r.prior.is_none());
1346
1347        // The reducer ran under the same lock: the manifest projection exists.
1348        let m = crate::read_manifest(&paths).unwrap();
1349        assert_eq!(m.run_id.as_str(), paths.run_id.as_str());
1350    }
1351
1352    #[test]
1353    fn append_and_apply_idempotent_appended_path_returns_fresh_seq() {
1354        let tmp = TempDir::new().unwrap();
1355        let paths = fresh_run(&tmp);
1356        bootstrap_live_node(&paths); // seq 1 run.created, seq 2 node.created
1357
1358        let before = read_all_events(&paths.events()).unwrap().len();
1359        let data = json!({ "status": "running" });
1360        let outcome = RunLock::with_lock(&paths, |lock| {
1361            append_and_apply_idempotent(
1362                &paths,
1363                lock,
1364                "node.status",
1365                Some(&nid("n-0001")),
1366                "k1",
1367                |_seq| Ok(data.clone()),
1368            )
1369        })
1370        .unwrap();
1371        match outcome {
1372            AppendOutcome::Appended { seq } => {
1373                assert_eq!(seq, 3, "fresh append takes the next seq");
1374            }
1375            other => panic!("expected Appended, got {other:?}"),
1376        }
1377        assert_eq!(
1378            read_all_events(&paths.events()).unwrap().len(),
1379            before + 1,
1380            "a fresh key appends exactly one event"
1381        );
1382    }
1383
1384    #[test]
1385    fn append_and_apply_idempotent_replay_returns_prior_without_appending() {
1386        let tmp = TempDir::new().unwrap();
1387        let paths = fresh_run(&tmp);
1388        bootstrap_live_node(&paths);
1389        let node = nid("n-0001");
1390        let data = json!({ "status": "running" });
1391
1392        let first = RunLock::with_lock(&paths, |lock| {
1393            append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1394                Ok(data.clone())
1395            })
1396        })
1397        .unwrap();
1398        let first_seq = match first {
1399            AppendOutcome::Appended { seq } => seq,
1400            other => panic!("expected Appended, got {other:?}"),
1401        };
1402        let after_first = read_all_events(&paths.events()).unwrap().len();
1403
1404        // Same kind + key + node + data → a true replay: nothing appended, the
1405        // prior event (its seq + data) is returned.
1406        let replay = RunLock::with_lock(&paths, |lock| {
1407            append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1408                Ok(data.clone())
1409            })
1410        })
1411        .unwrap();
1412        match replay {
1413            AppendOutcome::IdempotentReplay { prior } => {
1414                assert_eq!(prior.seq, first_seq);
1415                assert_eq!(prior.node_id.as_deref(), Some("n-0001"));
1416                assert_eq!(prior.data, data);
1417            }
1418            other => panic!("expected IdempotentReplay, got {other:?}"),
1419        }
1420        assert_eq!(
1421            read_all_events(&paths.events()).unwrap().len(),
1422            after_first,
1423            "a replay must not append a new event"
1424        );
1425    }
1426
1427    #[test]
1428    fn append_and_apply_idempotent_conflict_on_different_data() {
1429        let tmp = TempDir::new().unwrap();
1430        let paths = fresh_run(&tmp);
1431        bootstrap_live_node(&paths);
1432        let node = nid("n-0001");
1433
1434        let first = RunLock::with_lock(&paths, |lock| {
1435            append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1436                Ok(json!({ "status": "running" }))
1437            })
1438        })
1439        .unwrap();
1440        let first_seq = match first {
1441            AppendOutcome::Appended { seq } => seq,
1442            other => panic!("expected Appended, got {other:?}"),
1443        };
1444        let after_first = read_all_events(&paths.events()).unwrap().len();
1445
1446        // Same key, DIFFERENT payload → conflict, carrying the prior event's seq;
1447        // nothing new is appended.
1448        let conflict = RunLock::with_lock(&paths, |lock| {
1449            append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
1450                Ok(json!({ "status": "done" }))
1451            })
1452        })
1453        .unwrap();
1454        match conflict {
1455            AppendOutcome::Conflict { prior } => {
1456                assert_eq!(prior.seq, first_seq);
1457                assert_eq!(prior.data, json!({ "status": "running" }));
1458            }
1459            other => panic!("expected Conflict, got {other:?}"),
1460        }
1461        assert_eq!(
1462            read_all_events(&paths.events()).unwrap().len(),
1463            after_first,
1464            "a conflict must not append a new event"
1465        );
1466    }
1467
1468    #[test]
1469    fn append_and_apply_idempotent_conflict_on_different_node_id() {
1470        // Same key + same data but a different envelope node is still a reused
1471        // key for a different request → conflict, not a silent replay.
1472        let tmp = TempDir::new().unwrap();
1473        let paths = fresh_run(&tmp);
1474        bootstrap_live_node(&paths);
1475        // A second live node so the conflicting append targets a real node.
1476        append_and_apply_event(
1477            &paths,
1478            "node.created",
1479            Some(&nid("n-0002")),
1480            None,
1481            json!({ "kind": "spinoff" }),
1482        )
1483        .unwrap();
1484        let data = json!({ "status": "running" });
1485
1486        RunLock::with_lock(&paths, |lock| {
1487            append_and_apply_idempotent(
1488                &paths,
1489                lock,
1490                "node.status",
1491                Some(&nid("n-0001")),
1492                "k1",
1493                |_seq| Ok(data.clone()),
1494            )
1495        })
1496        .unwrap();
1497
1498        let conflict = RunLock::with_lock(&paths, |lock| {
1499            append_and_apply_idempotent(
1500                &paths,
1501                lock,
1502                "node.status",
1503                Some(&nid("n-0002")),
1504                "k1",
1505                |_seq| Ok(data.clone()),
1506            )
1507        })
1508        .unwrap();
1509        assert!(
1510            matches!(conflict, AppendOutcome::Conflict { prior } if prior.node_id.as_deref() == Some("n-0001")),
1511            "a node-id mismatch under the same key is a conflict"
1512        );
1513    }
1514
1515    #[test]
1516    fn append_and_apply_idempotent_rejects_empty_key() {
1517        let tmp = TempDir::new().unwrap();
1518        let paths = fresh_run(&tmp);
1519        bootstrap_live_node(&paths);
1520        let err = RunLock::with_lock(&paths, |lock| {
1521            append_and_apply_idempotent(
1522                &paths,
1523                lock,
1524                "node.status",
1525                Some(&nid("n-0001")),
1526                "",
1527                |_seq| Ok(json!({ "status": "running" })),
1528            )
1529        })
1530        .unwrap_err();
1531        assert!(matches!(err, Error::EmptyIdempotencyKey), "got {err:?}");
1532    }
1533
1534    #[test]
1535    fn append_and_apply_event_idempotent_replay_returns_prior_without_appending() {
1536        let tmp = TempDir::new().unwrap();
1537        let paths = fresh_run(&tmp);
1538        bootstrap_live_node(&paths);
1539
1540        let data = serde_json::json!({ "status": "running" });
1541        let first = append_and_apply_event(
1542            &paths,
1543            "node.status",
1544            Some(&nid("n-0001")),
1545            Some("k1"),
1546            data.clone(),
1547        )
1548        .unwrap();
1549        assert!(!first.idempotent_replay);
1550        let before = read_all_events(&paths.events()).unwrap().len();
1551
1552        // Same kind + key: a replay returns the prior event and appends nothing.
1553        let replay = append_and_apply_event(
1554            &paths,
1555            "node.status",
1556            Some(&nid("n-0001")),
1557            Some("k1"),
1558            data.clone(),
1559        )
1560        .unwrap();
1561        assert!(replay.idempotent_replay);
1562        assert!(
1563            !replay.applied,
1564            "an idempotent replay applies nothing this call (applied: false)"
1565        );
1566        assert_eq!(replay.seq, first.seq);
1567        let prior = replay.prior.expect("replay carries the prior event");
1568        assert_eq!(prior.node_id.as_deref(), Some("n-0001"));
1569        assert_eq!(prior.data, data);
1570        assert_eq!(
1571            read_all_events(&paths.events()).unwrap().len(),
1572            before,
1573            "replay must not append a new line"
1574        );
1575    }
1576
1577    #[test]
1578    fn append_and_apply_event_reducer_noop_is_still_a_success() {
1579        let tmp = TempDir::new().unwrap();
1580        let paths = fresh_run(&tmp);
1581        bootstrap_live_node(&paths);
1582
1583        // Settle the node terminal. A real state change → `applied: true`.
1584        let n0001 = nid("n-0001");
1585        let settle = append_and_apply_event(
1586            &paths,
1587            "node.report",
1588            Some(&n0001),
1589            None,
1590            serde_json::json!({ "success": true }),
1591        )
1592        .unwrap();
1593        assert!(
1594            settle.applied,
1595            "a report that terminalizes a live node applied a projection op"
1596        );
1597        assert_eq!(
1598            crate::read_node(&paths, &n0001).unwrap().status,
1599            crate::schema::Status::Done
1600        );
1601
1602        // A later status event is dropped by the terminal-state guard, but the
1603        // append still happened: the result names the appended event's seq and
1604        // is not a replay. The node stays Done. `applied` is FALSE — the reducer
1605        // planned zero ops (issue `reducer-adopt-explicit-merge`).
1606        let before = read_all_events(&paths.events()).unwrap().len();
1607        let r = append_and_apply_event(
1608            &paths,
1609            "node.status",
1610            Some(&n0001),
1611            None,
1612            serde_json::json!({ "status": "running" }),
1613        )
1614        .unwrap();
1615        assert!(!r.idempotent_replay);
1616        assert!(
1617            !r.applied,
1618            "a dead event dropped by the terminal guard reports applied: false"
1619        );
1620        assert_eq!(r.seq as usize, before + 1);
1621        assert_eq!(
1622            read_all_events(&paths.events()).unwrap().len(),
1623            before + 1,
1624            "the event is appended even when the reducer no-ops"
1625        );
1626        assert_eq!(
1627            crate::read_node(&paths, &n0001).unwrap().status,
1628            crate::schema::Status::Done,
1629            "terminal status is frozen"
1630        );
1631    }
1632
1633    #[test]
1634    fn bootstrap_advances_the_watermark_past_every_appended_event() {
1635        // Baseline for the replay tests: the normal append path keeps the
1636        // watermark pinned to the last appended seq, so `applied_seq == last`
1637        // whenever the log is clean.
1638        let tmp = TempDir::new().unwrap();
1639        let paths = fresh_run(&tmp);
1640        bootstrap_live_node(&paths); // seq 1 run.created, seq 2 node.created
1641        assert_eq!(
1642            crate::read_manifest(&paths).unwrap().applied_seq,
1643            2,
1644            "watermark tracks the last appended event"
1645        );
1646    }
1647
1648    #[test]
1649    fn append_replays_unapplied_tail_before_appending() {
1650        use crate::schema::Status;
1651        // Failure scenario 1: a reducer crash after the event-row fsync but
1652        // before the projection/watermark write leaves the log ahead of the
1653        // projections. The next lock acquisition must replay that tail.
1654        let tmp = TempDir::new().unwrap();
1655        let paths = fresh_run(&tmp);
1656        bootstrap_live_node(&paths); // applied_seq == 2, node n-0001 Pending
1657        let n0001 = nid("n-0001");
1658
1659        // Append a tail event (seq 3) WITHOUT running the reducer — exactly the
1660        // on-disk state a crash between the row fsync and the projection write
1661        // would leave behind. The raw append still needs the witness (lock held).
1662        RunLock::with_lock(&paths, |lock| {
1663            append_event_with_seq(
1664                lock,
1665                &paths,
1666                3,
1667                "node.status",
1668                Some(&n0001),
1669                None,
1670                json!({ "status": "running" }),
1671            )
1672        })
1673        .unwrap();
1674        assert_eq!(
1675            crate::read_node(&paths, &n0001).unwrap().status,
1676            Status::Pending,
1677            "the tail event's projection has not landed yet"
1678        );
1679        assert_eq!(crate::read_manifest(&paths).unwrap().applied_seq, 2);
1680
1681        // Any new append acquires the lock and replays seq 3 first, so the new
1682        // event takes seq 4 and the stale projection is healed.
1683        let r = append_and_apply_event(
1684            &paths,
1685            "run.status",
1686            None,
1687            None,
1688            json!({ "status": "running" }),
1689        )
1690        .unwrap();
1691        assert_eq!(r.seq, 4, "the new event follows the replayed tail");
1692        assert_eq!(
1693            crate::read_node(&paths, &n0001).unwrap().status,
1694            Status::Running,
1695            "the previously-unapplied tail event is now folded"
1696        );
1697        assert_eq!(
1698            crate::read_manifest(&paths).unwrap().applied_seq,
1699            4,
1700            "the watermark now covers the whole log"
1701        );
1702    }
1703
1704    #[test]
1705    fn legacy_manifest_without_applied_seq_migrates_on_next_write() {
1706        use crate::schema::Status;
1707        // A `manifest.json` written before `applied_seq` existed must read back
1708        // as 0 (serde default) and self-migrate on the next write via an
1709        // idempotent full replay — without double-counting counters or
1710        // resurrecting a terminal node (failure scenario 2's no-double-count
1711        // guarantee, exercised over the whole log).
1712        let tmp = TempDir::new().unwrap();
1713        let paths = fresh_run(&tmp);
1714        bootstrap_live_node(&paths);
1715        let n0001 = nid("n-0001");
1716        append_and_apply_event(
1717            &paths,
1718            "node.report",
1719            Some(&n0001),
1720            None,
1721            json!({ "success": true }),
1722        )
1723        .unwrap(); // seq 3 → node Done, applied_seq == 3, node_count == 1
1724
1725        // Rewrite the manifest WITHOUT an `applied_seq` field, mimicking a
1726        // pre-watermark binary's output.
1727        let mut mv: serde_json::Value =
1728            serde_json::from_slice(&std::fs::read(paths.manifest()).unwrap()).unwrap();
1729        assert!(mv.as_object_mut().unwrap().remove("applied_seq").is_some());
1730        std::fs::write(paths.manifest(), serde_json::to_vec_pretty(&mv).unwrap()).unwrap();
1731        assert_eq!(
1732            crate::read_manifest(&paths).unwrap().applied_seq,
1733            0,
1734            "a legacy manifest reads as applied_seq 0"
1735        );
1736
1737        // The next write triggers a full idempotent replay of seq 1..=3 (all
1738        // no-ops) and advances the watermark to last_seq.
1739        append_and_apply_event(
1740            &paths,
1741            "run.status",
1742            None,
1743            None,
1744            json!({ "status": "running" }),
1745        )
1746        .unwrap(); // seq 4
1747        let m = crate::read_manifest(&paths).unwrap();
1748        assert_eq!(m.applied_seq, 4, "watermark caught up to the log");
1749        assert_eq!(
1750            m.node_count, 1,
1751            "full replay did not double-count node_count"
1752        );
1753        assert_eq!(
1754            crate::read_node(&paths, &n0001).unwrap().status,
1755            Status::Done,
1756            "replaying its history did not resurrect the terminal node"
1757        );
1758    }
1759
1760    #[test]
1761    fn replay_skips_events_with_unsafe_ids_and_never_escapes_run_dir() {
1762        // Issue `reducer-path-traversal-defense`: the reducer must independently
1763        // defend against ids read from `events.jsonl` that bypass the CLI
1764        // validators — a corrupt log, a restored backup, or a future writer.
1765        // We craft a log straight onto disk (skipping the append gate) holding
1766        // two poison `discussion.opened` lines and one good one, then drive a
1767        // catch-up replay and assert: nothing escapes the run dir, the poison
1768        // events are skipped (not fatal), and the good event still applies.
1769        let tmp = TempDir::new().unwrap();
1770        let paths = fresh_run(&tmp);
1771        bootstrap_live_node(&paths); // applied_seq == 2, node n-0001 live
1772
1773        // seq 3 — a traversal-laden id; seq 4 — an empty id. Both fail their
1774        // strict `parse_str`, so `reduce_event_to_ops` rejects them. seq 5 —
1775        // a well-formed id that must be applied despite the poison lines
1776        // preceding it. All three raw appends share one held lock.
1777        RunLock::with_lock(&paths, |lock| {
1778            append_event_with_seq(
1779                lock,
1780                &paths,
1781                3,
1782                "discussion.opened",
1783                Some(&nid("n-0001")),
1784                None,
1785                json!({ "discussion_id": "../escape", "node_id": "n-0001", "topic": "evil" }),
1786            )?;
1787            append_event_with_seq(
1788                lock,
1789                &paths,
1790                4,
1791                "discussion.opened",
1792                Some(&nid("n-0001")),
1793                None,
1794                json!({ "discussion_id": "", "node_id": "n-0001", "topic": "evil" }),
1795            )?;
1796            append_event_with_seq(
1797                lock,
1798                &paths,
1799                5,
1800                "discussion.opened",
1801                Some(&nid("n-0001")),
1802                None,
1803                json!({ "discussion_id": "d-abcdefghij", "node_id": "n-0001", "topic": "ok" }),
1804            )
1805        })
1806        .unwrap();
1807
1808        // The poison lines must NOT abort the replay (the regression this fixes:
1809        // a `..`-laden id is a valid envelope quarantine can't excise, so a hard
1810        // error here would brick every future append on the run).
1811        replay_unapplied(&paths, &paths.events()).expect("poison lines skipped, not fatal");
1812
1813        // The good discussion landed.
1814        let good = crate::projections::read_discussion_opt(
1815            &paths,
1816            &crate::schema::DiscussionId::parse_str("d-abcdefghij").unwrap(),
1817        )
1818        .unwrap();
1819        assert!(good.is_some(), "the valid discussion was applied");
1820
1821        // Nothing escaped: `discussions/../escape.json` would have resolved to
1822        // `<run>/escape.json` — it must not exist — and the discussions dir
1823        // holds exactly the one good file (the two poison ids wrote nothing).
1824        assert!(
1825            !paths.root.join("escape.json").exists(),
1826            "traversal must not have written outside discussions/"
1827        );
1828        let entries: Vec<_> = std::fs::read_dir(paths.discussions_dir())
1829            .unwrap()
1830            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1831            .collect();
1832        assert_eq!(
1833            entries,
1834            vec!["d-abcdefghij.json".to_string()],
1835            "only the good discussion file exists; poison ids joined no path"
1836        );
1837
1838        // The watermark jumped past the skipped seqs to the applied good event,
1839        // and the derived counter reflects the single real discussion.
1840        let m = crate::read_manifest(&paths).unwrap();
1841        assert_eq!(
1842            m.applied_seq, 5,
1843            "watermark advanced past the skipped poison"
1844        );
1845        assert_eq!(m.open_discussions, 1, "only the good discussion is counted");
1846    }
1847
1848    #[test]
1849    fn node_count_desync_heals_on_replay() {
1850        // Faithful reproduction of issue `manifest-counter-desync`: a crash left
1851        // the node projection on disk but lost the follow-on manifest write (the
1852        // counter bump + watermark advance). Before the fix, the replay
1853        // short-circuited on the already-existing node and the stale counter
1854        // stuck forever; now the counter is re-derived at the watermark advance.
1855        let tmp = TempDir::new().unwrap();
1856        let paths = fresh_run(&tmp);
1857        bootstrap_live_node(&paths); // node n-0001 on disk, node_count == 1, applied_seq == 2
1858
1859        // Rewind the manifest to the exact mid-crash state: the node file
1860        // exists, but the manifest still shows the pre-node counter and a
1861        // watermark that sits before the `node.created` at seq 2.
1862        let mut m = crate::read_manifest(&paths).unwrap();
1863        assert_eq!(m.node_count, 1, "precondition: bootstrap counted the node");
1864        m.node_count = 0;
1865        m.applied_seq = 1;
1866        write_manifest(&paths, &m).unwrap();
1867
1868        // The next append acquires the lock, replays seq 2 (node already exists,
1869        // so the reducer plans zero ops), and re-derives the counter when it
1870        // advances the watermark past seq 2.
1871        append_and_apply_event(
1872            &paths,
1873            "run.status",
1874            None,
1875            None,
1876            json!({ "status": "running" }),
1877        )
1878        .unwrap();
1879
1880        let healed = crate::read_manifest(&paths).unwrap();
1881        assert_eq!(
1882            healed.node_count, 1,
1883            "node_count converged to the true projection count"
1884        );
1885        assert!(healed.applied_seq >= 2, "watermark caught up past the node");
1886    }
1887
1888    #[test]
1889    fn open_discussions_desync_heals_on_replay() {
1890        // The decrement variant of the same hazard: a `discussion.resolved`
1891        // whose projection landed (the discussion is `Resolved` on disk) but
1892        // whose manifest decrement was lost. The old `saturating_sub` was
1893        // unreachable on replay (the reducer short-circuits the already-resolved
1894        // discussion), so the count stayed too high; deriving heals it.
1895        let tmp = TempDir::new().unwrap();
1896        let paths = fresh_run(&tmp);
1897        bootstrap_live_node(&paths); // applied_seq == 2
1898        append_and_apply_event(
1899            &paths,
1900            "discussion.opened",
1901            Some(&nid("n-0001")),
1902            None,
1903            json!({ "discussion_id": "d-fxtrdscssn", "node_id": "n-0001", "topic": "x" }),
1904        )
1905        .unwrap(); // seq 3 → open_discussions == 1
1906        append_and_apply_event(
1907            &paths,
1908            "discussion.resolved",
1909            Some(&nid("n-0001")),
1910            None,
1911            json!({ "discussion_id": "d-fxtrdscssn", "resolution": "drop" }),
1912        )
1913        .unwrap(); // seq 4 → discussion Resolved, open_discussions == 0
1914        let mut m = crate::read_manifest(&paths).unwrap();
1915        assert_eq!(m.open_discussions, 0, "precondition: resolve decremented");
1916
1917        // Simulate the resolve's manifest write being lost: the discussion is
1918        // Resolved on disk, but the manifest still counts it as open and the
1919        // watermark sits before the resolve at seq 4.
1920        m.open_discussions = 1;
1921        m.applied_seq = 3;
1922        write_manifest(&paths, &m).unwrap();
1923
1924        append_and_apply_event(
1925            &paths,
1926            "run.status",
1927            None,
1928            None,
1929            json!({ "status": "running" }),
1930        )
1931        .unwrap();
1932        assert_eq!(
1933            crate::read_manifest(&paths).unwrap().open_discussions,
1934            0,
1935            "open_discussions converged after the resolved discussion was re-folded"
1936        );
1937    }
1938
1939    #[test]
1940    fn full_replay_does_not_double_count_any_counter() {
1941        // Idempotence across a full from-scratch replay: re-folding every event
1942        // must re-derive the same totals, never accumulate. Covers all three
1943        // counters at once (node, discussion, spinoff).
1944        let tmp = TempDir::new().unwrap();
1945        let paths = fresh_run(&tmp);
1946        bootstrap_live_node(&paths);
1947        append_and_apply_event(
1948            &paths,
1949            "node.created",
1950            Some(&nid("n-0002")),
1951            None,
1952            json!({ "kind": "spinoff" }),
1953        )
1954        .unwrap();
1955        append_and_apply_event(
1956            &paths,
1957            "discussion.opened",
1958            Some(&nid("n-0001")),
1959            None,
1960            json!({ "discussion_id": "d-fxtrdscssn", "node_id": "n-0001", "topic": "x" }),
1961        )
1962        .unwrap();
1963        append_and_apply_event(
1964            &paths,
1965            "spinoff.proposed",
1966            Some(&nid("n-0001")),
1967            None,
1968            json!({
1969                "proposal_id": "s-fxtrspnoff",
1970                "proposed_title": "t",
1971                "proposed_kind": "spinoff",
1972                "node_id": "n-0001",
1973            }),
1974        )
1975        .unwrap();
1976        let before = crate::read_manifest(&paths).unwrap();
1977        assert_eq!(
1978            (
1979                before.node_count,
1980                before.open_discussions,
1981                before.pending_spinoffs
1982            ),
1983            (2, 1, 1),
1984            "precondition: two nodes, one open discussion, one pending spinoff"
1985        );
1986
1987        // Reset the watermark to force a full idempotent replay of the whole log
1988        // on the next append (the legacy-migration path), and deliberately
1989        // corrupt every counter so a heal is observable.
1990        let mut m = before;
1991        m.applied_seq = 0;
1992        m.node_count = 99;
1993        m.open_discussions = 99;
1994        m.pending_spinoffs = 99;
1995        write_manifest(&paths, &m).unwrap();
1996        append_and_apply_event(
1997            &paths,
1998            "run.status",
1999            None,
2000            None,
2001            json!({ "status": "running" }),
2002        )
2003        .unwrap();
2004
2005        let after = crate::read_manifest(&paths).unwrap();
2006        assert_eq!(
2007            (
2008                after.node_count,
2009                after.open_discussions,
2010                after.pending_spinoffs
2011            ),
2012            (2, 1, 1),
2013            "counters re-derived to the true totals — no double-count across full replay"
2014        );
2015    }
2016
2017    #[test]
2018    fn idempotent_replay_catches_up_projection_before_returning() {
2019        use crate::projections::write_manifest;
2020        use crate::schema::Status;
2021        use crate::write_node;
2022        // Requirement 3: an idempotency-key replay must ensure the projection is
2023        // caught up (`applied_seq >= prior.seq`) before returning the prior
2024        // envelope — never a "found, but not yet applied" result.
2025        let tmp = TempDir::new().unwrap();
2026        let paths = fresh_run(&tmp);
2027        bootstrap_live_node(&paths);
2028        let n0001 = nid("n-0001");
2029
2030        // A keyed event lands and folds normally...
2031        let first = append_and_apply_event(
2032            &paths,
2033            "node.status",
2034            Some(&n0001),
2035            Some("k1"),
2036            json!({ "status": "running" }),
2037        )
2038        .unwrap(); // seq 3
2039        assert!(!first.idempotent_replay);
2040
2041        // ...then simulate a crash that lost the fold: rewind the watermark
2042        // below seq 3 and revert the node to its pre-event Pending state.
2043        let mut m = crate::read_manifest(&paths).unwrap();
2044        m.applied_seq = 2;
2045        write_manifest(&paths, &m).unwrap();
2046        let mut n = crate::read_node(&paths, &n0001).unwrap();
2047        n.status = Status::Pending;
2048        write_node(&paths, &n).unwrap();
2049
2050        // The idempotent retry returns the prior seq AND catches the projection
2051        // up first.
2052        let replay = append_and_apply_event(
2053            &paths,
2054            "node.status",
2055            Some(&n0001),
2056            Some("k1"),
2057            json!({ "status": "running" }),
2058        )
2059        .unwrap();
2060        assert!(replay.idempotent_replay);
2061        assert_eq!(replay.seq, first.seq);
2062        assert!(
2063            crate::read_manifest(&paths).unwrap().applied_seq >= first.seq,
2064            "watermark caught up before the replay returned"
2065        );
2066        assert_eq!(
2067            crate::read_node(&paths, &n0001).unwrap().status,
2068            Status::Running,
2069            "the prior event's projection is durable before returning"
2070        );
2071    }
2072
2073    /// Build a `RunPaths` over a fresh tempdir and write `bytes` verbatim to
2074    /// `events.jsonl` — verbatim so a test can craft torn-line boundaries
2075    /// (a missing trailing `\n`) that the append path never produces.
2076    fn paths_with_events(tmp: &TempDir, bytes: &[u8]) -> RunPaths {
2077        let dir = tmp.path().join("run");
2078        std::fs::create_dir_all(&dir).unwrap();
2079        let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
2080        std::fs::write(paths.events(), bytes).unwrap();
2081        paths
2082    }
2083
2084    /// Run [`find_prior_with_key`] under a freshly-acquired exclusive lock —
2085    /// the witness it now requires. The scan is read-only, so taking the lock
2086    /// just to mint the witness is exactly what a real caller does.
2087    fn scan(paths: &RunPaths, kind: &str, key: &str) -> Result<Option<PriorEvent>> {
2088        RunLock::with_lock(paths, |w| find_prior_with_key(w, paths, kind, key))
2089    }
2090
2091    #[test]
2092    fn find_prior_with_key_missing_log_is_none() {
2093        let tmp = TempDir::new().unwrap();
2094        let dir = tmp.path().join("run");
2095        std::fs::create_dir_all(&dir).unwrap();
2096        let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
2097        // No events.jsonl written at all.
2098        let got = scan(&paths, "node.report", "k1").unwrap();
2099        assert!(got.is_none());
2100    }
2101
2102    #[test]
2103    fn find_prior_with_key_finds_the_matching_line() {
2104        let tmp = TempDir::new().unwrap();
2105        let log = concat!(
2106            r#"{"seq":1,"kind":"node.status","idempotency_key":"k0","node_id":"n-1","data":{}}"#,
2107            "\n",
2108            r#"{"seq":2,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
2109            "\n",
2110        );
2111        let paths = paths_with_events(&tmp, log.as_bytes());
2112        let got = scan(&paths, "node.report", "k1").unwrap().expect("match");
2113        assert_eq!(got.seq, 2);
2114        assert_eq!(got.node_id.as_deref(), Some("n-1"));
2115        assert_eq!(got.data, serde_json::json!({"ok": true}));
2116    }
2117
2118    #[test]
2119    fn find_prior_with_key_no_match_is_none() {
2120        let tmp = TempDir::new().unwrap();
2121        let log = concat!(
2122            r#"{"seq":1,"kind":"node.report","idempotency_key":"other","node_id":"n-1","data":{}}"#,
2123            "\n",
2124        );
2125        let paths = paths_with_events(&tmp, log.as_bytes());
2126        assert!(scan(&paths, "node.report", "k1").unwrap().is_none());
2127    }
2128
2129    #[test]
2130    fn find_prior_with_key_tolerates_torn_final_line() {
2131        // A complete record, then a crash-truncated final line with NO
2132        // trailing newline — exactly what `recover_last_seq` tolerates.
2133        // The scan must still return the earlier match and never error.
2134        let tmp = TempDir::new().unwrap();
2135        let mut log = String::new();
2136        log.push_str(
2137            r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
2138        );
2139        log.push('\n');
2140        log.push_str(r#"{"seq":2,"kind":"node.rep"#); // torn mid-write, no newline
2141        let paths = paths_with_events(&tmp, log.as_bytes());
2142
2143        let got = scan(&paths, "node.report", "k1")
2144            .unwrap()
2145            .expect("match before the torn tail");
2146        assert_eq!(got.seq, 1);
2147
2148        // A torn final line with no matching key ahead of it returns None,
2149        // not an error.
2150        let tmp2 = TempDir::new().unwrap();
2151        let paths2 = paths_with_events(&tmp2, br#"{"seq":1,"kind":"node.rep"#);
2152        assert!(scan(&paths2, "node.report", "k1").unwrap().is_none());
2153    }
2154
2155    #[test]
2156    fn find_prior_with_key_ignores_valid_json_final_line_without_newline() {
2157        // The dangerous case: a crash landed a COMPLETE, valid-JSON event
2158        // but the trailing newline never flushed. `recover_last_seq`
2159        // discards any newline-less tail, so it considers this event
2160        // unwritten (returns 0). The dedup scan MUST agree and return None
2161        // — otherwise it would report "already appended", the caller skips
2162        // the append, and the event is lost / the seq double-counts.
2163        let tmp = TempDir::new().unwrap();
2164        let line =
2165            br#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#;
2166        let paths = paths_with_events(&tmp, line);
2167        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2168        assert!(
2169            scan(&paths, "node.report", "k1").unwrap().is_none(),
2170            "torn tail must be ignored even when it parses as valid JSON"
2171        );
2172    }
2173
2174    #[test]
2175    fn find_prior_with_key_skips_nonmatching_line_missing_seq() {
2176        // `seq` is not a match key, so a NON-matching envelope that happens
2177        // to lack `seq` must be skimmed past, not treated as corruption that
2178        // aborts the scan before a later match. (The pre-lift scanner's
2179        // probe didn't require `seq`; making it required would have been a
2180        // regression that hid a real key behind an unrelated seq-less line.)
2181        let tmp = TempDir::new().unwrap();
2182        let log = concat!(
2183            r#"{"kind":"node.status","idempotency_key":"other","node_id":"n-1","data":{}}"#,
2184            "\n",
2185            r#"{"seq":2,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
2186            "\n",
2187        );
2188        let paths = paths_with_events(&tmp, log.as_bytes());
2189        let got = scan(&paths, "node.report", "k1")
2190            .unwrap()
2191            .expect("match after a seq-less non-matching line");
2192        assert_eq!(got.seq, 2);
2193        assert_eq!(got.node_id.as_deref(), Some("n-1"));
2194    }
2195
2196    #[test]
2197    fn find_prior_with_key_matched_line_bad_payload_is_corrupt_log() {
2198        // A line that skims fine (kind + key match) but whose full payload
2199        // is malformed (`node_id` is a number, not a string) is event-log
2200        // corruption — it must surface as CorruptEventLog (exit 1), not a
2201        // generic JSON/io error (exit 2).
2202        let tmp = TempDir::new().unwrap();
2203        let log = concat!(
2204            r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":42,"data":{}}"#,
2205            "\n",
2206        );
2207        let paths = paths_with_events(&tmp, log.as_bytes());
2208        let err = scan(&paths, "node.report", "k1").unwrap_err();
2209        assert!(
2210            matches!(err, Error::CorruptEventLog { .. }),
2211            "expected CorruptEventLog, got {err:?}"
2212        );
2213    }
2214
2215    #[test]
2216    fn find_prior_with_key_handles_crlf_line_endings() {
2217        let tmp = TempDir::new().unwrap();
2218        let log = concat!(
2219            r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
2220            "\r\n",
2221        );
2222        let paths = paths_with_events(&tmp, log.as_bytes());
2223        let got = scan(&paths, "node.report", "k1")
2224            .unwrap()
2225            .expect("CRLF-terminated match");
2226        assert_eq!(got.seq, 1);
2227    }
2228
2229    #[test]
2230    fn find_prior_with_key_tolerates_partial_utf8_torn_tail() {
2231        // A crash can cut a multi-byte UTF-8 sequence mid-character. With
2232        // byte-oriented reading this torn (newline-less) tail is tolerated
2233        // like any other partial write, not surfaced as an I/O error.
2234        let tmp = TempDir::new().unwrap();
2235        let mut log = Vec::new();
2236        log.extend_from_slice(
2237            br#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
2238        );
2239        log.push(b'\n');
2240        log.extend_from_slice(&[0xF0, 0x9F]); // start of a 4-byte char, truncated
2241        let paths = paths_with_events(&tmp, &log);
2242        let got = scan(&paths, "node.report", "k1")
2243            .unwrap()
2244            .expect("match before the partial-UTF8 tail");
2245        assert_eq!(got.seq, 1);
2246    }
2247
2248    #[test]
2249    fn recover_last_seq_newline_terminated_garbage_is_corrupt_log() {
2250        // Consistency guard with find_prior_with_key: a newline-terminated
2251        // final line that isn't valid JSON is CorruptEventLog from BOTH
2252        // readers, so the CLI maps both to the same corrupt-event-log exit.
2253        let tmp = TempDir::new().unwrap();
2254        let paths = paths_with_events(&tmp, b"{not json at all\n");
2255        let err = recover_last_seq(&paths.events()).unwrap_err();
2256        assert!(
2257            matches!(err, Error::CorruptEventLog { .. }),
2258            "expected CorruptEventLog, got {err:?}"
2259        );
2260    }
2261
2262    #[test]
2263    fn rejected_event_is_not_appended() {
2264        // The transactional fix: a reducer-rejected event must error BEFORE
2265        // any durable write, so events.jsonl never gains a poison line.
2266        let tmp = TempDir::new().unwrap();
2267        let paths = fresh_run(&tmp);
2268        bootstrap_live_node(&paths);
2269        let before = read_all_events(&paths.events()).unwrap().len();
2270
2271        // `node.report` with neither success nor cancelled → reducer rejects.
2272        let err =
2273            append_and_apply_event(&paths, "node.report", Some(&nid("n-0001")), None, json!({}))
2274                .unwrap_err();
2275        assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
2276
2277        assert_eq!(
2278            read_all_events(&paths.events()).unwrap().len(),
2279            before,
2280            "a rejected event must not be appended"
2281        );
2282        // The log is still clean and re-readable (no poison line stranded it).
2283        assert!(recover_last_seq(&paths.events()).is_ok());
2284        let next = append_and_apply_event(
2285            &paths,
2286            "node.report",
2287            Some(&nid("n-0001")),
2288            None,
2289            json!({ "success": true }),
2290        )
2291        .unwrap();
2292        assert_eq!(
2293            next.seq as usize,
2294            before + 1,
2295            "the next valid append reuses the seq the rejected event never consumed"
2296        );
2297    }
2298
2299    #[test]
2300    fn validate_event_agrees_with_apply_event() {
2301        // Drift guard: `validate_event` (the pre-append gate) must return Err
2302        // in EXACTLY the cases `apply_event` would, for the same state — else
2303        // it would refuse a harmless no-op or let a poison line through.
2304        use crate::reducer::{apply_event, validate_event};
2305
2306        fn ev(paths: &RunPaths, kind: &str, node_id: Option<&str>, data: Value) -> Event {
2307            Event {
2308                ts: Utc::now(),
2309                seq: 999,
2310                kind: kind.to_string(),
2311                run_id: paths.run_id.clone(),
2312                node_id: node_id.map(|s| crate::schema::NodeId::parse_str(s).unwrap()),
2313                idempotency_key: None,
2314                data,
2315            }
2316        }
2317        // validate is read-only, so running it first leaves apply's pre-state
2318        // intact; we compare the two verdicts on the same fresh run.
2319        fn agree(paths: &RunPaths, e: &Event, label: &str) {
2320            let v = validate_event(paths, e).is_err();
2321            let a = apply_event(paths, e).is_err();
2322            assert_eq!(v, a, "{label}: validate_err={v} apply_err={a}");
2323        }
2324
2325        // Live node: bad report rejected; good report accepted; missing
2326        // node_id rejected; bad status rejected.
2327        {
2328            let tmp = TempDir::new().unwrap();
2329            let paths = fresh_run(&tmp);
2330            bootstrap_live_node(&paths);
2331            agree(
2332                &paths,
2333                &ev(&paths, "node.report", Some("n-0001"), json!({})),
2334                "report-bare",
2335            );
2336        }
2337        {
2338            let tmp = TempDir::new().unwrap();
2339            let paths = fresh_run(&tmp);
2340            bootstrap_live_node(&paths);
2341            agree(
2342                &paths,
2343                &ev(
2344                    &paths,
2345                    "node.report",
2346                    Some("n-0001"),
2347                    json!({ "success": true }),
2348                ),
2349                "report-good",
2350            );
2351        }
2352        {
2353            let tmp = TempDir::new().unwrap();
2354            let paths = fresh_run(&tmp);
2355            bootstrap_live_node(&paths);
2356            agree(
2357                &paths,
2358                &ev(&paths, "node.report", None, json!({})),
2359                "report-no-node-id",
2360            );
2361        }
2362        {
2363            let tmp = TempDir::new().unwrap();
2364            let paths = fresh_run(&tmp);
2365            bootstrap_live_node(&paths);
2366            agree(
2367                &paths,
2368                &ev(&paths, "node.status", Some("n-0001"), json!({})),
2369                "status-missing",
2370            );
2371        }
2372        // Terminal node: a malformed report is a clean no-op (guard before
2373        // validate) — both must accept it.
2374        {
2375            let tmp = TempDir::new().unwrap();
2376            let paths = fresh_run(&tmp);
2377            bootstrap_live_node(&paths);
2378            append_and_apply_event(
2379                &paths,
2380                "node.report",
2381                Some(&nid("n-0001")),
2382                None,
2383                json!({ "success": true }),
2384            )
2385            .unwrap();
2386            agree(
2387                &paths,
2388                &ev(&paths, "node.report", Some("n-0001"), json!({})),
2389                "report-bare-on-terminal",
2390            );
2391        }
2392        // Missing node: a status with no `status` field is a no-op.
2393        {
2394            let tmp = TempDir::new().unwrap();
2395            let paths = fresh_run(&tmp);
2396            agree(
2397                &paths,
2398                &ev(&paths, "node.status", Some("n-0001"), json!({})),
2399                "status-missing-node",
2400            );
2401        }
2402        // Existing manifest: a run.status with no `status` is rejected.
2403        {
2404            let tmp = TempDir::new().unwrap();
2405            let paths = fresh_run(&tmp);
2406            bootstrap_live_node(&paths);
2407            agree(
2408                &paths,
2409                &ev(&paths, "run.status", None, json!({})),
2410                "run-status-missing",
2411            );
2412        }
2413        // Open discussion: a resolve without `resolution` is rejected.
2414        {
2415            let tmp = TempDir::new().unwrap();
2416            let paths = fresh_run(&tmp);
2417            bootstrap_live_node(&paths);
2418            append_and_apply_event(
2419                &paths,
2420                "discussion.opened",
2421                Some(&nid("n-0001")),
2422                None,
2423                json!({ "discussion_id": "d-abcdefghij", "topic": "t", "node_id": "n-0001" }),
2424            )
2425            .unwrap();
2426            agree(
2427                &paths,
2428                &ev(
2429                    &paths,
2430                    "discussion.resolved",
2431                    None,
2432                    json!({ "discussion_id": "d-abcdefghij" }),
2433                ),
2434                "resolve-missing-resolution",
2435            );
2436        }
2437        // node.created: new node missing `kind` rejected; replay over an
2438        // existing node with bad payload is a no-op (existence short-circuit).
2439        {
2440            let tmp = TempDir::new().unwrap();
2441            let paths = fresh_run(&tmp);
2442            agree(
2443                &paths,
2444                &ev(&paths, "node.created", Some("n-0002"), json!({})),
2445                "node-created-missing-kind",
2446            );
2447        }
2448        {
2449            let tmp = TempDir::new().unwrap();
2450            let paths = fresh_run(&tmp);
2451            bootstrap_live_node(&paths);
2452            agree(
2453                &paths,
2454                &ev(&paths, "node.created", Some("n-0001"), json!({})),
2455                "node-created-replay-bad-payload",
2456            );
2457        }
2458        // discussion.opened missing `topic`.
2459        {
2460            let tmp = TempDir::new().unwrap();
2461            let paths = fresh_run(&tmp);
2462            bootstrap_live_node(&paths);
2463            agree(
2464                &paths,
2465                &ev(
2466                    &paths,
2467                    "discussion.opened",
2468                    Some("n-0001"),
2469                    json!({ "discussion_id": "d-abcdefghij", "node_id": "n-0001" }),
2470                ),
2471                "discussion-opened-missing-topic",
2472            );
2473        }
2474        // spinoff.proposed missing `proposed_title`; spinoff.{approved,rejected}
2475        // with an unparseable proposal id.
2476        {
2477            let tmp = TempDir::new().unwrap();
2478            let paths = fresh_run(&tmp);
2479            bootstrap_live_node(&paths);
2480            agree(
2481                &paths,
2482                &ev(
2483                    &paths,
2484                    "spinoff.proposed",
2485                    Some("n-0001"),
2486                    json!({ "proposal_id": "p-abcdefghij", "proposed_kind": "spinoff", "node_id": "n-0001" }),
2487                ),
2488                "spinoff-proposed-missing-title",
2489            );
2490        }
2491        {
2492            let tmp = TempDir::new().unwrap();
2493            let paths = fresh_run(&tmp);
2494            agree(
2495                &paths,
2496                &ev(
2497                    &paths,
2498                    "spinoff.approved",
2499                    None,
2500                    json!({ "proposal_id": "not a valid id" }),
2501                ),
2502                "spinoff-approved-bad-id",
2503            );
2504            agree(
2505                &paths,
2506                &ev(
2507                    &paths,
2508                    "spinoff.rejected",
2509                    None,
2510                    json!({ "proposal_id": "not a valid id" }),
2511                ),
2512                "spinoff-rejected-bad-id",
2513            );
2514        }
2515        // child.spawned: missing/invalid child_run_id.
2516        {
2517            let tmp = TempDir::new().unwrap();
2518            let paths = fresh_run(&tmp);
2519            agree(
2520                &paths,
2521                &ev(&paths, "child.spawned", Some("n-0001"), json!({})),
2522                "child-spawned-missing-child-run-id",
2523            );
2524            agree(
2525                &paths,
2526                &ev(
2527                    &paths,
2528                    "child.spawned",
2529                    Some("n-0001"),
2530                    json!({ "child_run_id": "bad" }),
2531                ),
2532                "child-spawned-bad-child-run-id",
2533            );
2534        }
2535        // Cross-run envelope and unknown kind.
2536        {
2537            let tmp = TempDir::new().unwrap();
2538            let paths = fresh_run(&tmp);
2539            let mut foreign = ev(&paths, "run.status", None, json!({ "status": "running" }));
2540            foreign.run_id = crate::schema::RunId::parse_str("02jxsnap000000000000000000").unwrap();
2541            agree(&paths, &foreign, "cross-run");
2542            agree(
2543                &paths,
2544                &ev(&paths, "totally.unknown", None, json!({})),
2545                "unknown-kind",
2546            );
2547        }
2548    }
2549
2550    #[test]
2551    fn read_all_events_drops_torn_final_line() {
2552        // The bug this fixes: `read_all_events` used to silently ACCEPT a
2553        // valid-JSON final line lacking a trailing newline — a line
2554        // `recover_last_seq` discards as an uncommitted partial write. Now it
2555        // shares the torn-tail policy: the torn final line is dropped without
2556        // error, and the reader agrees with `recover_last_seq`.
2557        let tmp = TempDir::new().unwrap();
2558        let mut log = String::new();
2559        log.push_str(
2560            r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2561        );
2562        log.push('\n');
2563        // A COMPLETE, valid-JSON event whose trailing newline never flushed.
2564        log.push_str(
2565            r#"{"ts":"2026-06-12T00:00:00Z","seq":2,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2566        );
2567        let paths = paths_with_events(&tmp, log.as_bytes());
2568
2569        let events = read_all_events(&paths.events()).unwrap();
2570        assert_eq!(
2571            events.iter().map(|e| e.seq).collect::<Vec<_>>(),
2572            vec![1],
2573            "torn final line must be dropped, not parsed"
2574        );
2575        // And it agrees with the recovery path.
2576        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
2577    }
2578
2579    #[test]
2580    fn recover_last_seq_rejects_seq_only_last_line() {
2581        // A `\n`-terminated last line that is valid JSON with a `seq` but is
2582        // NOT a valid event envelope (missing ts/kind/run_id) must be rejected
2583        // by recover_last_seq, matching read_all_events — otherwise an append
2584        // would continue past a line replay can never fold.
2585        let tmp = TempDir::new().unwrap();
2586        let paths = paths_with_events(&tmp, b"{\"seq\":99}\n");
2587        let err = recover_last_seq(&paths.events()).unwrap_err();
2588        assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
2589        // And the forward reader agrees.
2590        assert!(matches!(
2591            read_all_events(&paths.events()).unwrap_err(),
2592            Error::CorruptEventLog { .. }
2593        ));
2594    }
2595
2596    #[test]
2597    fn recover_last_seq_skips_multiple_trailing_blank_lines() {
2598        // External editing can leave several trailing blank lines. The forward
2599        // reader skips them; seq recovery must walk back over all of them to
2600        // the last real record (not just one), so the two readers agree.
2601        let tmp = TempDir::new().unwrap();
2602        let mut log = String::new();
2603        log.push_str(
2604            r#"{"ts":"2026-06-12T00:00:00Z","seq":7,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2605        );
2606        log.push_str("\n\n\n\n");
2607        let paths = paths_with_events(&tmp, log.as_bytes());
2608        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 7);
2609        let events = read_all_events(&paths.events()).unwrap();
2610        assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![7]);
2611    }
2612
2613    #[test]
2614    fn recover_last_seq_skips_trailing_whitespace_only_lines() {
2615        // External editing can leave trailing lines holding only spaces, tabs,
2616        // or stray CRs. Recovery must walk back over every whitespace-only line
2617        // to the last real record, not stop at (and fail to parse) the blanks.
2618        let tmp = TempDir::new().unwrap();
2619        let mut log = String::new();
2620        log.push_str(
2621            r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2622        );
2623        log.push_str("\n  \n\t\n \r\n");
2624        let paths = paths_with_events(&tmp, log.as_bytes());
2625        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
2626    }
2627
2628    #[test]
2629    fn recover_last_seq_all_whitespace_file_is_zero() {
2630        // A log holding only blank/whitespace lines carries no event — recovery
2631        // returns the zero-event sentinel rather than erroring on the blanks.
2632        let tmp = TempDir::new().unwrap();
2633        let paths = paths_with_events(&tmp, b"\n  \n\t\n \r\n");
2634        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2635    }
2636
2637    #[test]
2638    fn recover_last_seq_single_newline_terminated_record_is_regression_guard() {
2639        // The common, healthy case: one record with a single trailing newline
2640        // must still recover its seq unchanged after the blank-line tolerance.
2641        let tmp = TempDir::new().unwrap();
2642        let paths = paths_with_events(
2643            &tmp,
2644            concat!(
2645                r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2646                "\n",
2647            )
2648            .as_bytes(),
2649        );
2650        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
2651    }
2652
2653    #[test]
2654    fn read_all_events_rejects_corrupt_middle_line() {
2655        // A newline-terminated garbage line FOLLOWED by another line is
2656        // interior corruption — a hard `CorruptEventLog`, never a silent skip.
2657        let tmp = TempDir::new().unwrap();
2658        let log = concat!(
2659            r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2660            "\n",
2661            "{not valid json at all\n",
2662            r#"{"ts":"2026-06-12T00:00:00Z","seq":3,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2663            "\n",
2664        );
2665        let paths = paths_with_events(&tmp, log.as_bytes());
2666        let err = read_all_events(&paths.events()).unwrap_err();
2667        match err {
2668            Error::CorruptEventLog { reason, .. } => {
2669                assert!(reason.contains("line 2"), "reason was: {reason}");
2670            }
2671            other => panic!("expected CorruptEventLog, got {other:?}"),
2672        }
2673    }
2674
2675    #[test]
2676    fn append_truncates_torn_tail_before_writing() {
2677        // A crash left a valid record then a torn (newline-less) partial
2678        // write. The next append must truncate the torn bytes BEFORE writing,
2679        // so the log never gains a `…torn…{"seq":N}` malformed line.
2680        let tmp = TempDir::new().unwrap();
2681        let mut bytes = Vec::new();
2682        bytes.extend_from_slice(
2683            br#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2684        );
2685        bytes.push(b'\n');
2686        bytes.extend_from_slice(br#"{"seq":2,"kind":"TORN_PARTIAL_NEVER_FLUSHED"#); // no newline
2687        let paths = paths_with_events(&tmp, &bytes);
2688
2689        // The torn tail is ignored for seq recovery (last complete seq = 1).
2690        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
2691
2692        // `marker` is an unknown kind → reducer no-op, so the append succeeds
2693        // without any projection prerequisites.
2694        let r = append_and_apply_event(&paths, "marker", None, None, serde_json::json!({"x": 1}))
2695            .unwrap();
2696        assert_eq!(r.seq, 2, "seq continues from the last complete record");
2697
2698        let raw = std::fs::read(paths.events()).unwrap();
2699        assert!(
2700            raw.ends_with(b"\n"),
2701            "log must be newline-terminated after a clean append"
2702        );
2703        assert!(
2704            !String::from_utf8_lossy(&raw).contains("TORN_PARTIAL_NEVER_FLUSHED"),
2705            "the torn tail must be truncated away before the append"
2706        );
2707        let events = read_all_events(&paths.events()).unwrap();
2708        assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2]);
2709    }
2710
2711    #[test]
2712    fn append_truncates_all_torn_file_to_empty_then_writes_seq_1() {
2713        // The whole file is one torn (newline-less) partial write — no complete
2714        // record exists. truncate_torn_tail must cut it to empty, and the next
2715        // append starts a fresh seq 1.
2716        let tmp = TempDir::new().unwrap();
2717        let paths = paths_with_events(&tmp, br#"{"seq":1,"kind":"marker"#);
2718        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2719
2720        let r = append_and_apply_event(&paths, "marker", None, None, json!({})).unwrap();
2721        assert_eq!(r.seq, 1);
2722        let events = read_all_events(&paths.events()).unwrap();
2723        assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
2724    }
2725
2726    #[test]
2727    fn truncate_torn_tail_cuts_partial_line_at_last_newline() {
2728        // The headline case (issue torn-write-truncate-tail): a complete
2729        // record followed by a torn (newline-less) partial write. Recovery
2730        // must cut the file back to the byte immediately after the last
2731        // complete record's trailing `\n` — the partial bytes are gone.
2732        let tmp = TempDir::new().unwrap();
2733        let complete = r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2734        let mut bytes = Vec::new();
2735        bytes.extend_from_slice(complete.as_bytes());
2736        bytes.push(b'\n');
2737        let keep = bytes.len() as u64; // offset just past seq-5's newline
2738        bytes.extend_from_slice(br#"{"seq":6,"par"#); // torn mid-line, no newline
2739        let paths = paths_with_events(&tmp, &bytes);
2740
2741        truncate_torn_tail(&paths.events()).unwrap();
2742
2743        let raw = std::fs::read(paths.events()).unwrap();
2744        assert_eq!(
2745            raw.len() as u64,
2746            keep,
2747            "file must end at the offset after seq-5's newline"
2748        );
2749        assert!(raw.ends_with(b"\n"), "file is newline-terminated after cut");
2750        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
2751    }
2752
2753    #[test]
2754    fn truncate_torn_tail_clean_file_is_noop() {
2755        // A file already ending in `\n` is the clean, common case: recovery
2756        // must leave every byte untouched (no rewrite, no length change).
2757        let tmp = TempDir::new().unwrap();
2758        let log = concat!(
2759            r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2760            "\n",
2761        );
2762        let paths = paths_with_events(&tmp, log.as_bytes());
2763
2764        truncate_torn_tail(&paths.events()).unwrap();
2765
2766        assert_eq!(
2767            std::fs::read(paths.events()).unwrap(),
2768            log.as_bytes(),
2769            "a clean, newline-terminated log must be left byte-for-byte intact"
2770        );
2771    }
2772
2773    #[test]
2774    fn truncate_torn_tail_zero_length_file_is_noop() {
2775        // An empty log has no tail to cut: recovery is a no-op and the file
2776        // stays empty.
2777        let tmp = TempDir::new().unwrap();
2778        let paths = paths_with_events(&tmp, b"");
2779        truncate_torn_tail(&paths.events()).unwrap();
2780        assert_eq!(std::fs::read(paths.events()).unwrap(), b"");
2781    }
2782
2783    #[test]
2784    fn truncate_torn_tail_missing_file_is_noop() {
2785        // No `events.jsonl` at all (a run that never appended): recovery must
2786        // not create the file or error.
2787        let tmp = TempDir::new().unwrap();
2788        let dir = tmp.path().join("run");
2789        std::fs::create_dir_all(&dir).unwrap();
2790        let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
2791        truncate_torn_tail(&paths.events()).unwrap();
2792        assert!(!paths.events().exists());
2793    }
2794
2795    #[test]
2796    fn truncate_torn_tail_single_complete_row_is_noop() {
2797        // Exactly one complete `\n`-terminated record and nothing else: the
2798        // last byte is already a newline, so there is no tail to cut.
2799        let tmp = TempDir::new().unwrap();
2800        let log = concat!(
2801            r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2802            "\n",
2803        );
2804        let paths = paths_with_events(&tmp, log.as_bytes());
2805        truncate_torn_tail(&paths.events()).unwrap();
2806        assert_eq!(std::fs::read(paths.events()).unwrap(), log.as_bytes());
2807    }
2808
2809    #[test]
2810    fn truncate_torn_tail_single_partial_row_truncates_to_zero() {
2811        // The whole file is one torn (newline-less) partial write with no
2812        // complete record ahead of it: there is nothing to keep, so recovery
2813        // truncates the file to zero length.
2814        let tmp = TempDir::new().unwrap();
2815        let paths = paths_with_events(&tmp, br#"{"seq":1,"kind":"marker"#);
2816        truncate_torn_tail(&paths.events()).unwrap();
2817        assert_eq!(
2818            std::fs::read(paths.events()).unwrap(),
2819            b"",
2820            "a file holding only a partial row must be cut to empty"
2821        );
2822        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
2823    }
2824
2825    #[test]
2826    fn quarantine_excises_corrupt_middle_line_and_recovers() {
2827        // A valid record, a newline-terminated garbage line, then another
2828        // valid record. Quarantine must rename the original aside, write a
2829        // recovered log holding only the two valid lines, and report the bad
2830        // line's byte offset.
2831        let tmp = TempDir::new().unwrap();
2832        let good1 = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2833        let bad = "{not valid json at all";
2834        let good3 = r#"{"ts":"2026-06-12T00:00:00Z","seq":3,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2835        let log = format!("{good1}\n{bad}\n{good3}\n");
2836        let paths = paths_with_events(&tmp, log.as_bytes());
2837
2838        // Strict replay chokes on the poison line beforehand.
2839        assert!(matches!(
2840            read_all_events(&paths.events()).unwrap_err(),
2841            Error::CorruptEventLog { .. }
2842        ));
2843
2844        let q = quarantine_corrupt_lines(&paths, "20260612T000000Z")
2845            .unwrap()
2846            .expect("a corrupt line was excised");
2847        // The bad line started at the byte after `good1\n`.
2848        assert_eq!(q.removed_byte_offsets, vec![(good1.len() + 1) as u64]);
2849        assert_eq!(
2850            q.backup_path.file_name().unwrap().to_str().unwrap(),
2851            "events.jsonl.corrupt-20260612T000000Z.bak"
2852        );
2853
2854        // The backup is the verbatim original; the recovered log now replays
2855        // strictly with only the two valid records.
2856        assert_eq!(std::fs::read(&q.backup_path).unwrap(), log.as_bytes());
2857        let events = read_all_events(&paths.events()).unwrap();
2858        assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
2859    }
2860
2861    #[test]
2862    fn quarantine_clean_log_is_noop() {
2863        // A log with no corruption must not be renamed or rewritten.
2864        let tmp = TempDir::new().unwrap();
2865        let log = concat!(
2866            r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
2867            "\n",
2868        );
2869        let paths = paths_with_events(&tmp, log.as_bytes());
2870        assert!(quarantine_corrupt_lines(&paths, "20260612T000000Z")
2871            .unwrap()
2872            .is_none());
2873        // No backup created; original untouched.
2874        assert_eq!(std::fs::read(paths.events()).unwrap(), log.as_bytes());
2875        let bak = paths
2876            .events()
2877            .with_file_name("events.jsonl.corrupt-20260612T000000Z.bak");
2878        assert!(!bak.exists());
2879    }
2880
2881    #[test]
2882    fn quarantine_missing_log_is_none() {
2883        let tmp = TempDir::new().unwrap();
2884        let dir = tmp.path().join("run");
2885        std::fs::create_dir_all(&dir).unwrap();
2886        let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
2887        assert!(quarantine_corrupt_lines(&paths, "20260612T000000Z")
2888            .unwrap()
2889            .is_none());
2890    }
2891
2892    #[test]
2893    fn quarantine_preserves_torn_tail_and_excises_only_corruption() {
2894        // A valid record, a corrupt newline-terminated line, then a torn
2895        // (newline-less) final line. Only the corrupt middle line is excised;
2896        // the torn tail is retained verbatim (the readers tolerate it as an
2897        // in-flight partial write — excising it would change behavior).
2898        let tmp = TempDir::new().unwrap();
2899        let good = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
2900        let bad = "{garbage";
2901        let torn = r#"{"seq":2,"kind":"node.rep"#; // mid-write, no newline
2902        let mut log = Vec::new();
2903        log.extend_from_slice(format!("{good}\n{bad}\n{torn}").as_bytes());
2904        let paths = paths_with_events(&tmp, &log);
2905
2906        let q = quarantine_corrupt_lines(&paths, "20260612T000000Z")
2907            .unwrap()
2908            .expect("the corrupt middle line was excised");
2909        assert_eq!(q.removed_byte_offsets, vec![(good.len() + 1) as u64]);
2910
2911        let recovered = std::fs::read(paths.events()).unwrap();
2912        assert_eq!(recovered, format!("{good}\n{torn}").as_bytes());
2913        // The torn tail still recovers the last complete seq as 1.
2914        assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
2915    }
2916
2917    #[test]
2918    fn find_prior_with_key_rejects_torn_middle_line() {
2919        // A newline-terminated garbage line FOLLOWED by another line: this
2920        // is interior corruption, not an in-flight tail. It must be a hard
2921        // error, never a silent skip — a skipped line could carry the very
2922        // key being looked up and let the caller double-append.
2923        let tmp = TempDir::new().unwrap();
2924        let log = concat!(
2925            r#"{"seq":1,"kind":"node.report","idempotency_key":"k0","node_id":"n-1","data":{}}"#,
2926            "\n",
2927            "{not valid json at all\n",
2928            r#"{"seq":3,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
2929            "\n",
2930        );
2931        let paths = paths_with_events(&tmp, log.as_bytes());
2932        let err = scan(&paths, "node.report", "k1").unwrap_err();
2933        match err {
2934            Error::CorruptEventLog { reason, .. } => {
2935                assert!(reason.contains("line 2"), "reason was: {reason}");
2936                assert!(reason.contains("last good seq 1"), "reason was: {reason}");
2937            }
2938            other => panic!("expected CorruptEventLog, got {other:?}"),
2939        }
2940    }
2941}