1use 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
44pub const MEMORY_PROVENANCE_ENTRY_SCHEMA: &str = "chio.memory_provenance_entry.v1";
47
48pub const MEMORY_PROVENANCE_GENESIS_PREV_HASH: &str =
52 "0000000000000000000000000000000000000000000000000000000000000000";
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct MemoryProvenanceEntry {
64 pub entry_id: String,
66 pub store: String,
68 pub key: String,
71 pub capability_id: String,
73 pub receipt_id: String,
75 pub written_at: u64,
77 pub prev_hash: String,
80 pub hash: String,
83}
84
85#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140#[serde(rename_all = "snake_case", tag = "status")]
141pub enum ProvenanceVerification {
142 Verified {
144 entry: MemoryProvenanceEntry,
145 chain_digest: String,
148 },
149 Unverified { reason: UnverifiedReason },
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum UnverifiedReason {
159 NoProvenance,
162 ChainTampered,
165 ChainLinkBroken,
168 StoreUnavailable,
173}
174
175impl UnverifiedReason {
176 #[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#[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
200pub trait MemoryProvenanceStore: Send + Sync {
212 fn append(
214 &self,
215 input: MemoryProvenanceAppend,
216 ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError>;
217
218 fn get_entry(
222 &self,
223 entry_id: &str,
224 ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
225
226 fn latest_for_key(
229 &self,
230 store: &str,
231 key: &str,
232 ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
233
234 fn verify_entry(&self, entry_id: &str)
239 -> Result<ProvenanceVerification, MemoryProvenanceError>;
240
241 fn chain_digest(&self) -> Result<String, MemoryProvenanceError>;
245}
246
247pub 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#[must_use]
279pub fn next_entry_id() -> String {
280 format!("mem-prov-{}", Uuid::now_v7())
281}
282
283#[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum MemoryActionKind {
459 Write { store: String, key: String },
460 Read { store: String, key: String },
461}
462
463#[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}