Skip to main content

heartbit_core/memory/
namespaced.rs

1//! Namespaced memory wrapper that scopes all operations to a tenant or agent prefix.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use crate::auth::TenantScope;
8use crate::error::Error;
9
10use super::{Confidentiality, Memory, MemoryEntry, MemoryQuery};
11
12/// Wraps a `Memory` store with namespace prefixing for agent isolation.
13///
14/// Each agent's memory entries get IDs prefixed with `{agent_name}:` for provenance.
15/// Recall can search within the agent's namespace or across all namespaces.
16///
17/// When `max_confidentiality` is set, recall queries are capped at that level
18/// regardless of what the caller requests. This is the enforcement point for
19/// sensor security — even if the LLM is tricked into calling `memory_recall`,
20/// the store-level filter prevents confidential data from being returned.
21pub struct NamespacedMemory {
22    inner: Arc<dyn Memory>,
23    agent_name: String,
24    max_confidentiality: Option<Confidentiality>,
25    default_store_confidentiality: Confidentiality,
26}
27
28impl NamespacedMemory {
29    pub fn new(inner: Arc<dyn Memory>, agent_name: impl Into<String>) -> Self {
30        Self {
31            inner,
32            agent_name: agent_name.into(),
33            max_confidentiality: None,
34            default_store_confidentiality: Confidentiality::Public,
35        }
36    }
37
38    /// Set the maximum confidentiality level for recall queries.
39    ///
40    /// When set, all recall queries through this namespace will be capped at this
41    /// level — entries with higher confidentiality are filtered out at the store level.
42    pub fn with_max_confidentiality(mut self, cap: Option<Confidentiality>) -> Self {
43        self.max_confidentiality = cap;
44        self
45    }
46
47    /// Set the minimum confidentiality level for new entries stored through this namespace.
48    ///
49    /// When an entry is stored with a confidentiality level below this floor, it
50    /// will be upgraded to this level. Entries already at or above this level are
51    /// left unchanged. This prevents LLM-driven downgrade attacks and ensures
52    /// private conversations (e.g. Telegram DMs) are stored as `Confidential`
53    /// by default without requiring the LLM to specify it.
54    pub fn with_default_store_confidentiality(mut self, level: Confidentiality) -> Self {
55        self.default_store_confidentiality = level;
56        self
57    }
58
59    fn prefix_id(&self, id: &str) -> String {
60        format!("{}:{}", self.agent_name, id)
61    }
62}
63
64impl Memory for NamespacedMemory {
65    fn store(
66        &self,
67        scope: &TenantScope,
68        mut entry: MemoryEntry,
69    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
70        entry.id = self.prefix_id(&entry.id);
71        entry.agent = self.agent_name.clone();
72        // Enforce minimum confidentiality floor for this namespace.
73        // If the entry's level is below the namespace default, upgrade it.
74        // This prevents LLM-driven downgrade attacks (e.g. storing as Internal
75        // when the namespace default is Confidential).
76        if entry.confidentiality < self.default_store_confidentiality {
77            entry.confidentiality = self.default_store_confidentiality;
78        }
79        // Clone scope for the async block.
80        let scope = scope.clone();
81        Box::pin(async move { self.inner.store(&scope, entry).await })
82    }
83
84    fn recall(
85        &self,
86        scope: &TenantScope,
87        query: MemoryQuery,
88    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryEntry>, Error>> + Send + '_>> {
89        // Always force recall to this agent's namespace. Ignoring caller-supplied
90        // agent values prevents cross-namespace reads via prompt injection.
91        let mut query = MemoryQuery {
92            agent: Some(self.agent_name.clone()),
93            ..query
94        };
95        // Enforce max_confidentiality cap — use the stricter of the two
96        if let Some(cap) = self.max_confidentiality {
97            query.max_confidentiality = Some(match query.max_confidentiality {
98                Some(existing) if existing < cap => existing,
99                _ => cap,
100            });
101        }
102        let prefix = format!("{}:", self.agent_name);
103        let scope = scope.clone();
104        Box::pin(async move {
105            let mut entries = self.inner.recall(&scope, query).await?;
106            // Strip namespace prefix from IDs so consumers see unprefixed IDs.
107            // This ensures update/forget (which re-add the prefix) work correctly.
108            for entry in &mut entries {
109                if let Some(stripped) = entry.id.strip_prefix(&prefix) {
110                    entry.id = stripped.to_string();
111                }
112            }
113            Ok(entries)
114        })
115    }
116
117    fn update(
118        &self,
119        scope: &TenantScope,
120        id: &str,
121        content: String,
122    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
123        let prefixed = self.prefix_id(id);
124        let scope = scope.clone();
125        Box::pin(async move { self.inner.update(&scope, &prefixed, content).await })
126    }
127
128    fn forget(
129        &self,
130        scope: &TenantScope,
131        id: &str,
132    ) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send + '_>> {
133        let prefixed = self.prefix_id(id);
134        let scope = scope.clone();
135        Box::pin(async move { self.inner.forget(&scope, &prefixed).await })
136    }
137
138    fn add_link(
139        &self,
140        scope: &TenantScope,
141        id: &str,
142        related_id: &str,
143    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
144        let prefixed_id = self.prefix_id(id);
145        let prefixed_related = self.prefix_id(related_id);
146        let scope = scope.clone();
147        Box::pin(async move {
148            self.inner
149                .add_link(&scope, &prefixed_id, &prefixed_related)
150                .await
151        })
152    }
153
154    fn prune(
155        &self,
156        scope: &TenantScope,
157        min_strength: f64,
158        min_age: chrono::Duration,
159        _agent_prefix: Option<&str>,
160    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
161        // Always scope to this namespace — ignore caller-supplied prefix.
162        // This ensures a NamespacedMemory for user A never prunes user B's entries.
163        let scope = scope.clone();
164        let agent_name = self.agent_name.clone();
165        Box::pin(async move {
166            self.inner
167                .prune(&scope, min_strength, min_age, Some(&agent_name))
168                .await
169        })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::memory::in_memory::InMemoryStore;
177    use chrono::Utc;
178
179    use super::super::{Confidentiality, MemoryType};
180
181    fn test_scope() -> TenantScope {
182        TenantScope::default()
183    }
184
185    fn make_entry(id: &str, content: &str) -> MemoryEntry {
186        MemoryEntry {
187            id: id.into(),
188            agent: String::new(),
189            content: content.into(),
190            category: "fact".into(),
191            tags: vec![],
192            created_at: Utc::now(),
193            last_accessed: Utc::now(),
194            access_count: 0,
195            importance: 5,
196            memory_type: MemoryType::default(),
197            keywords: vec![],
198            summary: None,
199            strength: 1.0,
200            related_ids: vec![],
201            source_ids: vec![],
202            embedding: None,
203            confidentiality: Confidentiality::default(),
204            author_user_id: None,
205            author_tenant_id: None,
206        }
207    }
208
209    #[tokio::test]
210    async fn store_prefixes_id_and_agent() {
211        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
212        let ns = NamespacedMemory::new(inner.clone(), "researcher");
213
214        ns.store(&test_scope(), make_entry("m1", "test data"))
215            .await
216            .unwrap();
217
218        // Raw store should have prefixed entry
219        let all = inner
220            .recall(
221                &test_scope(),
222                MemoryQuery {
223                    limit: 10,
224                    ..Default::default()
225                },
226            )
227            .await
228            .unwrap();
229        assert_eq!(all.len(), 1);
230        assert_eq!(all[0].id, "researcher:m1");
231        assert_eq!(all[0].agent, "researcher");
232
233        // Namespaced recall should return unprefixed IDs
234        let ns_results = ns
235            .recall(
236                &test_scope(),
237                MemoryQuery {
238                    limit: 10,
239                    ..Default::default()
240                },
241            )
242            .await
243            .unwrap();
244        assert_eq!(ns_results[0].id, "m1"); // prefix stripped
245    }
246
247    #[tokio::test]
248    async fn recall_filters_by_agent() {
249        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
250        let ns_a = NamespacedMemory::new(inner.clone(), "agent_a");
251        let ns_b = NamespacedMemory::new(inner.clone(), "agent_b");
252
253        ns_a.store(&test_scope(), make_entry("m1", "data from A"))
254            .await
255            .unwrap();
256        ns_b.store(&test_scope(), make_entry("m2", "data from B"))
257            .await
258            .unwrap();
259
260        // Agent A should only see its own memories
261        let results = ns_a
262            .recall(
263                &test_scope(),
264                MemoryQuery {
265                    limit: 10,
266                    ..Default::default()
267                },
268            )
269            .await
270            .unwrap();
271        assert_eq!(results.len(), 1);
272        assert_eq!(results[0].content, "data from A");
273
274        // Agent B should only see its own memories
275        let results = ns_b
276            .recall(
277                &test_scope(),
278                MemoryQuery {
279                    limit: 10,
280                    ..Default::default()
281                },
282            )
283            .await
284            .unwrap();
285        assert_eq!(results.len(), 1);
286        assert_eq!(results[0].content, "data from B");
287    }
288
289    #[tokio::test]
290    async fn namespace_forces_own_agent_even_with_explicit_override() {
291        // NamespacedMemory always forces its own agent namespace, even when
292        // the caller explicitly sets an agent name. Cross-agent access
293        // requires the raw inner store (e.g., via shared_memory_tools).
294        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
295        let ns_a = NamespacedMemory::new(inner.clone(), "agent_a");
296        let ns_b = NamespacedMemory::new(inner.clone(), "agent_b");
297
298        ns_a.store(&test_scope(), make_entry("m1", "from A"))
299            .await
300            .unwrap();
301        ns_b.store(&test_scope(), make_entry("m2", "from B"))
302            .await
303            .unwrap();
304
305        // Even with explicit empty agent, namespace forces own agent
306        let results = ns_a
307            .recall(
308                &test_scope(),
309                MemoryQuery {
310                    agent: Some(String::new()),
311                    limit: 10,
312                    ..Default::default()
313                },
314            )
315            .await
316            .unwrap();
317        // Returns agent_a's entries (not empty — the override is ignored)
318        assert_eq!(results.len(), 1);
319        assert_eq!(results[0].content, "from A");
320
321        // Cross-agent access requires the raw inner store
322        let all = inner
323            .recall(
324                &test_scope(),
325                MemoryQuery {
326                    limit: 10,
327                    ..Default::default()
328                },
329            )
330            .await
331            .unwrap();
332        assert_eq!(all.len(), 2);
333    }
334
335    #[tokio::test]
336    async fn recall_then_update_roundtrip() {
337        // Critical: LLM sees unprefixed IDs from recall, uses them in update.
338        // update must re-prefix correctly (no double-prefix).
339        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
340        let ns = NamespacedMemory::new(inner.clone(), "agent_a");
341
342        ns.store(&test_scope(), make_entry("m1", "original"))
343            .await
344            .unwrap();
345
346        // Recall gives us unprefixed ID
347        let results = ns
348            .recall(
349                &test_scope(),
350                MemoryQuery {
351                    limit: 10,
352                    ..Default::default()
353                },
354            )
355            .await
356            .unwrap();
357        assert_eq!(results[0].id, "m1");
358
359        // Update using the unprefixed ID from recall
360        ns.update(
361            &test_scope(),
362            &results[0].id,
363            "updated via recall ID".into(),
364        )
365        .await
366        .unwrap();
367
368        // Verify the update worked
369        let results = ns
370            .recall(
371                &test_scope(),
372                MemoryQuery {
373                    limit: 10,
374                    ..Default::default()
375                },
376            )
377            .await
378            .unwrap();
379        assert_eq!(results[0].content, "updated via recall ID");
380    }
381
382    #[tokio::test]
383    async fn update_uses_prefixed_id() {
384        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
385        let ns = NamespacedMemory::new(inner.clone(), "agent_a");
386
387        ns.store(&test_scope(), make_entry("m1", "original"))
388            .await
389            .unwrap();
390        ns.update(&test_scope(), "m1", "updated".into())
391            .await
392            .unwrap();
393
394        let results = ns
395            .recall(
396                &test_scope(),
397                MemoryQuery {
398                    limit: 10,
399                    ..Default::default()
400                },
401            )
402            .await
403            .unwrap();
404        assert_eq!(results[0].content, "updated");
405    }
406
407    #[tokio::test]
408    async fn forget_uses_prefixed_id() {
409        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
410        let ns = NamespacedMemory::new(inner.clone(), "agent_a");
411
412        ns.store(&test_scope(), make_entry("m1", "to delete"))
413            .await
414            .unwrap();
415        assert!(ns.forget(&test_scope(), "m1").await.unwrap());
416
417        let results = ns
418            .recall(
419                &test_scope(),
420                MemoryQuery {
421                    limit: 10,
422                    ..Default::default()
423                },
424            )
425            .await
426            .unwrap();
427        assert!(results.is_empty());
428    }
429
430    #[tokio::test]
431    async fn add_link_delegates_with_prefix() {
432        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
433        let ns = NamespacedMemory::new(inner.clone(), "agent_a");
434
435        ns.store(&test_scope(), make_entry("m1", "first"))
436            .await
437            .unwrap();
438        ns.store(&test_scope(), make_entry("m2", "second"))
439            .await
440            .unwrap();
441
442        // Link via namespaced (unprefixed IDs)
443        ns.add_link(&test_scope(), "m1", "m2").await.unwrap();
444
445        // Verify in raw store that prefixed IDs are linked
446        let all = inner
447            .recall(
448                &test_scope(),
449                MemoryQuery {
450                    limit: 10,
451                    ..Default::default()
452                },
453            )
454            .await
455            .unwrap();
456        let m1 = all.iter().find(|e| e.id == "agent_a:m1").unwrap();
457        let m2 = all.iter().find(|e| e.id == "agent_a:m2").unwrap();
458        assert!(m1.related_ids.contains(&"agent_a:m2".to_string()));
459        assert!(m2.related_ids.contains(&"agent_a:m1".to_string()));
460    }
461
462    #[tokio::test]
463    async fn max_confidentiality_caps_recall() {
464        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
465        let ns = NamespacedMemory::new(inner.clone(), "agent_a")
466            .with_max_confidentiality(Some(Confidentiality::Public));
467
468        // Store entries at different confidentiality levels
469        let mut public_entry = make_entry("m1", "public data");
470        public_entry.confidentiality = Confidentiality::Public;
471        ns.store(&test_scope(), public_entry).await.unwrap();
472
473        let mut confidential_entry = make_entry("m2", "confidential data");
474        confidential_entry.confidentiality = Confidentiality::Confidential;
475        // Store via inner directly to bypass namespace (then prefix manually)
476        confidential_entry.id = "agent_a:m2".into();
477        confidential_entry.agent = "agent_a".into();
478        inner
479            .store(&test_scope(), confidential_entry)
480            .await
481            .unwrap();
482
483        // Recall should only return the public entry
484        let results = ns
485            .recall(
486                &test_scope(),
487                MemoryQuery {
488                    limit: 10,
489                    ..Default::default()
490                },
491            )
492            .await
493            .unwrap();
494        assert_eq!(results.len(), 1);
495        assert_eq!(results[0].content, "public data");
496    }
497
498    #[tokio::test]
499    async fn no_confidentiality_cap_returns_all() {
500        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
501        let ns = NamespacedMemory::new(inner.clone(), "agent_a");
502
503        let mut public_entry = make_entry("m1", "public data");
504        public_entry.confidentiality = Confidentiality::Public;
505        ns.store(&test_scope(), public_entry).await.unwrap();
506
507        let mut confidential_entry = make_entry("m2", "confidential data");
508        confidential_entry.confidentiality = Confidentiality::Confidential;
509        ns.store(&test_scope(), confidential_entry).await.unwrap();
510
511        let results = ns
512            .recall(
513                &test_scope(),
514                MemoryQuery {
515                    limit: 10,
516                    ..Default::default()
517                },
518            )
519            .await
520            .unwrap();
521        assert_eq!(results.len(), 2);
522    }
523
524    #[tokio::test]
525    async fn confidentiality_cap_uses_stricter_of_two() {
526        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
527        // Namespace cap at Internal
528        let ns = NamespacedMemory::new(inner.clone(), "agent_a")
529            .with_max_confidentiality(Some(Confidentiality::Internal));
530
531        let mut public_entry = make_entry("m1", "public data");
532        public_entry.confidentiality = Confidentiality::Public;
533        ns.store(&test_scope(), public_entry).await.unwrap();
534
535        let mut internal_entry = make_entry("m2", "internal data");
536        internal_entry.confidentiality = Confidentiality::Internal;
537        ns.store(&test_scope(), internal_entry).await.unwrap();
538
539        let mut confidential_entry = make_entry("m3", "confidential data");
540        confidential_entry.confidentiality = Confidentiality::Confidential;
541        // Store via inner directly (bypassing namespace)
542        confidential_entry.id = "agent_a:m3".into();
543        confidential_entry.agent = "agent_a".into();
544        inner
545            .store(&test_scope(), confidential_entry)
546            .await
547            .unwrap();
548
549        // Even with query requesting Confidential cap, namespace cap (Internal) wins
550        let results = ns
551            .recall(
552                &test_scope(),
553                MemoryQuery {
554                    limit: 10,
555                    max_confidentiality: Some(Confidentiality::Confidential),
556                    ..Default::default()
557                },
558            )
559            .await
560            .unwrap();
561        assert_eq!(results.len(), 2); // Public + Internal, not Confidential
562
563        // With query requesting Public (stricter than namespace Internal), query wins
564        let results = ns
565            .recall(
566                &test_scope(),
567                MemoryQuery {
568                    limit: 10,
569                    max_confidentiality: Some(Confidentiality::Public),
570                    ..Default::default()
571                },
572            )
573            .await
574            .unwrap();
575        assert_eq!(results.len(), 1); // Only Public
576    }
577
578    #[tokio::test]
579    async fn default_store_confidentiality_upgrades_public() {
580        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
581        let ns = NamespacedMemory::new(inner.clone(), "tg_agent")
582            .with_default_store_confidentiality(Confidentiality::Confidential);
583
584        // Store with default (Public) → should be upgraded to Confidential
585        let entry = make_entry("m1", "private chat data");
586        ns.store(&test_scope(), entry).await.unwrap();
587
588        // Check raw store: entry should be stored as Confidential
589        let all = inner
590            .recall(
591                &test_scope(),
592                MemoryQuery {
593                    limit: 10,
594                    ..Default::default()
595                },
596            )
597            .await
598            .unwrap();
599        assert_eq!(all.len(), 1);
600        assert_eq!(all[0].confidentiality, Confidentiality::Confidential);
601    }
602
603    #[tokio::test]
604    async fn default_store_confidentiality_enforces_minimum_floor() {
605        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
606        let ns = NamespacedMemory::new(inner.clone(), "tg_agent")
607            .with_default_store_confidentiality(Confidentiality::Confidential);
608
609        // Store with Internal (below Confidential floor) → should be upgraded
610        let mut entry = make_entry("m1", "internal data");
611        entry.confidentiality = Confidentiality::Internal;
612        ns.store(&test_scope(), entry).await.unwrap();
613
614        let all = inner
615            .recall(
616                &test_scope(),
617                MemoryQuery {
618                    limit: 10,
619                    ..Default::default()
620                },
621            )
622            .await
623            .unwrap();
624        assert_eq!(all.len(), 1);
625        assert_eq!(all[0].confidentiality, Confidentiality::Confidential);
626    }
627
628    #[tokio::test]
629    async fn default_store_confidentiality_preserves_higher_level() {
630        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
631        let ns = NamespacedMemory::new(inner.clone(), "tg_agent")
632            .with_default_store_confidentiality(Confidentiality::Confidential);
633
634        // Store with Restricted (above Confidential floor) → should NOT be changed
635        let mut entry = make_entry("m1", "secret data");
636        entry.confidentiality = Confidentiality::Restricted;
637        ns.store(&test_scope(), entry).await.unwrap();
638
639        let all = inner
640            .recall(
641                &test_scope(),
642                MemoryQuery {
643                    limit: 10,
644                    ..Default::default()
645                },
646            )
647            .await
648            .unwrap();
649        assert_eq!(all.len(), 1);
650        assert_eq!(all[0].confidentiality, Confidentiality::Restricted);
651    }
652
653    #[tokio::test]
654    async fn prune_delegates_to_inner() {
655        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
656        let ns = NamespacedMemory::new(inner.clone(), "agent_a");
657
658        let mut entry = make_entry("m1", "weak memory");
659        entry.strength = 0.01;
660        entry.created_at = Utc::now() - chrono::Duration::hours(48);
661        entry.last_accessed = Utc::now() - chrono::Duration::hours(48);
662        ns.store(&test_scope(), entry).await.unwrap();
663
664        let pruned = ns
665            .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
666            .await
667            .unwrap();
668        assert_eq!(pruned, 1);
669
670        // Verify entry is gone
671        let results = ns
672            .recall(
673                &test_scope(),
674                MemoryQuery {
675                    limit: 10,
676                    ..Default::default()
677                },
678            )
679            .await
680            .unwrap();
681        assert!(results.is_empty());
682    }
683
684    /// Prune via NamespacedMemory only affects this namespace, not others.
685    #[tokio::test]
686    async fn prune_scoped_to_own_namespace() {
687        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
688        let ns_a = NamespacedMemory::new(inner.clone(), "agent_a");
689        let ns_b = NamespacedMemory::new(inner.clone(), "agent_b");
690
691        let mut weak_a = make_entry("m1", "weak from A");
692        weak_a.strength = 0.01;
693        weak_a.created_at = Utc::now() - chrono::Duration::hours(48);
694        weak_a.last_accessed = Utc::now() - chrono::Duration::hours(48);
695        ns_a.store(&test_scope(), weak_a).await.unwrap();
696
697        let mut weak_b = make_entry("m1", "weak from B");
698        weak_b.strength = 0.01;
699        weak_b.created_at = Utc::now() - chrono::Duration::hours(48);
700        weak_b.last_accessed = Utc::now() - chrono::Duration::hours(48);
701        ns_b.store(&test_scope(), weak_b).await.unwrap();
702
703        // Prune via namespace A only removes A's weak entries, not B's
704        let pruned = ns_a
705            .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
706            .await
707            .unwrap();
708        assert_eq!(pruned, 1, "should only prune agent_a's entry");
709
710        // A's entry is gone
711        let a_results = ns_a
712            .recall(
713                &test_scope(),
714                MemoryQuery {
715                    limit: 10,
716                    ..Default::default()
717                },
718            )
719            .await
720            .unwrap();
721        assert!(a_results.is_empty());
722
723        // B's entry is still there
724        let b_results = ns_b
725            .recall(
726                &test_scope(),
727                MemoryQuery {
728                    limit: 10,
729                    ..Default::default()
730                },
731            )
732            .await
733            .unwrap();
734        assert_eq!(
735            b_results.len(),
736            1,
737            "agent_b's entry must survive agent_a's prune"
738        );
739        assert_eq!(b_results[0].content, "weak from B");
740    }
741
742    /// Multi-tenant prune isolation: user A's prune never deletes user B's memories.
743    #[tokio::test]
744    async fn multi_tenant_prune_isolation() {
745        let shared: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
746        let alice = NamespacedMemory::new(shared.clone(), "user:alice");
747        let bob = NamespacedMemory::new(shared.clone(), "user:bob");
748
749        // Both users have weak+old entries AND strong entries
750        let mut weak_alice = make_entry("m1", "alice weak");
751        weak_alice.strength = 0.01;
752        weak_alice.created_at = Utc::now() - chrono::Duration::hours(48);
753        weak_alice.last_accessed = Utc::now() - chrono::Duration::hours(48);
754        alice.store(&test_scope(), weak_alice).await.unwrap();
755
756        let mut strong_alice = make_entry("m2", "alice strong");
757        strong_alice.strength = 0.9;
758        alice.store(&test_scope(), strong_alice).await.unwrap();
759
760        let mut weak_bob = make_entry("m1", "bob weak");
761        weak_bob.strength = 0.01;
762        weak_bob.created_at = Utc::now() - chrono::Duration::hours(48);
763        weak_bob.last_accessed = Utc::now() - chrono::Duration::hours(48);
764        bob.store(&test_scope(), weak_bob).await.unwrap();
765
766        let mut strong_bob = make_entry("m2", "bob strong");
767        strong_bob.strength = 0.9;
768        bob.store(&test_scope(), strong_bob).await.unwrap();
769
770        // Alice prunes — should only remove alice's weak entry
771        let pruned = alice
772            .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
773            .await
774            .unwrap();
775        assert_eq!(pruned, 1, "should only prune alice's weak entry");
776
777        // Bob prunes — should remove bob's weak entry. The fact that this returns 1
778        // (not 0) proves the entry survived Alice's prune.
779        let pruned = bob
780            .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
781            .await
782            .unwrap();
783        assert_eq!(pruned, 1, "bob's weak entry must survive alice's prune");
784
785        // Verify final state: each user has only their strong entry
786        let alice_results = alice
787            .recall(
788                &test_scope(),
789                MemoryQuery {
790                    limit: 10,
791                    ..Default::default()
792                },
793            )
794            .await
795            .unwrap();
796        assert_eq!(alice_results.len(), 1);
797        assert_eq!(alice_results[0].content, "alice strong");
798
799        let bob_results = bob
800            .recall(
801                &test_scope(),
802                MemoryQuery {
803                    limit: 10,
804                    ..Default::default()
805                },
806            )
807            .await
808            .unwrap();
809        assert_eq!(bob_results.len(), 1);
810        assert_eq!(bob_results[0].content, "bob strong");
811    }
812
813    /// Prune ignores caller-supplied agent_prefix — always uses own namespace.
814    #[tokio::test]
815    async fn prune_ignores_explicit_agent_prefix_override() {
816        let shared: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
817        let alice = NamespacedMemory::new(shared.clone(), "user:alice");
818        let bob = NamespacedMemory::new(shared.clone(), "user:bob");
819
820        let mut weak_bob = make_entry("m1", "bob weak");
821        weak_bob.strength = 0.01;
822        weak_bob.created_at = Utc::now() - chrono::Duration::hours(48);
823        weak_bob.last_accessed = Utc::now() - chrono::Duration::hours(48);
824        bob.store(&test_scope(), weak_bob).await.unwrap();
825
826        // Alice tries to prune with bob's prefix — should still only affect alice's namespace
827        let pruned = alice
828            .prune(
829                &test_scope(),
830                0.1,
831                chrono::Duration::hours(1),
832                Some("user:bob"),
833            )
834            .await
835            .unwrap();
836        assert_eq!(
837            pruned, 0,
838            "alice's prune must not affect bob even with explicit prefix"
839        );
840
841        let bob_results = bob
842            .recall(
843                &test_scope(),
844                MemoryQuery {
845                    limit: 10,
846                    ..Default::default()
847                },
848            )
849            .await
850            .unwrap();
851        assert_eq!(bob_results.len(), 1, "bob's entry must survive");
852    }
853
854    /// Recall always forces own namespace — explicit agent parameter is ignored.
855    #[tokio::test]
856    async fn recall_ignores_explicit_agent_override() {
857        let inner: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
858        let ns_a = NamespacedMemory::new(inner.clone(), "user:alice");
859        let ns_b = NamespacedMemory::new(inner.clone(), "user:bob");
860
861        ns_a.store(&test_scope(), make_entry("m1", "alice data"))
862            .await
863            .unwrap();
864        ns_b.store(&test_scope(), make_entry("m1", "bob data"))
865            .await
866            .unwrap();
867
868        // Even if we explicitly request bob's namespace, alice's NamespacedMemory
869        // should still return only alice's entries (prevents prompt injection).
870        let results = ns_a
871            .recall(
872                &test_scope(),
873                MemoryQuery {
874                    agent: Some("user:bob".into()),
875                    limit: 10,
876                    ..Default::default()
877                },
878            )
879            .await
880            .unwrap();
881        assert_eq!(results.len(), 1);
882        assert_eq!(results[0].content, "alice data");
883    }
884
885    /// Multi-tenant isolation: two users with `user:{id}` namespaces on the
886    /// same backing store cannot see each other's memories.
887    #[tokio::test]
888    async fn per_user_namespace_isolation() {
889        let shared: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
890        let alice = NamespacedMemory::new(shared.clone(), "user:alice");
891        let bob = NamespacedMemory::new(shared.clone(), "user:bob");
892
893        alice
894            .store(&test_scope(), make_entry("m1", "Alice's deal notes"))
895            .await
896            .unwrap();
897        bob.store(&test_scope(), make_entry("m1", "Bob's pipeline review"))
898            .await
899            .unwrap();
900
901        // Alice only sees her own memory
902        let alice_results = alice
903            .recall(
904                &test_scope(),
905                MemoryQuery {
906                    limit: 10,
907                    ..Default::default()
908                },
909            )
910            .await
911            .unwrap();
912        assert_eq!(alice_results.len(), 1);
913        assert_eq!(alice_results[0].content, "Alice's deal notes");
914        assert_eq!(alice_results[0].id, "m1"); // unprefixed
915
916        // Bob only sees his own memory
917        let bob_results = bob
918            .recall(
919                &test_scope(),
920                MemoryQuery {
921                    limit: 10,
922                    ..Default::default()
923                },
924            )
925            .await
926            .unwrap();
927        assert_eq!(bob_results.len(), 1);
928        assert_eq!(bob_results[0].content, "Bob's pipeline review");
929
930        // Raw store has both, namespaced
931        let all = shared
932            .recall(
933                &test_scope(),
934                MemoryQuery {
935                    limit: 10,
936                    ..Default::default()
937                },
938            )
939            .await
940            .unwrap();
941        assert_eq!(all.len(), 2);
942        let ids: Vec<&str> = all.iter().map(|e| e.id.as_str()).collect();
943        assert!(ids.contains(&"user:alice:m1"));
944        assert!(ids.contains(&"user:bob:m1"));
945    }
946
947    /// Shared/institutional memory is accessible alongside per-user memory
948    /// when the raw inner store is used directly (e.g., via shared_memory_read tool).
949    #[tokio::test]
950    async fn per_user_can_coexist_with_shared_institutional_memory() {
951        let shared: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
952
953        // Institutional memory stored without namespace (directly on shared store)
954        let mut institutional = make_entry("shared:playbook", "Always follow up within 24h");
955        institutional.agent = "shared".into();
956        institutional.id = "shared:playbook".into();
957        shared.store(&test_scope(), institutional).await.unwrap();
958
959        // Per-user memory via namespace
960        let alice = NamespacedMemory::new(shared.clone(), "user:alice");
961        alice
962            .store(&test_scope(), make_entry("m1", "Alice's note"))
963            .await
964            .unwrap();
965
966        // Alice sees only her own memories through namespace
967        let alice_results = alice
968            .recall(
969                &test_scope(),
970                MemoryQuery {
971                    limit: 10,
972                    ..Default::default()
973                },
974            )
975            .await
976            .unwrap();
977        assert_eq!(alice_results.len(), 1);
978        assert_eq!(alice_results[0].content, "Alice's note");
979
980        // Raw store has both: institutional + Alice's namespaced entry
981        let all = shared
982            .recall(
983                &test_scope(),
984                MemoryQuery {
985                    limit: 10,
986                    ..Default::default()
987                },
988            )
989            .await
990            .unwrap();
991        assert_eq!(all.len(), 2);
992    }
993}