Skip to main content

dbmd_core/
log.rs

1//! `log` — the append-only, month-rotating chronological log.
2//!
3//! One logical timeline: the active `log.md` at the store root plus
4//! `log/<YYYY-MM>.md` archives. [`Log::append`] rolls older months into
5//! archives on write so the active file stays current-month. [`Log::tail`] and
6//! [`Log::since`] **reverse-read from EOF**. Both read each file they touch in
7//! full — the on-disk order is not guaranteed monotonic, so neither can
8//! early-stop within a file — and select by timestamp: `tail` keeps the `n`
9//! newest, `since` keeps everything newer than the cutoff. Both cross into
10//! month archives only as far back as the requested window reaches (by the
11//! cutoff's month for `since`, by the current `n`th-newest's month for `tail`)
12//! — never the whole history.
13//!
14//! Append-only contract: there is no rewrite API. Corrective entries go on the
15//! end; out-of-order timestamps are a validate warning (`LOG_OUT_OF_ORDER`),
16//! signalling a probable rewrite.
17
18use std::collections::BTreeMap;
19use std::fs::File;
20use std::io::{Read, Seek, SeekFrom};
21use std::path::{Path, PathBuf};
22
23use chrono::{DateTime, Datelike, FixedOffset, NaiveDateTime, TimeZone, Utc};
24
25use crate::store::Store;
26
27/// The on-disk header timestamp format: `YYYY-MM-DD HH:MM` (minute precision,
28/// no timezone). Parsing reattaches UTC; emitting renders the entry's own
29/// wall-clock, so a read→write→read round-trip is stable at minute precision.
30const TS_FORMAT: &str = "%Y-%m-%d %H:%M";
31
32/// The frontmatter block written when the active `log.md` is created.
33const LOG_FRONTMATTER: &str = "---\ntype: log\n---\n\n# Curator log\n";
34
35/// Block size for the backward (reverse-from-EOF) reader.
36const REVERSE_BLOCK: usize = 8 * 1024;
37
38/// Bound one active/monthly curator log before allocating it. Logs are primary
39/// text data but not an asset transport; a larger file is hostile/corrupt and
40/// must be repaired or sharded before an in-process parse.
41const MAX_LOG_FILE_BYTES: u64 = 256 * 1024 * 1024;
42
43fn read_log_file(store: &Store, path: &Path) -> std::io::Result<String> {
44    let bytes = store.read_bounded(path, MAX_LOG_FILE_BYTES)?;
45    String::from_utf8(bytes)
46        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
47}
48
49/// A recognized `log.md` entry kind. Custom kinds are valid in the format
50/// (`dbmd validate` warns on unrecognized via `LOG_UNKNOWN_KIND`); this enum
51/// carries the recognized vocabulary plus a [`LogKind::Custom`] catch-all so an
52/// unknown kind round-trips without loss.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum LogKind {
55    /// A source artifact was ingested.
56    Ingest,
57    /// A file was created.
58    Create,
59    /// A file was updated.
60    Update,
61    /// A file was deleted.
62    Delete,
63    /// A file was renamed/moved.
64    Rename,
65    /// A wiki-link was added.
66    Link,
67    /// A validation pass ran.
68    Validate,
69    /// The index was rebuilt.
70    IndexRebuild,
71    /// A contradiction between sources was flagged.
72    Contradiction,
73    /// Any kind outside the recognized vocabulary, preserved verbatim.
74    Custom(String),
75}
76
77impl LogKind {
78    /// The canonical lowercase string for this kind, as it appears in a log
79    /// header (`ingest`, `index-rebuild`, …).
80    pub fn as_str(&self) -> &str {
81        match self {
82            LogKind::Ingest => "ingest",
83            LogKind::Create => "create",
84            LogKind::Update => "update",
85            LogKind::Delete => "delete",
86            LogKind::Rename => "rename",
87            LogKind::Link => "link",
88            LogKind::Validate => "validate",
89            LogKind::IndexRebuild => "index-rebuild",
90            LogKind::Contradiction => "contradiction",
91            LogKind::Custom(s) => s,
92        }
93    }
94
95    /// Parse a kind from its header token; non-canonical tokens become
96    /// [`LogKind::Custom`].
97    pub fn parse(token: &str) -> LogKind {
98        match token {
99            "ingest" => LogKind::Ingest,
100            "create" => LogKind::Create,
101            "update" => LogKind::Update,
102            "delete" => LogKind::Delete,
103            "rename" => LogKind::Rename,
104            "link" => LogKind::Link,
105            "validate" => LogKind::Validate,
106            "index-rebuild" => LogKind::IndexRebuild,
107            "contradiction" => LogKind::Contradiction,
108            other => LogKind::Custom(other.to_string()),
109        }
110    }
111
112    /// True if this is one of the recognized kinds (i.e. not
113    /// [`LogKind::Custom`]).
114    pub fn is_recognized(&self) -> bool {
115        !matches!(self, LogKind::Custom(_))
116    }
117}
118
119/// One parsed `log.md` entry: a header
120/// (`## [YYYY-MM-DD HH:MM] <kind> | <object>`) plus its body.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct LogEntry {
123    /// The entry timestamp from the header.
124    pub timestamp: DateTime<FixedOffset>,
125    /// The entry kind.
126    pub kind: LogKind,
127    /// The object slot — a store-relative path/wiki-link target, or `None` for
128    /// store-wide actions like `validate`.
129    pub object: Option<String>,
130    /// The free-form body (one or more lines) explaining what happened.
131    pub note: String,
132}
133
134impl LogEntry {
135    /// Render this entry as it appears on disk: the `## [...]` header line,
136    /// then the note body, then a trailing blank line so successive entries are
137    /// separated. The note is emitted with header-shaped continuation lines
138    /// **escaped** (see [`escape_note_line`]) so a note line that happens to
139    /// match the entry-header shape (`## [YYYY-MM-DD HH:MM] <kind> | <obj>`) can
140    /// never be mistaken for a real entry header on readback or on the next
141    /// rotation. The escape round-trips exactly through [`unescape_note_line`].
142    fn render(&self) -> String {
143        let ts = self.timestamp.format(TS_FORMAT);
144        let mut out = String::new();
145        match &self.object {
146            Some(obj) => {
147                out.push_str(&format!("## [{}] {} | {}\n", ts, self.kind.as_str(), obj));
148            }
149            None => {
150                out.push_str(&format!("## [{}] {}\n", ts, self.kind.as_str()));
151            }
152        }
153        // Trim only the structural line terminators (`\n`/`\r`) — the trailing
154        // blank line separating entries is appended below, so a note's own
155        // trailing newlines would otherwise stack up and shift on every
156        // re-render. Spaces and tabs are legitimate note *content* and must be
157        // preserved verbatim, so the round-trip is exact: readback
158        // (`parse_entries`) trims the same `['\n', '\r']` set and no more, and a
159        // note ending in a space (`"note 0 "`) must reconstruct unchanged.
160        let note = self.note.trim_end_matches(['\n', '\r']);
161        if !note.is_empty() {
162            // Escape per line: a note line that parses as an entry header is
163            // prefixed so it is no longer at column 0 as `## [` — it stays note
164            // body on readback and on rotation, never a fabricated entry.
165            for (i, line) in note.split('\n').enumerate() {
166                if i > 0 {
167                    out.push('\n');
168                }
169                out.push_str(&escape_note_line(line));
170            }
171            out.push('\n');
172        }
173        out.push('\n');
174        out
175    }
176
177    /// The `(year, month)` of this entry's wall-clock timestamp — the rotation
178    /// bucket.
179    fn year_month(&self) -> (i32, u32) {
180        (self.timestamp.year(), self.timestamp.month())
181    }
182}
183
184/// The store's chronological log: a thin handle for the append-only timeline.
185/// All methods take the [`Store`] so they resolve the active `log.md` and the
186/// `log/` archives under the store root.
187#[derive(Debug, Clone)]
188pub struct Log;
189
190impl Log {
191    /// Atomically append `entry` to the active `log.md`, creating it (with
192    /// `type: log` frontmatter) if absent. **If the active log holds entries
193    /// from a prior month, roll those older months into `log/<YYYY-MM>.md`
194    /// first** (atomic move), keeping the active file to the current month.
195    ///
196    /// **Concurrency.** `append` is a read-modify-write of the whole active file
197    /// (`write_atomic` is atomic at the file level, but the read→render→write
198    /// window is not). Two concurrent appenders — the manager and a cron-driven
199    /// background system, say — would otherwise both read the same N-entry
200    /// snapshot and each write N+1 entries, the second rename clobbering the
201    /// first and silently dropping an audit entry. We serialize the whole
202    /// read-modify-write under an advisory file lock (`flock`, held for the
203    /// duration) so concurrent appends queue instead of racing. The lock is
204    /// advisory and process-scoped; it guards the toolkit's own appends, which is
205    /// the realistic contention path.
206    pub fn append(store: &Store, entry: &LogEntry) -> crate::Result<()> {
207        let active = active_log_path(store);
208
209        // Serialize concurrent appends for the whole read-modify-write. Held
210        // until `_lock` drops at function exit (covering both the rotation and
211        // the plain-append paths). A lock failure is non-fatal: we proceed
212        // unlocked rather than refuse to log (best-effort, same posture as the
213        // pre-fix behaviour on platforms without advisory locks).
214        let _lock = AppendLock::acquire(store, &active)?;
215
216        // Read the active file's current contents (if any). The "current month"
217        // is the month of the entry being appended (the newest in the timeline);
218        // every existing entry from a strictly-earlier month rolls to archives.
219        let current_ym = entry.year_month();
220
221        if store.regular_file_exists(&active)? {
222            let content = read_log_file(store, &active)?;
223            let (header, entries) = parse_active(&content);
224
225            // Partition existing entries into prior-month (roll out) and
226            // current-or-later (keep in the active file).
227            let mut by_month: BTreeMap<(i32, u32), Vec<LogEntry>> = BTreeMap::new();
228            let mut keep: Vec<LogEntry> = Vec::new();
229            for e in entries {
230                if e.year_month() < current_ym {
231                    by_month.entry(e.year_month()).or_default().push(e);
232                } else {
233                    keep.push(e);
234                }
235            }
236
237            // A rotation is two non-atomic durable writes (archive append, then
238            // active trim). The marker disambiguates a crash-retry re-roll from a
239            // fresh rotation so a genuinely-distinct same-minute entry is never
240            // dropped (see `rotation_marker_path`). `recovering` is captured
241            // BEFORE we (re)write the marker, so the current attempt's archive
242            // append uses the right mode; the marker only changes what a LATER
243            // retry sees.
244            let marker = rotation_marker_path(store);
245            let recovering = store.regular_file_exists(&marker)?;
246
247            if !by_month.is_empty() {
248                // Roll each prior month into its archive (atomic per-file),
249                // appending to any existing archive for that month.
250                let dir = archive_dir(store);
251                store.create_dir_all(&dir)?;
252                // Mark the rotation in-flight so a crash before the active trim
253                // is recoverable as a re-roll (deduped), not re-appended.
254                if !recovering {
255                    store.write_atomic(&marker, b"")?;
256                }
257
258                // Scope the crash-recovery dedup correctly. The marker only tells
259                // us a rotation may have been interrupted; on its own it does NOT
260                // prove these specific entries are a re-roll. A genuine interrupted
261                // rotation commits its archive appends FIRST and crashes before the
262                // active trim, so on retry every prior-month entry still in the
263                // active file already has a matching copy in its archive — the
264                // whole roll-out batch is a multiset-subset of the archives. Only
265                // then is the dedup the right thing: suppress the copies the prior
266                // attempt already wrote.
267                //
268                // If instead some prior-month entry has NO matching archive copy,
269                // no completed archive write exists to re-roll: the marker is stale
270                // (e.g. committed/synced into a `merge=union` clone after a crash
271                // stranded it) and these entries are a FRESH roll. Treating them as
272                // a re-roll would dedup a genuinely-distinct same-(minute,kind,
273                // object,note) entry against an unrelated pre-existing archive entry
274                // and, because the active file is trimmed unconditionally below,
275                // drop it from disk entirely. So we only enter recovery mode when
276                // the entire batch is already reflected in the archives. The
277                // tradeoff favors preservation: a rare genuine partial multi-month
278                // crash may re-append an already-archived entry (a visible,
279                // recoverable duplicate) rather than ever silently losing one.
280                let recovering_reroll = recovering && batch_is_archived(store, &by_month)?;
281
282                for ((y, m), month_entries) in &by_month {
283                    let path = archive_path(store, *y, *m);
284                    append_to_archive(store, &path, month_entries, recovering_reroll)?;
285                }
286
287                // Rewrite the active file to the kept (current-month) entries
288                // plus the new entry — atomically.
289                let mut body = String::new();
290                for e in &keep {
291                    body.push_str(&e.render());
292                }
293                body.push_str(&entry.render());
294                let full = compose_active(&header, &body);
295                store.write_atomic(&active, full.as_bytes())?;
296                // Rotation committed (active trimmed): clear the in-flight marker.
297                let _ = store.remove_file(&marker);
298                return Ok(());
299            }
300
301            // No rotation needed. If a stale marker lingers (a crash that trimmed
302            // the active file but never deleted the marker), clear it so the next
303            // real rotation is treated as fresh, not stuck in recovery mode.
304            if recovering {
305                let _ = store.remove_file(&marker);
306            }
307            // Plain atomic append of the rendered entry.
308            let mut full = content;
309            if !full.ends_with('\n') {
310                full.push('\n');
311            }
312            full.push_str(&entry.render());
313            store.write_atomic(&active, full.as_bytes())?;
314            Ok(())
315        } else {
316            // Fresh log: frontmatter + the single entry.
317            let body = entry.render();
318            let full = compose_active(LOG_FRONTMATTER, &body);
319            store.write_atomic(&active, full.as_bytes())?;
320            Ok(())
321        }
322    }
323
324    /// The `n` most-recent entries **by timestamp**, returned oldest→newest.
325    ///
326    /// **Out-of-order safety (mirrors [`Log::since`]).** The log is append-only
327    /// but *not* guaranteed to be in non-decreasing timestamp order on disk: a
328    /// corrective entry is appended below the entry it corrects, a
329    /// backdated/clock-skewed write lands physically after newer entries, and a
330    /// `merge=union` clone merge interleaves both sides until a later agent
331    /// reorders. Out-of-order is only a `LOG_OUT_OF_ORDER` warning, never
332    /// rejected. So the last `n` *physical* entries are **not** the `n` newest
333    /// by time — taking them would omit a genuinely-recent entry that sits
334    /// physically before an older one, and the documented curator warm-up
335    /// (`dbmd log tail 20`) would report a stale picture of what was done lately.
336    /// We therefore feed every entry of each file we touch through a bounded
337    /// newest-by-timestamp window and let it select the true top `n`.
338    ///
339    /// Bounded cost: the active `log.md` is kept to the current month by
340    /// rotation, so a full read of it is cheap and is not a whole-store walk.
341    /// Across archives we *can* prune: each `log/<YYYY-MM>.md` holds only entries
342    /// from that month (rotation buckets by the entry's own year-month), so once
343    /// the window is full, an archive whose month is strictly before the
344    /// window-minimum's month cannot contain any entry newer than the current
345    /// `n`th-newest. We cross archives newest-month-first and stop at the first
346    /// such archive.
347    pub fn tail(store: &Store, n: usize) -> crate::Result<Vec<LogEntry>> {
348        if n == 0 {
349            return Ok(Vec::new());
350        }
351
352        // A bounded window of the `n` entries with the largest timestamps. No
353        // within-file early stop: out-of-order entries mean a newer entry can
354        // sit physically before an older one, so each file is read fully.
355        let mut window = NewestWindow::new(n);
356        // Active↔archive overlap dedup, narrowly scoped AND gated on the
357        // crash-recovery marker (mirrors `since` and the write side). The only
358        // legitimate source of an active↔archive overlap is a rotation
359        // interrupted between its two non-atomic durable writes (archive append
360        // committed, active trim not), which leaves the SAME entry in both the
361        // untrimmed active file and its month archive — exactly the state the
362        // `.rotating` marker records (see `rotation_marker_path`). When the
363        // marker is ABSENT (normal operation) there is no such overlap: a
364        // backdated append that rotated as a FRESH roll and merely collides on
365        // (minute,kind,object,note) with an active entry is a genuinely-DISTINCT
366        // event the write side deliberately preserved on disk, and the reader
367        // must report it. The old code deduped unconditionally and silently
368        // dropped that distinct entry (the bug). We therefore build `active_seen`
369        // — and suppress matching archive entries below — ONLY while the marker
370        // is present. Even then it suppresses only an ARCHIVE entry that matches
371        // an ACTIVE one, never active-vs-active or archive-vs-archive.
372        let recovering = store.regular_file_exists(&rotation_marker_path(store))?;
373        let mut active_seen: std::collections::HashSet<EntryKey> = std::collections::HashSet::new();
374
375        // Active file: scan fully (current-month-bounded by rotation). Record
376        // every identity for overlap detection *only while recovering* (the
377        // marker is present); consider every entry regardless — a same-minute
378        // duplicate WITHIN the active file is two distinct appends.
379        let active = active_log_path(store);
380        if store.regular_file_exists(&active)? {
381            reverse_collect(store, &active, |e| {
382                if recovering {
383                    active_seen.insert(entry_key(&e));
384                }
385                window.consider(e);
386                false
387            })?;
388        }
389
390        // Archives, newest-month-first. Once the window is full, an archive
391        // whose month is strictly before the window-minimum's month holds only
392        // entries older than the current cutoff, so it (and every older archive)
393        // is skippable.
394        for archive in list_archives_desc(store)? {
395            if let (true, Some(cutoff_ym), Some(arch_ym)) = (
396                window.is_full(),
397                window.min_year_month(),
398                archive_year_month(&archive),
399            ) {
400                if arch_ym < cutoff_ym {
401                    break;
402                }
403            }
404            reverse_collect(store, &archive, |e| {
405                // Suppress only a crash-retry active↔archive overlap, and only
406                // when recovering (marker present). `active_seen` is empty
407                // otherwise, so this never suppresses in normal operation — a
408                // distinct same-(minute,kind,object,note) archive entry survives
409                // even when an active entry collides on those fields. Archives
410                // are never deduped against each other.
411                if !active_seen.contains(&entry_key(&e)) {
412                    window.consider(e);
413                }
414                false
415            })?;
416        }
417
418        Ok(window.into_sorted())
419    }
420
421    /// Entries strictly newer than `time`, reverse-scanning active → archives.
422    ///
423    /// **No within-file early stop.** The log is append-only but *not*
424    /// guaranteed to be in non-decreasing timestamp order on disk: a corrective
425    /// entry is appended below the entry it corrects (SPEC: "if a finding is
426    /// wrong, append a corrective entry below it"), a backdated/clock-skewed
427    /// write lands physically after newer entries, and a `merge=union` clone
428    /// merge interleaves both sides until a later agent reorders. Out-of-order
429    /// is only a `LOG_OUT_OF_ORDER` warning, never rejected. So a newer entry
430    /// can sit physically *before* an older one; stopping at the first
431    /// older-than-`time` entry would silently drop those — the documented
432    /// curator warm-up (`dbmd log since <ts>`) would miss real recent work.
433    /// We therefore read every entry of each file we touch.
434    ///
435    /// Bounded cost: the active `log.md` is kept to the current month by
436    /// rotation, so a full read of it is cheap (the same read `tail` does for a
437    /// large `n`) and is not a whole-store walk. Across archives we *can* stop:
438    /// each `log/<YYYY-MM>.md` holds only entries from that month (rotation
439    /// buckets by the entry's own year-month), so an archive whose month is
440    /// strictly before `time`'s month cannot contain any entry newer than
441    /// `time`. We cross archives newest-month-first and stop at the first whose
442    /// month is entirely at or before `time`'s.
443    pub fn since(store: &Store, time: DateTime<FixedOffset>) -> crate::Result<Vec<LogEntry>> {
444        let mut collected: Vec<LogEntry> = Vec::new();
445        // Active↔archive overlap dedup, narrowly scoped AND gated on the
446        // crash-recovery marker (mirrors `tail` and the write side). An overlap
447        // (the SAME entry in both the untrimmed active file and the archive)
448        // arises ONLY from a rotation interrupted between its two non-atomic
449        // durable writes — exactly the state the `.rotating` marker records (see
450        // `rotation_marker_path`). When the marker is ABSENT (normal operation)
451        // there is no overlap to mask: a backdated append that rotated as a FRESH
452        // roll and merely collides on (minute,kind,object,note) with an active
453        // entry is a genuinely-DISTINCT event the write side preserved on disk,
454        // and the reader must report it. Deduping unconditionally silently
455        // dropped that distinct entry (the bug). We therefore record ACTIVE
456        // identities — and suppress matching archive entries below — ONLY while
457        // recovering; even then it suppresses an ARCHIVE entry against an ACTIVE
458        // one, never active-vs-active or archive-vs-archive.
459        let recovering = store.regular_file_exists(&rotation_marker_path(store))?;
460        let mut active_seen: std::collections::HashSet<EntryKey> = std::collections::HashSet::new();
461
462        // Active file: scan fully, no early stop (out-of-order safe). Collect
463        // every in-window entry (a same-minute duplicate within the active file
464        // is two distinct appends), recording identities for overlap detection
465        // only while recovering (the marker is present).
466        let active = active_log_path(store);
467        if store.regular_file_exists(&active)? {
468            reverse_collect(store, &active, |e| {
469                if e.timestamp > time {
470                    if recovering {
471                        active_seen.insert(entry_key(&e));
472                    }
473                    collected.push(e);
474                }
475                false
476            })?;
477        }
478
479        // The cutoff's own (year, month): any archive strictly before it holds
480        // only older entries and is skippable. Archive months are bucketed on
481        // the UTC calendar (on-disk timestamps are offset-free and re-read as
482        // UTC; rotation buckets by the entry's UTC year-month), so the pruning
483        // calendar must be UTC too. A non-UTC `since` offset (advertised in the
484        // CLI hint, e.g. `…T00:30:00+07:00`) whose local month differs from its
485        // UTC month would otherwise prune away an archive holding entries that
486        // are strictly newer than `time` — `time.year()/.month()` read the
487        // offset-LOCAL calendar, not UTC.
488        let cutoff_utc = time.with_timezone(&Utc);
489        let cutoff_ym = (cutoff_utc.year(), cutoff_utc.month());
490
491        for archive in list_archives_desc(store)? {
492            // Archives are newest-month-first; once a month is strictly before
493            // the cutoff's month, every remaining (older) archive is too.
494            if let Some(arch_ym) = archive_year_month(&archive) {
495                if arch_ym < cutoff_ym {
496                    break;
497                }
498            }
499            // Scan this archive fully — within a month, entries may still be
500            // out of order, so no within-file early stop.
501            reverse_collect(store, &archive, |e| {
502                // Suppress only a crash-retry active↔archive overlap, and only
503                // when recovering (marker present). `active_seen` is empty
504                // otherwise, so a distinct same-(minute,kind,object,note) archive
505                // entry survives in normal operation even when an active entry
506                // collides on those fields.
507                if e.timestamp > time && !active_seen.contains(&entry_key(&e)) {
508                    collected.push(e);
509                }
510                false
511            })?;
512        }
513
514        collected.reverse();
515        Ok(collected)
516    }
517
518    /// The timestamp of the most recent `validate` entry — the default `since`
519    /// window for working-set validation ([`crate::validate::validate_working_set`]).
520    pub fn last_validate_at(store: &Store) -> crate::Result<Option<DateTime<FixedOffset>>> {
521        let mut found: Option<DateTime<FixedOffset>> = None;
522
523        let active = active_log_path(store);
524        if store.regular_file_exists(&active)? {
525            reverse_collect(store, &active, |e| {
526                if e.kind == LogKind::Validate {
527                    found = Some(e.timestamp);
528                    true
529                } else {
530                    false
531                }
532            })?;
533        }
534
535        if found.is_none() {
536            for archive in list_archives_desc(store)? {
537                reverse_collect(store, &archive, |e| {
538                    if e.kind == LogKind::Validate {
539                        found = Some(e.timestamp);
540                        true
541                    } else {
542                        false
543                    }
544                })?;
545                if found.is_some() {
546                    break;
547                }
548            }
549        }
550
551        Ok(found)
552    }
553
554    /// Parse a single entry header (`## [YYYY-MM-DD HH:MM] <kind> | <object>`)
555    /// into its timestamp, kind, and object. Returns `None` if the line isn't a
556    /// well-formed entry header.
557    pub fn parse_header(line: &str) -> Option<(DateTime<FixedOffset>, LogKind, Option<String>)> {
558        let line = line.trim_end_matches(['\n', '\r']);
559        let rest = line.strip_prefix("## [")?;
560        let close = rest.find(']')?;
561        let ts_str = &rest[..close];
562        let timestamp = parse_timestamp(ts_str)?;
563
564        // Everything after the closing bracket: ` <kind> | <object>` or
565        // ` <kind>`.
566        let after = rest[close + 1..].trim();
567        if after.is_empty() {
568            return None;
569        }
570
571        let (kind_str, object) = match after.split_once('|') {
572            Some((k, o)) => {
573                let obj = o.trim();
574                let obj = if obj.is_empty() {
575                    None
576                } else {
577                    Some(obj.to_string())
578                };
579                (k.trim(), obj)
580            }
581            None => (after, None),
582        };
583
584        if kind_str.is_empty() {
585            return None;
586        }
587
588        Some((timestamp, LogKind::parse(kind_str), object))
589    }
590}
591
592// ── Internal helpers ────────────────────────────────────────────────────────
593
594/// A bounded window of the `n` entries with the largest timestamps, fed by a
595/// **reverse (newest-physical-first) scan** and used by [`Log::tail`].
596///
597/// Why this exists: the last `n` *physical* entries are the `n` newest only
598/// when the log is in non-decreasing time order. That's the append-only
599/// contract, not a guarantee — a backdated, clock-skewed, or merge-interleaved
600/// entry violates it (and trips the `LOG_OUT_OF_ORDER` validate warning). The
601/// window decouples `tail` from that assumption: it keeps the `n` largest
602/// timestamps seen regardless of the order they arrive in, so the caller can
603/// read each file fully (no fragile within-file early stop) and still get the
604/// true top `n`.
605///
606/// Tie-break: entries sharing a timestamp at the window boundary are ordered by
607/// **physical recency** — the one appended later (encountered earlier in the
608/// reverse scan, i.e. a smaller `arrival`) wins. "Newest" means most-recently
609/// recorded.
610struct NewestWindow {
611    cap: usize,
612    /// Min-by-(timestamp, then physical-oldest) heap: the root is always the
613    /// next entry to evict once the window is full.
614    heap: std::collections::BinaryHeap<WindowItem>,
615    /// Count of entries fed in, in reverse-scan order, used as the tie-break
616    /// key (0 = newest physical).
617    next_arrival: u64,
618}
619
620impl NewestWindow {
621    fn new(cap: usize) -> Self {
622        NewestWindow {
623            cap,
624            heap: std::collections::BinaryHeap::with_capacity(cap),
625            next_arrival: 0,
626        }
627    }
628
629    /// Offer one entry from the scan. If the window isn't full it's kept; once
630    /// full, it's kept (evicting the current minimum) iff its timestamp is `>=`
631    /// the window minimum. Equal-timestamp boundary entries resolve by physical
632    /// recency (see the type doc).
633    fn consider(&mut self, entry: LogEntry) {
634        let arrival = self.next_arrival;
635        self.next_arrival += 1;
636
637        if self.heap.len() < self.cap {
638            self.heap.push(WindowItem { entry, arrival });
639            return;
640        }
641
642        // Window full. The heap root is the current minimum (oldest-by-
643        // timestamp held; on a tie, the oldest-physical).
644        let root = self.heap.peek().expect("full window has a root");
645        if entry.timestamp > root.entry.timestamp {
646            // Strictly newer than the window minimum: it belongs; evict the min.
647            self.heap.pop();
648            self.heap.push(WindowItem { entry, arrival });
649        }
650        // On `<=` we keep the window as-is. `<` is plainly too old. `==` is the
651        // tie case: the scan is newest-physical-first, so this entry is
652        // physically *older* than the held one of equal timestamp, and the
653        // tie-break keeps the physically-newer (most-recently-recorded) entry —
654        // so the incoming one is dropped.
655    }
656
657    /// Whether the window already holds its full `cap` entries.
658    fn is_full(&self) -> bool {
659        self.heap.len() >= self.cap
660    }
661
662    /// The `(year, month)` of the window's current minimum (oldest kept) entry,
663    /// or `None` when the window is empty. Used to prune older archives: an
664    /// archive month strictly before this can't beat the current cutoff.
665    fn min_year_month(&self) -> Option<(i32, u32)> {
666        self.heap
667            .peek()
668            .map(|item| (item.entry.timestamp.year(), item.entry.timestamp.month()))
669    }
670
671    /// The held entries, oldest→newest (chronological), ties broken
672    /// oldest-physical→newest-physical.
673    fn into_sorted(self) -> Vec<LogEntry> {
674        let mut items: Vec<WindowItem> = self.heap.into_vec();
675        // Ascending by timestamp; on a tie, oldest-physical (larger arrival)
676        // first so the most-recently-recorded entry sorts last.
677        items.sort_by(|a, b| {
678            a.entry
679                .timestamp
680                .cmp(&b.entry.timestamp)
681                .then(b.arrival.cmp(&a.arrival))
682        });
683        items.into_iter().map(|i| i.entry).collect()
684    }
685}
686
687/// One slot in [`NewestWindow`]'s heap. `Ord` is defined so the heap is a
688/// **min-heap on `(timestamp, physical-oldest)`**: `BinaryHeap` is a max-heap,
689/// so the root (max under this `Ord`) is the eviction candidate — the smallest
690/// timestamp, and on a tie the oldest-physical (largest `arrival`).
691struct WindowItem {
692    entry: LogEntry,
693    arrival: u64,
694}
695
696impl PartialEq for WindowItem {
697    fn eq(&self, other: &Self) -> bool {
698        self.entry.timestamp == other.entry.timestamp && self.arrival == other.arrival
699    }
700}
701impl Eq for WindowItem {}
702
703impl Ord for WindowItem {
704    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
705        // Reverse on timestamp so the *smallest* timestamp is the heap max
706        // (eviction candidate). On equal timestamps, the larger `arrival`
707        // (older physical) is the heap max so it is evicted first.
708        other
709            .entry
710            .timestamp
711            .cmp(&self.entry.timestamp)
712            .then(self.arrival.cmp(&other.arrival))
713    }
714}
715impl PartialOrd for WindowItem {
716    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
717        Some(self.cmp(other))
718    }
719}
720
721/// An advisory, exclusive lock serializing concurrent [`Log::append`] calls.
722///
723/// Held on a dedicated sibling lock file (`<active>.lock`) rather than on
724/// `log.md` itself: `write_atomic` replaces the active file by `rename`, so the
725/// active inode changes under us and a lock on its fd would not cover the new
726/// file. The lock file is stable, so the lock spans the whole read-modify-write.
727///
728/// On Unix this is `flock(LOCK_EX)`, released on drop (or implicitly when the
729/// process exits / the fd closes, so a crash never strands the lock). The
730/// lock file is created if absent and intentionally left on disk between runs
731/// (locking it does not depend on its contents). On non-Unix targets the lock
732/// is a no-op — db.md's append surface is Unix-targeted, and a missing advisory
733/// lock degrades to the pre-fix last-writer-wins, never to incorrectness of a
734/// single writer.
735struct AppendLock {
736    _file: File,
737}
738
739impl AppendLock {
740    /// Acquire the exclusive append lock for the store whose active log is
741    /// `active`. Best-effort: any failure to open or lock the lock file yields
742    /// an unlocked guard (we log rather than refuse to log). Blocks until the
743    /// lock is granted when another appender holds it.
744    fn acquire(store: &Store, active: &Path) -> crate::Result<AppendLock> {
745        Ok(AppendLock {
746            _file: store.lock_file(&lock_path_for(active))?,
747        })
748    }
749}
750
751/// The advisory-lock sibling path for an active log file (`<name>.lock`).
752fn lock_path_for(active: &Path) -> PathBuf {
753    let mut name = active
754        .file_name()
755        .map(|s| s.to_os_string())
756        .unwrap_or_else(|| std::ffi::OsString::from("log.md"));
757    name.push(".lock");
758    match active.parent() {
759        Some(parent) => parent.join(name),
760        None => PathBuf::from(name),
761    }
762}
763
764/// The active `log.md` path under the store root.
765fn active_log_path(store: &Store) -> PathBuf {
766    let _ = store;
767    PathBuf::from("log.md")
768}
769
770/// The `log/` archive directory under the store root.
771fn archive_dir(store: &Store) -> PathBuf {
772    let _ = store;
773    PathBuf::from("log")
774}
775
776/// The `log/<YYYY-MM>.md` archive path for a given month.
777fn archive_path(store: &Store, year: i32, month: u32) -> PathBuf {
778    archive_dir(store).join(format!("{year:04}-{month:02}.md"))
779}
780
781/// The crash-recovery marker for an in-progress rotation.
782///
783/// Its **presence** at the start of [`Log::append`] means a prior rotation
784/// appended prior-month entries to their archives but may not have trimmed the
785/// active file (a crash, or an active-rewrite error, between the two non-atomic
786/// durable writes). The retry must then DEDUP the re-rolled entries against the
787/// archive so it adds nothing.
788///
789/// Its **absence** means a fresh rotation: every prior-month entry being rolled
790/// is genuinely new to its archive and is appended UNCONDITIONALLY. This is the
791/// load-bearing distinction — a content-only dedup cannot tell an idempotent
792/// re-roll of one physical entry from a genuinely-distinct same-minute repeat
793/// (on-disk headers are minute-precision, so two real appends to the same object
794/// in the same minute with the same note render byte-identically). Gating the
795/// dedup on "are we recovering a crashed rotation?" lets a backdated duplicate
796/// survive while still suppressing a true re-roll.
797///
798/// Lives in `log/` (toolkit-managed; a dotfile, so never walked, indexed, or
799/// validated as content — `list_archives_desc` matches only `YYYY-MM.md`).
800fn rotation_marker_path(store: &Store) -> PathBuf {
801    archive_dir(store).join(".rotating")
802}
803
804/// Parse a `YYYY-MM-DD HH:MM` header timestamp, reattaching UTC. `None` on any
805/// malformed shape.
806fn parse_timestamp(s: &str) -> Option<DateTime<FixedOffset>> {
807    let naive = NaiveDateTime::parse_from_str(s.trim(), TS_FORMAT).ok()?;
808    let utc = FixedOffset::east_opt(0)?;
809    utc.from_local_datetime(&naive).single()
810}
811
812/// Split a `log.md` / archive file into its leading frontmatter+heading block
813/// (everything up to and including the line before the first `## [` header) and
814/// its parsed entries. If there are no entries, the whole content is the header
815/// block.
816fn parse_active(content: &str) -> (String, Vec<LogEntry>) {
817    match find_first_header(content) {
818        Some(idx) => {
819            let header = content[..idx].to_string();
820            let entries = parse_entries(&content[idx..]);
821            (header, entries)
822        }
823        None => (content.to_string(), Vec::new()),
824    }
825}
826
827/// Byte offset of the first **valid** entry header — a `## [` line-start that
828/// [`Log::parse_header`] accepts — or `None`.
829///
830/// Crucially this skips `## [`-SHAPED lines that `parse_header` REJECTS (a
831/// merge-orphaned note, an exporter-malformed line) appearing before the first
832/// real entry: everything up to the first valid header becomes the preserved
833/// `header` block in [`parse_active`], so a rotation re-emits it verbatim.
834/// Returning the first `## [`-shaped line instead (as this once did) put those
835/// pre-entry lines into the entries region, where [`parse_entries`] — which
836/// opens an entry only on a parseable header — dropped them on the floor,
837/// silently erasing append-only content on the next rotation.
838fn find_first_header(content: &str) -> Option<usize> {
839    let mut offset = 0usize;
840    for line in content.split_inclusive('\n') {
841        let line_str = line.trim_end_matches(['\r', '\n']);
842        if line_str.starts_with("## [") && Log::parse_header(line_str).is_some() {
843            return Some(offset);
844        }
845        offset += line.len();
846    }
847    None
848}
849
850/// Whether `line` is a note line that — left unescaped — could be mistaken for
851/// an entry header. It is *header-ambiguous* when it is a (possibly empty) run
852/// of leading backslashes followed by a string that [`Log::parse_header`]
853/// accepts. The escape (one leading backslash) and only the escape is added to,
854/// or stripped from, such lines, so the transform is fully reversible:
855/// `## [..]` (a real header shape in note text) ⇄ `\## [..]`, and a literal
856/// `\## [..]` a note already contains ⇄ `\\## [..]`.
857fn is_header_ambiguous(line: &str) -> bool {
858    let stripped = line.trim_start_matches('\\');
859    // Only treat it as ambiguous if some backslashes were the *only* prefix and
860    // the remainder is a valid header — a backslash run that does not lead into
861    // a header (e.g. `\not a header`) is ordinary note text, left untouched.
862    Log::parse_header(stripped).is_some()
863}
864
865/// Escape one note line for on-disk emission so it can never be parsed as an
866/// entry header (the [write-path fix] for header-shaped notes corrupting the
867/// append-only log). A header-ambiguous line is prefixed with a single
868/// backslash, moving its `## [` off column 0; every other line is emitted
869/// verbatim. Reversed exactly by [`unescape_note_line`].
870fn escape_note_line(line: &str) -> std::borrow::Cow<'_, str> {
871    if is_header_ambiguous(line) {
872        std::borrow::Cow::Owned(format!("\\{line}"))
873    } else {
874        std::borrow::Cow::Borrowed(line)
875    }
876}
877
878/// Reverse [`escape_note_line`]: strip exactly one leading backslash from a
879/// header-ambiguous on-disk note line, restoring the literal the author wrote.
880/// A line that is not header-ambiguous (including a genuine `\not a header`) is
881/// returned untouched, so the round-trip is lossless for arbitrary note text.
882fn unescape_note_line(line: &str) -> std::borrow::Cow<'_, str> {
883    if let Some(rest) = line.strip_prefix('\\') {
884        if is_header_ambiguous(line) {
885            return std::borrow::Cow::Borrowed(rest);
886        }
887    }
888    std::borrow::Cow::Borrowed(line)
889}
890
891/// Parse every entry in a slice that begins at (or before, header-block
892/// included) a sequence of `## [` headers. Headers that fail to parse are
893/// skipped (their body folds into the previous valid entry's note is avoided —
894/// they simply start no new entry).
895fn parse_entries(text: &str) -> Vec<LogEntry> {
896    let mut entries: Vec<LogEntry> = Vec::new();
897    let mut cur_header: Option<(DateTime<FixedOffset>, LogKind, Option<String>)> = None;
898    let mut cur_note: Vec<&str> = Vec::new();
899
900    let flush = |entries: &mut Vec<LogEntry>,
901                 header: &mut Option<(DateTime<FixedOffset>, LogKind, Option<String>)>,
902                 note: &mut Vec<&str>| {
903        if let Some((timestamp, kind, object)) = header.take() {
904            // Reverse the per-line header escape `render` applies so an escaped
905            // header-shaped note line round-trips back to its literal form.
906            let joined = note
907                .iter()
908                .map(|line| unescape_note_line(line))
909                .collect::<Vec<_>>()
910                .join("\n");
911            let note_str = joined.trim_matches(['\n', '\r']).to_string();
912            entries.push(LogEntry {
913                timestamp,
914                kind,
915                object,
916                note: note_str,
917            });
918        }
919        note.clear();
920    };
921
922    for line in text.lines() {
923        if line.starts_with("## [") {
924            if let Some(parsed) = Log::parse_header(line) {
925                // Close the previous entry, start a new one.
926                flush(&mut entries, &mut cur_header, &mut cur_note);
927                cur_header = Some(parsed);
928                continue;
929            }
930            // Unparseable `## [` line: treat as body of the current entry.
931        }
932        if cur_header.is_some() {
933            cur_note.push(line);
934        }
935    }
936    flush(&mut entries, &mut cur_header, &mut cur_note);
937    entries
938}
939
940/// Recompose an active/archive file from a header block and an entry body.
941fn compose_active(header: &str, body: &str) -> String {
942    let mut out = String::new();
943    out.push_str(header);
944    if !header.is_empty() && !header.ends_with('\n') {
945        out.push('\n');
946    }
947    // Exactly one blank line between the heading block and the first entry.
948    if !header.is_empty() && !out.ends_with("\n\n") {
949        out.push('\n');
950    }
951    out.push_str(body);
952    out
953}
954
955/// Append entries to a month archive, creating it with `type: log` frontmatter
956/// if absent. Atomic (temp-file rename). Entries are appended in the given
957/// order (callers pass them already chronological within the month).
958///
959/// **`recovering` — the re-roll gate.** Rotation in [`Log::append`] is two
960/// non-atomic durable writes: roll prior-month entries into the archive, then
961/// rewrite (trim) the active file. If the process crashes or the active rewrite
962/// errors *after* the archive write commits, the prior-month entries remain in
963/// the still-untrimmed active file and the agent's retry re-rolls them here. A
964/// naive concatenate would then duplicate every entry, amplifying on each retry.
965///
966/// We CANNOT dedup that away by content alone: on-disk headers are
967/// minute-precision, so two genuinely-distinct appends to the same object in the
968/// same minute with the same note render byte-identically — indistinguishable
969/// from a re-roll of one physical entry. Deduping unconditionally therefore
970/// silently destroyed a legitimately-distinct backdated duplicate (the bug).
971///
972/// So the caller passes `recovering`: `true` only when an in-progress-rotation
973/// marker was found (a crash-retry), where we dedup the incoming batch against
974/// the archive **by multiplicity** (skip an incoming entry only while the
975/// archive still holds an unconsumed copy of its identity) so a re-roll of the
976/// SAME physical entries adds nothing. On a fresh rotation (`false`) every entry
977/// is genuinely new to the archive and is appended unconditionally, so a
978/// distinct same-minute repeat survives.
979fn append_to_archive(
980    store: &Store,
981    path: &Path,
982    entries: &[LogEntry],
983    recovering: bool,
984) -> crate::Result<()> {
985    if store.regular_file_exists(path)? {
986        let existing = read_log_file(store, path)?;
987
988        let mut body = String::new();
989        if recovering {
990            // Crash-retry: the prior (crashed) attempt may already have appended
991            // some/all of these. Dedup by MULTIPLICITY, not set-membership, so a
992            // partial-then-retried roll converges exactly and a re-roll of the
993            // full batch is a no-op.
994            let (_header, existing_entries) = parse_active(&existing);
995            let mut remaining: std::collections::HashMap<EntryKey, usize> =
996                std::collections::HashMap::new();
997            for e in &existing_entries {
998                *remaining.entry(entry_key(e)).or_insert(0) += 1;
999            }
1000            for e in entries {
1001                match remaining.get_mut(&entry_key(e)) {
1002                    // An archived copy is still unconsumed: this incoming entry is
1003                    // that re-roll, suppress it.
1004                    Some(count) if *count > 0 => *count -= 1,
1005                    _ => body.push_str(&e.render()),
1006                }
1007            }
1008        } else {
1009            // Fresh rotation: append every entry. A same-minute, same-fields
1010            // entry that already exists in the archive is a DISTINCT append, not
1011            // a re-roll, and must be preserved.
1012            for e in entries {
1013                body.push_str(&e.render());
1014            }
1015        }
1016
1017        // Nothing new to add (a fully-duplicate re-roll): leave the archive
1018        // byte-for-byte untouched (append-only: don't rewrite identical data).
1019        if body.is_empty() {
1020            return Ok(());
1021        }
1022
1023        let mut full = existing;
1024        if !full.ends_with('\n') {
1025            full.push('\n');
1026        }
1027        full.push_str(&body);
1028        store.write_atomic(path, full.as_bytes())?;
1029    } else {
1030        let mut body = String::new();
1031        for e in entries {
1032            body.push_str(&e.render());
1033        }
1034        let full = compose_active(LOG_FRONTMATTER, &body);
1035        store.write_atomic(path, full.as_bytes())?;
1036    }
1037    Ok(())
1038}
1039
1040/// True iff every prior-month entry about to be rolled out (`by_month`) already
1041/// has a matching, unconsumed copy in its month archive — i.e. the whole
1042/// roll-out batch is a multiset-subset of the archives.
1043///
1044/// This is the load-bearing test for "is the `.rotating` marker a genuine
1045/// interrupted-rotation re-roll, or a stale marker over a fresh roll?" A genuine
1046/// interrupted rotation commits its per-month archive appends BEFORE the active
1047/// trim, so on retry the still-untrimmed active file's prior-month entries are
1048/// all present in the archives — exactly the duplicates the dedup must suppress.
1049/// If any entry is missing from its archive, no completed archive write exists to
1050/// re-roll: the marker is stale and these are a fresh roll that must be appended,
1051/// never deduped (deduping a genuinely-distinct same-(minute,kind,object,note)
1052/// entry against an unrelated pre-existing archive copy would, with the
1053/// unconditional active trim, drop it from disk — the bug).
1054///
1055/// Multiset semantics: each archived copy is consumed at most once, so two
1056/// distinct same-minute entries in the batch require two archived copies to count
1057/// as a re-roll. Cheap: only the months actually being rolled are read, and only
1058/// when the marker is present (the cold recovery path).
1059fn batch_is_archived(
1060    store: &Store,
1061    by_month: &BTreeMap<(i32, u32), Vec<LogEntry>>,
1062) -> crate::Result<bool> {
1063    for ((y, m), month_entries) in by_month {
1064        let path = archive_path(store, *y, *m);
1065        if !store.regular_file_exists(&path)? {
1066            // No archive for this month: nothing was rolled here yet, so the
1067            // batch cannot be a completed re-roll.
1068            return Ok(false);
1069        }
1070        let (_header, archived) = parse_active(&read_log_file(store, &path)?);
1071        let mut available: std::collections::HashMap<EntryKey, usize> =
1072            std::collections::HashMap::new();
1073        for e in &archived {
1074            *available.entry(entry_key(e)).or_insert(0) += 1;
1075        }
1076        for e in month_entries {
1077            match available.get_mut(&entry_key(e)) {
1078                Some(count) if *count > 0 => *count -= 1,
1079                _ => return Ok(false),
1080            }
1081        }
1082    }
1083    Ok(true)
1084}
1085
1086/// A hashable identity for a log entry, used to dedup an idempotent archive
1087/// re-roll (see [`append_to_archive`]). Two entries are "the same" when their
1088/// timestamp, kind, object, and note all match — exactly the fields that
1089/// round-trip through `render`/`parse`, so a re-rolled entry compares equal to
1090/// the one already archived. Owned (rather than borrowed) so keys from the
1091/// existing archive and from the incoming entries share one type regardless of
1092/// where they came from; the cost is paid only on the cold rotation path.
1093type EntryKey = (DateTime<FixedOffset>, String, Option<String>, String);
1094
1095/// Derive the dedup key for `e` (see [`EntryKey`]). Keying on `kind.as_str()`
1096/// (rather than `LogKind`, which is not `Hash`) is exact: `as_str`/`parse`
1097/// round-trips every recognized kind and preserves any `Custom` token.
1098fn entry_key(e: &LogEntry) -> EntryKey {
1099    (
1100        e.timestamp,
1101        e.kind.as_str().to_string(),
1102        e.object.clone(),
1103        e.note.clone(),
1104    )
1105}
1106
1107/// Every `log/<YYYY-MM>.md` archive, sorted **newest month first**.
1108fn list_archives_desc(store: &Store) -> crate::Result<Vec<PathBuf>> {
1109    let dir = archive_dir(store);
1110    if !store.directory_exists(&dir).unwrap_or(false) {
1111        return Ok(Vec::new());
1112    }
1113    let mut months: Vec<(String, PathBuf)> = Vec::new();
1114    for name in store.regular_file_names(&dir)? {
1115        let name = match name.to_str() {
1116            Some(n) => n,
1117            None => continue,
1118        };
1119        // Match `YYYY-MM.md`.
1120        if let Some(stem) = name.strip_suffix(".md") {
1121            if is_year_month(stem) {
1122                months.push((stem.to_string(), dir.join(name)));
1123            }
1124        }
1125    }
1126    // `YYYY-MM` strings sort lexically == chronologically; reverse for newest
1127    // first.
1128    months.sort_by(|a, b| b.0.cmp(&a.0));
1129    Ok(months.into_iter().map(|(_, p)| p).collect())
1130}
1131
1132/// The `(year, month)` an archive file represents, parsed from its
1133/// `log/<YYYY-MM>.md` name. `None` if the name isn't a well-formed month
1134/// archive (in which case the caller scans it rather than risk skipping it).
1135fn archive_year_month(path: &Path) -> Option<(i32, u32)> {
1136    let stem = path
1137        .file_name()
1138        .and_then(|s| s.to_str())
1139        .and_then(|n| n.strip_suffix(".md"))?;
1140    if !is_year_month(stem) {
1141        return None;
1142    }
1143    let year: i32 = stem[..4].parse().ok()?;
1144    let month: u32 = stem[5..7].parse().ok()?;
1145    // The month must be a real calendar month. A hand-created / externally-
1146    // produced `log/2026-00.md` or `log/2026-13.md` parses as two digits but
1147    // names no month; returning `Some((year, 0))` would sort it below every
1148    // legitimate month, so the newest-month-first early-break in `since`/`tail`
1149    // could prune it and silently drop its entries. Out-of-range → `None`, so the
1150    // caller scans the file instead of risk-skipping it (the safe fallback).
1151    if !(1..=12).contains(&month) {
1152        return None;
1153    }
1154    Some((year, month))
1155}
1156
1157/// True if `s` looks like `YYYY-MM` (4 digits, dash, 2 digits).
1158fn is_year_month(s: &str) -> bool {
1159    let bytes = s.as_bytes();
1160    if bytes.len() != 7 {
1161        return false;
1162    }
1163    bytes[..4].iter().all(u8::is_ascii_digit)
1164        && bytes[4] == b'-'
1165        && bytes[5].is_ascii_digit()
1166        && bytes[6].is_ascii_digit()
1167}
1168
1169/// Reverse-read `path` from EOF, parsing entries newest-first and feeding each
1170/// to `take`. `take` returns `true` to stop early (enough collected). The file
1171/// is read backward in blocks; only the tail region needed to satisfy `take`
1172/// is read — the whole file is read only if `take` never returns `true`.
1173fn reverse_collect<F>(store: &Store, path: &Path, mut take: F) -> crate::Result<()>
1174where
1175    F: FnMut(LogEntry) -> bool,
1176{
1177    let mut file = store.open_regular(path)?;
1178    let len = file.metadata()?.len();
1179    if len > MAX_LOG_FILE_BYTES {
1180        return Err(std::io::Error::new(
1181            std::io::ErrorKind::InvalidData,
1182            "log file exceeds the bounded parser limit",
1183        )
1184        .into());
1185    }
1186    if len == 0 {
1187        return Ok(());
1188    }
1189
1190    // Algorithm: grow a tail buffer leftward one block at a time, emitting
1191    // entries strictly newest-first as their left boundary is confirmed, and
1192    // stopping the instant `take` says enough. The whole file is read only if
1193    // `take` never returns `true` (e.g. `tail(n)` with n ≥ entry count).
1194    //
1195    // Invariant: a `## [` line-start anywhere in the buffer is a *complete*
1196    // entry — its header is the entry's first line, and its body lies to the
1197    // right and is therefore already buffered (we read right-to-left). So we
1198    // never split an entry across blocks.
1199    //
1200    // `buf` holds the file's bytes from absolute offset `start` (growing
1201    // leftward toward 0) to EOF. `emitted_abs` records the absolute offsets of
1202    // headers already handed to `take`, so re-visiting a header in a later block
1203    // never double-emits.
1204    let mut buf: Vec<u8> = Vec::new();
1205    let mut start = len;
1206    // O(1) membership: a `Vec` + `.contains()` here would be O(E²) across a large
1207    // single-month file (every header re-checked against all prior emissions).
1208    let mut emitted_abs: std::collections::HashSet<u64> = std::collections::HashSet::new();
1209    // Every header's absolute offset found so far, ascending. Built
1210    // *incrementally*: each block contributes only the markers whose `#` starts
1211    // inside it (all strictly smaller than any already-known offset, so they
1212    // prepend in order). This is the fix for the accidental O(file²) scan — the
1213    // old code re-ran `header_offsets` over the whole accumulated buffer on every
1214    // block (O(file²/block) byte comparisons on the default no-early-stop
1215    // tail/since path); now each byte is scanned for a header exactly once.
1216    let mut headers: Vec<u64> = Vec::new();
1217    let mut stop = false;
1218    // The first backward block has no already-scanned region to its right, so it
1219    // scans exactly `[0, block)`; every later block scans one byte further
1220    // (`block + 1`) to re-classify the prior block's deferred left-edge candidate
1221    // now that its left neighbour is buffered (see the scan call below).
1222    let mut first = true;
1223
1224    while start > 0 && !stop {
1225        let block = std::cmp::min(REVERSE_BLOCK as u64, start);
1226        let new_start = start - block;
1227        file.seek(SeekFrom::Start(new_start))?;
1228        let mut chunk = vec![0u8; block as usize];
1229        file.read_exact(&mut chunk)?;
1230        chunk.extend_from_slice(&buf);
1231        buf = chunk;
1232        start = new_start;
1233
1234        // Scan the freshly-prepended block (buffer indices `[0, block)`) for new
1235        // header markers. A marker straddling the block boundary has its `#` in
1236        // this window and so is still caught (see `header_offsets_range`).
1237        //
1238        // One subtlety the scan must respect: a `## [` whose `#` sits at the
1239        // block's LEFT edge (buffer index 0, absolute offset `start`) cannot have
1240        // its line-start confirmed yet when `start > 0` — the byte at `start - 1`
1241        // is not buffered. Treating index 0 as a line start there fabricates an
1242        // entry from a mid-line `## [` fragment that happens to align with a block
1243        // boundary. So `header_offsets_range` DEFERS the leftmost candidate when
1244        // `base` is not the true file start, and we re-scan one byte further
1245        // right next time: after the first block the buffer carries the previous
1246        // block's left-edge byte at index `block` with its left neighbour now in
1247        // hand, so extending the window to `block + 1` re-classifies that exactly
1248        // once. `first` guards the first block (nothing to re-check on its right).
1249        let base_is_file_start = start == 0;
1250        let scan_hi = if first { block } else { block + 1 } as usize;
1251        let mut new_headers = header_offsets_range(&buf, start, 0, scan_hi, base_is_file_start);
1252        first = false;
1253        if !new_headers.is_empty() {
1254            new_headers.extend_from_slice(&headers);
1255            headers = new_headers;
1256        }
1257
1258        // Process newest (largest offset) → oldest (smallest), emitting any
1259        // header not yet emitted. Hold back only the buffer's *leftmost* header
1260        // while we have not reached file start (`start > 0`): older entries may
1261        // still lie to its left in unread blocks, and newest-first order
1262        // requires we not emit it until we've confirmed it really is the oldest
1263        // (or read enough to bound it on the left). One extra block read at
1264        // most; on the next iteration its left boundary is in-buffer.
1265        for i in (0..headers.len()).rev() {
1266            let abs = headers[i];
1267            if emitted_abs.contains(&abs) {
1268                continue;
1269            }
1270            let is_oldest_in_buf = i == 0;
1271            if is_oldest_in_buf && start > 0 {
1272                continue;
1273            }
1274
1275            let entry_text = entry_text_at(&buf, start, abs, &headers, i);
1276            if let Some(entry) = parse_single_entry(&entry_text) {
1277                emitted_abs.insert(abs);
1278                if take(entry) {
1279                    stop = true;
1280                    break;
1281                }
1282            } else {
1283                emitted_abs.insert(abs);
1284            }
1285        }
1286    }
1287
1288    // Reached file start (or stopped). If we stopped, done. If we reached
1289    // start, emit any held-back oldest header(s) now (start == 0 means the
1290    // buffer's first header is genuinely the oldest). `headers` already holds
1291    // every offset (the loop scanned down to start == 0), so reuse it.
1292    if !stop && start == 0 {
1293        for i in (0..headers.len()).rev() {
1294            let abs = headers[i];
1295            if emitted_abs.contains(&abs) {
1296                continue;
1297            }
1298            let entry_text = entry_text_at(&buf, start, abs, &headers, i);
1299            if let Some(entry) = parse_single_entry(&entry_text) {
1300                emitted_abs.insert(abs);
1301                if take(entry) {
1302                    break;
1303                }
1304            } else {
1305                emitted_abs.insert(abs);
1306            }
1307        }
1308    }
1309
1310    Ok(())
1311}
1312
1313/// Absolute byte offsets of every **valid** entry-header line-start (`## […]`)
1314/// in `buf`, where `buf` begins at absolute offset `base`.
1315///
1316/// Only a `## [` line that [`Log::parse_header`] accepts is an entry boundary,
1317/// mirroring the forward parser ([`parse_entries`]), which folds an unparseable
1318/// `## [` line into the preceding entry's note rather than starting a new entry.
1319/// Without this validity check the reverse reader would split a real entry's
1320/// multi-line note at a continuation line beginning at column 0 with `## [`
1321/// (a shape the SPEC permits — notes are "one or more lines" with no
1322/// restriction), truncating the note and dropping the carved pseudo-entry, so
1323/// `tail`/`since`/`last_validate_at` would return a note diverging from the
1324/// intact on-disk bytes.
1325///
1326/// Whole-buffer convenience wrapper over [`header_offsets_range`]. The runtime
1327/// reverse reader now always scans incrementally (one freshly-prepended window
1328/// per backward block), so this whole-buffer form is retained only as the
1329/// oracle the range-scan tests check the incremental scan against.
1330#[cfg(test)]
1331fn header_offsets(buf: &[u8], base: u64) -> Vec<u64> {
1332    // The whole-buffer oracle treats `base` as the file start iff it is 0, so a
1333    // `## [` at buffer index 0 is a real line-start there.
1334    header_offsets_range(buf, base, 0, buf.len(), base == 0)
1335}
1336
1337/// Like [`header_offsets`] but only reports header *markers whose `#` starts in*
1338/// `buf[scan_lo..scan_hi)`, while still consulting bytes outside that window —
1339/// to the left for the line-start (`buf[i-1] == b'\n'`) check and to the right
1340/// for the header line's content. This is the incremental scan
1341/// [`reverse_collect`] uses: each backward block searches only the freshly-
1342/// prepended region for *new* markers, so total header-scan work is linear in
1343/// the file size, not the O(file²) of re-scanning the whole growing buffer on
1344/// every block.
1345///
1346/// A `## [` marker that *straddles* the boundary (its `#` in the new block, its
1347/// `[` or trailing bytes in the already-scanned region) is still detected here:
1348/// its `#` index is `< scan_hi`, so it falls in this window, and it was never
1349/// reported by an earlier scan (whose window was `[block, …)`, strictly to the
1350/// right of this one) — so each marker is reported exactly once across all
1351/// blocks.
1352///
1353/// **Left-edge line-start safety.** A `## [` whose `#` is at buffer index 0 has
1354/// no buffered left neighbour, so its line-start cannot be confirmed unless
1355/// index 0 really is the file start. `base_is_file_start` says so: when it is
1356/// `false`, an index-0 candidate is DEFERRED (not reported) rather than assumed
1357/// to be at a line start — otherwise a mid-line `## […]` fragment that happens
1358/// to align with a block's left edge would be fabricated into an entry,
1359/// truncating the real entry's note and (after rotation) corrupting the
1360/// append-only archive. The caller re-scans that byte on the next block, once
1361/// its left neighbour is buffered, so a genuine boundary header is still found
1362/// exactly once.
1363fn header_offsets_range(
1364    buf: &[u8],
1365    base: u64,
1366    scan_lo: usize,
1367    scan_hi: usize,
1368    base_is_file_start: bool,
1369) -> Vec<u64> {
1370    const PAT: &[u8] = b"## [";
1371    let mut out = Vec::new();
1372    let n = buf.len();
1373    let hi = scan_hi.min(n);
1374    let mut i = scan_lo;
1375    // A marker's `#` must start strictly before `hi`; the pattern/line content
1376    // may read past `hi` into `buf` (the right neighbour is already buffered).
1377    while i < hi && i + PAT.len() <= n {
1378        if &buf[i..i + PAT.len()] == PAT {
1379            // Index 0 is a line start only when it is the genuine file start;
1380            // otherwise its left neighbour is unbuffered and the candidate is
1381            // deferred to the next block (see the doc comment).
1382            let at_line_start = if i == 0 {
1383                base_is_file_start
1384            } else {
1385                buf[i - 1] == b'\n'
1386            };
1387            if at_line_start && is_valid_header_line(buf, i) {
1388                out.push(base + i as u64);
1389                // skip ahead past this marker
1390                i += PAT.len();
1391                continue;
1392            }
1393        }
1394        i += 1;
1395    }
1396    out
1397}
1398
1399/// Whether the `## [` line starting at byte `i` in `buf` parses as a valid
1400/// entry header. Reads the line up to (but not including) the next `\n` (or
1401/// buffer end) and defers to [`Log::parse_header`] — the same validity gate the
1402/// forward parser applies, keeping the reverse reader's boundary set identical
1403/// to the forward one.
1404fn is_valid_header_line(buf: &[u8], i: usize) -> bool {
1405    let line_end = buf[i..]
1406        .iter()
1407        .position(|&b| b == b'\n')
1408        .map(|p| i + p)
1409        .unwrap_or(buf.len());
1410    let line = String::from_utf8_lossy(&buf[i..line_end]);
1411    Log::parse_header(&line).is_some()
1412}
1413
1414/// Extract the text of the entry whose header is at absolute offset
1415/// `header_abs` (the `headers[idx]` entry), spanning to the next header (or
1416/// buffer end). `buf` begins at absolute offset `base`.
1417fn entry_text_at(buf: &[u8], base: u64, header_abs: u64, headers: &[u64], idx: usize) -> String {
1418    let rel_start = (header_abs - base) as usize;
1419    let rel_end = if idx + 1 < headers.len() {
1420        (headers[idx + 1] - base) as usize
1421    } else {
1422        buf.len()
1423    };
1424    String::from_utf8_lossy(&buf[rel_start..rel_end]).into_owned()
1425}
1426
1427/// Parse a single entry from a text block that begins at its header line.
1428fn parse_single_entry(text: &str) -> Option<LogEntry> {
1429    parse_entries(text).into_iter().next()
1430}
1431
1432#[cfg(test)]
1433mod tests {
1434    use super::*;
1435    use crate::parser::Config;
1436    use std::fs;
1437    use tempfile::TempDir;
1438
1439    /// Build a `Store` rooted at a fresh temp dir with a minimal `DB.md`.
1440    /// Construct the `Store` struct directly so the test stays narrow and never
1441    /// exercises the `Store::open` parser path.
1442    fn temp_store() -> (TempDir, Store) {
1443        let dir = tempfile::tempdir().expect("tempdir");
1444        fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
1445        let store = Store::from_root_and_config(dir.path(), Config::default()).unwrap();
1446        (dir, store)
1447    }
1448
1449    /// Resolve a store-relative helper path for the few test fixtures that
1450    /// intentionally bypass `Store` and author an on-disk crash state directly.
1451    fn test_abs(store: &Store, relative: impl AsRef<Path>) -> PathBuf {
1452        store.root.join(relative)
1453    }
1454
1455    /// Regression (adversarial review): a hand-created / externally-produced
1456    /// archive with an out-of-range month (`00`, `13`..`99`) must NOT parse as a
1457    /// real month archive — otherwise its `(year, 0)` bucket sorts below every
1458    /// legitimate month and the newest-first early-break in `since`/`tail` can
1459    /// silently prune it. Out-of-range → `None` (the caller scans it instead).
1460    #[test]
1461    fn archive_year_month_rejects_out_of_range_months() {
1462        use std::path::Path;
1463        assert_eq!(
1464            archive_year_month(Path::new("log/2026-05.md")),
1465            Some((2026, 5))
1466        );
1467        assert_eq!(
1468            archive_year_month(Path::new("log/2026-01.md")),
1469            Some((2026, 1))
1470        );
1471        assert_eq!(
1472            archive_year_month(Path::new("log/2026-12.md")),
1473            Some((2026, 12))
1474        );
1475        for bad in ["log/2026-00.md", "log/2026-13.md", "log/2026-99.md"] {
1476            assert_eq!(
1477                archive_year_month(Path::new(bad)),
1478                None,
1479                "{bad} has an out-of-range month and must not parse as an archive"
1480            );
1481        }
1482    }
1483
1484    /// A timestamp at UTC from `YYYY-MM-DD HH:MM` components.
1485    fn ts(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<FixedOffset> {
1486        let naive = chrono::NaiveDate::from_ymd_opt(y, mo, d)
1487            .unwrap()
1488            .and_hms_opt(h, mi, 0)
1489            .unwrap();
1490        FixedOffset::east_opt(0)
1491            .unwrap()
1492            .from_local_datetime(&naive)
1493            .single()
1494            .unwrap()
1495    }
1496
1497    #[allow(clippy::too_many_arguments)] // test fixture builder; struct-ifying churns every call site
1498    fn entry(
1499        y: i32,
1500        mo: u32,
1501        d: u32,
1502        h: u32,
1503        mi: u32,
1504        kind: LogKind,
1505        object: Option<&str>,
1506        note: &str,
1507    ) -> LogEntry {
1508        LogEntry {
1509            timestamp: ts(y, mo, d, h, mi),
1510            kind,
1511            object: object.map(|s| s.to_string()),
1512            note: note.to_string(),
1513        }
1514    }
1515
1516    // ── parse_header ────────────────────────────────────────────────────────
1517
1518    #[test]
1519    fn parse_header_with_object() {
1520        let (t, k, o) =
1521            Log::parse_header("## [2026-05-27 10:00] ingest | sources/emails/x.eml").unwrap();
1522        assert_eq!(t, ts(2026, 5, 27, 10, 0));
1523        assert_eq!(k, LogKind::Ingest);
1524        assert_eq!(o.as_deref(), Some("sources/emails/x.eml"));
1525    }
1526
1527    #[test]
1528    fn parse_header_without_object_is_none_object() {
1529        let (t, k, o) = Log::parse_header("## [2026-05-27 10:20] validate").unwrap();
1530        assert_eq!(t, ts(2026, 5, 27, 10, 20));
1531        assert_eq!(k, LogKind::Validate);
1532        assert_eq!(o, None);
1533    }
1534
1535    #[test]
1536    fn parse_header_custom_kind_roundtrips_token() {
1537        let (_, k, o) = Log::parse_header("## [2026-05-27 10:00] proposal | records/x").unwrap();
1538        assert_eq!(k, LogKind::Custom("proposal".to_string()));
1539        assert!(!k.is_recognized());
1540        assert_eq!(o.as_deref(), Some("records/x"));
1541    }
1542
1543    #[test]
1544    fn parse_header_index_rebuild_hyphenated_kind() {
1545        let (_, k, _) = Log::parse_header("## [2026-05-27 10:00] index-rebuild").unwrap();
1546        assert_eq!(k, LogKind::IndexRebuild);
1547        assert_eq!(k.as_str(), "index-rebuild");
1548    }
1549
1550    #[test]
1551    fn parse_header_rejects_non_headers() {
1552        assert!(Log::parse_header("Not a header").is_none());
1553        assert!(Log::parse_header("# Curator log").is_none());
1554        assert!(Log::parse_header("## [garbage] ingest | x").is_none());
1555        assert!(Log::parse_header("## [2026-05-27 10:00]").is_none()); // no kind
1556                                                                       // A bracketed but non-timestamp date must be rejected (LOG_BAD_TIMESTAMP territory).
1557        assert!(Log::parse_header("## [2026-13-40 99:99] ingest | x").is_none());
1558    }
1559
1560    // ── kind round-trip ───────────────────────────────────────────────────────
1561
1562    #[test]
1563    fn kind_as_str_parse_roundtrip_for_all_recognized() {
1564        for k in [
1565            LogKind::Ingest,
1566            LogKind::Create,
1567            LogKind::Update,
1568            LogKind::Delete,
1569            LogKind::Rename,
1570            LogKind::Link,
1571            LogKind::Validate,
1572            LogKind::IndexRebuild,
1573            LogKind::Contradiction,
1574        ] {
1575            assert_eq!(LogKind::parse(k.as_str()), k);
1576            assert!(k.is_recognized());
1577        }
1578    }
1579
1580    // ── append: creation + frontmatter ───────────────────────────────────────
1581
1582    #[test]
1583    fn append_creates_log_with_frontmatter_and_entry() {
1584        let (_d, store) = temp_store();
1585        let e = entry(
1586            2026,
1587            5,
1588            27,
1589            10,
1590            0,
1591            LogKind::Ingest,
1592            Some("sources/emails/x.eml"),
1593            "Email received.",
1594        );
1595        Log::append(&store, &e).unwrap();
1596
1597        let content = fs::read_to_string(store.root.join("log.md")).unwrap();
1598        // type: log frontmatter present.
1599        assert!(
1600            content.starts_with("---\ntype: log\n---\n"),
1601            "missing log frontmatter; got:\n{content}"
1602        );
1603        // The entry header is rendered verbatim.
1604        assert!(content.contains("## [2026-05-27 10:00] ingest | sources/emails/x.eml"));
1605        assert!(content.contains("Email received."));
1606        // No archive dir created when nothing rotates.
1607        assert!(!store.root.join("log").exists());
1608    }
1609
1610    // ── append → tail → since round-trip ─────────────────────────────────────
1611
1612    #[test]
1613    fn append_tail_since_roundtrip() {
1614        let (_d, store) = temp_store();
1615        let e1 = entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "first");
1616        let e2 = entry(2026, 5, 27, 10, 5, LogKind::Create, Some("b"), "second");
1617        let e3 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "third");
1618        Log::append(&store, &e1).unwrap();
1619        Log::append(&store, &e2).unwrap();
1620        Log::append(&store, &e3).unwrap();
1621
1622        // tail(2) returns the two newest, in chronological order.
1623        let tail = Log::tail(&store, 2).unwrap();
1624        assert_eq!(tail.len(), 2);
1625        assert_eq!(tail[0], e2);
1626        assert_eq!(tail[1], e3);
1627
1628        // tail(n) larger than the log returns everything, chronologically.
1629        let all = Log::tail(&store, 99).unwrap();
1630        assert_eq!(all, vec![e1.clone(), e2.clone(), e3.clone()]);
1631
1632        // since(10:05) returns strictly-newer entries (excludes the 10:05 one).
1633        let since = Log::since(&store, ts(2026, 5, 27, 10, 5)).unwrap();
1634        assert_eq!(since, vec![e3.clone()]);
1635
1636        // since before everything returns all.
1637        let since_all = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
1638        assert_eq!(since_all, vec![e1, e2, e3]);
1639    }
1640
1641    #[test]
1642    fn tail_zero_is_empty() {
1643        let (_d, store) = temp_store();
1644        Log::append(
1645            &store,
1646            &entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "x"),
1647        )
1648        .unwrap();
1649        assert!(Log::tail(&store, 0).unwrap().is_empty());
1650    }
1651
1652    #[test]
1653    fn tail_and_since_on_missing_log_are_empty() {
1654        let (_d, store) = temp_store();
1655        assert!(Log::tail(&store, 5).unwrap().is_empty());
1656        assert!(Log::since(&store, ts(2000, 1, 1, 0, 0)).unwrap().is_empty());
1657        assert!(Log::last_validate_at(&store).unwrap().is_none());
1658    }
1659
1660    #[test]
1661    fn since_exact_timestamp_is_exclusive() {
1662        let (_d, store) = temp_store();
1663        let e = entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "PASS");
1664        Log::append(&store, &e).unwrap();
1665        // Equal timestamp must NOT be included (strictly newer).
1666        assert!(Log::since(&store, ts(2026, 5, 27, 10, 0))
1667            .unwrap()
1668            .is_empty());
1669    }
1670
1671    // ── since: out-of-order on disk (append-only correction / merge=union) ────
1672
1673    /// Write a `log.md` at the store root from `entries` in the EXACT given
1674    /// physical order, with the standard `type: log` frontmatter. Unlike
1675    /// [`Log::append`] (which always lands the newest entry at EOF), this lets a
1676    /// test author the non-monotonic on-disk shape the SPEC permits — a
1677    /// backdated corrective entry below the entry it corrects, or a
1678    /// `merge=union` interleave.
1679    fn write_raw_log(store: &Store, entries: &[LogEntry]) {
1680        let mut content = String::from(LOG_FRONTMATTER);
1681        content.push('\n');
1682        for e in entries {
1683            content.push_str(&e.render());
1684        }
1685        fs::write(store.root.join("log.md"), content).expect("write raw log.md");
1686    }
1687
1688    #[test]
1689    fn since_returns_newer_entries_even_when_disk_order_is_non_monotonic() {
1690        // The demonstrated regression: a curator appended a backdated CORRECTIVE
1691        // entry (10:00) below newer entries (10:10, 10:05), so the physical
1692        // on-disk order is 10:10, 10:05, 10:00 — newest-first, not chronological.
1693        // The append-only SPEC explicitly permits this ("append a corrective
1694        // entry below it"; out-of-order is only LOG_OUT_OF_ORDER, a warning).
1695        let (_d, store) = temp_store();
1696        let e_1010 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "newest");
1697        let e_1005 = entry(2026, 5, 27, 10, 5, LogKind::Create, Some("b"), "middle");
1698        let e_1000 = entry(
1699            2026,
1700            5,
1701            27,
1702            10,
1703            0,
1704            LogKind::Update,
1705            Some("a"),
1706            "backdated fix",
1707        );
1708        // Physical order on disk: 10:10, 10:05, then the backdated 10:00 LAST.
1709        write_raw_log(&store, &[e_1010, e_1005, e_1000]);
1710
1711        // since 10:02 must return BOTH entries strictly newer than 10:02
1712        // (10:05 and 10:10). The old early-stop hit the physically-last 10:00
1713        // entry (<= 10:02), stopped, and returned EMPTY — silently dropping the
1714        // two newer entries that sit earlier in the file.
1715        let got = Log::since(&store, ts(2026, 5, 27, 10, 2)).unwrap();
1716        let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
1717        assert_eq!(
1718            stamps,
1719            [ts(2026, 5, 27, 10, 5), ts(2026, 5, 27, 10, 10)]
1720                .into_iter()
1721                .collect(),
1722            "since(10:02) must include both 10:05 and 10:10 despite the backdated \
1723             10:00 entry sitting physically last, and exclude 10:00; got {got:?}"
1724        );
1725
1726        // A cutoff before everything still returns all three, regardless of the
1727        // scrambled disk order.
1728        let all = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
1729        let all_stamps: std::collections::BTreeSet<_> = all.iter().map(|e| e.timestamp).collect();
1730        assert_eq!(
1731            all_stamps,
1732            [
1733                ts(2026, 5, 27, 10, 0),
1734                ts(2026, 5, 27, 10, 5),
1735                ts(2026, 5, 27, 10, 10),
1736            ]
1737            .into_iter()
1738            .collect()
1739        );
1740    }
1741
1742    #[test]
1743    fn since_crosses_archive_when_newer_entry_is_out_of_order_inside_it() {
1744        // Out-of-order INSIDE an archive month, with the cutoff landing in that
1745        // month. The April archive is authored newest-physical-first (04-20,
1746        // then a backdated 04-05 last); a naive early-stop on the first
1747        // older-than-cutoff entry would miss the later April entry. The active
1748        // file holds a clean May entry. Cutoff = mid-April.
1749        let (_d, store) = temp_store();
1750
1751        // Active file: one current-month (May) entry.
1752        let may = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1753        write_raw_log(&store, &[may]);
1754
1755        // April archive authored out of order: 04-20 first, backdated 04-05 last.
1756        let apr_late = entry(
1757            2026,
1758            4,
1759            20,
1760            9,
1761            0,
1762            LogKind::Create,
1763            Some("apr-b"),
1764            "apr-late",
1765        );
1766        let apr_early = entry(
1767            2026,
1768            4,
1769            5,
1770            9,
1771            0,
1772            LogKind::Ingest,
1773            Some("apr-a"),
1774            "apr-early",
1775        );
1776        let dir = store.root.join("log");
1777        store.create_dir_all(&dir).unwrap();
1778        let mut arch = String::from(LOG_FRONTMATTER);
1779        arch.push('\n');
1780        arch.push_str(&apr_late.render());
1781        arch.push_str(&apr_early.render());
1782        fs::write(dir.join("2026-04.md"), arch).unwrap();
1783
1784        // since mid-April: the later April entry (04-20) AND the May entry must
1785        // come back; the early April entry (04-05) must not.
1786        let got = Log::since(&store, ts(2026, 4, 15, 0, 0)).unwrap();
1787        let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
1788        assert_eq!(
1789            stamps,
1790            [ts(2026, 4, 20, 9, 0), ts(2026, 5, 2, 8, 0)]
1791                .into_iter()
1792                .collect(),
1793            "since(mid-April) must include the out-of-order later April entry \
1794             and the May entry, and exclude the earlier April entry; got {got:?}"
1795        );
1796    }
1797
1798    // ── multi-line notes ──────────────────────────────────────────────────────
1799
1800    #[test]
1801    fn multiline_note_is_preserved() {
1802        let (_d, store) = temp_store();
1803        let e = entry(
1804            2026,
1805            5,
1806            27,
1807            10,
1808            0,
1809            LogKind::Create,
1810            Some("records/x"),
1811            "Line one.\nLine two.\nLine three.",
1812        );
1813        Log::append(&store, &e).unwrap();
1814        let got = Log::tail(&store, 1).unwrap();
1815        assert_eq!(got[0].note, "Line one.\nLine two.\nLine three.");
1816    }
1817
1818    #[test]
1819    fn empty_note_roundtrips_as_empty() {
1820        let (_d, store) = temp_store();
1821        let e = entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "");
1822        Log::append(&store, &e).unwrap();
1823        let got = Log::tail(&store, 1).unwrap();
1824        assert_eq!(got[0], e);
1825        assert_eq!(got[0].note, "");
1826    }
1827
1828    // ── last_validate_at ─────────────────────────────────────────────────────
1829
1830    #[test]
1831    fn last_validate_at_finds_most_recent_validate() {
1832        let (_d, store) = temp_store();
1833        Log::append(
1834            &store,
1835            &entry(2026, 5, 27, 10, 0, LogKind::Validate, None, "first pass"),
1836        )
1837        .unwrap();
1838        Log::append(
1839            &store,
1840            &entry(2026, 5, 27, 10, 5, LogKind::Create, Some("a"), "made a"),
1841        )
1842        .unwrap();
1843        Log::append(
1844            &store,
1845            &entry(2026, 5, 27, 10, 10, LogKind::Validate, None, "second pass"),
1846        )
1847        .unwrap();
1848        Log::append(
1849            &store,
1850            &entry(2026, 5, 27, 10, 15, LogKind::Update, Some("a"), "edit a"),
1851        )
1852        .unwrap();
1853
1854        let last = Log::last_validate_at(&store).unwrap();
1855        assert_eq!(last, Some(ts(2026, 5, 27, 10, 10)));
1856    }
1857
1858    #[test]
1859    fn last_validate_at_none_when_no_validate() {
1860        let (_d, store) = temp_store();
1861        Log::append(
1862            &store,
1863            &entry(2026, 5, 27, 10, 0, LogKind::Create, Some("a"), "x"),
1864        )
1865        .unwrap();
1866        assert_eq!(Log::last_validate_at(&store).unwrap(), None);
1867    }
1868
1869    // ── month-boundary rotation ──────────────────────────────────────────────
1870
1871    #[test]
1872    fn rotation_rolls_prior_months_into_archives() {
1873        let (_d, store) = temp_store();
1874        // Two April entries and one May entry, all written while "current" was
1875        // their own month (append-only chronological order).
1876        let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
1877        let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
1878        Log::append(&store, &a1).unwrap();
1879        Log::append(&store, &a2).unwrap();
1880
1881        // Before rotation: no archive dir, both April entries in active.
1882        assert!(!store.root.join("log").exists());
1883
1884        // Appending a May entry must roll April into log/2026-04.md.
1885        let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may one");
1886        Log::append(&store, &m1).unwrap();
1887
1888        // Archive exists and holds both April entries with frontmatter.
1889        let arch_path = store.root.join("log").join("2026-04.md");
1890        assert!(arch_path.exists(), "expected April archive to be created");
1891        let arch = fs::read_to_string(&arch_path).unwrap();
1892        assert!(arch.starts_with("---\ntype: log\n---\n"));
1893        assert!(arch.contains("## [2026-04-10 09:00] ingest | apr-a"));
1894        assert!(arch.contains("## [2026-04-20 09:00] create | apr-b"));
1895        assert!(arch.contains("apr one"));
1896        assert!(arch.contains("apr two"));
1897
1898        // Active file now holds ONLY the May entry (no April entries).
1899        let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1900        assert!(active.contains("## [2026-05-02 08:00] update | may-a"));
1901        assert!(
1902            !active.contains("apr-a") && !active.contains("apr-b"),
1903            "April entries must be gone from the active file; got:\n{active}"
1904        );
1905
1906        // The full timeline (archives ++ active) is intact and chronological.
1907        let all = Log::tail(&store, 99).unwrap();
1908        assert_eq!(all, vec![a1, a2, m1]);
1909    }
1910
1911    #[test]
1912    fn rotation_groups_distinct_prior_months_into_separate_archives() {
1913        let (_d, store) = temp_store();
1914        // March + April entries accumulate, then a May append rolls BOTH prior
1915        // months into their own archive files.
1916        let mar = entry(2026, 3, 5, 9, 0, LogKind::Ingest, Some("mar"), "march");
1917        let apr = entry(2026, 4, 5, 9, 0, LogKind::Create, Some("apr"), "april");
1918        Log::append(&store, &mar).unwrap();
1919        Log::append(&store, &apr).unwrap();
1920        // At this point April is current, March already rolled into its archive.
1921        assert!(store.root.join("log").join("2026-03.md").exists());
1922
1923        let may = entry(2026, 5, 5, 9, 0, LogKind::Update, Some("may"), "may");
1924        Log::append(&store, &may).unwrap();
1925
1926        assert!(store.root.join("log").join("2026-03.md").exists());
1927        assert!(store.root.join("log").join("2026-04.md").exists());
1928
1929        // Each archive holds only its own month.
1930        let mar_arch = fs::read_to_string(store.root.join("log").join("2026-03.md")).unwrap();
1931        let apr_arch = fs::read_to_string(store.root.join("log").join("2026-04.md")).unwrap();
1932        assert!(mar_arch.contains("mar") && !mar_arch.contains("apr"));
1933        assert!(apr_arch.contains("apr") && !apr_arch.contains("mar"));
1934
1935        // Active holds only May.
1936        let active = fs::read_to_string(store.root.join("log.md")).unwrap();
1937        assert!(active.contains("may") && !active.contains("mar") && !active.contains("apr"));
1938
1939        // Timeline intact and ordered across both archives + active.
1940        let all = Log::tail(&store, 99).unwrap();
1941        assert_eq!(all, vec![mar, apr, may]);
1942    }
1943
1944    #[test]
1945    fn tail_crosses_into_archive_when_n_spans_month_boundary() {
1946        let (_d, store) = temp_store();
1947        let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr1");
1948        let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr2");
1949        let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1950        let m2 = entry(2026, 5, 3, 8, 0, LogKind::Update, Some("may-b"), "may2");
1951        for e in [&a1, &a2, &m1, &m2] {
1952            Log::append(&store, e).unwrap();
1953        }
1954        // April is now archived; active holds only May. tail(3) must reach back
1955        // into the archive for the third-newest entry.
1956        let tail3 = Log::tail(&store, 3).unwrap();
1957        assert_eq!(tail3, vec![a2.clone(), m1.clone(), m2.clone()]);
1958
1959        // tail within the active month does NOT need the archive but is still
1960        // correct.
1961        let tail2 = Log::tail(&store, 2).unwrap();
1962        assert_eq!(tail2, vec![m1, m2]);
1963    }
1964
1965    #[test]
1966    fn since_crosses_into_archive_and_early_stops() {
1967        let (_d, store) = temp_store();
1968        let a1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr1");
1969        let a2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr2");
1970        let m1 = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may1");
1971        for e in [&a1, &a2, &m1] {
1972            Log::append(&store, e).unwrap();
1973        }
1974        // since a mid-April time: must include the later April entry (from the
1975        // archive) and the May entry, but not the earlier April one.
1976        let got = Log::since(&store, ts(2026, 4, 15, 0, 0)).unwrap();
1977        assert_eq!(got, vec![a2, m1]);
1978    }
1979
1980    #[test]
1981    fn last_validate_at_crosses_into_archive() {
1982        let (_d, store) = temp_store();
1983        // A validate in April, then non-validate work that rolls April away.
1984        Log::append(
1985            &store,
1986            &entry(2026, 4, 10, 9, 0, LogKind::Validate, None, "apr validate"),
1987        )
1988        .unwrap();
1989        Log::append(
1990            &store,
1991            &entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may work"),
1992        )
1993        .unwrap();
1994        // Active has only the May update; the most-recent validate lives in the
1995        // April archive and must still be found.
1996        let last = Log::last_validate_at(&store).unwrap();
1997        assert_eq!(last, Some(ts(2026, 4, 10, 9, 0)));
1998    }
1999
2000    // ── reverse-read correctness on a large (multi-block) log ────────────────
2001
2002    #[test]
2003    fn reverse_read_correct_on_large_single_month_log() {
2004        let (_d, store) = temp_store();
2005        // Append many same-month entries with chunky multi-line notes so the
2006        // file spans well past one REVERSE_BLOCK (8 KiB). Timestamps are
2007        // strictly increasing (a real append-only log is monotonic): each entry
2008        // is 3 minutes after the previous, all within June, so physical order
2009        // equals chronological order and the last-k-physical ARE the k-newest.
2010        let n = 400usize;
2011        let mut expected: Vec<LogEntry> = Vec::new();
2012        for i in 0..n {
2013            let total_min = (i as u32) * 3;
2014            let day = 1 + total_min / (24 * 60);
2015            let hour = (total_min / 60) % 24;
2016            let min = total_min % 60;
2017            // Unique, multi-line note to bulk up the file and detect mis-parses.
2018            let note = format!(
2019                "entry number {i}\nbody line A for {i}\nbody line B for {i} with padding {}",
2020                "x".repeat(40)
2021            );
2022            let e = entry(
2023                2026,
2024                6,
2025                day,
2026                hour,
2027                min,
2028                LogKind::Update,
2029                Some(&format!("records/item-{i:04}")),
2030                &note,
2031            );
2032            Log::append(&store, &e).unwrap();
2033            expected.push(e);
2034        }
2035
2036        // File must actually be multi-block to exercise the backward reader.
2037        let size = fs::metadata(store.root.join("log.md")).unwrap().len();
2038        assert!(
2039            size > (REVERSE_BLOCK as u64) * 2,
2040            "test log not large enough ({size} bytes) to exercise multi-block reverse-read"
2041        );
2042
2043        // tail(5) must equal the 5 newest, exactly.
2044        let tail5 = Log::tail(&store, 5).unwrap();
2045        assert_eq!(tail5, expected[n - 5..].to_vec());
2046
2047        // tail(50) must equal the 50 newest.
2048        let tail50 = Log::tail(&store, 50).unwrap();
2049        assert_eq!(tail50, expected[n - 50..].to_vec());
2050
2051        // tail(all) must reconstruct the whole timeline in order.
2052        let all = Log::tail(&store, n + 10).unwrap();
2053        assert_eq!(all.len(), n);
2054        assert_eq!(all, expected);
2055    }
2056
2057    // ── tail on OUT-OF-ORDER logs (newest-by-timestamp, not last-physical) ────
2058    //
2059    // The append-only contract is non-decreasing time order, but it's only a
2060    // `LOG_OUT_OF_ORDER` warning when violated (corrective entries land below
2061    // the entry they correct; backdated / clock-skewed writes; `merge=union`
2062    // clone merges). `tail N` must return the N newest *by timestamp*, never the
2063    // last N *physical* entries.
2064
2065    /// Write `log.md` verbatim from rendered entries in the given **physical
2066    /// (file) order**, bypassing `Log::append` so the test controls on-disk
2067    /// order exactly (append never reorders within a month, but this is the
2068    /// clearest way to pin a specific physical layout).
2069    fn write_log_physical(store: &Store, entries: &[LogEntry]) {
2070        let mut body = String::new();
2071        for e in entries {
2072            body.push_str(&e.render());
2073        }
2074        let full = compose_active(LOG_FRONTMATTER, &body);
2075        fs::write(store.root.join("log.md"), full).expect("write log.md");
2076    }
2077
2078    #[test]
2079    fn tail_returns_newest_by_timestamp_on_demonstrated_out_of_order_log() {
2080        // The exact case from the review finding: physical order 10:10, 10:05,
2081        // 10:00 (a backdated entry tail). The OLD code returned the last two
2082        // physical entries {10:05, 10:00}; the correct answer is the two newest
2083        // by time {10:05, 10:10}.
2084        let (_d, store) = temp_store();
2085        let e_1010 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("c"), "ten-ten");
2086        let e_1005 = entry(
2087            2026,
2088            5,
2089            27,
2090            10,
2091            5,
2092            LogKind::Create,
2093            Some("b"),
2094            "ten-oh-five",
2095        );
2096        let e_1000 = entry(2026, 5, 27, 10, 0, LogKind::Ingest, Some("a"), "ten-oh-oh");
2097        // Physical order: newest first, then the two older ones — out of order.
2098        write_log_physical(&store, &[e_1010.clone(), e_1005.clone(), e_1000.clone()]);
2099
2100        let tail2 = Log::tail(&store, 2).unwrap();
2101        assert_eq!(
2102            tail2,
2103            vec![e_1005.clone(), e_1010.clone()],
2104            "tail(2) must be the two NEWEST by timestamp (chronological), \
2105             not the last two physical entries"
2106        );
2107        // The newest entry must be present and the oldest absent.
2108        assert!(tail2.contains(&e_1010), "newest (10:10) must be included");
2109        assert!(!tail2.contains(&e_1000), "oldest (10:00) must be excluded");
2110
2111        // tail(1) is just the single newest.
2112        assert_eq!(Log::tail(&store, 1).unwrap(), vec![e_1010.clone()]);
2113        // tail(all) is the full set in chronological order.
2114        assert_eq!(Log::tail(&store, 99).unwrap(), vec![e_1000, e_1005, e_1010]);
2115    }
2116
2117    #[test]
2118    fn tail_no_early_stop_when_newer_entry_sits_before_an_older_one() {
2119        // Guards the unsound within-file early stop: a newer entry (10:50) sits
2120        // PHYSICALLY BEFORE a much older one (10:00). Reading newest-physical-
2121        // first, the scan meets 10:00 before 10:50; any "stop at the first entry
2122        // below the window minimum" rule would bail and drop 10:50.
2123        //
2124        // Physical (top→bottom): 10:55, 10:10, 10:50, 10:00.
2125        // Reverse-scan order:     10:00, 10:50, 10:10, 10:55.
2126        let (_d, store) = temp_store();
2127        let e55 = entry(2026, 5, 27, 10, 55, LogKind::Update, Some("x55"), "55");
2128        let e10 = entry(2026, 5, 27, 10, 10, LogKind::Update, Some("x10"), "10");
2129        let e50 = entry(2026, 5, 27, 10, 50, LogKind::Update, Some("x50"), "50");
2130        let e00 = entry(2026, 5, 27, 10, 0, LogKind::Update, Some("x00"), "00");
2131        write_log_physical(
2132            &store,
2133            &[e55.clone(), e10.clone(), e50.clone(), e00.clone()],
2134        );
2135
2136        // The two newest by timestamp are 10:55 and 10:50 — NOT the early-stop
2137        // victim 10:10, and NOT the last-physical 10:00.
2138        let tail2 = Log::tail(&store, 2).unwrap();
2139        assert_eq!(tail2, vec![e50.clone(), e55.clone()]);
2140
2141        let tail3 = Log::tail(&store, 3).unwrap();
2142        assert_eq!(tail3, vec![e10.clone(), e50.clone(), e55.clone()]);
2143    }
2144
2145    #[test]
2146    fn tail_orders_equal_timestamps_by_physical_recency() {
2147        // Three entries share 10:00; one is at 09:59. tail(2) must keep both
2148        // 10:00 entries, and among the equal pair the one appended LATER
2149        // (physically last) sorts last ("newest" = most-recently recorded).
2150        let (_d, store) = temp_store();
2151        let early = entry(2026, 5, 27, 9, 59, LogKind::Create, Some("early"), "before");
2152        let tie_a = entry(
2153            2026,
2154            5,
2155            27,
2156            10,
2157            0,
2158            LogKind::Update,
2159            Some("tie-a"),
2160            "first 10:00",
2161        );
2162        let tie_b = entry(
2163            2026,
2164            5,
2165            27,
2166            10,
2167            0,
2168            LogKind::Update,
2169            Some("tie-b"),
2170            "second 10:00",
2171        );
2172        // Physical append order: early, tie_a, tie_b.
2173        write_log_physical(&store, &[early.clone(), tie_a.clone(), tie_b.clone()]);
2174
2175        let tail2 = Log::tail(&store, 2).unwrap();
2176        assert_eq!(
2177            tail2,
2178            vec![tie_a.clone(), tie_b.clone()],
2179            "both 10:00 entries kept, physically-later one (tie_b) last; 09:59 dropped"
2180        );
2181        // tail(1) keeps only the most-recently-recorded of the equal pair.
2182        assert_eq!(Log::tail(&store, 1).unwrap(), vec![tie_b]);
2183    }
2184
2185    #[test]
2186    fn tail_finds_newest_across_a_backdated_entry_spanning_the_month_boundary() {
2187        // A backdated entry can land physically after newer entries even across
2188        // a rotation: append May entries, then a June entry (rolls May to its
2189        // archive), then append a May-dated correction — it goes into the ACTIVE
2190        // file, physically after June. tail must still rank by timestamp, so the
2191        // June entry stays newest and the backdated May entry is not mistaken
2192        // for the tail.
2193        let (_d, store) = temp_store();
2194        let may1 = entry(2026, 5, 10, 9, 0, LogKind::Ingest, Some("may-1"), "may one");
2195        let may2 = entry(2026, 5, 20, 9, 0, LogKind::Create, Some("may-2"), "may two");
2196        let jun1 = entry(2026, 6, 2, 8, 0, LogKind::Update, Some("jun-1"), "jun one");
2197        Log::append(&store, &may1).unwrap();
2198        Log::append(&store, &may2).unwrap();
2199        Log::append(&store, &jun1).unwrap(); // rotates May -> log/2026-05.md
2200        assert!(store.root.join("log").join("2026-05.md").exists());
2201
2202        // A backdated May correction, appended now: it lands in the active file
2203        // (its month May is not strictly before the active month June), so the
2204        // active file is physically [jun1, may_corr] — out of order.
2205        let may_corr = entry(
2206            2026,
2207            5,
2208            25,
2209            9,
2210            0,
2211            LogKind::Update,
2212            Some("may-2"),
2213            "may correction",
2214        );
2215        Log::append(&store, &may_corr).unwrap();
2216        let active = fs::read_to_string(store.root.join("log.md")).unwrap();
2217        assert!(
2218            active.contains("jun-1") && active.contains("may correction"),
2219            "backdated May entry should be in the active file alongside June; got:\n{active}"
2220        );
2221
2222        // The single newest by timestamp is the June entry, even though the
2223        // backdated May entry is physically last.
2224        assert_eq!(Log::tail(&store, 1).unwrap(), vec![jun1.clone()]);
2225
2226        // tail(2): the two newest by time are may_corr (05-25) and jun1 (06-02).
2227        let tail2 = Log::tail(&store, 2).unwrap();
2228        assert_eq!(tail2, vec![may_corr.clone(), jun1.clone()]);
2229
2230        // tail(3) must reach into the May archive for the third-newest (may2,
2231        // 05-20), proving archive crossing still works on an out-of-order store.
2232        let tail3 = Log::tail(&store, 3).unwrap();
2233        assert_eq!(tail3, vec![may2.clone(), may_corr.clone(), jun1.clone()]);
2234
2235        // tail(all) reconstructs the whole timeline in chronological order.
2236        let all = Log::tail(&store, 99).unwrap();
2237        assert_eq!(all, vec![may1, may2, may_corr, jun1]);
2238    }
2239
2240    #[test]
2241    fn parse_entries_skips_unparseable_header_folding_into_body() {
2242        // A `## [` line that is NOT a valid header should not start a new entry;
2243        // it folds into the preceding entry's note. This guards the
2244        // parse_entries header-validation branch.
2245        let text = "\
2246## [2026-05-27 10:00] create | records/x
2247Body mentions a literal: ## [not a real header here]
2248More body.
2249
2250## [2026-05-27 10:05] update | records/y
2251Second.
2252";
2253        let entries = parse_entries(text);
2254        assert_eq!(entries.len(), 2);
2255        assert_eq!(entries[0].kind, LogKind::Create);
2256        assert!(entries[0].note.contains("## [not a real header here]"));
2257        assert!(entries[0].note.contains("More body."));
2258        assert_eq!(entries[1].kind, LogKind::Update);
2259        assert_eq!(entries[1].note, "Second.");
2260    }
2261
2262    // ── append-only: corrective entries go on the end ─────────────────────────
2263
2264    #[test]
2265    fn append_only_corrective_entry_goes_on_end_without_rewriting() {
2266        let (_d, store) = temp_store();
2267        let original = entry(
2268            2026,
2269            5,
2270            27,
2271            10,
2272            0,
2273            LogKind::Update,
2274            Some("records/northstar"),
2275            "Seat count 120 -> 175.",
2276        );
2277        Log::append(&store, &original).unwrap();
2278        let after_first = fs::read_to_string(store.root.join("log.md")).unwrap();
2279
2280        // A correction is a NEW entry appended on the end; the original text is
2281        // left byte-for-byte intact (append-only contract: no rewrite API).
2282        let correction = entry(
2283            2026,
2284            5,
2285            27,
2286            11,
2287            0,
2288            LogKind::Update,
2289            Some("records/northstar"),
2290            "Correction: seat count is 165, not 175.",
2291        );
2292        Log::append(&store, &correction).unwrap();
2293        let after_second = fs::read_to_string(store.root.join("log.md")).unwrap();
2294
2295        assert!(
2296            after_second.starts_with(&after_first),
2297            "appending must not rewrite earlier bytes"
2298        );
2299        assert!(after_second.contains("Correction: seat count is 165, not 175."));
2300
2301        // Both entries are readable, in order.
2302        let all = Log::tail(&store, 99).unwrap();
2303        assert_eq!(all, vec![original, correction]);
2304    }
2305
2306    // ── concurrent append safety (atomic via temp-file rename) ────────────────
2307
2308    #[test]
2309    fn concurrent_appends_are_atomic_and_total() {
2310        use std::sync::{Arc, Barrier};
2311        use std::thread;
2312
2313        let (_d, store) = temp_store();
2314        // Seed the file so all threads take the read-modify-write path.
2315        Log::append(
2316            &store,
2317            &entry(2026, 7, 1, 0, 0, LogKind::Create, Some("seed"), "seed"),
2318        )
2319        .unwrap();
2320
2321        let threads = 8usize;
2322        let per = 25usize;
2323        let barrier = Arc::new(Barrier::new(threads));
2324        let store = Arc::new(store);
2325
2326        let mut handles = Vec::new();
2327        for tnum in 0..threads {
2328            let b = Arc::clone(&barrier);
2329            let s = Arc::clone(&store);
2330            handles.push(thread::spawn(move || {
2331                b.wait();
2332                for i in 0..per {
2333                    let e = entry(
2334                        2026,
2335                        7,
2336                        1,
2337                        (tnum % 24) as u32,
2338                        (i % 60) as u32,
2339                        LogKind::Update,
2340                        Some(&format!("t{tnum}-i{i}")),
2341                        &format!("thread {tnum} item {i}"),
2342                    );
2343                    Log::append(&s, &e).unwrap();
2344                }
2345            }));
2346        }
2347        for h in handles {
2348            h.join().unwrap();
2349        }
2350
2351        // The atomic temp-file-rename write means no append truncates or
2352        // corrupts another: the file must remain parseable and every line of
2353        // every entry header must be well-formed. Crucially, no entry should be
2354        // lost to a torn write of the *content already on disk* — though
2355        // interleaved read-modify-write WILL drop some appends (last-writer-
2356        // wins on the snapshot). We therefore assert integrity + that the file
2357        // never went empty / corrupt, not an exact count.
2358        let content = fs::read_to_string(store.root.join("log.md")).unwrap();
2359        assert!(content.starts_with("---\ntype: log\n---\n"));
2360
2361        // Every `## [` line must parse as a valid header (no half-written line).
2362        for line in content.lines() {
2363            if line.starts_with("## [") {
2364                assert!(
2365                    Log::parse_header(line).is_some(),
2366                    "corrupt/torn header line on disk: {line:?}"
2367                );
2368            }
2369        }
2370
2371        // The seed entry must survive (it was written before the race and
2372        // every snapshot included it).
2373        assert!(content.contains("## [2026-07-01 00:00] create | seed"));
2374
2375        // The reverse reader must still produce a clean, fully-parseable view.
2376        let all = Log::tail(&store, 10_000).unwrap();
2377        assert!(!all.is_empty());
2378        // No duplicate adjacent identical headers from a torn write: every
2379        // returned entry must have a recognized-or-custom kind and a parseable
2380        // timestamp (already guaranteed by parse), and the list must be
2381        // internally consistent (re-render → re-parse identity for each).
2382        for e in &all {
2383            let rendered = e.render();
2384            let reparsed = parse_single_entry(&rendered).unwrap();
2385            assert_eq!(&reparsed, e);
2386        }
2387    }
2388
2389    // ── render/parse identity ────────────────────────────────────────────────
2390
2391    #[test]
2392    fn render_then_parse_is_identity() {
2393        let cases = vec![
2394            entry(
2395                2026,
2396                1,
2397                2,
2398                3,
2399                4,
2400                LogKind::Ingest,
2401                Some("sources/a.eml"),
2402                "n",
2403            ),
2404            entry(
2405                2026,
2406                12,
2407                31,
2408                23,
2409                59,
2410                LogKind::Validate,
2411                None,
2412                "PASS - 0 errors",
2413            ),
2414            entry(
2415                2026,
2416                6,
2417                15,
2418                12,
2419                30,
2420                LogKind::Custom("proposal".to_string()),
2421                Some("records/p"),
2422                "multi\nline\nnote",
2423            ),
2424            entry(2026, 6, 15, 12, 30, LogKind::Contradiction, Some("obj"), ""),
2425        ];
2426        for e in cases {
2427            let rendered = e.render();
2428            let parsed = parse_single_entry(&rendered).unwrap_or_else(|| {
2429                panic!("failed to reparse rendered entry:\n{rendered}");
2430            });
2431            assert_eq!(parsed, e, "round-trip mismatch for {e:?}");
2432        }
2433    }
2434
2435    // ── regression: rotation re-roll must not duplicate archive entries (#3) ──
2436
2437    /// Count occurrences of `needle` in `haystack` (non-overlapping).
2438    fn count_occurrences(haystack: &str, needle: &str) -> usize {
2439        haystack.matches(needle).count()
2440    }
2441
2442    #[test]
2443    fn regression_archive_reroll_is_idempotent_after_interrupted_rotation() {
2444        // Reconstructs the finding's exact failure window: rotation is two
2445        // non-atomic durable writes — (1) roll prior-month entries into the
2446        // archive, then (2) trim the active file. If the process crashes or the
2447        // active rewrite errors AFTER step (1) commits, the prior-month entries
2448        // stay in the untrimmed active file, the agent retries, and the retry
2449        // re-rolls the SAME entries into the archive a second time. The
2450        // mechanism is precisely a second `append_to_archive` of identical
2451        // entries onto an archive that already holds them.
2452        let (_d, store) = temp_store();
2453        let dir = archive_dir(&store);
2454        let arch = archive_path(&store, 2026, 4);
2455
2456        let apr1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
2457        let apr2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
2458        let month = [apr1.clone(), apr2.clone()];
2459
2460        // First roll: a FRESH rotation (no in-progress marker) appends both.
2461        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
2462        append_to_archive(&store, &arch, &month, false).unwrap();
2463
2464        // The retries are crash-RECOVERIES (the in-progress-rotation marker is
2465        // present), so they dedup the re-rolled identical entries to a no-op.
2466        // Pre-fix this blindly concatenated, doubling every entry; do it twice to
2467        // prove the amplification a real retry loop would cause is suppressed.
2468        append_to_archive(&store, &arch, &month, true).unwrap();
2469        append_to_archive(&store, &arch, &month, true).unwrap();
2470
2471        let archived = store.read_text_bounded(&arch, u64::MAX).unwrap();
2472        // Each entry header must appear EXACTLY once despite the re-rolls.
2473        assert_eq!(
2474            count_occurrences(&archived, "## [2026-04-10 09:00] ingest | apr-a"),
2475            1,
2476            "re-rolled archive duplicated the first April entry; got:\n{archived}"
2477        );
2478        assert_eq!(
2479            count_occurrences(&archived, "## [2026-04-20 09:00] create | apr-b"),
2480            1,
2481            "re-rolled archive duplicated the second April entry; got:\n{archived}"
2482        );
2483
2484        // And the reader surface (`since`) must return each entry once, not the
2485        // duplicated set the pre-fix archive would have yielded.
2486        let got = Log::since(&store, ts(2026, 4, 1, 0, 0)).unwrap();
2487        assert_eq!(
2488            got,
2489            vec![apr1, apr2],
2490            "since over the re-rolled archive must return each April entry once"
2491        );
2492    }
2493
2494    #[test]
2495    fn regression_rotation_reroll_after_active_untrimmed_does_not_duplicate() {
2496        // End-to-end variant driving the real `Log::append` rotation path. We
2497        // rotate April into its archive via a May append, then SIMULATE the
2498        // partial failure by restoring the pre-trim active file (April + May)
2499        // and re-running `append` — exactly the state a crash-between-the-two-
2500        // writes / failed-active-rewrite + agent-retry produces. The archive
2501        // must still hold each April entry once.
2502        let (_d, store) = temp_store();
2503        let apr1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
2504        let apr2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
2505        Log::append(&store, &apr1).unwrap();
2506        Log::append(&store, &apr2).unwrap();
2507
2508        // Snapshot the active file holding both April entries (this is what is
2509        // still on disk if the post-rotation active rewrite never lands).
2510        let active_path = test_abs(&store, active_log_path(&store));
2511        let pre_rotation_active = fs::read_to_string(&active_path).unwrap();
2512
2513        // A May append rotates April out and trims the active file.
2514        let may = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may-a"), "may one");
2515        Log::append(&store, &may).unwrap();
2516        let arch = test_abs(&store, archive_path(&store, 2026, 4));
2517        assert!(arch.exists(), "April should have rotated to its archive");
2518
2519        // Simulate the crash/error: the active rewrite never persisted, so the
2520        // active file still contains the (now also archived) April entries.
2521        fs::write(&active_path, &pre_rotation_active).unwrap();
2522        // A real crash leaves the in-progress-rotation marker behind too — it is
2523        // deleted only AFTER the active trim commits. Restore it so the retry is
2524        // recognized as a crash-recovery re-roll (deduped), not a fresh rotation
2525        // (which would correctly append a genuinely-distinct repeat).
2526        fs::write(test_abs(&store, rotation_marker_path(&store)), b"").unwrap();
2527
2528        // The agent retries the append. Re-partitioning sees April as prior
2529        // months again and re-rolls them — which must NOT duplicate the archive.
2530        let may2 = entry(2026, 5, 3, 8, 0, LogKind::Update, Some("may-b"), "may two");
2531        Log::append(&store, &may2).unwrap();
2532
2533        let archived = fs::read_to_string(&arch).unwrap();
2534        assert_eq!(
2535            count_occurrences(&archived, "## [2026-04-10 09:00] ingest | apr-a"),
2536            1,
2537            "retried rotation duplicated an April entry in the archive; got:\n{archived}"
2538        );
2539        assert_eq!(
2540            count_occurrences(&archived, "## [2026-04-20 09:00] create | apr-b"),
2541            1,
2542            "retried rotation duplicated an April entry in the archive; got:\n{archived}"
2543        );
2544    }
2545
2546    /// THE BUG (write side, data-loss). A STALE `.rotating` marker (e.g.
2547    /// committed/synced into a `merge=union` clone after a crash stranded it
2548    /// between the archive write and the active trim) must NOT make a fresh
2549    /// rotation treat a genuinely-distinct same-(minute,kind,object,note) entry
2550    /// as a crash re-roll and silently drop it.
2551    ///
2552    /// On-disk shape (the exact CLI repro): the January archive already holds one
2553    /// `dup` entry (authored independently); the active log holds a February entry
2554    /// AND a SECOND, distinct, byte-identical-at-minute-precision January `dup`
2555    /// (a backdated append that merged in). The stale marker is present. Rotating
2556    /// (a March append) rolls both prior months out. The marker is NOT proof of a
2557    /// re-roll here: February is absent from the archives, so no completed prior
2558    /// rotation of this batch exists. Pre-fix, the global marker flag drove the
2559    /// archive dedup against the ENTIRE existing archive, suppressed the active
2560    /// `dup` against the unrelated pre-existing archive `dup`, wrote nothing to the
2561    /// archive, and still trimmed the active file — so the entry vanished from
2562    /// disk. The archive must end with BOTH `dup` entries.
2563    #[test]
2564    fn regression_stale_marker_does_not_drop_distinct_same_minute_on_fresh_roll() {
2565        let (_d, store) = temp_store();
2566        let dir = archive_dir(&store);
2567        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
2568
2569        // Pre-existing, independently-authored January archive entry.
2570        let jan = entry(2026, 1, 15, 9, 0, LogKind::Create, Some("dup"), "body");
2571        let mut arch = String::from(LOG_FRONTMATTER);
2572        arch.push('\n');
2573        arch.push_str(&jan.render());
2574        fs::write(test_abs(&store, archive_path(&store, 2026, 1)), arch).unwrap();
2575
2576        // Active file: a February entry plus a SECOND, distinct, byte-identical
2577        // January `dup` (backdated, physically alongside February).
2578        let feb = entry(2026, 2, 5, 9, 0, LogKind::Create, Some("feb"), "feb");
2579        write_raw_log(&store, &[feb, jan.clone()]);
2580
2581        // A stale rotation marker lingers (crash stranded it; not gitignored, so
2582        // it can ride into a clone).
2583        fs::write(test_abs(&store, rotation_marker_path(&store)), b"").unwrap();
2584
2585        // A March append rotates January AND February out as a FRESH roll.
2586        let mar = entry(2026, 3, 1, 0, 0, LogKind::Create, Some("mar"), "mar");
2587        Log::append(&store, &mar).unwrap();
2588
2589        // The genuinely-distinct January `dup` must survive: BOTH copies in the
2590        // archive, the entry never lost.
2591        let jan_arch = fs::read_to_string(test_abs(&store, archive_path(&store, 2026, 1))).unwrap();
2592        assert_eq!(
2593            count_occurrences(&jan_arch, "## [2026-01-15 09:00] create | dup"),
2594            2,
2595            "stale marker dropped a distinct same-minute January entry; got:\n{jan_arch}"
2596        );
2597        // February rolled to its own (newly created) archive exactly once.
2598        let feb_arch = fs::read_to_string(test_abs(&store, archive_path(&store, 2026, 2))).unwrap();
2599        assert_eq!(
2600            count_occurrences(&feb_arch, "## [2026-02-05 09:00] create | feb"),
2601            1,
2602            "February did not roll cleanly; got:\n{feb_arch}"
2603        );
2604        // The marker is cleared after the committed rotation.
2605        assert!(
2606            !test_abs(&store, rotation_marker_path(&store)).exists(),
2607            "rotation marker must be cleared after a committed rotation"
2608        );
2609        // The reader agrees: both January `dup`s are visible (no marker now).
2610        let dups = Log::since(&store, ts(2026, 1, 1, 0, 0))
2611            .unwrap()
2612            .into_iter()
2613            .filter(|e| e.object.as_deref() == Some("dup"))
2614            .count();
2615        assert_eq!(dups, 2, "since must report both distinct January dups");
2616    }
2617
2618    /// PRESERVED INVARIANT (write side). A GENUINE interrupted rotation — the
2619    /// whole prior-month roll-out batch is already present in the archives (the
2620    /// archive write committed before the crash), the same entries still sit in
2621    /// the untrimmed active file, and the marker is present — must STILL dedup the
2622    /// re-roll exactly once. This is the case the scoped recovery dedup must keep
2623    /// suppressing; the fix narrows recovery mode to "batch already archived",
2624    /// which this scenario satisfies.
2625    #[test]
2626    fn regression_true_crash_retry_still_dedups_when_whole_batch_already_archived() {
2627        let (_d, store) = temp_store();
2628        let dir = archive_dir(&store);
2629        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
2630
2631        let apr1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
2632        let apr2 = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
2633
2634        // The interrupted rotation already committed both April entries to the
2635        // archive.
2636        let mut arch = String::from(LOG_FRONTMATTER);
2637        arch.push('\n');
2638        arch.push_str(&apr1.render());
2639        arch.push_str(&apr2.render());
2640        fs::write(test_abs(&store, archive_path(&store, 2026, 4)), arch).unwrap();
2641
2642        // The active file still holds the SAME April entries (trim never landed).
2643        write_raw_log(&store, &[apr1.clone(), apr2.clone()]);
2644
2645        // The crash left the in-progress-rotation marker behind.
2646        fs::write(test_abs(&store, rotation_marker_path(&store)), b"").unwrap();
2647
2648        // The agent retries with a May append: April is re-rolled. Because the
2649        // whole April batch is already in the archive, this is a true re-roll and
2650        // must NOT duplicate.
2651        let may = entry(2026, 5, 2, 8, 0, LogKind::Update, Some("may"), "may one");
2652        Log::append(&store, &may).unwrap();
2653
2654        let archived = fs::read_to_string(test_abs(&store, archive_path(&store, 2026, 4))).unwrap();
2655        assert_eq!(
2656            count_occurrences(&archived, "## [2026-04-10 09:00] ingest | apr-a"),
2657            1,
2658            "true crash-retry duplicated the first April entry; got:\n{archived}"
2659        );
2660        assert_eq!(
2661            count_occurrences(&archived, "## [2026-04-20 09:00] create | apr-b"),
2662            1,
2663            "true crash-retry duplicated the second April entry; got:\n{archived}"
2664        );
2665    }
2666
2667    /// Adversarial review (#7) — two GENUINELY-DISTINCT appends that render
2668    /// byte-identically at minute precision (same minute/kind/object/note) must
2669    /// BOTH survive rotation. The backdated-duplicate case: apr1 rotates in May;
2670    /// the backdated apr2 lands in the active file later and rotates in June as a
2671    /// FRESH roll (no in-progress marker), so it must be appended even though the
2672    /// April archive already holds the byte-identical apr1. Pre-fix the
2673    /// set-membership dedup dropped apr2 — silent, unrecoverable audit-log loss.
2674    #[test]
2675    fn regression_distinct_same_minute_entries_both_survive_rotation() {
2676        let (_d, store) = temp_store();
2677        let apr1 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("x"), "dup");
2678        let apr2 = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("x"), "dup");
2679
2680        Log::append(&store, &apr1).unwrap();
2681        // A May append rotates apr1 into the April archive and COMPLETES (no
2682        // marker left behind).
2683        Log::append(
2684            &store,
2685            &entry(2026, 5, 2, 8, 0, LogKind::Ingest, Some("may"), "m"),
2686        )
2687        .unwrap();
2688        // The backdated apr2 lands in the active file beside the May entry.
2689        Log::append(&store, &apr2).unwrap();
2690        // A June append rotates the May entry AND apr2 out. apr2 is a fresh roll.
2691        Log::append(
2692            &store,
2693            &entry(2026, 6, 1, 8, 0, LogKind::Ingest, Some("jun"), "j"),
2694        )
2695        .unwrap();
2696
2697        let archived = fs::read_to_string(test_abs(&store, archive_path(&store, 2026, 4))).unwrap();
2698        assert_eq!(
2699            count_occurrences(&archived, "## [2026-04-10 09:00] ingest | x"),
2700            2,
2701            "two distinct same-minute April appends must BOTH survive rotation; got:\n{archived}"
2702        );
2703        // The reader must return both too (read-dedup must not collapse distinct
2704        // same-minute archive entries).
2705        let got = Log::since(&store, ts(2026, 4, 1, 0, 0)).unwrap();
2706        let dups = got
2707            .iter()
2708            .filter(|e| e.object.as_deref() == Some("x"))
2709            .count();
2710        assert_eq!(
2711            dups, 2,
2712            "since must return both distinct same-minute entries; got {got:#?}"
2713        );
2714    }
2715
2716    /// Adversarial review (#12) — `tail`/`since` must return two byte-identical
2717    /// same-minute entries that both live in the ACTIVE log (no archive). Pre-fix
2718    /// a global content-keyed `seen` set suppressed the second on read, so the
2719    /// reader under-reported what was on disk (`grep` saw 2, `tail` saw 1).
2720    #[test]
2721    fn regression_tail_since_return_distinct_same_minute_active_entries() {
2722        let (_d, store) = temp_store();
2723        Log::append(
2724            &store,
2725            &entry(2026, 6, 10, 9, 0, LogKind::Ingest, Some("x"), "dup"),
2726        )
2727        .unwrap();
2728        Log::append(
2729            &store,
2730            &entry(2026, 6, 10, 9, 0, LogKind::Ingest, Some("x"), "dup"),
2731        )
2732        .unwrap();
2733
2734        let tail = Log::tail(&store, 20).unwrap();
2735        assert_eq!(
2736            tail.len(),
2737            2,
2738            "tail must return both same-minute active entries; got {tail:#?}"
2739        );
2740        let since = Log::since(&store, ts(2026, 6, 1, 0, 0)).unwrap();
2741        assert_eq!(
2742            since.len(),
2743            2,
2744            "since must return both same-minute active entries; got {since:#?}"
2745        );
2746    }
2747
2748    // ── regression: read-side active↔archive dedup must be marker-gated ────────
2749
2750    /// THE BUG (HIGH, data-loss). Two GENUINELY-DISTINCT same-(minute,kind,
2751    /// object,note) entries, one in the active `log.md` and one in its month
2752    /// archive, with NO rotation marker present (normal operation). The write
2753    /// side deliberately preserved both on disk (a backdated append after a
2754    /// completed rotation that merely collides on those minute-precision fields
2755    /// is a distinct real event). Pre-fix the read side deduped the
2756    /// active↔archive overlap UNCONDITIONALLY, so `tail`/`since` silently
2757    /// dropped the archive copy — reporting 1 where disk holds 2. The fix gates
2758    /// that dedup on the `.rotating` marker (mirroring the write side); with no
2759    /// marker, both must come back.
2760    #[test]
2761    fn regression_tail_since_keep_distinct_split_entries_without_rotation_marker() {
2762        let (_d, store) = temp_store();
2763
2764        // Author the SAME (minute,kind,object,note) entry once in the May
2765        // archive and once in the active file. This is exactly the on-disk shape
2766        // the repro produces: an entry rotates into log/2026-05.md, then a second
2767        // backdated append of identical fields lands in the active log.md after a
2768        // later-month rotation completed (so NO marker lingers).
2769        let dir = archive_dir(&store);
2770        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
2771        let dup = entry(
2772            2026,
2773            5,
2774            10,
2775            8,
2776            0,
2777            LogKind::Ingest,
2778            Some("records/x.md"),
2779            "same note text",
2780        );
2781        let mut arch = String::from(LOG_FRONTMATTER);
2782        arch.push('\n');
2783        arch.push_str(&dup.render());
2784        fs::write(test_abs(&store, archive_path(&store, 2026, 5)), arch).unwrap();
2785
2786        // Active file: a current-month (June) entry plus the SECOND distinct copy
2787        // of the May-dated event (backdated, so physically alongside June).
2788        let jun = entry(
2789            2026,
2790            6,
2791            1,
2792            9,
2793            0,
2794            LogKind::Create,
2795            Some("records/june.md"),
2796            "june",
2797        );
2798        write_raw_log(&store, &[jun, dup.clone()]);
2799
2800        // No rotation marker => normal operation => trust the disk: BOTH distinct
2801        // copies of the May event must be reported by since and tail.
2802        assert!(
2803            !test_abs(&store, rotation_marker_path(&store)).exists(),
2804            "precondition: no rotation marker (normal operation)"
2805        );
2806
2807        let since = Log::since(&store, ts(2026, 5, 1, 0, 0)).unwrap();
2808        let since_dups = since.iter().filter(|e| **e == dup).count();
2809        assert_eq!(
2810            since_dups, 2,
2811            "since must return BOTH distinct same-minute entries split across \
2812             active+archive when no rotation marker is present; got {since:#?}"
2813        );
2814
2815        let tail = Log::tail(&store, 10).unwrap();
2816        let tail_dups = tail.iter().filter(|e| **e == dup).count();
2817        assert_eq!(
2818            tail_dups, 2,
2819            "tail must return BOTH distinct same-minute entries split across \
2820             active+archive when no rotation marker is present; got {tail:#?}"
2821        );
2822    }
2823
2824    /// PRESERVED INVARIANT. When a rotation IS in flight (the `.rotating` marker
2825    /// is present), an interrupted rotation can leave the SAME physical entry in
2826    /// both the untrimmed active file and its archive. That crash-induced
2827    /// duplicate must still be deduped on read so it is not double-reported —
2828    /// the gating must not throw away the legitimate crash-recovery masking.
2829    #[test]
2830    fn regression_tail_since_dedup_crash_overlap_when_rotation_marker_present() {
2831        let (_d, store) = temp_store();
2832
2833        // Simulate the mid-rotation crash state: the SAME physical May entry is
2834        // in BOTH the archive (write committed) and the active file (trim never
2835        // landed), and the in-flight marker is still on disk.
2836        let dir = archive_dir(&store);
2837        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
2838        let rolled = entry(
2839            2026,
2840            5,
2841            10,
2842            8,
2843            0,
2844            LogKind::Ingest,
2845            Some("records/x.md"),
2846            "same note text",
2847        );
2848        let mut arch = String::from(LOG_FRONTMATTER);
2849        arch.push('\n');
2850        arch.push_str(&rolled.render());
2851        fs::write(test_abs(&store, archive_path(&store, 2026, 5)), arch).unwrap();
2852
2853        // Active file still holds the un-trimmed May entry plus a current-month
2854        // (June) entry — the pre-trim shape a crash leaves behind.
2855        let jun = entry(
2856            2026,
2857            6,
2858            1,
2859            9,
2860            0,
2861            LogKind::Create,
2862            Some("records/june.md"),
2863            "june",
2864        );
2865        write_raw_log(&store, &[jun, rolled.clone()]);
2866
2867        // The crash leaves the in-progress-rotation marker behind.
2868        fs::write(test_abs(&store, rotation_marker_path(&store)), b"").unwrap();
2869        assert!(
2870            test_abs(&store, rotation_marker_path(&store)).exists(),
2871            "precondition: rotation marker present (crash mid-rotation)"
2872        );
2873
2874        // Recovering => the active↔archive overlap is the crash duplicate, masked
2875        // on read: the May entry must be reported ONCE, not twice.
2876        let since = Log::since(&store, ts(2026, 5, 1, 0, 0)).unwrap();
2877        let since_dups = since.iter().filter(|e| **e == rolled).count();
2878        assert_eq!(
2879            since_dups, 1,
2880            "since must dedup the crash-induced active↔archive overlap when the \
2881             rotation marker is present; got {since:#?}"
2882        );
2883
2884        let tail = Log::tail(&store, 10).unwrap();
2885        let tail_dups = tail.iter().filter(|e| **e == rolled).count();
2886        assert_eq!(
2887            tail_dups, 1,
2888            "tail must dedup the crash-induced active↔archive overlap when the \
2889             rotation marker is present; got {tail:#?}"
2890        );
2891    }
2892
2893    /// Adversarial review (#15) — rotation must NOT erase lines before the first
2894    /// VALID entry header. An active log whose entries region opens with a
2895    /// `## [`-shaped line that `parse_header` rejects (a merge orphan / malformed
2896    /// export) before the first real entry: pre-fix `find_first_header` landed on
2897    /// it, `parse_entries` dropped it (no open entry yet), and the rotation
2898    /// re-emitted without it — silently erasing append-only content. The fix
2899    /// folds everything before the first valid header into the preserved header
2900    /// block, which rotation re-emits verbatim.
2901    #[test]
2902    fn regression_rotation_preserves_lines_before_first_valid_header() {
2903        let (_d, store) = temp_store();
2904        let active = test_abs(&store, active_log_path(&store));
2905        let content = "---\ntype: log\n---\n\n## [orphan from a merge] stray text\n## [2026-04-10 09:00] ingest | x\nbody line\n";
2906        fs::write(&active, content).unwrap();
2907
2908        // A June append rotates the April entry out and rewrites the active file.
2909        Log::append(
2910            &store,
2911            &entry(2026, 6, 1, 8, 0, LogKind::Ingest, Some("jun"), "j"),
2912        )
2913        .unwrap();
2914
2915        let active_after = fs::read_to_string(&active).unwrap();
2916        let arch_after =
2917            fs::read_to_string(test_abs(&store, archive_path(&store, 2026, 4))).unwrap_or_default();
2918        assert!(
2919            active_after.contains("orphan from a merge") || arch_after.contains("orphan from a merge"),
2920            "the pre-first-valid-header line was erased by rotation;\nactive:\n{active_after}\narchive:\n{arch_after}"
2921        );
2922        // Sanity: the real April entry still rotated into its archive.
2923        assert!(
2924            arch_after.contains("## [2026-04-10 09:00] ingest | x"),
2925            "the valid April entry must still rotate to its archive; got:\n{arch_after}"
2926        );
2927    }
2928
2929    // ── regression: reverse reader keeps a `## [` continuation note line (#10) ─
2930
2931    #[test]
2932    fn regression_reverse_reader_preserves_note_line_starting_with_bracket_header() {
2933        // SPEC permits a note of "one or more lines" with no restriction on a
2934        // continuation line starting at column 0 with `## [`. The forward parser
2935        // folds such an unparseable `## [` line into the note; the reverse
2936        // reader (tail/since/last_validate_at) must agree, not split on it.
2937        let (_d, store) = temp_store();
2938        let multi = "First line.\n## [draft outline] more\nThird line.";
2939        let e = entry(
2940            2026,
2941            5,
2942            27,
2943            10,
2944            0,
2945            LogKind::Update,
2946            Some("records/x"),
2947            multi,
2948        );
2949        // Author the log verbatim (render writes the note as-is); this is the
2950        // on-disk shape a hand-written / appended multi-line note produces.
2951        write_raw_log(&store, std::slice::from_ref(&e));
2952
2953        // Pre-fix: header_offsets treated `## [draft outline] more` as a second
2954        // entry boundary, truncating the note to "First line." and dropping the
2955        // carved (non-header) fragment. Post-fix: the full note survives.
2956        let got = Log::tail(&store, 1).unwrap();
2957        assert_eq!(got.len(), 1, "the single entry must be returned");
2958        assert_eq!(
2959            got[0].note, multi,
2960            "reverse reader truncated the note at the `## [` continuation line; \
2961             got {:?}",
2962            got[0].note
2963        );
2964        assert_eq!(got[0], e, "the whole entry must round-trip through tail");
2965
2966        // `since` (the other reverse-reading surface) must agree.
2967        let since = Log::since(&store, ts(2026, 5, 27, 9, 0)).unwrap();
2968        assert_eq!(since, vec![e]);
2969    }
2970
2971    // ── regression: `since` archive pruning uses the UTC month, not local (#11) ─
2972
2973    /// A `DateTime<FixedOffset>` at the given fixed offset (hours east of UTC).
2974    fn ts_offset(
2975        y: i32,
2976        mo: u32,
2977        d: u32,
2978        h: u32,
2979        mi: u32,
2980        offset_hours: i32,
2981    ) -> DateTime<FixedOffset> {
2982        let naive = chrono::NaiveDate::from_ymd_opt(y, mo, d)
2983            .unwrap()
2984            .and_hms_opt(h, mi, 0)
2985            .unwrap();
2986        FixedOffset::east_opt(offset_hours * 3600)
2987            .unwrap()
2988            .from_local_datetime(&naive)
2989            .single()
2990            .unwrap()
2991    }
2992
2993    #[test]
2994    fn regression_since_prunes_archives_on_utc_month_not_local_offset_month() {
2995        // Archive months are bucketed on the UTC calendar. A `since` cutoff with
2996        // a non-UTC offset near a month boundary must not prune an archive whose
2997        // UTC month equals the cutoff's UTC month just because the cutoff's
2998        // LOCAL month is later.
2999        let (_d, store) = temp_store();
3000
3001        // April archive: an entry late on 2026-04-30 at 18:00 UTC.
3002        let apr = entry(
3003            2026,
3004            4,
3005            30,
3006            18,
3007            0,
3008            LogKind::Update,
3009            Some("apr-late"),
3010            "april late",
3011        );
3012        let dir = archive_dir(&store);
3013        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
3014        let mut arch = String::from(LOG_FRONTMATTER);
3015        arch.push('\n');
3016        arch.push_str(&apr.render());
3017        fs::write(test_abs(&store, archive_path(&store, 2026, 4)), arch).unwrap();
3018
3019        // Active file: a clean May entry, so an archive scan is actually needed.
3020        let may = entry(2026, 5, 5, 8, 0, LogKind::Update, Some("may-a"), "may one");
3021        write_raw_log(&store, std::slice::from_ref(&may));
3022
3023        // Cutoff 2026-05-01T00:30:00+07:00 == 2026-04-30T17:30:00Z. The April
3024        // 18:00 UTC entry is strictly newer than this instant.
3025        let cutoff = ts_offset(2026, 5, 1, 0, 30, 7);
3026        // Sanity: the cutoff's UTC month is April, its local month is May.
3027        assert_eq!((cutoff.year(), cutoff.month()), (2026, 5));
3028        assert_eq!(
3029            (
3030                cutoff.with_timezone(&Utc).year(),
3031                cutoff.with_timezone(&Utc).month()
3032            ),
3033            (2026, 4)
3034        );
3035
3036        // Pre-fix: cutoff_ym = (2026, 5) from local fields, so the (2026, 4)
3037        // archive was pruned and the genuinely-newer 18:00 UTC entry was dropped
3038        // — `since` returned only the May entry. Post-fix: cutoff_ym is UTC
3039        // (2026, 4), the April archive is scanned, and both come back.
3040        let got = Log::since(&store, cutoff).unwrap();
3041        let stamps: std::collections::BTreeSet<_> = got.iter().map(|e| e.timestamp).collect();
3042        assert_eq!(
3043            stamps,
3044            [ts(2026, 4, 30, 18, 0), ts(2026, 5, 5, 8, 0)]
3045                .into_iter()
3046                .collect(),
3047            "since(non-UTC cutoff near a month boundary) must include the April \
3048             archive entry newer than the cutoff instant; got {got:?}"
3049        );
3050    }
3051
3052    // ── regression: header-shaped note line corrupts the append-only log (#critical)
3053
3054    #[test]
3055    fn note_line_shaped_like_a_header_is_escaped_and_round_trips() {
3056        // A `contradiction` note quoting an earlier entry header is the
3057        // demonstrated corruption: the verbatim `## [2020-01-01 00:00] delete |
3058        // …` line was parsed as a REAL entry on readback (fabricated entry, real
3059        // note truncated). With write-path escaping it stays note body.
3060        let (_d, store) = temp_store();
3061        let note = "quoting earlier entry:\n## [2020-01-01 00:00] delete | records/contacts/jane.md\nend of quote";
3062        let e = entry(
3063            2026,
3064            6,
3065            11,
3066            4,
3067            41,
3068            LogKind::Contradiction,
3069            Some("records/contacts/jane.md"),
3070            note,
3071        );
3072        Log::append(&store, &e).unwrap();
3073
3074        // On disk: the header-shaped note line must NOT sit at column 0 as a
3075        // `## [` header — `grep "^## \["` must see exactly the one real header.
3076        let raw = fs::read_to_string(store.root.join("log.md")).unwrap();
3077        let header_lines = raw.lines().filter(|l| l.starts_with("## [")).count();
3078        assert_eq!(
3079            header_lines, 1,
3080            "exactly one real entry header may sit at column 0; got:\n{raw}"
3081        );
3082
3083        // Readback returns ONE entry, with the full note intact (no fabricated
3084        // 2020 entry, no truncation).
3085        let got = Log::tail(&store, 10).unwrap();
3086        assert_eq!(got.len(), 1, "exactly one entry; got {got:?}");
3087        assert_eq!(got[0].note, note, "note must round-trip verbatim");
3088        assert_eq!(got[0], e);
3089        let since = Log::since(&store, ts(2026, 1, 1, 0, 0)).unwrap();
3090        assert_eq!(since, vec![e.clone()]);
3091    }
3092
3093    #[test]
3094    fn header_shaped_note_survives_a_later_rotation_uncorrupted() {
3095        // Physical corruption: pre-fix, the fabricated past-dated pseudo-entry
3096        // (year 2020 < current) was rolled into an archive on the NEXT append,
3097        // splitting the real note. With escaping the line is note text, so a
3098        // later append never sees a phantom prior-month entry to roll out.
3099        let (_d, store) = temp_store();
3100        let note = "see\n## [2020-01-01 00:00] delete | records/x.md\nbelow";
3101        let first = entry(
3102            2026,
3103            6,
3104            11,
3105            4,
3106            41,
3107            LogKind::Contradiction,
3108            Some("records/x.md"),
3109            note,
3110        );
3111        Log::append(&store, &first).unwrap();
3112
3113        // Append another current-month entry — the path that re-parses + may
3114        // rotate. No 2020 archive must be created and the first note stays whole.
3115        let second = entry(
3116            2026,
3117            6,
3118            11,
3119            5,
3120            0,
3121            LogKind::Update,
3122            Some("records/y.md"),
3123            "y",
3124        );
3125        Log::append(&store, &second).unwrap();
3126
3127        assert!(
3128            !store.root.join("log").join("2020-01.md").exists(),
3129            "a header-shaped note line must not fabricate a 2020 archive"
3130        );
3131        let got = Log::tail(&store, 10).unwrap();
3132        assert_eq!(got.len(), 2, "two real entries only; got {got:?}");
3133        let first_back = got
3134            .iter()
3135            .find(|e| e.object.as_deref() == Some("records/x.md"));
3136        assert_eq!(
3137            first_back.map(|e| e.note.as_str()),
3138            Some(note),
3139            "the header-shaped note must survive the rotation pass intact"
3140        );
3141    }
3142
3143    #[test]
3144    fn escape_unescape_note_line_round_trips_including_literal_backslash() {
3145        // The escape must be lossless for arbitrary note lines, including a line
3146        // the author genuinely wrote starting with `\` before a header shape.
3147        let valid_header = "## [2020-01-01 00:00] delete | x";
3148        // A real header shape: escaped on write, restored on read.
3149        assert_eq!(
3150            &*escape_note_line(valid_header),
3151            &format!("\\{valid_header}")
3152        );
3153        let escaped = escape_note_line(valid_header).into_owned();
3154        assert_eq!(&*unescape_note_line(&escaped), valid_header);
3155        // An already-`\`-prefixed header-shape line escapes to two backslashes
3156        // and restores to one (never collapses to a bare header).
3157        let pre = format!("\\{valid_header}");
3158        assert_eq!(&*escape_note_line(&pre), &format!("\\{pre}"));
3159        let pre_escaped = escape_note_line(&pre).into_owned();
3160        assert_eq!(&*unescape_note_line(&pre_escaped), &pre);
3161        // Ordinary text (including a `\` that does NOT lead into a header) is
3162        // untouched both ways.
3163        for plain in ["plain note", "## [not a header]", "\\not a header", ""] {
3164            assert_eq!(&*escape_note_line(plain), plain);
3165            assert_eq!(&*unescape_note_line(plain), plain);
3166        }
3167    }
3168
3169    // ── regression: reverse reader scans each block once (no O(file²)) (#perf) ──
3170
3171    #[test]
3172    fn reverse_read_correct_with_header_straddling_a_block_boundary() {
3173        // The incremental per-block header scan must still catch a `## [` marker
3174        // whose `#` falls in one block but whose bytes extend into the already-
3175        // scanned region. Build a log whose total size crosses several blocks and
3176        // verify a full read reconstructs every entry — the straddle case is hit
3177        // by construction across the many block boundaries.
3178        let (_d, store) = temp_store();
3179        let n = 600usize;
3180        let mut expected: Vec<LogEntry> = Vec::new();
3181        for i in 0..n {
3182            let total_min = (i as u32) * 2;
3183            let day = 1 + total_min / (24 * 60);
3184            let hour = (total_min / 60) % 24;
3185            let min = total_min % 60;
3186            // Vary note length so headers land at many offsets relative to the
3187            // fixed 8 KiB block grid, exercising boundary straddles.
3188            let note = format!("note {i} {}", "y".repeat(i % 97));
3189            let e = entry(
3190                2026,
3191                6,
3192                day,
3193                hour,
3194                min,
3195                LogKind::Update,
3196                Some(&format!("records/item-{i:05}")),
3197                &note,
3198            );
3199            Log::append(&store, &e).unwrap();
3200            expected.push(e);
3201        }
3202        let size = fs::metadata(store.root.join("log.md")).unwrap().len();
3203        assert!(
3204            size > (REVERSE_BLOCK as u64) * 3,
3205            "test log not large enough ({size} bytes) to cross several blocks"
3206        );
3207        let all = Log::tail(&store, n + 10).unwrap();
3208        assert_eq!(all, expected, "every entry must reconstruct across blocks");
3209        // A small tail must also be exact (the n-newest by timestamp).
3210        assert_eq!(Log::tail(&store, 7).unwrap(), expected[n - 7..].to_vec());
3211    }
3212
3213    #[test]
3214    fn header_offsets_range_finds_boundary_straddling_marker_once() {
3215        // Two headers; `header_offsets` (whole-buffer) finds both. The range
3216        // scan with a window that splits the buffer between them must report the
3217        // one in its window exactly once, consulting the left neighbour for the
3218        // line-start check.
3219        let buf =
3220            b"## [2026-06-01 00:00] update | a\nnote a\n## [2026-06-01 00:01] update | b\nnote b\n";
3221        let full = header_offsets(buf, 0);
3222        assert_eq!(full.len(), 2, "both headers found over the whole buffer");
3223        let second = full[1] as usize;
3224        // A window covering only the SECOND header's `#` reports just it. Its `#`
3225        // is not at index 0, so `base_is_file_start` is irrelevant here.
3226        let only_second = header_offsets_range(buf, 0, second, second + 1, false);
3227        assert_eq!(only_second, vec![full[1]]);
3228        // A window covering only the FIRST reports just it (right content read
3229        // past the window into the buffer). `base == 0` is the true file start,
3230        // so the index-0 candidate is a real line start.
3231        let only_first = header_offsets_range(buf, 0, 0, 1, true);
3232        assert_eq!(only_first, vec![full[0]]);
3233        // Disjoint windows partition the markers with no double-count.
3234        let mut combined = header_offsets_range(buf, 0, 0, second, true);
3235        combined.extend(header_offsets_range(buf, 0, second, buf.len(), false));
3236        assert_eq!(combined, full);
3237    }
3238
3239    /// CRITICAL regression: a MID-LINE `## [<valid header>]` fragment inside a
3240    /// real entry's note that happens to align with a reverse-read block boundary
3241    /// must NOT be fabricated into an entry. The incremental backward scan reads
3242    /// each block's left edge before its left neighbour is buffered; treating
3243    /// buffer index 0 as a line start there would carve a phantom entry from the
3244    /// fragment and truncate the real entry's note. The fix defers the left-edge
3245    /// candidate until its neighbour is read, so the fragment is correctly seen
3246    /// as note body (its `#` is not at a line start).
3247    #[test]
3248    fn reverse_read_does_not_fabricate_entry_from_midline_header_at_block_boundary() {
3249        let (_d, store) = temp_store();
3250
3251        // A single real entry. Its note carries a mid-line `## [` fragment that
3252        // is a *valid* header shape but is NOT at column 0 (so the writer's
3253        // column-0 escape correctly leaves it verbatim — it is the trigger).
3254        let fragment = "see ## [2020-01-01 00:00] delete | records/x.md";
3255        let hash_in_fragment = fragment.find("##").expect("fragment has `##`");
3256
3257        // Build the raw active log by hand so the fragment's `#` lands at the
3258        // FIRST backward block's left edge: the reverse reader anchors its blocks
3259        // at EOF (`new_start = len - REVERSE_BLOCK` on the first block), so the
3260        // `#` must sit exactly `REVERSE_BLOCK` bytes before EOF. We append note
3261        // padding AFTER the fragment to push EOF out to that distance.
3262        //
3263        // Layout (one entry):
3264        //   <frontmatter>\n## [<header>] | records/real.md\nlead\n<fragment><tail>\n\n
3265        let header_line = "## [2026-06-14 10:00] update | records/real.md\n";
3266        let mut head = String::from(LOG_FRONTMATTER);
3267        head.push('\n');
3268        head.push_str(header_line);
3269        head.push_str("lead\n");
3270        head.push_str(fragment); // fragment opens the second note line
3271
3272        // Absolute offset of the fragment's `#`.
3273        let hash_off = head.len() - fragment.len() + hash_in_fragment;
3274        // We append `<tail>\n\n`. Bytes after `#` = (head.len() - hash_off) +
3275        // tail_len + 2. Need that == REVERSE_BLOCK so `#` is at `len -
3276        // REVERSE_BLOCK` (the first block's left edge).
3277        let after_hash_in_head = head.len() - hash_off;
3278        let tail_len = REVERSE_BLOCK
3279            .checked_sub(after_hash_in_head + 2)
3280            .expect("REVERSE_BLOCK comfortably exceeds the post-`#` head bytes");
3281        let mut body = head;
3282        body.push_str(&"z".repeat(tail_len)); // valid note bytes on the fragment line
3283        body.push('\n');
3284        body.push('\n');
3285        fs::write(store.root.join("log.md"), &body).unwrap();
3286
3287        // The file must be large enough to cross at least one block boundary.
3288        assert!(
3289            body.len() as u64 > REVERSE_BLOCK as u64,
3290            "test log must span >1 block (len {})",
3291            body.len()
3292        );
3293        // And the fragment's `#` sits exactly at the first block's left edge.
3294        let real_hash_off = body.find("see ##").unwrap() + hash_in_fragment;
3295        assert_eq!(
3296            real_hash_off,
3297            body.len() - REVERSE_BLOCK,
3298            "fragment `#` must land on the first backward block's left edge to exercise the bug"
3299        );
3300
3301        // Reverse read must return EXACTLY ONE entry — the real one — and never a
3302        // fabricated `2020-01-01 delete records/x.md` carved from the fragment.
3303        let got = Log::tail(&store, 10).unwrap();
3304        assert_eq!(
3305            got.len(),
3306            1,
3307            "exactly the one real entry; got {} (a fabricated entry means the boundary `#` was mis-read as a header): {got:#?}",
3308            got.len()
3309        );
3310        let only = &got[0];
3311        assert_eq!(only.object.as_deref(), Some("records/real.md"));
3312        assert_eq!(only.timestamp, ts(2026, 6, 14, 10, 0));
3313        // The note is intact end-to-end (not truncated at the fragment): both the
3314        // lead and the verbatim fragment survive.
3315        assert!(
3316            only.note.contains("lead"),
3317            "note keeps its lead; got {:?}",
3318            only.note
3319        );
3320        assert!(
3321            only.note.contains(fragment),
3322            "note keeps the verbatim mid-line fragment (not truncated); got {:?}",
3323            only.note
3324        );
3325    }
3326
3327    // ── regression: tail/since dedup across active+archive on interrupted rotation
3328
3329    #[test]
3330    fn tail_and_since_dedup_entries_present_in_both_active_and_archive() {
3331        // Reconstructs the finding's crash window: the archive write committed
3332        // but the active rewrite never trimmed, so the same April entries live in
3333        // BOTH the untrimmed active file and `log/2026-04.md`. Readers must
3334        // return each entry ONCE, not twice.
3335        //
3336        // A real crash in that window necessarily leaves the `.rotating` marker
3337        // behind — it is written BEFORE the archive append (Log::append step 1)
3338        // and removed only AFTER the active trim commits, so any state where the
3339        // archive holds the entries but the active was never trimmed has the
3340        // marker present. The read-side overlap dedup is gated on that marker
3341        // (mirroring the write side); without it, an active↔archive collision is
3342        // treated as two genuinely-distinct entries, not a crash duplicate. So
3343        // the test must set the marker to model the crash it claims to.
3344        let (_d, store) = temp_store();
3345        let apr_a = entry(2026, 4, 10, 9, 0, LogKind::Ingest, Some("apr-a"), "apr one");
3346        let apr_b = entry(2026, 4, 20, 9, 0, LogKind::Create, Some("apr-b"), "apr two");
3347
3348        // Active file still holds both April entries (the un-trimmed state).
3349        write_raw_log(&store, &[apr_a.clone(), apr_b.clone()]);
3350        // The committed step-1 archive holds the same two entries.
3351        let dir = archive_dir(&store);
3352        fs::create_dir_all(test_abs(&store, &dir)).unwrap();
3353        let mut arch = String::from(LOG_FRONTMATTER);
3354        arch.push('\n');
3355        arch.push_str(&apr_a.render());
3356        arch.push_str(&apr_b.render());
3357        fs::write(test_abs(&store, archive_path(&store, 2026, 4)), arch).unwrap();
3358        // The crash leaves the in-progress-rotation marker on disk; this is what
3359        // authorizes the read-side overlap dedup.
3360        fs::write(test_abs(&store, rotation_marker_path(&store)), b"").unwrap();
3361
3362        // `since` must return each April entry exactly once.
3363        let since = Log::since(&store, ts(2026, 4, 1, 0, 0)).unwrap();
3364        assert_eq!(
3365            since,
3366            vec![apr_a.clone(), apr_b.clone()],
3367            "since must dedup the doubly-present entries; got {since:?}"
3368        );
3369
3370        // `tail` must too — no duplicate window slots.
3371        let tail = Log::tail(&store, 10).unwrap();
3372        assert_eq!(
3373            tail,
3374            vec![apr_a, apr_b],
3375            "tail must dedup the doubly-present entries; got {tail:?}"
3376        );
3377    }
3378
3379    #[cfg(unix)]
3380    #[test]
3381    fn append_stays_on_opened_root_after_path_replacement() {
3382        use std::os::unix::fs::symlink;
3383
3384        let sandbox = tempfile::tempdir().unwrap();
3385        let root = sandbox.path().join("store");
3386        fs::create_dir_all(&root).unwrap();
3387        fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
3388        let store = Store::open_strict(&root).unwrap();
3389        let detached = sandbox.path().join("detached");
3390        fs::rename(&root, &detached).unwrap();
3391
3392        let replacement = sandbox.path().join("replacement");
3393        fs::create_dir_all(&replacement).unwrap();
3394        fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
3395        fs::write(replacement.join("log.md"), "replacement log sentinel\n").unwrap();
3396        symlink(&replacement, &root).unwrap();
3397
3398        let owned = entry(
3399            2026,
3400            7,
3401            30,
3402            12,
3403            0,
3404            LogKind::Create,
3405            Some("owned"),
3406            "owned event",
3407        );
3408        Log::append(&store, &owned).unwrap();
3409        assert!(fs::read_to_string(detached.join("log.md"))
3410            .unwrap()
3411            .contains("owned event"));
3412        assert_eq!(
3413            fs::read_to_string(replacement.join("log.md")).unwrap(),
3414            "replacement log sentinel\n"
3415        );
3416    }
3417}