Skip to main content

mnemo_core/model/
write_provenance.rs

1//! Write-time provenance — a tamper-evident record of *who wrote each memory
2//! under what authority*.
3//!
4//! This complements the read-time receipt in [`crate::provenance`]
5//! (`ReadProvenance`, which proves which records a recall cited). A
6//! [`WriteProvenance`] is recorded at REMEMBER / SHARE time and captures, per
7//! memory:
8//!
9//! - the writing **principal**,
10//! - the **capability** under which the write was authorised
11//!   ([`crate::model::capability::Capability`] id),
12//! - the **session / trace id**,
13//! - a **timestamp**,
14//!
15//! chained by hash so the whole write history is tamper-evident. It exists so
16//! that after a poisoning incident the store can be cleaned **by principal or by
17//! session** (FORGET BY PROVENANCE) instead of wiped — targeted remediation
18//! instead of a reset.
19//!
20//! Chain scheme: `content_hash = SHA-256(memory_id ‖ principal ‖ capability_id ‖
21//! session_id ‖ op ‖ authored_at ‖ prev_hash)`, and each record's `prev_hash` is
22//! the previous record's `content_hash`. Tampering with any field, or reordering
23//! / deleting a record, breaks the chain at [`verify_provenance_chain`].
24
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use subtle::ConstantTimeEq;
29use uuid::Uuid;
30
31use crate::hash::ChainVerificationResult;
32
33/// The write operation a provenance record attributes.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum WriteOp {
37    Remember,
38    Share,
39}
40
41impl WriteOp {
42    fn as_bytes(self) -> &'static [u8] {
43        match self {
44            WriteOp::Remember => b"remember",
45            WriteOp::Share => b"share",
46        }
47    }
48
49    pub fn as_str(self) -> &'static str {
50        match self {
51            WriteOp::Remember => "remember",
52            WriteOp::Share => "share",
53        }
54    }
55}
56
57/// A write-time flag recorded on a provenance record. Flags are **hashed into**
58/// the record's `content_hash`, so a flag cannot be stripped without breaking the
59/// chain — the security signal is tamper-evident, not advisory metadata.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum WriteFlag {
63    /// The written content has the SHAPE of a provider-returned opaque reasoning
64    /// payload (arXiv:2608.09867) — see [`crate::opaque_reasoning`]. Shape only:
65    /// this is NOT a proof the payload contains a secret. Recorded so such a write
66    /// can be found and revoked (by principal or session) later.
67    OpaqueReasoningPayload,
68}
69
70impl WriteFlag {
71    pub fn as_str(self) -> &'static str {
72        match self {
73            WriteFlag::OpaqueReasoningPayload => "opaque_reasoning_payload",
74        }
75    }
76
77    /// Parse from the stored string name. Unknown values are ignored (`None`) so
78    /// a forward-compatible reader never fails on a flag it does not know. (Named
79    /// `from_name`, not `from_str`, since it returns `Option` and does not follow
80    /// the `std::str::FromStr` `Result` contract.)
81    pub fn from_name(s: &str) -> Option<Self> {
82        match s {
83            "opaque_reasoning_payload" => Some(WriteFlag::OpaqueReasoningPayload),
84            _ => None,
85        }
86    }
87}
88
89/// Serialize a flag set to the single-column storage form: a comma-joined list
90/// of stable string names, sorted+deduped for a deterministic representation.
91/// Empty set → empty string.
92pub fn flags_to_storage(flags: &[WriteFlag]) -> String {
93    let mut names: Vec<&'static str> = flags.iter().map(|f| f.as_str()).collect();
94    names.sort_unstable();
95    names.dedup();
96    names.join(",")
97}
98
99/// Parse the storage form back to a flag set (sorted+deduped; unknown names
100/// skipped). Empty/whitespace → empty.
101pub fn flags_from_storage(s: &str) -> Vec<WriteFlag> {
102    let mut out: Vec<WriteFlag> = s
103        .split(',')
104        .map(|t| t.trim())
105        .filter(|t| !t.is_empty())
106        .filter_map(WriteFlag::from_name)
107        .collect();
108    out.sort_unstable();
109    out.dedup();
110    out
111}
112
113/// One tamper-evident provenance record for a single memory write.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct WriteProvenance {
116    pub id: Uuid,
117    pub memory_id: Uuid,
118    pub principal: String,
119    /// The capability the write was authorised under, if any.
120    pub capability_id: Option<Uuid>,
121    /// Session / trace id, so a whole session's writes can be revoked together.
122    pub session_id: Option<String>,
123    pub op: WriteOp,
124    pub authored_at: DateTime<Utc>,
125    /// Write-time flags (e.g. an opaque-reasoning-payload shape match). Hashed
126    /// into `content_hash`, so a flag is tamper-evident.
127    #[serde(default)]
128    pub flags: Vec<WriteFlag>,
129    /// Previous record's `content_hash`; `None` for the first record.
130    pub prev_hash: Option<Vec<u8>>,
131    pub content_hash: Vec<u8>,
132}
133
134/// Deterministic content hash over the provenance fields + `flags` + `prev_hash`.
135///
136/// `flags` is folded in via its canonical storage form (sorted+deduped), so an
137/// empty flag set contributes nothing — a pre-flags record and a no-flags record
138/// hash identically, which keeps older records verifiable.
139#[allow(clippy::too_many_arguments)]
140pub fn compute_provenance_hash(
141    memory_id: &Uuid,
142    principal: &str,
143    capability_id: &Option<Uuid>,
144    session_id: &Option<String>,
145    op: WriteOp,
146    authored_at: &DateTime<Utc>,
147    flags: &[WriteFlag],
148    prev_hash: Option<&[u8]>,
149) -> Vec<u8> {
150    let mut h = Sha256::new();
151    h.update(memory_id.as_bytes());
152    h.update(principal.as_bytes());
153    if let Some(cid) = capability_id {
154        h.update(cid.as_bytes());
155    }
156    if let Some(sid) = session_id {
157        h.update(sid.as_bytes());
158    }
159    h.update(op.as_bytes());
160    h.update(authored_at.to_rfc3339().as_bytes());
161    // Empty flag set → empty string → no bytes added (older records still hash
162    // the same). Non-empty is order-independent via the sorted storage form.
163    let flag_repr = flags_to_storage(flags);
164    if !flag_repr.is_empty() {
165        h.update(flag_repr.as_bytes());
166    }
167    if let Some(p) = prev_hash {
168        h.update(p);
169    }
170    h.finalize().to_vec()
171}
172
173impl WriteProvenance {
174    /// Build a provenance record chained onto `prev_hash`, computing its
175    /// `content_hash`. `prev_hash` is the previous record's `content_hash`
176    /// (`None` for the first record in the store's chain).
177    #[allow(clippy::too_many_arguments)]
178    pub fn new(
179        memory_id: Uuid,
180        principal: impl Into<String>,
181        capability_id: Option<Uuid>,
182        session_id: Option<String>,
183        op: WriteOp,
184        flags: Vec<WriteFlag>,
185        prev_hash: Option<Vec<u8>>,
186    ) -> Self {
187        let principal = principal.into();
188        let authored_at = Utc::now();
189        let mut flags = flags;
190        flags.sort_unstable();
191        flags.dedup();
192        let content_hash = compute_provenance_hash(
193            &memory_id,
194            &principal,
195            &capability_id,
196            &session_id,
197            op,
198            &authored_at,
199            &flags,
200            prev_hash.as_deref(),
201        );
202        Self {
203            id: Uuid::now_v7(),
204            memory_id,
205            principal,
206            capability_id,
207            session_id,
208            op,
209            authored_at,
210            flags,
211            prev_hash,
212            content_hash,
213        }
214    }
215
216    /// Recompute this record's `content_hash` from its fields and compare
217    /// (constant-time) to the stored value. `false` = the record was mutated.
218    pub fn content_hash_valid(&self) -> bool {
219        let expected = compute_provenance_hash(
220            &self.memory_id,
221            &self.principal,
222            &self.capability_id,
223            &self.session_id,
224            self.op,
225            &self.authored_at,
226            &self.flags,
227            self.prev_hash.as_deref(),
228        );
229        bool::from(expected.ct_eq(&self.content_hash))
230    }
231}
232
233/// Verify an ordered provenance chain: every record's `content_hash` must match
234/// its fields, and every `prev_hash` must equal the previous record's
235/// `content_hash`. Reordering, deleting, or mutating any record breaks it.
236pub fn verify_provenance_chain(records: &[WriteProvenance]) -> ChainVerificationResult {
237    let mut verified = 0;
238    for (i, rec) in records.iter().enumerate() {
239        if !rec.content_hash_valid() {
240            return ChainVerificationResult {
241                valid: false,
242                total_records: records.len(),
243                verified_records: verified,
244                first_broken_at: Some(rec.id),
245                error_message: Some(format!("provenance content hash mismatch at {}", rec.id)),
246            };
247        }
248        let expected_prev = if i == 0 {
249            None
250        } else {
251            Some(records[i - 1].content_hash.as_slice())
252        };
253        if rec.prev_hash.as_deref() != expected_prev {
254            return ChainVerificationResult {
255                valid: false,
256                total_records: records.len(),
257                verified_records: verified,
258                first_broken_at: Some(rec.id),
259                error_message: Some(format!("provenance chain link broken at {}", rec.id)),
260            };
261        }
262        verified += 1;
263    }
264    ChainVerificationResult {
265        valid: true,
266        total_records: records.len(),
267        verified_records: verified,
268        first_broken_at: None,
269        error_message: None,
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn chain(n: usize, principal: &str) -> Vec<WriteProvenance> {
278        let mut out: Vec<WriteProvenance> = Vec::new();
279        for _ in 0..n {
280            let prev = out.last().map(|r| r.content_hash.clone());
281            out.push(WriteProvenance::new(
282                Uuid::now_v7(),
283                principal,
284                None,
285                Some("sess-1".to_string()),
286                WriteOp::Remember,
287                Vec::new(),
288                prev,
289            ));
290        }
291        out
292    }
293
294    #[test]
295    fn valid_chain_verifies() {
296        let recs = chain(5, "alice");
297        let r = verify_provenance_chain(&recs);
298        assert!(r.valid);
299        assert_eq!(r.verified_records, 5);
300        assert!(r.first_broken_at.is_none());
301    }
302
303    #[test]
304    fn empty_chain_is_valid() {
305        assert!(verify_provenance_chain(&[]).valid);
306    }
307
308    #[test]
309    fn mutating_a_field_breaks_the_chain() {
310        let mut recs = chain(3, "alice");
311        recs[1].principal = "mallory".to_string(); // content_hash no longer matches
312        let r = verify_provenance_chain(&recs);
313        assert!(!r.valid);
314        assert_eq!(r.first_broken_at, Some(recs[1].id));
315        assert!(r.error_message.unwrap().contains("content hash mismatch"));
316    }
317
318    #[test]
319    fn deleting_a_record_breaks_the_link() {
320        let mut recs = chain(4, "alice");
321        recs.remove(2); // now recs[2].prev_hash points at the deleted record
322        let r = verify_provenance_chain(&recs);
323        assert!(!r.valid);
324        assert!(r.error_message.unwrap().contains("chain link broken"));
325    }
326
327    #[test]
328    fn capability_and_session_are_hashed() {
329        let cid = Uuid::now_v7();
330        let a = WriteProvenance::new(
331            Uuid::now_v7(),
332            "p",
333            Some(cid),
334            Some("s".to_string()),
335            WriteOp::Share,
336            Vec::new(),
337            None,
338        );
339        // Same memory/principal but different capability => different hash.
340        let mut b = a.clone();
341        b.capability_id = Some(Uuid::now_v7());
342        assert!(!b.content_hash_valid());
343    }
344
345    #[test]
346    fn flags_are_hashed_and_tamper_evident() {
347        // An empty flag set hashes identically to a record built before flags
348        // existed (empty repr contributes no bytes).
349        let mid = Uuid::now_v7();
350        let no_flags = WriteProvenance::new(mid, "p", None, None, WriteOp::Remember, vec![], None);
351        assert!(no_flags.content_hash_valid());
352
353        // A flagged record verifies...
354        let flagged = WriteProvenance::new(
355            mid,
356            "p",
357            None,
358            None,
359            WriteOp::Remember,
360            vec![WriteFlag::OpaqueReasoningPayload],
361            None,
362        );
363        assert!(flagged.content_hash_valid());
364
365        // ...but stripping the flag off a flagged record breaks the hash.
366        let mut stripped = flagged.clone();
367        stripped.flags.clear();
368        assert!(
369            !stripped.content_hash_valid(),
370            "removing a recorded flag must break the content hash"
371        );
372    }
373
374    #[test]
375    fn flags_storage_roundtrips_sorted_deduped() {
376        let f = vec![
377            WriteFlag::OpaqueReasoningPayload,
378            WriteFlag::OpaqueReasoningPayload,
379        ];
380        let s = flags_to_storage(&f);
381        assert_eq!(s, "opaque_reasoning_payload");
382        assert_eq!(
383            flags_from_storage(&s),
384            vec![WriteFlag::OpaqueReasoningPayload]
385        );
386        assert!(flags_from_storage("").is_empty());
387        assert!(flags_from_storage("unknown_future_flag").is_empty());
388    }
389}