Skip to main content

wm_memory/
revision.rs

1//! Per-memory revision chain (V8 S11c) — tamper-evident content history.
2//!
3//! Every `memory.update` that changes content appends one revision entry
4//! to the `revisions` DBI, keyed `rev:{galaxy}:{memory_id}:{seq}`. The
5//! chain is *self-verifying by construction*:
6//!
7//! - `seq` is continuous (gaps prove deletion);
8//! - `entry[n].new_hash == entry[n+1].old_hash` (breaks prove splicing);
9//! - the last `new_hash` must equal the memory's current `content_hash`
10//!   (mismatch proves an out-of-band rewrite).
11//!
12//! Entries carry hashes, not full content — cheap local tamper-*evidence*
13//! (recovery of old text rides `wm backup`). The chain hashes nothing of
14//! its own; it needs no keys and no crypto infrastructure, and it detects
15//! the exact rewrite class the write-audit journal cannot see: edits that
16//! are declared, journaled, and still leave no record of what was there
17//! before.
18
19use serde::{Deserialize, Serialize};
20use wm_core::Galaxy;
21
22use crate::memory::MemoryId;
23
24/// One content revision of a memory — appended on every content change.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct MemoryRevision {
27    /// 0-based position in this memory's revision chain.
28    pub seq: u32,
29    /// Unix timestamp (seconds) of the update dispatch.
30    pub timestamp: u64,
31    /// Content hash immediately before the change.
32    pub old_hash: String,
33    /// Content hash immediately after the change.
34    pub new_hash: String,
35    /// Attributed actor session (WM session id), when the dispatch ran
36    /// inside one.
37    #[serde(default)]
38    pub actor_session: Option<String>,
39    /// Attributed actor user label from MCP `_meta` (client-asserted).
40    #[serde(default)]
41    pub actor_user: Option<String>,
42    /// Mandala compartment the dispatch ran under (when declared).
43    /// S11b parity: the write-audit journal carries the same field, so a
44    /// revision and its journal entry attribute identically.
45    #[serde(default)]
46    pub actor_compartment: Option<String>,
47}
48
49/// Who performed a revision — snapshotted from the dispatch context.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct RevisionActor {
52    pub session: Option<String>,
53    pub user: Option<String>,
54    pub compartment: Option<String>,
55}
56
57/// Result of walking one memory's revision chain.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct RevisionChainReport {
60    pub entries: usize,
61    /// False when the chain has internal breaks (seq gaps, hash-linkage
62    /// splices) or the head hash does not match the live content hash.
63    pub valid: bool,
64    /// Human-readable break descriptions; empty when valid.
65    pub breaks: Vec<String>,
66    /// The last entry's `new_hash` equals the memory's current
67    /// `content_hash` (vacuously true for a memory with no revisions).
68    pub matches_head: bool,
69}
70
71/// Verify a revision chain against the memory's current content hash.
72#[must_use]
73pub fn verify_chain(entries: &[MemoryRevision], current_hash: &str) -> RevisionChainReport {
74    let mut breaks = Vec::new();
75    for (i, entry) in entries.iter().enumerate() {
76        if entry.seq != i as u32 {
77            breaks.push(format!(
78                "seq discontinuity at position {i}: expected {i}, found {}",
79                entry.seq
80            ));
81        }
82        if i > 0 {
83            let prev = &entries[i - 1];
84            if prev.new_hash != entry.old_hash {
85                breaks.push(format!(
86                    "hash-linkage break at seq {}: prev new_hash != entry old_hash",
87                    entry.seq
88                ));
89            }
90        }
91    }
92    let matches_head = match entries.last() {
93        None => true,
94        Some(last) => last.new_hash == current_hash,
95    };
96    if !matches_head {
97        breaks.push(
98            "head mismatch: last revision new_hash != current memory content_hash".to_string(),
99        );
100    }
101    RevisionChainReport {
102        entries: entries.len(),
103        valid: breaks.is_empty(),
104        breaks,
105        matches_head,
106    }
107}
108
109/// LMDB key for one revision: `rev:{galaxy}:{memory_id}:{seq:010}`.
110/// Zero-padded seq keeps lexicographic order == numeric order.
111#[must_use]
112pub fn revision_key(galaxy: Galaxy, id: MemoryId, seq: u32) -> Vec<u8> {
113    format!("rev:{}:{}:{seq:010}", galaxy.db_name(), id).into_bytes()
114}
115
116/// Key prefix covering every revision of one memory (for range scans).
117#[must_use]
118pub fn revision_prefix(galaxy: Galaxy, id: MemoryId) -> Vec<u8> {
119    format!("rev:{}:{}:", galaxy.db_name(), id).into_bytes()
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn rev(seq: u32, old_hash: &str, new_hash: &str) -> MemoryRevision {
127        MemoryRevision {
128            seq,
129            timestamp: 1_700_000_000,
130            old_hash: old_hash.to_string(),
131            new_hash: new_hash.to_string(),
132            actor_session: Some("ses-1".to_string()),
133            actor_user: None,
134            actor_compartment: None,
135        }
136    }
137
138    #[test]
139    fn intact_chain_is_valid() {
140        let entries = vec![rev(0, "h0", "h1"), rev(1, "h1", "h2"), rev(2, "h2", "h3")];
141        let report = verify_chain(&entries, "h3");
142        assert!(report.valid, "{:?}", report.breaks);
143        assert!(report.matches_head);
144        assert_eq!(report.entries, 3);
145    }
146
147    #[test]
148    fn deleted_entry_breaks_seq_continuity() {
149        let entries = vec![rev(0, "h0", "h1"), rev(2, "h1", "h3")];
150        let report = verify_chain(&entries, "h3");
151        assert!(!report.valid);
152        assert!(
153            report
154                .breaks
155                .iter()
156                .any(|b| b.contains("seq discontinuity"))
157        );
158    }
159
160    #[test]
161    fn spliced_entry_breaks_hash_linkage() {
162        // An attacker rewrites entry 0's old_hash; linkage to the (removed)
163        // prehistory breaks, and the splice is visible even though seq runs
164        // continuously.
165        let mut entries = vec![rev(0, "h0", "h1"), rev(1, "h1", "h2")];
166        entries[0].new_hash = "forged".to_string();
167        let report = verify_chain(&entries, "h2");
168        assert!(!report.valid);
169        assert!(report.breaks.iter().any(|b| b.contains("hash-linkage")));
170    }
171
172    #[test]
173    fn out_of_band_rewrite_breaks_head_match() {
174        let entries = vec![rev(0, "h0", "h1")];
175        let report = verify_chain(&entries, "h_undisclosed_edit");
176        assert!(!report.valid);
177        assert!(!report.matches_head);
178        assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
179    }
180
181    #[test]
182    fn empty_chain_is_vacuously_valid() {
183        let report = verify_chain(&[], "anything");
184        assert!(report.valid);
185        assert!(report.matches_head);
186        assert_eq!(report.entries, 0);
187    }
188
189    #[test]
190    fn keys_sort_numerically() {
191        let id = MemoryId::nil();
192        let mut keys: Vec<Vec<u8>> = (0..12u32)
193            .map(|s| revision_key(Galaxy::Codex, id, s))
194            .collect();
195        keys.sort();
196        for (i, key) in keys.iter().enumerate() {
197            assert!(String::from_utf8_lossy(key).ends_with(&format!("{i:010}")));
198        }
199        assert!(revision_prefix(Galaxy::Codex, id).starts_with(b"rev:"));
200    }
201}