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