Skip to main content

chio_kernel/
memory_provenance.rs

1//! Phase 18.2: 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. Phase 18.1 governs the writes at the
8//! guard layer; Phase 18.2 is the **evidence** side of that story: 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.
211pub trait MemoryProvenanceStore: Send + Sync {
212    /// Append a new entry, computing the chain linkage atomically.
213    fn append(
214        &self,
215        input: MemoryProvenanceAppend,
216    ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError>;
217
218    /// Fetch an entry by its unique id. Returns `Ok(None)` when the id
219    /// is absent; consumers should treat that as
220    /// [`UnverifiedReason::NoProvenance`] when it happens during a read.
221    fn get_entry(
222        &self,
223        entry_id: &str,
224    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
225
226    /// Fetch the most-recent entry for a `(store, key)` pair, or
227    /// `Ok(None)` when no entry has ever been appended for that key.
228    fn latest_for_key(
229        &self,
230        store: &str,
231        key: &str,
232    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
233
234    /// Verify a specific entry: recompute its hash, confirm its
235    /// `prev_hash` matches the entry that sits immediately before it
236    /// (or the genesis marker for entry #1), and return
237    /// [`ProvenanceVerification::Verified`] when everything checks out.
238    fn verify_entry(&self, entry_id: &str)
239        -> Result<ProvenanceVerification, MemoryProvenanceError>;
240
241    /// Aggregate digest of the chain -- the `hash` of the tail entry,
242    /// or [`MEMORY_PROVENANCE_GENESIS_PREV_HASH`] when the chain is
243    /// empty. Useful for embedding into receipts as a snapshot marker.
244    fn chain_digest(&self) -> Result<String, MemoryProvenanceError>;
245}
246
247/// Compute the canonical hash that binds every field of an entry into
248/// the chain.
249///
250/// Separated from [`MemoryProvenanceEntry::expected_hash`] so SQLite
251/// impls can call it before they have constructed the full entry.
252pub fn recompute_entry_hash(
253    entry_id: &str,
254    store: &str,
255    key: &str,
256    capability_id: &str,
257    receipt_id: &str,
258    written_at: u64,
259    prev_hash: &str,
260) -> Result<String, MemoryProvenanceError> {
261    let input = MemoryProvenanceHashInput {
262        schema: MEMORY_PROVENANCE_ENTRY_SCHEMA,
263        entry_id,
264        store,
265        key,
266        capability_id,
267        receipt_id,
268        written_at,
269        prev_hash,
270    };
271    let bytes = canonical_json_bytes(&input)
272        .map_err(|error| MemoryProvenanceError::Serialization(error.to_string()))?;
273    Ok(sha256_hex(&bytes))
274}
275
276/// Mint a new entry id. UUIDv7 so ids sort monotonically by issuance
277/// time, matching [`crate::receipt_support::next_receipt_id`].
278#[must_use]
279pub fn next_entry_id() -> String {
280    format!("mem-prov-{}", Uuid::now_v7())
281}
282
283// ---------------------------------------------------------------------
284// In-memory reference implementation.
285// ---------------------------------------------------------------------
286
287/// Thread-safe in-memory [`MemoryProvenanceStore`]. Useful for tests and
288/// for ephemeral deployments; production deployments should use the
289/// SQLite-backed store in `chio-store-sqlite`.
290#[derive(Default)]
291pub struct InMemoryMemoryProvenanceStore {
292    entries: Mutex<Vec<MemoryProvenanceEntry>>,
293}
294
295impl InMemoryMemoryProvenanceStore {
296    #[must_use]
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Test helper: overwrite an already-committed entry's `hash`
302    /// in place to simulate tamper. Always returns the previous entry
303    /// for assertion convenience.
304    #[cfg(test)]
305    pub(crate) fn tamper_entry_hash(
306        &self,
307        entry_id: &str,
308        forged_hash: &str,
309    ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError> {
310        let mut guard = self
311            .entries
312            .lock()
313            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
314        for entry in guard.iter_mut() {
315            if entry.entry_id == entry_id {
316                let previous = entry.clone();
317                entry.hash = forged_hash.to_string();
318                return Ok(previous);
319            }
320        }
321        Err(MemoryProvenanceError::NotFound(entry_id.to_string()))
322    }
323}
324
325impl MemoryProvenanceStore for InMemoryMemoryProvenanceStore {
326    fn append(
327        &self,
328        input: MemoryProvenanceAppend,
329    ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError> {
330        let mut guard = self
331            .entries
332            .lock()
333            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
334        let prev_hash = guard
335            .last()
336            .map(|entry| entry.hash.clone())
337            .unwrap_or_else(|| MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string());
338        let entry_id = next_entry_id();
339        let hash = recompute_entry_hash(
340            &entry_id,
341            &input.store,
342            &input.key,
343            &input.capability_id,
344            &input.receipt_id,
345            input.written_at,
346            &prev_hash,
347        )?;
348        let entry = MemoryProvenanceEntry {
349            entry_id,
350            store: input.store,
351            key: input.key,
352            capability_id: input.capability_id,
353            receipt_id: input.receipt_id,
354            written_at: input.written_at,
355            prev_hash,
356            hash,
357        };
358        guard.push(entry.clone());
359        Ok(entry)
360    }
361
362    fn get_entry(
363        &self,
364        entry_id: &str,
365    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError> {
366        let guard = self
367            .entries
368            .lock()
369            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
370        Ok(guard
371            .iter()
372            .find(|entry| entry.entry_id == entry_id)
373            .cloned())
374    }
375
376    fn latest_for_key(
377        &self,
378        store: &str,
379        key: &str,
380    ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError> {
381        let guard = self
382            .entries
383            .lock()
384            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
385        Ok(guard
386            .iter()
387            .rev()
388            .find(|entry| entry.store == store && entry.key == key)
389            .cloned())
390    }
391
392    fn verify_entry(
393        &self,
394        entry_id: &str,
395    ) -> Result<ProvenanceVerification, MemoryProvenanceError> {
396        let guard = self
397            .entries
398            .lock()
399            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
400        let Some(index) = guard.iter().position(|entry| entry.entry_id == entry_id) else {
401            return Ok(ProvenanceVerification::Unverified {
402                reason: UnverifiedReason::NoProvenance,
403            });
404        };
405        let entry = &guard[index];
406        let expected = entry.expected_hash()?;
407        if expected != entry.hash {
408            return Ok(ProvenanceVerification::Unverified {
409                reason: UnverifiedReason::ChainTampered,
410            });
411        }
412        let expected_prev = if index == 0 {
413            MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string()
414        } else {
415            guard[index - 1].hash.clone()
416        };
417        if expected_prev != entry.prev_hash {
418            return Ok(ProvenanceVerification::Unverified {
419                reason: UnverifiedReason::ChainLinkBroken,
420            });
421        }
422        let chain_digest = guard
423            .last()
424            .map(|tail| tail.hash.clone())
425            .unwrap_or_else(|| MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string());
426        Ok(ProvenanceVerification::Verified {
427            entry: entry.clone(),
428            chain_digest,
429        })
430    }
431
432    fn chain_digest(&self) -> Result<String, MemoryProvenanceError> {
433        let guard = self
434            .entries
435            .lock()
436            .map_err(|_| MemoryProvenanceError::Backend("entries mutex poisoned".to_string()))?;
437        Ok(guard
438            .last()
439            .map(|entry| entry.hash.clone())
440            .unwrap_or_else(|| MEMORY_PROVENANCE_GENESIS_PREV_HASH.to_string()))
441    }
442}
443
444// ---------------------------------------------------------------------
445// Memory action resolution helpers.
446//
447// The kernel does not depend on `chio-guards`, so we reimplement just
448// enough memory-action detection here to wire the provenance chain
449// without touching `ToolAction`. This is intentionally conservative:
450// it accepts the same tool-name conventions `chio-guards::action`
451// already uses (`memory_write`, `remember`, `vector_upsert`, etc.)
452// plus the canonical argument keys for store / key extraction.
453// ---------------------------------------------------------------------
454
455/// Classification of a memory-shaped tool call extracted from a
456/// `ToolCallRequest`. Empty `key` values mean "whole collection".
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum MemoryActionKind {
459    Write { store: String, key: String },
460    Read { store: String, key: String },
461}
462
463/// Inspect `tool_name` + `arguments` and return a memory action if the
464/// call matches one of the well-known memory-write / memory-read tool
465/// name conventions. Returns `None` for everything else so non-memory
466/// tool calls bypass the provenance hook entirely.
467#[must_use]
468pub fn classify_memory_action(
469    tool_name: &str,
470    arguments: &serde_json::Value,
471) -> Option<MemoryActionKind> {
472    let tool = tool_name.to_ascii_lowercase();
473
474    if is_memory_write_tool_name(&tool) {
475        let (store, key) = extract_store_and_key(&tool, arguments);
476        return Some(MemoryActionKind::Write { store, key });
477    }
478    if is_memory_read_tool_name(&tool) {
479        let (store, key) = extract_store_and_key(&tool, arguments);
480        return Some(MemoryActionKind::Read { store, key });
481    }
482    None
483}
484
485fn is_memory_write_tool_name(tool: &str) -> bool {
486    matches!(
487        tool,
488        "memory_write"
489            | "remember"
490            | "store_memory"
491            | "vector_upsert"
492            | "vector_write"
493            | "upsert"
494            | "pinecone_upsert"
495            | "weaviate_write"
496            | "qdrant_upsert"
497    )
498}
499
500fn is_memory_read_tool_name(tool: &str) -> bool {
501    matches!(
502        tool,
503        "memory_read"
504            | "recall"
505            | "retrieve_memory"
506            | "vector_query"
507            | "vector_search"
508            | "similarity_search"
509            | "pinecone_query"
510            | "weaviate_search"
511            | "qdrant_search"
512    )
513}
514
515fn extract_store_and_key(tool: &str, arguments: &serde_json::Value) -> (String, String) {
516    let store = arguments
517        .get("collection")
518        .or_else(|| arguments.get("index"))
519        .or_else(|| arguments.get("namespace"))
520        .or_else(|| arguments.get("store"))
521        .and_then(|value| value.as_str())
522        .map(str::to_string)
523        .unwrap_or_else(|| tool.to_string());
524    let key = arguments
525        .get("id")
526        .or_else(|| arguments.get("key"))
527        .or_else(|| arguments.get("memory_id"))
528        .and_then(|value| value.as_str())
529        .map(str::to_string)
530        .unwrap_or_default();
531    (store, key)
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn append_assigns_genesis_prev_hash_and_hex_hash() {
540        let store = InMemoryMemoryProvenanceStore::new();
541        let entry = store
542            .append(MemoryProvenanceAppend {
543                store: "vector:rag-notes".into(),
544                key: "doc-1".into(),
545                capability_id: "cap-1".into(),
546                receipt_id: "rcpt-1".into(),
547                written_at: 100,
548            })
549            .expect("append succeeds");
550        assert_eq!(entry.prev_hash, MEMORY_PROVENANCE_GENESIS_PREV_HASH);
551        assert_eq!(entry.hash.len(), 64);
552        assert!(entry.hash.chars().all(|c| c.is_ascii_hexdigit()));
553    }
554
555    #[test]
556    fn append_links_successive_entries_via_prev_hash() {
557        let store = InMemoryMemoryProvenanceStore::new();
558        let first = store
559            .append(MemoryProvenanceAppend {
560                store: "s".into(),
561                key: "a".into(),
562                capability_id: "cap-1".into(),
563                receipt_id: "rcpt-1".into(),
564                written_at: 100,
565            })
566            .unwrap();
567        let second = store
568            .append(MemoryProvenanceAppend {
569                store: "s".into(),
570                key: "b".into(),
571                capability_id: "cap-1".into(),
572                receipt_id: "rcpt-2".into(),
573                written_at: 101,
574            })
575            .unwrap();
576        assert_eq!(second.prev_hash, first.hash);
577        assert_ne!(second.hash, first.hash);
578    }
579
580    #[test]
581    fn latest_for_key_returns_most_recent_entry() {
582        let store = InMemoryMemoryProvenanceStore::new();
583        store
584            .append(MemoryProvenanceAppend {
585                store: "s".into(),
586                key: "doc-1".into(),
587                capability_id: "cap-1".into(),
588                receipt_id: "rcpt-1".into(),
589                written_at: 100,
590            })
591            .unwrap();
592        let later = store
593            .append(MemoryProvenanceAppend {
594                store: "s".into(),
595                key: "doc-1".into(),
596                capability_id: "cap-2".into(),
597                receipt_id: "rcpt-2".into(),
598                written_at: 150,
599            })
600            .unwrap();
601        let latest = store
602            .latest_for_key("s", "doc-1")
603            .unwrap()
604            .expect("an entry for doc-1 should exist");
605        assert_eq!(latest.entry_id, later.entry_id);
606        assert_eq!(latest.capability_id, "cap-2");
607    }
608
609    #[test]
610    fn verify_entry_detects_hash_tamper() {
611        let store = InMemoryMemoryProvenanceStore::new();
612        let entry = store
613            .append(MemoryProvenanceAppend {
614                store: "s".into(),
615                key: "doc-1".into(),
616                capability_id: "cap-1".into(),
617                receipt_id: "rcpt-1".into(),
618                written_at: 100,
619            })
620            .unwrap();
621        let forged = "f".repeat(64);
622        store
623            .tamper_entry_hash(&entry.entry_id, &forged)
624            .expect("test helper should overwrite the entry");
625        let verification = store.verify_entry(&entry.entry_id).unwrap();
626        assert!(
627            matches!(
628                verification,
629                ProvenanceVerification::Unverified {
630                    reason: UnverifiedReason::ChainTampered
631                }
632            ),
633            "expected chain_tampered verification, got {verification:?}"
634        );
635    }
636
637    #[test]
638    fn verify_entry_flags_unverified_when_id_absent() {
639        let store = InMemoryMemoryProvenanceStore::new();
640        let verification = store.verify_entry("missing-id").unwrap();
641        assert!(matches!(
642            verification,
643            ProvenanceVerification::Unverified {
644                reason: UnverifiedReason::NoProvenance
645            }
646        ));
647    }
648
649    #[test]
650    fn classify_memory_action_detects_writes_and_reads() {
651        let args = serde_json::json!({"collection": "notes", "id": "doc-42"});
652        match classify_memory_action("memory_write", &args) {
653            Some(MemoryActionKind::Write { store, key }) => {
654                assert_eq!(store, "notes");
655                assert_eq!(key, "doc-42");
656            }
657            other => panic!("expected MemoryActionKind::Write, got {other:?}"),
658        }
659        match classify_memory_action("vector_query", &args) {
660            Some(MemoryActionKind::Read { store, key }) => {
661                assert_eq!(store, "notes");
662                assert_eq!(key, "doc-42");
663            }
664            other => panic!("expected MemoryActionKind::Read, got {other:?}"),
665        }
666        assert!(classify_memory_action("read_file", &args).is_none());
667    }
668
669    #[test]
670    fn chain_digest_matches_tail_hash() {
671        let store = InMemoryMemoryProvenanceStore::new();
672        assert_eq!(
673            store.chain_digest().unwrap(),
674            MEMORY_PROVENANCE_GENESIS_PREV_HASH
675        );
676        let entry = store
677            .append(MemoryProvenanceAppend {
678                store: "s".into(),
679                key: "k".into(),
680                capability_id: "cap-1".into(),
681                receipt_id: "rcpt-1".into(),
682                written_at: 10,
683            })
684            .unwrap();
685        assert_eq!(store.chain_digest().unwrap(), entry.hash);
686    }
687}