Skip to main content

chio_kernel/
memory_provenance.rs

1//! Memory-entry provenance.
2//!
3//! Structural security gap #3 in `docs/protocols/STRUCTURAL-SECURITY-FIXES.md`
4//! points out that agent memory writes (vector DBs, conversation history,
5//! scratchpads) normally happen outside Chio's guard pipeline, which lets a
6//! compromised or confused agent plant cross-session prompt-injection
7//! payloads with no attribution. The guard layer governs the writes; this
8//! module is the **evidence** side: every
9//! governed write appends an entry to an append-only, hash-chained
10//! provenance log that ties the write to the capability and receipt that
11//! authorized it. On read, the kernel looks up the latest provenance
12//! entry for the `(store, key)` pair and attaches it to the receipt as
13//! `memory_provenance` evidence.
14//!
15//! Keys are *pairs* (`store`, `key`); the empty key string is the
16//! canonical "whole-collection" marker emitted by `MemoryRead` when a
17//! read does not target a specific document id. Reads whose key has no
18//! chain entry are marked [`ProvenanceVerification::Unverified`] so the
19//! caller can distinguish "never governed" from "tampered chain".
20//!
21//! Fail-closed semantics:
22//! * Append returns [`MemoryProvenanceError`] on any store failure; the
23//!   kernel wiring treats that as a fatal error on the memory-write
24//!   path (the write has already been signed as allowed, but the
25//!   provenance chain must not silently drop entries).
26//! * Verification returns [`ProvenanceVerification::Unverified`] rather
27//!   than an error when the chain is intact but no entry exists, and
28//!   returns it with a `tampered: true` reason when the stored hash
29//!   disagrees with what canonical-JSON + SHA-256 would produce.
30//!
31//! The trait is intentionally synchronous and mirrors the pattern used
32//! by [`crate::approval::ApprovalStore`],
33//! [`crate::execution_nonce::ExecutionNonceStore`], and the other kernel
34//! stores: in-memory reference impl lives here, SQLite impl lives in
35//! `chio-store-sqlite`.
36
37use std::sync::Mutex;
38
39use chio_core::canonical::canonical_json_bytes;
40use chio_core::crypto::sha256_hex;
41use serde::{Deserialize, Serialize};
42use uuid::Uuid;
43
44/// Schema tag used in canonical-JSON hashing. Bumping this invalidates
45/// existing chains.
46pub const MEMORY_PROVENANCE_ENTRY_SCHEMA: &str = "chio.memory_provenance_entry.v1";
47
48/// Sentinel `prev_hash` used for the first entry in a chain. Kept as a
49/// fixed 64-character hex string of zeros so canonical-JSON hashing is
50/// deterministic and the chain has no special-case branch.
51pub const MEMORY_PROVENANCE_GENESIS_PREV_HASH: &str =
52    "0000000000000000000000000000000000000000000000000000000000000000";
53
54/// Entry committed to the append-only provenance chain.
55///
56/// `hash = sha256_hex(canonical_json(MemoryProvenanceHashInput))`, where
57/// the hash input carries every field *except* `hash` itself and is
58/// serialised in the canonical-JSON form mandated by the rest of Chio
59/// (RFC 8785 via [`chio_core::canonical::canonical_json_bytes`]). The
60/// `prev_hash` field is baked into the hash input, so replacing or
61/// reordering entries after the fact breaks the chain.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct MemoryProvenanceEntry {
64    /// Globally unique entry id, assigned by the store.
65    pub entry_id: String,
66    /// Memory store / collection / namespace the write targeted.
67    pub store: String,
68    /// Key, document id, or namespace identifier within `store`.
69    /// Empty string is the canonical "whole-collection" marker.
70    pub key: String,
71    /// Capability id that authorized the write.
72    pub capability_id: String,
73    /// Receipt id emitted for the write.
74    pub receipt_id: String,
75    /// Unix seconds at write time.
76    pub written_at: u64,
77    /// `hash` of the previous entry in the chain, or
78    /// [`MEMORY_PROVENANCE_GENESIS_PREV_HASH`] for the very first entry.
79    pub prev_hash: String,
80    /// `sha256_hex(canonical_json(self_without_hash))`. Verified by
81    /// [`recompute_entry_hash`].
82    pub hash: String,
83}
84
85/// Canonical-JSON form used to compute `MemoryProvenanceEntry.hash`.
86///
87/// Kept in lockstep with [`MemoryProvenanceEntry`] minus the `hash`
88/// field: every other field participates in the hash, and the `schema`
89/// tag binds the format so an old chain cannot be mis-interpreted under
90/// a future schema.
91#[derive(Debug, Clone, Serialize)]
92struct MemoryProvenanceHashInput<'a> {
93    schema: &'a str,
94    entry_id: &'a str,
95    store: &'a str,
96    key: &'a str,
97    capability_id: &'a str,
98    receipt_id: &'a str,
99    written_at: u64,
100    prev_hash: &'a str,
101}
102
103impl MemoryProvenanceEntry {
104    /// Return the canonical hash for this entry, ignoring the currently
105    /// stored `hash` field. Used by [`MemoryProvenanceStore::verify_entry`]
106    /// implementations to detect in-place tampering.
107    pub fn expected_hash(&self) -> Result<String, MemoryProvenanceError> {
108        recompute_entry_hash(
109            &self.entry_id,
110            &self.store,
111            &self.key,
112            &self.capability_id,
113            &self.receipt_id,
114            self.written_at,
115            &self.prev_hash,
116        )
117    }
118}
119
120/// Input accepted by [`MemoryProvenanceStore::append`].
121///
122/// The store assigns `entry_id`, `prev_hash`, and `hash` internally;
123/// callers (the kernel wiring) only supply the business-level fields.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct MemoryProvenanceAppend {
126    pub store: String,
127    pub key: String,
128    pub capability_id: String,
129    pub receipt_id: String,
130    pub written_at: u64,
131}
132
133/// Result of looking up provenance for a `(store, key)` pair.
134///
135/// `Verified` carries the entry whose chain linkage and hash both
136/// check out. `Unverified` is the fail-closed signal that either no
137/// entry was ever written, the chain has been tampered, or the chain
138/// cannot currently be read (store unavailable).
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140#[serde(rename_all = "snake_case", tag = "status")]
141pub enum ProvenanceVerification {
142    /// A chain entry was found and its hash / link verified locally.
143    Verified {
144        entry: MemoryProvenanceEntry,
145        /// Current aggregate chain digest, useful for correlating
146        /// receipts with a chain root.
147        chain_digest: String,
148    },
149    /// No chain entry for this `(store, key)` pair, OR the chain is
150    /// currently inaccessible / tampered. `reason` narrows the case so
151    /// receipt consumers can log it without swallowing failures.
152    Unverified { reason: UnverifiedReason },
153}
154
155/// Why a memory read could not be verified against the provenance chain.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum UnverifiedReason {
159    /// No entry has ever been appended for the `(store, key)` pair.
160    /// The memory entry predates governance (or bypassed it).
161    NoProvenance,
162    /// A stored entry exists but its recomputed hash disagrees with
163    /// the hash field. Chain tamper detected.
164    ChainTampered,
165    /// A stored entry exists but its `prev_hash` does not line up with
166    /// the entry that sits before it. Chain linkage broken.
167    ChainLinkBroken,
168    /// The provenance store is unavailable (mutex poisoned, SQLite
169    /// error, etc.). Operators must treat this as fail-closed: the
170    /// memory read surfaces the `Unverified` verdict so callers can
171    /// deny rather than silently accept.
172    StoreUnavailable,
173}
174
175impl UnverifiedReason {
176    /// Stable string label for this reason, useful for logs and
177    /// receipt metadata.
178    #[must_use]
179    pub fn as_str(&self) -> &'static str {
180        match self {
181            Self::NoProvenance => "no_provenance",
182            Self::ChainTampered => "chain_tampered",
183            Self::ChainLinkBroken => "chain_link_broken",
184            Self::StoreUnavailable => "store_unavailable",
185        }
186    }
187}
188
189/// Errors returned by [`MemoryProvenanceStore`] implementations.
190#[derive(Debug, thiserror::Error)]
191pub enum MemoryProvenanceError {
192    #[error("memory provenance store backend error: {0}")]
193    Backend(String),
194    #[error("memory provenance canonical serialization failed: {0}")]
195    Serialization(String),
196    #[error("memory provenance entry not found: {0}")]
197    NotFound(String),
198}
199
200/// Contract for the append-only, hash-chained memory provenance log.
201///
202/// Implementations MUST:
203/// 1. Compute `prev_hash` by reading the tail entry inside the same
204///    transactional scope as the append, so concurrent appenders cannot
205///    both read the same tail and produce a forked chain.
206/// 2. Populate `hash` with [`recompute_entry_hash`] (or equivalent) so
207///    every consumer can independently verify the entry.
208/// 3. Keep the chain insertion order total: `append` followed by
209///    `latest_for_key` / `chain_digest` must observe the freshly written
210///    entry.
211/// 4. Treat a byte-equivalent replay with the same `receipt_id` as idempotent
212///    and reject reuse of that receipt id for different provenance.
213pub trait MemoryProvenanceStore: Send + Sync {
214    /// Append a new entry, computing the chain linkage atomically.
215    fn append(
216        &self,
217        input: MemoryProvenanceAppend,
218    ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError>;
219
220    /// Fetch an entry by its unique id. Returns `Ok(None)` when the id
221    /// is absent; consumers should treat that as
222    /// [`UnverifiedReason::NoProvenance`] when it happens during a read.
223    fn get_entry(
224        &self,
225        entry_id: &str,
226    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
227
228    /// Fetch the most-recent entry for a `(store, key)` pair, or
229    /// `Ok(None)` when no entry has ever been appended for that key.
230    fn latest_for_key(
231        &self,
232        store: &str,
233        key: &str,
234    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
235
236    /// Verify a specific entry: recompute its hash, confirm its
237    /// `prev_hash` matches the entry that sits immediately before it
238    /// (or the genesis marker for entry #1), and return
239    /// [`ProvenanceVerification::Verified`] when everything checks out.
240    fn verify_entry(&self, entry_id: &str)
241        -> Result<ProvenanceVerification, MemoryProvenanceError>;
242
243    /// Aggregate digest of the chain -- the `hash` of the tail entry,
244    /// or [`MEMORY_PROVENANCE_GENESIS_PREV_HASH`] when the chain is
245    /// empty. Useful for embedding into receipts as a snapshot marker.
246    fn chain_digest(&self) -> Result<String, MemoryProvenanceError>;
247}
248
249/// Compute the canonical hash that binds every field of an entry into
250/// the chain.
251///
252/// Separated from [`MemoryProvenanceEntry::expected_hash`] so SQLite
253/// impls can call it before they have constructed the full entry.
254pub fn recompute_entry_hash(
255    entry_id: &str,
256    store: &str,
257    key: &str,
258    capability_id: &str,
259    receipt_id: &str,
260    written_at: u64,
261    prev_hash: &str,
262) -> Result<String, MemoryProvenanceError> {
263    let input = MemoryProvenanceHashInput {
264        schema: MEMORY_PROVENANCE_ENTRY_SCHEMA,
265        entry_id,
266        store,
267        key,
268        capability_id,
269        receipt_id,
270        written_at,
271        prev_hash,
272    };
273    let bytes = canonical_json_bytes(&input)
274        .map_err(|error| MemoryProvenanceError::Serialization(error.to_string()))?;
275    Ok(sha256_hex(&bytes))
276}
277
278/// Mint a new entry id. UUIDv7 so ids sort monotonically by issuance
279/// time, matching [`crate::receipt_support::next_receipt_id`].
280#[must_use]
281pub fn next_entry_id() -> String {
282    format!("mem-prov-{}", Uuid::now_v7())
283}
284
285// ---------------------------------------------------------------------
286// In-memory reference implementation.
287// ---------------------------------------------------------------------
288
289/// Thread-safe in-memory [`MemoryProvenanceStore`]. Useful for tests and
290/// for ephemeral deployments; production deployments should use the
291/// SQLite-backed store in `chio-store-sqlite`.
292#[derive(Default)]
293pub struct InMemoryMemoryProvenanceStore {
294    entries: Mutex<Vec<MemoryProvenanceEntry>>,
295}
296
297impl InMemoryMemoryProvenanceStore {
298    #[must_use]
299    pub fn new() -> Self {
300        Self::default()
301    }
302
303    /// Test helper: overwrite an already-committed entry's `hash`
304    /// in place to simulate tamper. Always returns the previous entry
305    /// for assertion convenience.
306    #[cfg(test)]
307    pub(crate) fn tamper_entry_hash(
308        &self,
309        entry_id: &str,
310        forged_hash: &str,
311    ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError> {
312        let mut guard = self
313            .entries
314            .lock()
315            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
316        for entry in guard.iter_mut() {
317            if entry.entry_id == entry_id {
318                let previous = entry.clone();
319                entry.hash = forged_hash.to_string();
320                return Ok(previous);
321            }
322        }
323        Err(MemoryProvenanceError::NotFound(entry_id.to_string()))
324    }
325}
326
327impl MemoryProvenanceStore for InMemoryMemoryProvenanceStore {
328    fn append(
329        &self,
330        input: MemoryProvenanceAppend,
331    ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError> {
332        let mut guard = self
333            .entries
334            .lock()
335            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
336        if let Some(existing) = guard
337            .iter()
338            .find(|entry| entry.receipt_id == input.receipt_id)
339        {
340            if existing.store == input.store
341                && existing.key == input.key
342                && existing.capability_id == input.capability_id
343                && existing.written_at == input.written_at
344            {
345                return Ok(existing.clone());
346            }
347            return Err(MemoryProvenanceError::Backend(
348                "memory provenance receipt id was reused with different fields".to_string(),
349            ));
350        }
351        let prev_hash = guard
352            .last()
353            .map(|entry| entry.hash.clone())
354            .unwrap_or_else(|| MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string());
355        let entry_id = next_entry_id();
356        let hash = recompute_entry_hash(
357            &entry_id,
358            &input.store,
359            &input.key,
360            &input.capability_id,
361            &input.receipt_id,
362            input.written_at,
363            &prev_hash,
364        )?;
365        let entry = MemoryProvenanceEntry {
366            entry_id,
367            store: input.store,
368            key: input.key,
369            capability_id: input.capability_id,
370            receipt_id: input.receipt_id,
371            written_at: input.written_at,
372            prev_hash,
373            hash,
374        };
375        guard.push(entry.clone());
376        Ok(entry)
377    }
378
379    fn get_entry(
380        &self,
381        entry_id: &str,
382    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError> {
383        let guard = self
384            .entries
385            .lock()
386            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
387        Ok(guard
388            .iter()
389            .find(|entry| entry.entry_id == entry_id)
390            .cloned())
391    }
392
393    fn latest_for_key(
394        &self,
395        store: &str,
396        key: &str,
397    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError> {
398        let guard = self
399            .entries
400            .lock()
401            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
402        Ok(guard
403            .iter()
404            .rev()
405            .find(|entry| entry.store == store && entry.key == key)
406            .cloned())
407    }
408
409    fn verify_entry(
410        &self,
411        entry_id: &str,
412    ) -> Result<ProvenanceVerification, MemoryProvenanceError> {
413        let guard = self
414            .entries
415            .lock()
416            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
417        let Some(index) = guard.iter().position(|entry| entry.entry_id == entry_id) else {
418            return Ok(ProvenanceVerification::Unverified {
419                reason: UnverifiedReason::NoProvenance,
420            });
421        };
422        let entry = &guard[index];
423        let expected = entry.expected_hash()?;
424        if expected != entry.hash {
425            return Ok(ProvenanceVerification::Unverified {
426                reason: UnverifiedReason::ChainTampered,
427            });
428        }
429        let expected_prev = if index == 0 {
430            MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string()
431        } else {
432            guard[index - 1].hash.clone()
433        };
434        if expected_prev != entry.prev_hash {
435            return Ok(ProvenanceVerification::Unverified {
436                reason: UnverifiedReason::ChainLinkBroken,
437            });
438        }
439        let chain_digest = guard
440            .last()
441            .map(|tail| tail.hash.clone())
442            .unwrap_or_else(|| MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string());
443        Ok(ProvenanceVerification::Verified {
444            entry: entry.clone(),
445            chain_digest,
446        })
447    }
448
449    fn chain_digest(&self) -> Result<String, MemoryProvenanceError> {
450        let guard = self
451            .entries
452            .lock()
453            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
454        Ok(guard
455            .last()
456            .map(|entry| entry.hash.clone())
457            .unwrap_or_else(|| MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string()))
458    }
459}
460
461// ---------------------------------------------------------------------
462// Memory action resolution helpers.
463//
464// The kernel does not depend on `chio-guards`, so we reimplement just
465// enough memory-action detection here to wire the provenance chain
466// without touching `ToolAction`. This is intentionally conservative:
467// it accepts the same tool-name conventions `chio-guards::action`
468// already uses (`memory_write`, `remember`, `vector_upsert`, etc.)
469// plus the canonical argument keys for store / key extraction.
470// ---------------------------------------------------------------------
471
472/// Classification of a memory-shaped tool call extracted from a
473/// `ToolCallRequest`. Empty `key` values mean "whole collection".
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum MemoryActionKind {
476    Write { store: String, key: String },
477    Read { store: String, key: String },
478}
479
480/// Inspect `tool_name` + `arguments` and return a memory action if the
481/// call matches one of the well-known memory-write / memory-read tool
482/// name conventions. Returns `None` for everything else so non-memory
483/// tool calls bypass the provenance hook entirely.
484#[must_use]
485pub fn classify_memory_action(
486    tool_name: &str,
487    arguments: &serde_json::Value,
488) -> Option<MemoryActionKind> {
489    let tool = tool_name.to_ascii_lowercase();
490
491    if is_memory_write_tool_name(&tool) {
492        let (store, key) = extract_store_and_key(&tool, arguments);
493        return Some(MemoryActionKind::Write { store, key });
494    }
495    if is_memory_read_tool_name(&tool) {
496        let (store, key) = extract_store_and_key(&tool, arguments);
497        return Some(MemoryActionKind::Read { store, key });
498    }
499    None
500}
501
502fn is_memory_write_tool_name(tool: &str) -> bool {
503    matches!(
504        tool,
505        "memory_write"
506            | "remember"
507            | "store_memory"
508            | "vector_upsert"
509            | "vector_write"
510            | "upsert"
511            | "pinecone_upsert"
512            | "weaviate_write"
513            | "qdrant_upsert"
514    )
515}
516
517fn is_memory_read_tool_name(tool: &str) -> bool {
518    matches!(
519        tool,
520        "memory_read"
521            | "recall"
522            | "retrieve_memory"
523            | "vector_query"
524            | "vector_search"
525            | "similarity_search"
526            | "pinecone_query"
527            | "weaviate_search"
528            | "qdrant_search"
529    )
530}
531
532fn extract_store_and_key(tool: &str, arguments: &serde_json::Value) -> (String, String) {
533    let store = arguments
534        .get("collection")
535        .or_else(|| arguments.get("index"))
536        .or_else(|| arguments.get("namespace"))
537        .or_else(|| arguments.get("store"))
538        .and_then(|value| value.as_str())
539        .map(str::to_string)
540        .unwrap_or_else(|| tool.to_string());
541    let key = arguments
542        .get("id")
543        .or_else(|| arguments.get("key"))
544        .or_else(|| arguments.get("memory_id"))
545        .and_then(|value| value.as_str())
546        .map(str::to_string)
547        .unwrap_or_default();
548    (store, key)
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn append_assigns_genesis_prev_hash_and_hex_hash() {
557        let store = InMemoryMemoryProvenanceStore::new();
558        let entry = store
559            .append(MemoryProvenanceAppend {
560                store: "vector:rag-notes".into(),
561                key: "doc-1".into(),
562                capability_id: "cap-1".into(),
563                receipt_id: "rcpt-1".into(),
564                written_at: 100,
565            })
566            .expect("append succeeds");
567        assert_eq!(entry.prev_hash, MEMORY_PROVENANCE_GENESIS_PREV_HASH);
568        assert_eq!(entry.hash.len(), 64);
569        assert!(entry.hash.chars().all(|c| c.is_ascii_hexdigit()));
570    }
571
572    #[test]
573    fn append_is_idempotent_by_receipt_id() {
574        let store = InMemoryMemoryProvenanceStore::new();
575        let input = MemoryProvenanceAppend {
576            store: "vector:rag-notes".into(),
577            key: "doc-1".into(),
578            capability_id: "cap-1".into(),
579            receipt_id: "rcpt-1".into(),
580            written_at: 100,
581        };
582        let first = store.append(input.clone()).expect("first append succeeds");
583        let replay = store.append(input).expect("replay succeeds");
584
585        assert_eq!(replay, first);
586        assert!(store
587            .append(MemoryProvenanceAppend {
588                store: "vector:rag-notes".into(),
589                key: "doc-2".into(),
590                capability_id: "cap-1".into(),
591                receipt_id: "rcpt-1".into(),
592                written_at: 100,
593            })
594            .is_err());
595    }
596
597    #[test]
598    fn append_links_successive_entries_via_prev_hash() {
599        let store = InMemoryMemoryProvenanceStore::new();
600        let first = store
601            .append(MemoryProvenanceAppend {
602                store: "s".into(),
603                key: "a".into(),
604                capability_id: "cap-1".into(),
605                receipt_id: "rcpt-1".into(),
606                written_at: 100,
607            })
608            .unwrap();
609        let second = store
610            .append(MemoryProvenanceAppend {
611                store: "s".into(),
612                key: "b".into(),
613                capability_id: "cap-1".into(),
614                receipt_id: "rcpt-2".into(),
615                written_at: 101,
616            })
617            .unwrap();
618        assert_eq!(second.prev_hash, first.hash);
619        assert_ne!(second.hash, first.hash);
620    }
621
622    #[test]
623    fn latest_for_key_returns_most_recent_entry() {
624        let store = InMemoryMemoryProvenanceStore::new();
625        store
626            .append(MemoryProvenanceAppend {
627                store: "s".into(),
628                key: "doc-1".into(),
629                capability_id: "cap-1".into(),
630                receipt_id: "rcpt-1".into(),
631                written_at: 100,
632            })
633            .unwrap();
634        let later = store
635            .append(MemoryProvenanceAppend {
636                store: "s".into(),
637                key: "doc-1".into(),
638                capability_id: "cap-2".into(),
639                receipt_id: "rcpt-2".into(),
640                written_at: 150,
641            })
642            .unwrap();
643        let latest = store
644            .latest_for_key("s", "doc-1")
645            .unwrap()
646            .expect("an entry for doc-1 should exist");
647        assert_eq!(latest.entry_id, later.entry_id);
648        assert_eq!(latest.capability_id, "cap-2");
649    }
650
651    #[test]
652    fn verify_entry_detects_hash_tamper() {
653        let store = InMemoryMemoryProvenanceStore::new();
654        let entry = store
655            .append(MemoryProvenanceAppend {
656                store: "s".into(),
657                key: "doc-1".into(),
658                capability_id: "cap-1".into(),
659                receipt_id: "rcpt-1".into(),
660                written_at: 100,
661            })
662            .unwrap();
663        let forged = "f".repeat(64);
664        store
665            .tamper_entry_hash(&entry.entry_id, &forged)
666            .expect("test helper should overwrite the entry");
667        let verification = store.verify_entry(&entry.entry_id).unwrap();
668        assert!(
669            matches!(
670                verification,
671                ProvenanceVerification::Unverified {
672                    reason: UnverifiedReason::ChainTampered
673                }
674            ),
675            "expected chain_tampered verification, got {verification:?}"
676        );
677    }
678
679    #[test]
680    fn verify_entry_flags_unverified_when_id_absent() {
681        let store = InMemoryMemoryProvenanceStore::new();
682        let verification = store.verify_entry("missing-id").unwrap();
683        assert!(matches!(
684            verification,
685            ProvenanceVerification::Unverified {
686                reason: UnverifiedReason::NoProvenance
687            }
688        ));
689    }
690
691    #[test]
692    fn classify_memory_action_detects_writes_and_reads() {
693        let args = serde_json::json!({"collection": "notes", "id": "doc-42"});
694        match classify_memory_action("memory_write", &args) {
695            Some(MemoryActionKind::Write { store, key }) => {
696                assert_eq!(store, "notes");
697                assert_eq!(key, "doc-42");
698            }
699            other => panic!("expected MemoryActionKind::Write, got {other:?}"),
700        }
701        match classify_memory_action("vector_query", &args) {
702            Some(MemoryActionKind::Read { store, key }) => {
703                assert_eq!(store, "notes");
704                assert_eq!(key, "doc-42");
705            }
706            other => panic!("expected MemoryActionKind::Read, got {other:?}"),
707        }
708        assert!(classify_memory_action("read_file", &args).is_none());
709    }
710
711    #[test]
712    fn chain_digest_matches_tail_hash() {
713        let store = InMemoryMemoryProvenanceStore::new();
714        assert_eq!(
715            store.chain_digest().unwrap(),
716            MEMORY_PROVENANCE_GENESIS_PREV_HASH
717        );
718        let entry = store
719            .append(MemoryProvenanceAppend {
720                store: "s".into(),
721                key: "k".into(),
722                capability_id: "cap-1".into(),
723                receipt_id: "rcpt-1".into(),
724                written_at: 10,
725            })
726            .unwrap();
727        assert_eq!(store.chain_digest().unwrap(), entry.hash);
728    }
729}