Skip to main content

memstead_base/filesystem/
changelog.rs

1//! JSONL changelog at `.memstead/changes.jsonl` for filesystem mems.
2//!
3//! filesystem mems have no commit history, so the agent's `note` parameter and
4//! the per-mutation provenance trail (timestamp, mutation type, entity
5//! id, actor) lands here instead. Append-only; one line per mutation;
6//! the file is created lazily on first append.
7//!
8//! ## Line shape
9//!
10//! Each line is a JSON object terminated by `\n`:
11//!
12//! ```json
13//! {"ts":"2026-05-08T15:42:13.001Z","kind":"create","entity":"mem:slug","actor":"agent","note":"first draft"}
14//! ```
15//!
16//! - `ts` — RFC 3339 timestamp with millisecond precision, always UTC
17//!   (`Z` suffix). Sortable lexicographically.
18//! - `kind` — mutation kind: `create`, `update`, `delete`, `relate`,
19//!   `rename`, or `batch`. Future kinds extend the set; readers should
20//!   tolerate unknown values.
21//! - `entity` — mem-relative entity id (`mem:slug` form), or
22//!   `null` for batch mutations that span multiple entities.
23//! - `actor` — caller category from
24//!   [`memstead_base::vcs::Actor::as_trailer`]: `agent`, `cli`, `app`,
25//!   `external`, `unknown`.
26//! - `note` — agent-authored provenance note when present. Omitted
27//!   from the JSON object when absent or whitespace-only.
28//! - `client` — optional caller identity (`name@version`). Omitted
29//!   when absent.
30//!
31//! ## Atomicity
32//!
33//! Writes use `OpenOptions::append(true)` with a single `write_all`
34//! call against the buffered line. POSIX guarantees `O_APPEND` writes
35//! ≤ `PIPE_BUF` (4 KiB on Linux/macOS) appear atomically; the line
36//! shape stays well under that. Concurrent writers from a single
37//! process are serialised by the file's append-mode kernel lock.
38//! Cross-process concurrency is out of scope (filesystem mems are single-writer
39//! per the plan).
40
41use std::io::Write as _;
42use std::path::{Path, PathBuf};
43
44use serde::Serialize;
45
46use crate::provenance::ProvenanceKind;
47use crate::vcs::{Actor, ClientId};
48
49/// Conventional path of the changelog inside a workspace root.
50pub fn changelog_path(workspace_root: &Path) -> PathBuf {
51    workspace_root
52        .join(crate::mem::MEM_META_DIR)
53        .join("changes.jsonl")
54}
55
56/// Mutation kind written to the `kind` field of each line. The set
57/// covers today's MCP mutating tools; new mutations extend the enum.
58/// Readers branch on the string form (stable wire shape).
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum MutationKind {
61    Create,
62    Update,
63    Delete,
64    Relate,
65    Rename,
66    Batch,
67}
68
69impl MutationKind {
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            MutationKind::Create => "create",
73            MutationKind::Update => "update",
74            MutationKind::Delete => "delete",
75            MutationKind::Relate => "relate",
76            MutationKind::Rename => "rename",
77            MutationKind::Batch => "batch",
78        }
79    }
80}
81
82/// Two enums name the same six mutation classes — one is the legacy
83/// folder-backend on-disk encoder, the other is the backend-neutral
84/// shape consumed by [`crate::backend::MemBackend`]. Bridge here so
85/// callers crossing between the two surfaces don't drift.
86impl From<ProvenanceKind> for MutationKind {
87    fn from(k: ProvenanceKind) -> Self {
88        match k {
89            ProvenanceKind::Create => MutationKind::Create,
90            ProvenanceKind::Update => MutationKind::Update,
91            ProvenanceKind::Delete => MutationKind::Delete,
92            ProvenanceKind::Relate => MutationKind::Relate,
93            ProvenanceKind::Rename => MutationKind::Rename,
94            ProvenanceKind::Batch => MutationKind::Batch,
95        }
96    }
97}
98
99impl From<MutationKind> for ProvenanceKind {
100    fn from(k: MutationKind) -> Self {
101        match k {
102            MutationKind::Create => ProvenanceKind::Create,
103            MutationKind::Update => ProvenanceKind::Update,
104            MutationKind::Delete => ProvenanceKind::Delete,
105            MutationKind::Relate => ProvenanceKind::Relate,
106            MutationKind::Rename => ProvenanceKind::Rename,
107            MutationKind::Batch => ProvenanceKind::Batch,
108        }
109    }
110}
111
112/// A single mutation event. Built at the call site in the filesystem
113/// engine (criterion 1's wiring) and passed to [`append_change`].
114pub struct ChangeEntry<'a> {
115    pub kind: MutationKind,
116    /// Mem-relative entity id, or `None` for batch mutations.
117    pub entity: Option<&'a str>,
118    pub actor: Actor,
119    /// Optional caller identity (e.g. `claude-code@2.1.0`).
120    pub client: Option<&'a ClientId>,
121    /// Optional agent-authored provenance note. Whitespace-only values
122    /// are treated as absent.
123    pub note: Option<&'a str>,
124    /// Correlation id linking every commit produced by a single
125    /// logical operation (notably multi-mem `memstead_rename`). `None`
126    /// for single-call mutations that don't participate in
127    /// correlation. Round-trips through the JSONL wire shape; reader
128    /// reconstructs the same value on `read_provenance`.
129    pub logical_operation_id: Option<&'a str>,
130    /// Caller-declared role (agent-trust plan 13); `Unspecified`
131    /// omits the field — absence recorded as absence.
132    pub role: crate::vcs::Role,
133    /// Caller-declared identity (agent-trust plan 15); `None` omits
134    /// the field — absence recorded as absence, same as the role.
135    pub identity: Option<&'a str>,
136}
137
138/// Errors surfaced by [`append_change`].
139#[derive(Debug, thiserror::Error)]
140pub enum ChangelogError {
141    #[error("changelog io error at {path}: {source}")]
142    Io {
143        path: PathBuf,
144        #[source]
145        source: std::io::Error,
146    },
147    #[error("changelog serialisation: {0}")]
148    Serialise(#[from] serde_json::Error),
149}
150
151/// Append a single change to `<workspace_root>/.memstead/changes.jsonl`.
152/// Creates the `.memstead/` parent directory and the file if absent.
153pub fn append_change(workspace_root: &Path, entry: &ChangeEntry<'_>) -> Result<(), ChangelogError> {
154    let now = std::time::SystemTime::now();
155    append_change_monotonic(workspace_root, entry, now)
156}
157
158/// Variant of [`append_change_at`] that guarantees the written `ts`
159/// is strictly greater than the changelog's current last-line `ts`.
160///
161/// The last-line `ts` doubles as the folder backend's drift cursor
162/// (`current_head()`), and the engine's self-write bookkeeping treats
163/// "cursor unchanged" as "no commit landed" — so two mutations landing
164/// inside the same millisecond must not share a `ts`, or the second
165/// one becomes invisible to drift detection and the events channel.
166/// When `now` is not strictly after the last line (same-millisecond
167/// commits, or a clock that stepped backwards), the written timestamp
168/// is bumped to `last + 1ms`. The format and the lexicographic-cursor
169/// dialect are unchanged — strictly increasing fixed-width RFC 3339
170/// stays strictly increasing lexicographically.
171///
172/// Production callers route here; [`append_change_at`] stays the exact
173/// write-this-timestamp primitive for deterministic tests.
174pub fn append_change_monotonic(
175    workspace_root: &Path,
176    entry: &ChangeEntry<'_>,
177    now: std::time::SystemTime,
178) -> Result<(), ChangelogError> {
179    // Compare at the cursor's own granularity — the formatted
180    // millisecond string — not raw `SystemTime`: `now` carries
181    // sub-millisecond nanos that make it "later" than the parsed
182    // last-line ts even when both format to the same millisecond,
183    // which is exactly the collision the clamp exists to prevent.
184    let effective = match last_line_ts(&changelog_path(workspace_root)) {
185        Some(last) if format_rfc3339_utc(now) <= format_rfc3339_utc(last) => {
186            last + std::time::Duration::from_millis(1)
187        }
188        _ => now,
189    };
190    append_change_at(workspace_root, entry, effective)
191}
192
193/// Read the `ts` of the changelog's last non-empty line, parsed back
194/// to a `SystemTime`. `None` when the file is absent, unreadable, or
195/// its last line doesn't carry a parseable `ts` — the monotonic clamp
196/// is best-effort and must never block an append. Reads only the tail
197/// of the file (last 4 KiB) so appends stay O(1) in changelog length;
198/// a line is well under 4 KiB per the atomicity contract above.
199fn last_line_ts(path: &Path) -> Option<std::time::SystemTime> {
200    use std::io::{Read as _, Seek as _, SeekFrom};
201    let mut file = std::fs::File::open(path).ok()?;
202    let len = file.metadata().ok()?.len();
203    let start = len.saturating_sub(4096);
204    file.seek(SeekFrom::Start(start)).ok()?;
205    let mut buf = Vec::new();
206    file.read_to_end(&mut buf).ok()?;
207    // Lossy: a seek that lands mid-UTF-8-char only garbles the first
208    // (incomplete) line of the tail, which the rev() scan never needs.
209    let tail = String::from_utf8_lossy(&buf);
210    tail.lines()
211        .rev()
212        .map(str::trim)
213        .filter(|l| !l.is_empty())
214        .filter_map(|l| {
215            let ts = serde_json::from_str::<serde_json::Value>(l)
216                .ok()?
217                .get("ts")?
218                .as_str()?
219                .to_string();
220            parse_rfc3339_utc(&ts)
221        })
222        .next()
223}
224
225/// Variant of [`append_change`] that takes the timestamp explicitly.
226/// Used by tests that need deterministic line ordering; production
227/// callers go through [`append_change`].
228pub fn append_change_at(
229    workspace_root: &Path,
230    entry: &ChangeEntry<'_>,
231    now: std::time::SystemTime,
232) -> Result<(), ChangelogError> {
233    let target = changelog_path(workspace_root);
234    if let Some(parent) = target.parent() {
235        std::fs::create_dir_all(parent).map_err(|e| ChangelogError::Io {
236            path: parent.to_path_buf(),
237            source: e,
238        })?;
239    }
240
241    let ts = format_rfc3339_utc(now);
242    let note = entry
243        .note
244        .map(str::trim)
245        .filter(|n| !n.is_empty())
246        .map(|s| s.to_string());
247    let client = entry.client.map(|c| format!("{}@{}", c.name, c.version));
248
249    #[derive(Serialize)]
250    struct Wire<'a> {
251        ts: &'a str,
252        kind: &'a str,
253        entity: Option<&'a str>,
254        actor: &'a str,
255        #[serde(skip_serializing_if = "Option::is_none")]
256        note: Option<String>,
257        #[serde(skip_serializing_if = "Option::is_none")]
258        client: Option<String>,
259        #[serde(skip_serializing_if = "Option::is_none", rename = "logical_op")]
260        logical_operation_id: Option<&'a str>,
261        #[serde(skip_serializing_if = "Option::is_none")]
262        role: Option<&'static str>,
263        #[serde(skip_serializing_if = "Option::is_none")]
264        identity: Option<&'a str>,
265    }
266
267    let mut line = serde_json::to_string(&Wire {
268        ts: &ts,
269        kind: entry.kind.as_str(),
270        entity: entry.entity,
271        actor: entry.actor.as_trailer(),
272        note,
273        client,
274        role: entry.role.as_trailer(),
275        identity: entry.identity,
276        logical_operation_id: entry.logical_operation_id,
277    })?;
278    line.push('\n');
279
280    let mut file = std::fs::OpenOptions::new()
281        .create(true)
282        .append(true)
283        .open(&target)
284        .map_err(|e| ChangelogError::Io {
285            path: target.clone(),
286            source: e,
287        })?;
288    file.write_all(line.as_bytes())
289        .map_err(|e| ChangelogError::Io {
290            path: target,
291            source: e,
292        })?;
293    Ok(())
294}
295
296/// Format a `SystemTime` as RFC 3339 with millisecond precision and
297/// the `Z` suffix. Always UTC. Hand-rolled because the project does
298/// not pull in `chrono` and the underlying `std::time::SystemTime` is
299/// epoch-based.
300///
301/// Public so the folder-backend [`crate::backend::MemBackend`] impl
302/// can reuse the same encoder when it constructs cursor strings.
303pub fn format_rfc3339_utc(now: std::time::SystemTime) -> String {
304    let dur = now
305        .duration_since(std::time::UNIX_EPOCH)
306        .unwrap_or_default();
307    let total_secs = dur.as_secs();
308    let millis = dur.subsec_millis();
309    let (y, m, d, hh, mm, ss) = decompose_unix_seconds(total_secs);
310    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
311}
312
313/// Inverse of [`format_rfc3339_utc`]. Returns `None` when `s` is not
314/// the exact 24-character `YYYY-MM-DDTHH:MM:SS.mmmZ` shape this
315/// crate emits — round-trip precision is the contract; lenient
316/// parsing is not. Used by the folder-backend `MemBackend::read_provenance`
317/// impl when reconstructing [`crate::provenance::Provenance`] records
318/// from on-disk JSONL lines.
319pub fn parse_rfc3339_utc(s: &str) -> Option<std::time::SystemTime> {
320    let bytes = s.as_bytes();
321    if bytes.len() != 24
322        || bytes[23] != b'Z'
323        || bytes[4] != b'-'
324        || bytes[7] != b'-'
325        || bytes[10] != b'T'
326        || bytes[13] != b':'
327        || bytes[16] != b':'
328        || bytes[19] != b'.'
329    {
330        return None;
331    }
332    let year: i32 = s.get(0..4)?.parse().ok()?;
333    let month: u32 = s.get(5..7)?.parse().ok()?;
334    let day: u32 = s.get(8..10)?.parse().ok()?;
335    let hour: u32 = s.get(11..13)?.parse().ok()?;
336    let minute: u32 = s.get(14..16)?.parse().ok()?;
337    let second: u32 = s.get(17..19)?.parse().ok()?;
338    let millis: u32 = s.get(20..23)?.parse().ok()?;
339    if month == 0
340        || month > 12
341        || day == 0
342        || day > 31
343        || hour > 23
344        || minute > 59
345        || second > 60
346        || millis > 999
347    {
348        return None;
349    }
350    let days = ymd_to_days(year, month, day)?;
351    let total_secs = days
352        .checked_mul(86_400)?
353        .checked_add(hour as i64 * 3_600)?
354        .checked_add(minute as i64 * 60)?
355        .checked_add(second as i64)?;
356    if total_secs < 0 {
357        return None;
358    }
359    Some(std::time::UNIX_EPOCH + std::time::Duration::new(total_secs as u64, millis * 1_000_000))
360}
361
362/// Civil-to-days inverse of the algorithm in [`decompose_unix_seconds`].
363/// Returns days since 1970-01-01. Hinnant's algorithm.
364fn ymd_to_days(year: i32, month: u32, day: u32) -> Option<i64> {
365    let y = if month <= 2 {
366        year as i64 - 1
367    } else {
368        year as i64
369    };
370    let m = month as i64;
371    let era = if y >= 0 { y } else { y - 399 } / 400;
372    let yoe = y - era * 400;
373    if !(0..=399).contains(&yoe) {
374        return None;
375    }
376    let doy_offset = if m > 2 { m - 3 } else { m + 9 };
377    let doy = (153 * doy_offset + 2) / 5 + day as i64 - 1;
378    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
379    Some(era * 146_097 + doe - 719_468)
380}
381
382/// Decompose a UNIX-epoch second count into (year, month, day, hour,
383/// minute, second) in UTC. Algorithm from Howard Hinnant's
384/// [chrono-Compatible Low-Level Date Algorithms]
385/// (https://howardhinnant.github.io/date_algorithms.html).
386///
387/// Valid for any seconds-since-epoch value the caller is likely to
388/// observe (`SystemTime` on POSIX is bounded by `time_t`, well within
389/// the algorithm's i64-domain).
390fn decompose_unix_seconds(total_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
391    const SECONDS_PER_DAY: u64 = 86_400;
392    let days = (total_secs / SECONDS_PER_DAY) as i64;
393    let secs_of_day = (total_secs % SECONDS_PER_DAY) as u32;
394    let hh = secs_of_day / 3_600;
395    let mm = (secs_of_day / 60) % 60;
396    let ss = secs_of_day % 60;
397
398    // Civil-from-days.
399    let z = days + 719_468;
400    let era = z.div_euclid(146_097);
401    let doe = z - era * 146_097;
402    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
403    let y = (yoe + era * 400) as i32;
404    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
405    let mp = (5 * doy + 2) / 153;
406    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
407    let m = if mp < 10 {
408        (mp + 3) as u32
409    } else {
410        (mp - 9) as u32
411    };
412    let y = if m <= 2 { y + 1 } else { y };
413    (y, m, d, hh, mm, ss)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::vcs::{Actor, ClientId};
420    use tempfile::TempDir;
421
422    fn read_lines(path: &Path) -> Vec<String> {
423        std::fs::read_to_string(path)
424            .unwrap()
425            .lines()
426            .map(|s| s.to_string())
427            .collect()
428    }
429
430    fn ts(seconds: u64, millis: u32) -> std::time::SystemTime {
431        std::time::UNIX_EPOCH + std::time::Duration::new(seconds, millis * 1_000_000)
432    }
433
434    #[test]
435    fn appends_a_create_line_with_all_fields() {
436        let tmp = TempDir::new().unwrap();
437        let client = ClientId {
438            name: "claude-code".into(),
439            version: "2.1.0".into(),
440        };
441        append_change_at(
442            tmp.path(),
443            &ChangeEntry {
444                kind: MutationKind::Create,
445                entity: Some("spec:hello"),
446                actor: Actor::Agent,
447                client: Some(&client),
448                note: Some("first draft"),
449                logical_operation_id: None,
450                role: crate::vcs::Role::Unspecified,
451                identity: None,
452            },
453            ts(1_715_000_000, 1),
454        )
455        .unwrap();
456
457        let lines = read_lines(&changelog_path(tmp.path()));
458        assert_eq!(lines.len(), 1);
459        let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
460        assert_eq!(value["kind"], "create");
461        assert_eq!(value["entity"], "spec:hello");
462        assert_eq!(value["actor"], "agent");
463        assert_eq!(value["client"], "claude-code@2.1.0");
464        assert_eq!(value["note"], "first draft");
465        // Timestamp shape is RFC 3339 UTC, ms precision, sortable.
466        let ts_str = value["ts"].as_str().unwrap();
467        assert!(ts_str.ends_with("Z"));
468        assert!(ts_str.contains("T"));
469        // 2024-05-06T12:53:20 UTC + 1ms == seconds=1_715_000_000, ms=1.
470        assert_eq!(ts_str, "2024-05-06T12:53:20.001Z");
471        // An identity-less entry carries no identity key — absence
472        // recorded as absence (agent-trust plan 15, criterion 3).
473        assert!(value.get("identity").is_none());
474    }
475
476    /// Agent-trust plan 15: a declared identity lands as the
477    /// `identity` field of the JSONL line, verbatim.
478    #[test]
479    fn appends_the_declared_identity() {
480        let tmp = TempDir::new().unwrap();
481        append_change_at(
482            tmp.path(),
483            &ChangeEntry {
484                kind: MutationKind::Update,
485                entity: Some("spec:hello"),
486                actor: Actor::Agent,
487                client: None,
488                note: None,
489                logical_operation_id: None,
490                role: crate::vcs::Role::Unspecified,
491                identity: Some("plenum-agent"),
492            },
493            ts(1_715_000_000, 1),
494        )
495        .unwrap();
496        let lines = read_lines(&changelog_path(tmp.path()));
497        let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
498        assert_eq!(value["identity"], "plenum-agent");
499    }
500
501    #[test]
502    fn omits_optional_fields_when_absent() {
503        let tmp = TempDir::new().unwrap();
504        append_change_at(
505            tmp.path(),
506            &ChangeEntry {
507                kind: MutationKind::Update,
508                entity: Some("spec:foo"),
509                actor: Actor::Cli,
510                client: None,
511                note: None,
512                logical_operation_id: None,
513                role: crate::vcs::Role::Unspecified,
514                identity: None,
515            },
516            ts(0, 0),
517        )
518        .unwrap();
519        let lines = read_lines(&changelog_path(tmp.path()));
520        let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
521        assert!(value.get("note").is_none());
522        assert!(value.get("client").is_none());
523    }
524
525    #[test]
526    fn whitespace_only_note_is_treated_as_absent() {
527        let tmp = TempDir::new().unwrap();
528        append_change_at(
529            tmp.path(),
530            &ChangeEntry {
531                kind: MutationKind::Update,
532                entity: Some("spec:foo"),
533                actor: Actor::Cli,
534                client: None,
535                note: Some("   \t   "),
536                logical_operation_id: None,
537                role: crate::vcs::Role::Unspecified,
538                identity: None,
539            },
540            ts(0, 0),
541        )
542        .unwrap();
543        let lines = read_lines(&changelog_path(tmp.path()));
544        let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
545        assert!(value.get("note").is_none());
546    }
547
548    #[test]
549    fn batch_mutation_writes_null_entity() {
550        let tmp = TempDir::new().unwrap();
551        append_change_at(
552            tmp.path(),
553            &ChangeEntry {
554                kind: MutationKind::Batch,
555                entity: None,
556                actor: Actor::Agent,
557                client: None,
558                note: Some("multi-entity refactor"),
559                logical_operation_id: None,
560                role: crate::vcs::Role::Unspecified,
561                identity: None,
562            },
563            ts(0, 0),
564        )
565        .unwrap();
566        let lines = read_lines(&changelog_path(tmp.path()));
567        let value: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
568        assert!(value["entity"].is_null());
569        assert_eq!(value["kind"], "batch");
570    }
571
572    #[test]
573    fn appends_create_then_update_in_order() {
574        let tmp = TempDir::new().unwrap();
575        for (kind, ent, t) in [
576            (MutationKind::Create, "a", 0),
577            (MutationKind::Update, "a", 1),
578            (MutationKind::Delete, "a", 2),
579        ] {
580            append_change_at(
581                tmp.path(),
582                &ChangeEntry {
583                    kind,
584                    entity: Some(ent),
585                    actor: Actor::Cli,
586                    client: None,
587                    note: None,
588                    logical_operation_id: None,
589                    role: crate::vcs::Role::Unspecified,
590                    identity: None,
591                },
592                ts(t, 0),
593            )
594            .unwrap();
595        }
596
597        let lines = read_lines(&changelog_path(tmp.path()));
598        assert_eq!(lines.len(), 3);
599        let kinds: Vec<String> = lines
600            .iter()
601            .map(|line| {
602                serde_json::from_str::<serde_json::Value>(line).unwrap()["kind"]
603                    .as_str()
604                    .unwrap()
605                    .to_string()
606            })
607            .collect();
608        assert_eq!(kinds, vec!["create", "update", "delete"]);
609    }
610
611    #[test]
612    fn creates_memstead_parent_lazily() {
613        let tmp = TempDir::new().unwrap();
614        assert!(!tmp.path().join(".memstead").exists());
615        append_change_at(
616            tmp.path(),
617            &ChangeEntry {
618                kind: MutationKind::Create,
619                entity: Some("a"),
620                actor: Actor::Cli,
621                client: None,
622                note: None,
623                logical_operation_id: None,
624                role: crate::vcs::Role::Unspecified,
625                identity: None,
626            },
627            ts(0, 0),
628        )
629        .unwrap();
630        assert!(tmp.path().join(".memstead").is_dir());
631        assert!(tmp.path().join(".memstead").join("changes.jsonl").is_file());
632    }
633
634    #[test]
635    fn does_not_create_memstead_until_first_change() {
636        let tmp = TempDir::new().unwrap();
637        // The `memstead init` step is responsible for creating `.memstead/`,
638        // but the changelog must not require an extra setup call —
639        // the helper creates the parent on demand. Verify that just
640        // reading `changelog_path` does not touch disk.
641        let _ = changelog_path(tmp.path());
642        assert!(!tmp.path().join(".memstead").exists());
643    }
644
645    #[test]
646    fn timestamp_handles_year_2026() {
647        // Sanity-check the civil-from-days helper at a recent epoch
648        // that does not sit on a friendly boundary. seconds=1_777_077_296
649        // is 2026-04-25 00:34:56 UTC.
650        let s = format_rfc3339_utc(ts(1_777_077_296, 0));
651        assert_eq!(s, "2026-04-25T00:34:56.000Z");
652    }
653
654    #[test]
655    fn timestamp_round_trips_a_known_2026_date() {
656        // 2026-05-08T12:34:56 UTC. Verifies the helper holds across
657        // months — leap-year handling at year boundaries lives in the
658        // epoch and 2024 tests above.
659        // Days since 1970-01-01:
660        //   56 full years (14 leap) + day-of-year 127 = 20581 days
661        // 20581 * 86400 + 12*3600 + 34*60 + 56 = 1_778_243_696
662        let s = format_rfc3339_utc(ts(1_778_243_696, 0));
663        assert_eq!(s, "2026-05-08T12:34:56.000Z");
664    }
665
666    #[test]
667    fn timestamp_handles_epoch() {
668        let s = format_rfc3339_utc(ts(0, 0));
669        assert_eq!(s, "1970-01-01T00:00:00.000Z");
670    }
671
672    fn bare_entry() -> ChangeEntry<'static> {
673        ChangeEntry {
674            kind: MutationKind::Create,
675            entity: Some("spec:x"),
676            actor: Actor::Agent,
677            client: None,
678            note: None,
679            logical_operation_id: None,
680            role: crate::vcs::Role::Unspecified,
681            identity: None,
682        }
683    }
684
685    fn line_ts(line: &str) -> String {
686        serde_json::from_str::<serde_json::Value>(line).unwrap()["ts"]
687            .as_str()
688            .unwrap()
689            .to_string()
690    }
691
692    #[test]
693    fn monotonic_append_bumps_same_millisecond_timestamp() {
694        // The last-line ts is the folder drift cursor; two commits in
695        // the same millisecond must still advance it, or the second
696        // becomes invisible to drift detection and the events channel.
697        let tmp = TempDir::new().unwrap();
698        let now = ts(1_778_243_696, 500);
699        append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
700        append_change_monotonic(tmp.path(), &bare_entry(), now).unwrap();
701        let lines = read_lines(&changelog_path(tmp.path()));
702        assert_eq!(line_ts(&lines[0]), "2026-05-08T12:34:56.500Z");
703        assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
704    }
705
706    #[test]
707    fn monotonic_append_bumps_backwards_clock() {
708        let tmp = TempDir::new().unwrap();
709        append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
710        // Clock stepped backwards a full second — still clamps to
711        // last + 1ms rather than writing a regressing cursor.
712        append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_695, 0)).unwrap();
713        let lines = read_lines(&changelog_path(tmp.path()));
714        assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
715    }
716
717    #[test]
718    fn monotonic_append_keeps_strictly_later_timestamp_verbatim() {
719        let tmp = TempDir::new().unwrap();
720        append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 500)).unwrap();
721        append_change_monotonic(tmp.path(), &bare_entry(), ts(1_778_243_696, 501)).unwrap();
722        let lines = read_lines(&changelog_path(tmp.path()));
723        assert_eq!(line_ts(&lines[1]), "2026-05-08T12:34:56.501Z");
724    }
725
726    #[test]
727    fn rfc3339_parser_round_trips_known_dates() {
728        for &(secs, ms) in &[
729            (0u64, 0u32),
730            (1_715_000_000, 1),
731            (1_777_077_296, 0),
732            (1_778_243_696, 0),
733            (1_778_243_696, 999),
734        ] {
735            let t = ts(secs, ms);
736            let s = format_rfc3339_utc(t);
737            let parsed = parse_rfc3339_utc(&s).expect("parse round-trip");
738            assert_eq!(parsed, t, "round-trip failed for {s}");
739        }
740    }
741
742    #[test]
743    fn rfc3339_parser_rejects_malformed_input() {
744        assert!(parse_rfc3339_utc("").is_none());
745        assert!(parse_rfc3339_utc("2026-05-08T12:34:56Z").is_none()); // missing ms
746        assert!(parse_rfc3339_utc("2026-05-08T12:34:56.000+02:00").is_none()); // wrong tz
747        assert!(parse_rfc3339_utc("2026-13-08T12:34:56.000Z").is_none()); // bad month
748        assert!(parse_rfc3339_utc("not a date at all aaaaa").is_none());
749    }
750
751    #[test]
752    fn mutation_kind_str_is_stable() {
753        // The string form is the wire shape — these values are read by
754        // external tools (jq, grep). Lock them.
755        assert_eq!(MutationKind::Create.as_str(), "create");
756        assert_eq!(MutationKind::Update.as_str(), "update");
757        assert_eq!(MutationKind::Delete.as_str(), "delete");
758        assert_eq!(MutationKind::Relate.as_str(), "relate");
759        assert_eq!(MutationKind::Rename.as_str(), "rename");
760        assert_eq!(MutationKind::Batch.as_str(), "batch");
761    }
762}
763
764/// A folder mem's ledger set against its file set, reported and never
765/// repaired (consistency-sweep 04/04, criteria 1 and 2).
766///
767/// The ledger is the change record AND the drift cursor, and it is written
768/// only by the engine. A hand-edited, hand-added or hand-deleted markdown
769/// file appends no line, so `changes_since` reports an edit that happened as
770/// never having happened, and a ledger line can name a file that is gone.
771/// Neither is a parse bug; both are the record and the files disagreeing, and
772/// nothing said so.
773#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
774pub struct LedgerReconciliation {
775    /// Entity ids the ledger records but whose file is absent.
776    pub ledger_without_file: Vec<String>,
777    /// Entity ids present as files that the ledger never mentions.
778    pub file_without_ledger: Vec<String>,
779}
780
781impl LedgerReconciliation {
782    pub fn is_clean(&self) -> bool {
783        self.ledger_without_file.is_empty() && self.file_without_ledger.is_empty()
784    }
785}
786
787/// Reconcile `mem_root`'s ledger against the markdown files beside it.
788///
789/// **Reads only.** No ledger line is written, rewritten or removed, and no
790/// file is touched. Writing lines for edits the engine did not author would
791/// fabricate provenance for a change it cannot attribute, which is why this
792/// reports rather than tidies (criterion 2).
793///
794/// Called on demand, never on the staleness probe: that runs before every
795/// operation, and turning it into a directory walk would change the cost
796/// profile of the whole folder backend.
797pub fn reconcile_ledger(mem_root: &Path) -> Result<LedgerReconciliation, ChangelogError> {
798    let mut on_disk: std::collections::BTreeSet<String> = Default::default();
799    let meta_dir = mem_root.join(crate::mem::MEM_META_DIR);
800    let mut stack = vec![mem_root.to_path_buf()];
801    while let Some(dir) = stack.pop() {
802        let entries = match std::fs::read_dir(&dir) {
803            Ok(e) => e,
804            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
805            Err(source) => {
806                return Err(ChangelogError::Io {
807                    path: dir.clone(),
808                    source,
809                });
810            }
811        };
812        for entry in entries.flatten() {
813            let path = entry.path();
814            // The engine's own sidecar is not entity content.
815            if path == meta_dir {
816                continue;
817            }
818            if path.is_dir() {
819                stack.push(path);
820            } else if path.extension().is_some_and(|x| x == "md")
821                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
822            {
823                on_disk.insert(stem.to_string());
824            }
825        }
826    }
827
828    let mut in_ledger: std::collections::BTreeSet<String> = Default::default();
829    let log_path = changelog_path(mem_root);
830    match std::fs::read_to_string(&log_path) {
831        Ok(raw) => {
832            for line in raw.lines() {
833                let trimmed = line.trim();
834                if trimmed.is_empty() {
835                    continue;
836                }
837                if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed)
838                    && let Some(entity) = v.get("entity").and_then(|x| x.as_str())
839                {
840                    // Ledger ids are mem-qualified (`mem--slug`); files are
841                    // named by the slug alone. Compare on the slug, which is
842                    // what both surfaces actually agree on.
843                    let slug = entity.rsplit("--").next().unwrap_or(entity);
844                    in_ledger.insert(slug.to_string());
845                }
846            }
847        }
848        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
849        Err(source) => {
850            return Err(ChangelogError::Io {
851                path: log_path,
852                source,
853            });
854        }
855    }
856
857    Ok(LedgerReconciliation {
858        ledger_without_file: in_ledger.difference(&on_disk).cloned().collect(),
859        file_without_ledger: on_disk.difference(&in_ledger).cloned().collect(),
860    })
861}
862
863#[cfg(test)]
864mod reconcile_tests {
865    use super::*;
866    use tempfile::TempDir;
867
868    fn seed(root: &Path, files: &[&str], ledger_entities: &[&str]) {
869        for f in files {
870            std::fs::write(root.join(format!("{f}.md")), b"# x\n").unwrap();
871        }
872        let meta = root.join(crate::mem::MEM_META_DIR);
873        std::fs::create_dir_all(&meta).unwrap();
874        let lines: String = ledger_entities
875            .iter()
876            .map(|e| format!(r#"{{"ts":"2026-08-27T00:00:00Z","entity":"docs--{e}"}}"#) + "\n")
877            .collect();
878        std::fs::write(meta.join("changes.jsonl"), lines).unwrap();
879    }
880
881    /// 04/04, criterion 1: both directions of disagreement are named, and
882    /// named apart. A ledger line with no file and a file with no ledger line
883    /// are different problems for the reader.
884    #[test]
885    fn both_directions_of_ledger_divergence_are_named_separately() {
886        let tmp = TempDir::new().unwrap();
887        seed(tmp.path(), &["alpha", "orphaned-file"], &["alpha", "ghost"]);
888        let r = reconcile_ledger(tmp.path()).unwrap();
889        assert_eq!(r.ledger_without_file, vec!["ghost".to_string()]);
890        assert_eq!(r.file_without_ledger, vec!["orphaned-file".to_string()]);
891        assert!(!r.is_clean());
892    }
893
894    /// Criterion 2: the check reads. A reconciliation that tidied the ledger
895    /// would be fabricating provenance for changes the engine cannot
896    /// attribute, so nothing it touches may move.
897    #[test]
898    fn reconciliation_writes_nothing_at_all() {
899        let tmp = TempDir::new().unwrap();
900        seed(tmp.path(), &["alpha", "orphaned-file"], &["alpha", "ghost"]);
901        let ledger = tmp
902            .path()
903            .join(crate::mem::MEM_META_DIR)
904            .join("changes.jsonl");
905        let before_ledger = std::fs::read(&ledger).unwrap();
906        let before_files: Vec<_> = std::fs::read_dir(tmp.path())
907            .unwrap()
908            .filter_map(|e| e.ok())
909            .map(|e| (e.path(), e.metadata().map(|m| m.len()).unwrap_or(0)))
910            .collect();
911
912        let _ = reconcile_ledger(tmp.path()).unwrap();
913
914        assert_eq!(std::fs::read(&ledger).unwrap(), before_ledger);
915        let after_files: Vec<_> = std::fs::read_dir(tmp.path())
916            .unwrap()
917            .filter_map(|e| e.ok())
918            .map(|e| (e.path(), e.metadata().map(|m| m.len()).unwrap_or(0)))
919            .collect();
920        assert_eq!(after_files.len(), before_files.len());
921    }
922
923    /// A mem whose ledger and files agree reports clean, so the check is a
924    /// signal rather than a permanent complaint.
925    #[test]
926    fn an_agreeing_mem_reconciles_clean() {
927        let tmp = TempDir::new().unwrap();
928        seed(tmp.path(), &["alpha", "beta"], &["alpha", "beta"]);
929        assert!(reconcile_ledger(tmp.path()).unwrap().is_clean());
930    }
931
932    /// The engine's own sidecar is not entity content: counting
933    /// `.memstead/` would report every mem as diverging forever.
934    #[test]
935    fn the_engine_sidecar_is_not_mistaken_for_an_entity() {
936        let tmp = TempDir::new().unwrap();
937        seed(tmp.path(), &["alpha"], &["alpha"]);
938        std::fs::write(
939            tmp.path().join(crate::mem::MEM_META_DIR).join("notes.md"),
940            b"not an entity\n",
941        )
942        .unwrap();
943        assert!(reconcile_ledger(tmp.path()).unwrap().is_clean());
944    }
945}