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 {
214 fn append(
216 &self,
217 input: MemoryProvenanceAppend,
218 ) -> Result<MemoryProvenanceEntry, MemoryProvenanceError>;
219
220 fn get_entry(
224 &self,
225 entry_id: &str,
226 ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
227
228 fn latest_for_key(
231 &self,
232 store: &str,
233 key: &str,
234 ) -> Result<Option<MemoryProvenanceEntry>, MemoryProvenanceError>;
235
236 fn verify_entry(&self, entry_id: &str)
241 -> Result<ProvenanceVerification, MemoryProvenanceError>;
242
243 fn chain_digest(&self) -> Result<String, MemoryProvenanceError>;
247}
248
249pub 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#[must_use]
281pub fn next_entry_id() -> String {
282 format!("mem-prov-{}", Uuid::now_v7())
283}
284
285#[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum MemoryActionKind {
476 Write { store: String, key: String },
477 Read { store: String, key: String },
478}
479
480#[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}