Skip to main content

bamboo_memory/memory_store/
mod.rs

1//! Bamboo's narrow native facade over the Jiandu memory store.
2
3use std::collections::{BTreeMap, HashSet};
4use std::ffi::OsString;
5use std::io;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10pub use jiandu_memory::memory_store::{
11    count_chars, normalize_retrieval_terms, normalize_tags, render_memory_freshness_note,
12    summary_json, truncate_chars, BlobScanItem, BlobScanReport, DreamReadResult, DreamSnapshot,
13    DuplicateCluster, DuplicateScanReport, DurableMemoryDocument, DurableMemoryStatus,
14    DurableMemoryType, FreshnessKind, MemoryConsolidateResult, MemoryContradictionResult,
15    MemoryDuplicateCandidate, MemoryMergeResult, MemoryPurgeResult, MemoryQueryOptions,
16    MemoryQueryResult, MemoryRecallCandidate, MemoryRecallOptions, MemoryRetrievalInput,
17    MemoryScope, MemorySplitPiece, MemorySplitResult, SessionState, TemporalGranularity,
18    DEFAULT_QUERY_LIMIT, DEFAULT_SESSION_TOPIC, MAX_EXPLICIT_MEMORY_ENTITIES,
19    MAX_EXPLICIT_MEMORY_KEYWORDS, MAX_MAX_CHARS, MAX_MEMORY_ENTITIES, MAX_MEMORY_ID_LEN,
20    MAX_MEMORY_KEYWORDS, MAX_MEMORY_QUERY_CHARS, MAX_MEMORY_TAGS, MAX_MEMORY_TAG_CHARS,
21    MAX_MEMORY_TITLE_LEN, MAX_QUERY_LIMIT, MAX_RETRIEVAL_TERM_CHARS,
22};
23
24/// Jiandu's inspection result plus Bamboo's flattened Dream timestamp.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct MemoryInspectResult {
27    pub scope: MemoryScope,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub project_key: Option<String>,
30    pub total_memories: usize,
31    #[serde(default)]
32    pub by_type: BTreeMap<String, usize>,
33    #[serde(default)]
34    pub by_status: BTreeMap<String, usize>,
35    #[serde(default)]
36    pub recent_ids: Vec<String>,
37    #[serde(default)]
38    pub view_files: Vec<String>,
39    #[serde(default)]
40    pub index_files: Vec<String>,
41    #[serde(default)]
42    pub state_files: Vec<String>,
43    #[serde(default)]
44    pub stale_candidate_count: usize,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub last_reindex_at: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub last_dream_at: Option<String>,
49    #[serde(default)]
50    pub topic_paths: Vec<String>,
51}
52
53/// Process-level opt-in used by Bamboo launchers to isolate Jiandu state.
54///
55/// Library callers remain deterministic: [`MemoryStore::with_defaults`] does
56/// not inspect this variable. Process entrypoints must resolve it explicitly
57/// and inject the resulting store into every runtime and tool consumer.
58pub const BAMBOO_JIANDU_DATA_DIR_ENV: &str = "BAMBOO_JIANDU_DATA_DIR";
59
60/// Validated selection of Bamboo's process-wide Jiandu data root.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum JianduDataRoot {
63    CanonicalDefault,
64    Explicit(PathBuf),
65}
66
67impl JianduDataRoot {
68    pub fn mode(&self) -> &'static str {
69        match self {
70            Self::CanonicalDefault => "default",
71            Self::Explicit(_) => "explicit",
72        }
73    }
74
75    pub fn into_path(self) -> PathBuf {
76        match self {
77            Self::CanonicalDefault => MemoryStore::default_data_dir(),
78            Self::Explicit(root) => root,
79        }
80    }
81}
82
83/// Validate a launcher-provided Jiandu root without reading or mutating the
84/// process environment. An explicitly configured value must be non-empty and
85/// absolute so a managed launch cannot silently escape its run-owned root.
86pub fn resolve_jiandu_data_root(explicit: Option<OsString>) -> Result<JianduDataRoot, String> {
87    let Some(explicit) = explicit else {
88        return Ok(JianduDataRoot::CanonicalDefault);
89    };
90    if explicit.is_empty() {
91        return Err(format!(
92            "{BAMBOO_JIANDU_DATA_DIR_ENV} must be a non-empty absolute path when set"
93        ));
94    }
95
96    let root = PathBuf::from(explicit);
97    if !root.is_absolute() {
98        return Err(format!(
99            "{BAMBOO_JIANDU_DATA_DIR_ENV} must be an absolute path when set"
100        ));
101    }
102    Ok(JianduDataRoot::Explicit(root))
103}
104
105/// Bamboo-facing memory handle. Jiandu remains private so Bamboo callers cannot
106/// bypass the facade's Project identity and inspection extensions.
107#[derive(Debug, Clone)]
108pub struct MemoryStore {
109    store: jiandu_memory::memory_store::MemoryStore,
110}
111
112impl Default for MemoryStore {
113    fn default() -> Self {
114        Self::with_defaults()
115    }
116}
117
118impl MemoryStore {
119    /// Construct a Jiandu store rooted at an explicit data directory.
120    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
121        Self {
122            store: jiandu_memory::memory_store::MemoryStore::new(data_dir),
123        }
124    }
125
126    /// Construct the production store at the independent `~/.jiandu` root.
127    pub fn with_defaults() -> Self {
128        Self::new(Self::default_data_dir())
129    }
130
131    /// Resolve the canonical production data root used by [`Self::with_defaults`].
132    pub fn default_data_dir() -> PathBuf {
133        dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"))
134    }
135
136    /// Bind Project memory to Bamboo's first-class Project identity.
137    pub fn for_project(&self, project_id: &bamboo_domain::ProjectId) -> Self {
138        let project_id = jiandu_memory::ProjectId::parse(project_id.as_str().to_owned())
139            .expect("Bamboo ProjectId must satisfy Jiandu's identical path-safe contract");
140        Self {
141            store: self.store.for_project(&project_id),
142        }
143    }
144
145    pub async fn read_session_topic(
146        &self,
147        session_id: &str,
148        topic: &str,
149    ) -> io::Result<Option<String>> {
150        self.store.read_session_topic(session_id, topic).await
151    }
152
153    pub async fn write_session_topic(
154        &self,
155        session_id: &str,
156        topic: &str,
157        content: &str,
158    ) -> io::Result<PathBuf> {
159        self.store
160            .write_session_topic(session_id, topic, content)
161            .await
162    }
163
164    pub async fn delete_session_topic(&self, session_id: &str, topic: &str) -> io::Result<bool> {
165        self.store.delete_session_topic(session_id, topic).await
166    }
167
168    pub async fn list_session_topics(&self, session_id: &str) -> io::Result<Vec<String>> {
169        self.store.list_session_topics(session_id).await
170    }
171
172    pub async fn read_session_topics_with_content(
173        &self,
174        session_id: &str,
175    ) -> io::Result<Vec<(String, String)>> {
176        self.store
177            .read_session_topics_with_content(session_id)
178            .await
179    }
180
181    pub async fn read_session_state(&self, session_id: &str) -> io::Result<SessionState> {
182        self.store.read_session_state(session_id).await
183    }
184
185    pub async fn mark_session_extracted(
186        &self,
187        session_id: &str,
188        extracted_at: &str,
189    ) -> io::Result<()> {
190        self.store
191            .mark_session_extracted(session_id, extracted_at)
192            .await
193    }
194
195    pub async fn read_memory_view(
196        &self,
197        scope: MemoryScope,
198        project_key: Option<&str>,
199    ) -> io::Result<Option<String>> {
200        self.store.read_memory_view(scope, project_key).await
201    }
202
203    #[allow(clippy::too_many_arguments)]
204    pub async fn query_scope(
205        &self,
206        scope: MemoryScope,
207        project_key: Option<&str>,
208        query: Option<&str>,
209        filter_types: Option<&HashSet<DurableMemoryType>>,
210        filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
211        filter_granularity: Option<&HashSet<TemporalGranularity>>,
212        options: &MemoryQueryOptions,
213    ) -> io::Result<MemoryQueryResult> {
214        self.store
215            .query_scope(
216                scope,
217                project_key,
218                query,
219                filter_types,
220                filter_statuses,
221                filter_granularity,
222                options,
223            )
224            .await
225    }
226
227    pub async fn inspect_scope(
228        &self,
229        scope: MemoryScope,
230        project_key: Option<&str>,
231    ) -> io::Result<MemoryInspectResult> {
232        let result = self.store.inspect_scope(scope, project_key).await?;
233        let last_dream_at = if scope == MemoryScope::Session {
234            None
235        } else {
236            self.store
237                .read_dream_snapshot(scope, project_key)
238                .await?
239                .snapshot
240                .map(|snapshot| snapshot.generated_at)
241        };
242
243        Ok(MemoryInspectResult {
244            scope: result.scope,
245            project_key: result.project_key,
246            total_memories: result.total_memories,
247            by_type: result.by_type,
248            by_status: result.by_status,
249            recent_ids: result.recent_ids,
250            view_files: result.view_files,
251            index_files: result.index_files,
252            state_files: result.state_files,
253            stale_candidate_count: result.stale_candidate_count,
254            last_reindex_at: result.last_reindex_at,
255            last_dream_at,
256            topic_paths: result.topic_paths,
257        })
258    }
259
260    pub async fn get_memory(
261        &self,
262        id: &str,
263        preferred_project_key: Option<&str>,
264    ) -> io::Result<Option<DurableMemoryDocument>> {
265        self.store.get_memory(id, preferred_project_key).await
266    }
267
268    #[allow(clippy::too_many_arguments)]
269    pub async fn write_memory(
270        &self,
271        scope: MemoryScope,
272        project_key: Option<&str>,
273        r#type: DurableMemoryType,
274        title: &str,
275        content: &str,
276        tags: &[String],
277        session_id: Option<&str>,
278        actor: &str,
279        allow_merge_if_similar: bool,
280        granularity: Option<TemporalGranularity>,
281    ) -> io::Result<DurableMemoryDocument> {
282        self.store
283            .write_memory(
284                scope,
285                project_key,
286                r#type,
287                title,
288                content,
289                tags,
290                session_id,
291                actor,
292                allow_merge_if_similar,
293                granularity,
294            )
295            .await
296    }
297
298    #[allow(clippy::too_many_arguments)]
299    pub async fn write_memory_with_retrieval(
300        &self,
301        scope: MemoryScope,
302        project_key: Option<&str>,
303        r#type: DurableMemoryType,
304        title: &str,
305        content: &str,
306        tags: &[String],
307        retrieval: &MemoryRetrievalInput,
308        session_id: Option<&str>,
309        actor: &str,
310        allow_merge_if_similar: bool,
311        granularity: Option<TemporalGranularity>,
312    ) -> io::Result<DurableMemoryDocument> {
313        self.store
314            .write_memory_with_retrieval(
315                scope,
316                project_key,
317                r#type,
318                title,
319                content,
320                tags,
321                retrieval,
322                session_id,
323                actor,
324                allow_merge_if_similar,
325                granularity,
326            )
327            .await
328    }
329
330    pub async fn archive_memory(
331        &self,
332        id: &str,
333        preferred_project_key: Option<&str>,
334        mode: DurableMemoryStatus,
335        reason: Option<&str>,
336    ) -> io::Result<Option<DurableMemoryDocument>> {
337        self.store
338            .archive_memory(id, preferred_project_key, mode, reason)
339            .await
340    }
341
342    pub async fn split_memory(
343        &self,
344        id: &str,
345        preferred_project_key: Option<&str>,
346        pieces: &[MemorySplitPiece],
347        session_id: Option<&str>,
348        actor: &str,
349    ) -> io::Result<Option<MemorySplitResult>> {
350        self.store
351            .split_memory(id, preferred_project_key, pieces, session_id, actor)
352            .await
353    }
354
355    pub async fn split_memory_with_retrieval(
356        &self,
357        id: &str,
358        preferred_project_key: Option<&str>,
359        pieces: &[MemorySplitPiece],
360        retrieval: &[MemoryRetrievalInput],
361        session_id: Option<&str>,
362        actor: &str,
363    ) -> io::Result<Option<MemorySplitResult>> {
364        self.store
365            .split_memory_with_retrieval(
366                id,
367                preferred_project_key,
368                pieces,
369                retrieval,
370                session_id,
371                actor,
372            )
373            .await
374    }
375
376    #[allow(clippy::too_many_arguments)]
377    pub async fn find_duplicate_candidates(
378        &self,
379        scope: MemoryScope,
380        project_key: Option<&str>,
381        r#type: Option<DurableMemoryType>,
382        title: &str,
383        content: &str,
384        tags: &[String],
385        limit: usize,
386    ) -> io::Result<Vec<MemoryDuplicateCandidate>> {
387        self.store
388            .find_duplicate_candidates(scope, project_key, r#type, title, content, tags, limit)
389            .await
390    }
391
392    #[allow(clippy::too_many_arguments)]
393    pub async fn find_duplicate_candidates_with_retrieval(
394        &self,
395        scope: MemoryScope,
396        project_key: Option<&str>,
397        r#type: Option<DurableMemoryType>,
398        title: &str,
399        content: &str,
400        tags: &[String],
401        retrieval: &MemoryRetrievalInput,
402        limit: usize,
403    ) -> io::Result<Vec<MemoryDuplicateCandidate>> {
404        self.store
405            .find_duplicate_candidates_with_retrieval(
406                scope,
407                project_key,
408                r#type,
409                title,
410                content,
411                tags,
412                retrieval,
413                limit,
414            )
415            .await
416    }
417
418    pub async fn scan_blob_candidates(
419        &self,
420        scope: MemoryScope,
421        project_key: Option<&str>,
422        min_appended_sections: usize,
423        limit: usize,
424    ) -> io::Result<BlobScanReport> {
425        self.store
426            .scan_blob_candidates(scope, project_key, min_appended_sections, limit)
427            .await
428    }
429
430    pub async fn scan_duplicate_clusters(
431        &self,
432        scope: MemoryScope,
433        project_key: Option<&str>,
434        min_score: f64,
435        max_members_per_cluster: usize,
436        limit: usize,
437    ) -> io::Result<DuplicateScanReport> {
438        self.store
439            .scan_duplicate_clusters(
440                scope,
441                project_key,
442                min_score,
443                max_members_per_cluster,
444                limit,
445            )
446            .await
447    }
448
449    pub async fn consolidate_memories(
450        &self,
451        ids: &[String],
452        preferred_project_key: Option<&str>,
453        merged: &MemorySplitPiece,
454        session_id: Option<&str>,
455        actor: &str,
456    ) -> io::Result<Option<MemoryConsolidateResult>> {
457        self.store
458            .consolidate_memories(ids, preferred_project_key, merged, session_id, actor)
459            .await
460    }
461
462    pub async fn consolidate_memories_with_retrieval(
463        &self,
464        ids: &[String],
465        preferred_project_key: Option<&str>,
466        merged: &MemorySplitPiece,
467        retrieval: &MemoryRetrievalInput,
468        session_id: Option<&str>,
469        actor: &str,
470    ) -> io::Result<Option<MemoryConsolidateResult>> {
471        self.store
472            .consolidate_memories_with_retrieval(
473                ids,
474                preferred_project_key,
475                merged,
476                retrieval,
477                session_id,
478                actor,
479            )
480            .await
481    }
482
483    #[allow(clippy::too_many_arguments)]
484    pub async fn purge_memories(
485        &self,
486        scope: MemoryScope,
487        project_key: Option<&str>,
488        filter_types: Option<&HashSet<DurableMemoryType>>,
489        filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
490        filter_granularity: Option<&HashSet<TemporalGranularity>>,
491        mode: DurableMemoryStatus,
492        reason: Option<&str>,
493    ) -> io::Result<MemoryPurgeResult> {
494        self.store
495            .purge_memories(
496                scope,
497                project_key,
498                filter_types,
499                filter_statuses,
500                filter_granularity,
501                mode,
502                reason,
503            )
504            .await
505    }
506
507    #[allow(clippy::too_many_arguments)]
508    pub async fn mark_memory_contradicted(
509        &self,
510        id: &str,
511        preferred_project_key: Option<&str>,
512        contradicted_by_ids: &[String],
513        reason: Option<&str>,
514        session_id: Option<&str>,
515        actor: &str,
516    ) -> io::Result<Option<MemoryContradictionResult>> {
517        self.store
518            .mark_memory_contradicted(
519                id,
520                preferred_project_key,
521                contradicted_by_ids,
522                reason,
523                session_id,
524                actor,
525            )
526            .await
527    }
528
529    #[allow(clippy::too_many_arguments)]
530    pub async fn merge_memory(
531        &self,
532        id: &str,
533        preferred_project_key: Option<&str>,
534        content: &str,
535        tags: &[String],
536        session_id: Option<&str>,
537        actor: &str,
538        source_memory_ids: &[String],
539    ) -> io::Result<Option<MemoryMergeResult>> {
540        self.store
541            .merge_memory(
542                id,
543                preferred_project_key,
544                content,
545                tags,
546                session_id,
547                actor,
548                source_memory_ids,
549            )
550            .await
551    }
552
553    #[allow(clippy::too_many_arguments)]
554    pub async fn merge_memory_with_retrieval(
555        &self,
556        id: &str,
557        preferred_project_key: Option<&str>,
558        content: &str,
559        tags: &[String],
560        retrieval: &MemoryRetrievalInput,
561        session_id: Option<&str>,
562        actor: &str,
563        source_memory_ids: &[String],
564    ) -> io::Result<Option<MemoryMergeResult>> {
565        self.store
566            .merge_memory_with_retrieval(
567                id,
568                preferred_project_key,
569                content,
570                tags,
571                retrieval,
572                session_id,
573                actor,
574                source_memory_ids,
575            )
576            .await
577    }
578
579    pub async fn rebuild_scope(
580        &self,
581        scope: MemoryScope,
582        project_key: Option<&str>,
583    ) -> io::Result<()> {
584        self.store.rebuild_scope(scope, project_key).await
585    }
586
587    pub async fn current_scope_generation(
588        &self,
589        scope: MemoryScope,
590        project_key: Option<&str>,
591    ) -> io::Result<String> {
592        self.store
593            .current_scope_generation(scope, project_key)
594            .await
595    }
596
597    pub async fn read_dream_snapshot(
598        &self,
599        scope: MemoryScope,
600        project_key: Option<&str>,
601    ) -> io::Result<DreamReadResult> {
602        self.store.read_dream_snapshot(scope, project_key).await
603    }
604
605    pub async fn publish_dream_snapshot(
606        &self,
607        scope: MemoryScope,
608        project_key: Option<&str>,
609        source_generation: &str,
610        content: &str,
611    ) -> io::Result<DreamSnapshot> {
612        self.store
613            .publish_dream_snapshot(scope, project_key, source_generation, content)
614            .await
615    }
616
617    pub async fn list_memory_documents(
618        &self,
619        scope: MemoryScope,
620        project_key: Option<&str>,
621    ) -> io::Result<Vec<DurableMemoryDocument>> {
622        self.store.list_memory_documents(scope, project_key).await
623    }
624
625    pub async fn count_scope_memories(
626        &self,
627        scope: MemoryScope,
628        project_key: Option<&str>,
629    ) -> io::Result<usize> {
630        self.store.count_scope_memories(scope, project_key).await
631    }
632
633    pub async fn enforce_scope_capacity(
634        &self,
635        scope: MemoryScope,
636        project_key: Option<&str>,
637        capacity: usize,
638        max_archivals: usize,
639    ) -> io::Result<Vec<String>> {
640        self.store
641            .enforce_scope_capacity(scope, project_key, capacity, max_archivals)
642            .await
643    }
644
645    pub async fn expire_stale_granularity(
646        &self,
647        scope: MemoryScope,
648        project_key: Option<&str>,
649    ) -> io::Result<Vec<String>> {
650        self.store
651            .expire_stale_granularity(scope, project_key)
652            .await
653    }
654}
655
656#[cfg(test)]
657mod data_root_tests {
658    use super::{resolve_jiandu_data_root, JianduDataRoot, BAMBOO_JIANDU_DATA_DIR_ENV};
659    use std::ffi::OsString;
660    use std::path::PathBuf;
661
662    #[test]
663    fn absent_override_selects_the_canonical_default() {
664        assert_eq!(
665            resolve_jiandu_data_root(None).unwrap(),
666            JianduDataRoot::CanonicalDefault
667        );
668    }
669
670    #[test]
671    fn absolute_override_is_preserved_exactly() {
672        let root = std::env::temp_dir().join("bamboo-explicit-jiandu-root");
673        assert!(root.is_absolute());
674        assert_eq!(
675            resolve_jiandu_data_root(Some(root.clone().into_os_string())).unwrap(),
676            JianduDataRoot::Explicit(root)
677        );
678    }
679
680    #[test]
681    fn empty_override_fails_closed() {
682        let error = resolve_jiandu_data_root(Some(OsString::new())).unwrap_err();
683        assert!(error.contains(BAMBOO_JIANDU_DATA_DIR_ENV));
684        assert!(error.contains("non-empty absolute path"));
685    }
686
687    #[test]
688    fn relative_override_fails_closed() {
689        let error =
690            resolve_jiandu_data_root(Some(OsString::from(PathBuf::from("relative/jiandu"))))
691                .unwrap_err();
692        assert!(error.contains(BAMBOO_JIANDU_DATA_DIR_ENV));
693        assert!(error.contains("absolute path"));
694    }
695}
696
697/// Run Jiandu's deterministic lexical shortlist without exposing the composed
698/// store to Bamboo callers.
699pub async fn shortlist_relevant_memories(
700    store: &MemoryStore,
701    project_key: Option<&str>,
702    query: &str,
703    options: &MemoryRecallOptions,
704) -> io::Result<Vec<MemoryRecallCandidate>> {
705    jiandu_memory::memory_store::shortlist_relevant_memories(
706        &store.store,
707        project_key,
708        query,
709        options,
710    )
711    .await
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use tempfile::tempdir;
718
719    async fn write_durable(
720        store: &MemoryStore,
721        scope: MemoryScope,
722        project_key: Option<&str>,
723        title: &str,
724        body: &str,
725    ) -> DurableMemoryDocument {
726        store
727            .write_memory(
728                scope,
729                project_key,
730                if scope == MemoryScope::Project {
731                    DurableMemoryType::Project
732                } else {
733                    DurableMemoryType::Reference
734                },
735                title,
736                body,
737                &["facade-test".to_string()],
738                Some("session-test"),
739                "facade-test",
740                false,
741                None,
742            )
743            .await
744            .expect("write durable memory")
745    }
746
747    #[test]
748    fn default_root_is_dot_jiandu_under_home() {
749        let expected =
750            dirs::home_dir().map_or_else(|| PathBuf::from(".jiandu"), |home| home.join(".jiandu"));
751        assert_eq!(MemoryStore::default_data_dir(), expected);
752    }
753
754    #[tokio::test]
755    async fn session_global_and_typed_project_round_trip_with_scope_isolation() {
756        let directory = tempdir().expect("tempdir");
757        let store = MemoryStore::new(directory.path());
758
759        store
760            .write_session_topic("session_1", DEFAULT_SESSION_TOPIC, "session note")
761            .await
762            .expect("write session note");
763        assert_eq!(
764            store
765                .read_session_topic("session_1", DEFAULT_SESSION_TOPIC)
766                .await
767                .expect("read session note")
768                .as_deref(),
769            Some("session note")
770        );
771
772        let global = write_durable(
773            &store,
774            MemoryScope::Global,
775            None,
776            "Global decision",
777            "Prefer deterministic lexical memory.",
778        )
779        .await;
780
781        let project_id =
782            bamboo_domain::ProjectId::parse("project_alpha").expect("valid Bamboo ProjectId");
783        let project_store = store.for_project(&project_id);
784        let project = write_durable(
785            &project_store,
786            MemoryScope::Project,
787            Some(project_id.as_str()),
788            "Project decision",
789            "Project alpha uses the Jiandu facade.",
790        )
791        .await;
792
793        assert!(project
794            .path
795            .starts_with(directory.path().join("projects/project_alpha/memory/v1")));
796        assert_eq!(
797            store
798                .get_memory(&global.frontmatter.id, None)
799                .await
800                .expect("read global")
801                .expect("global exists")
802                .body,
803            global.body
804        );
805        assert!(
806            store
807                .get_memory(&project.frontmatter.id, None)
808                .await
809                .expect("unscoped lookup")
810                .is_none(),
811            "unscoped lookup must not scan Projects"
812        );
813        assert!(project_store
814            .get_memory(&project.frontmatter.id, Some(project_id.as_str()))
815            .await
816            .expect("typed Project lookup")
817            .is_some());
818
819        let other_id =
820            bamboo_domain::ProjectId::parse("project_beta").expect("valid Bamboo ProjectId");
821        assert!(store
822            .for_project(&other_id)
823            .get_memory(&project.frontmatter.id, Some(other_id.as_str()))
824            .await
825            .expect("unrelated Project lookup")
826            .is_none());
827    }
828
829    #[tokio::test]
830    async fn inspect_reads_dream_timestamp_from_the_same_scope() {
831        let directory = tempdir().expect("tempdir");
832        let store = MemoryStore::new(directory.path());
833        write_durable(
834            &store,
835            MemoryScope::Global,
836            None,
837            "Global fact",
838            "Global memory remains separate.",
839        )
840        .await;
841
842        let global_generation = store
843            .current_scope_generation(MemoryScope::Global, None)
844            .await
845            .expect("global generation");
846        let global_dream = store
847            .publish_dream_snapshot(
848                MemoryScope::Global,
849                None,
850                &global_generation,
851                "Global orientation",
852            )
853            .await
854            .expect("publish global Dream");
855
856        let project_id =
857            bamboo_domain::ProjectId::parse("project_dream").expect("valid Bamboo ProjectId");
858        let project_store = store.for_project(&project_id);
859        write_durable(
860            &project_store,
861            MemoryScope::Project,
862            Some(project_id.as_str()),
863            "Project fact",
864            "Project memory remains separate.",
865        )
866        .await;
867
868        let before_project_dream = project_store
869            .inspect_scope(MemoryScope::Project, Some(project_id.as_str()))
870            .await
871            .expect("inspect Project before Dream");
872        assert_eq!(before_project_dream.last_dream_at, None);
873
874        let project_generation = project_store
875            .current_scope_generation(MemoryScope::Project, Some(project_id.as_str()))
876            .await
877            .expect("Project generation");
878        let project_dream = project_store
879            .publish_dream_snapshot(
880                MemoryScope::Project,
881                Some(project_id.as_str()),
882                &project_generation,
883                "Project orientation",
884            )
885            .await
886            .expect("publish Project Dream");
887
888        assert_eq!(
889            store
890                .inspect_scope(MemoryScope::Global, None)
891                .await
892                .expect("inspect Global")
893                .last_dream_at,
894            Some(global_dream.generated_at)
895        );
896        assert_eq!(
897            project_store
898                .inspect_scope(MemoryScope::Project, Some(project_id.as_str()))
899                .await
900                .expect("inspect Project")
901                .last_dream_at,
902            Some(project_dream.generated_at)
903        );
904    }
905
906    #[tokio::test]
907    async fn dream_cold_success_stale_and_failed_cas_preserve_prior_snapshot() {
908        let directory = tempdir().expect("tempdir");
909        let store = MemoryStore::new(directory.path());
910
911        let cold = store
912            .read_dream_snapshot(MemoryScope::Global, None)
913            .await
914            .expect("read cold Dream");
915        assert!(cold.snapshot.is_none());
916        assert!(!cold.stale);
917
918        let initial = store
919            .publish_dream_snapshot(
920                MemoryScope::Global,
921                None,
922                &cold.current_generation,
923                "Initial complete orientation",
924            )
925            .await
926            .expect("publish initial Dream");
927        let fresh = store
928            .read_dream_snapshot(MemoryScope::Global, None)
929            .await
930            .expect("read fresh Dream");
931        assert_eq!(fresh.snapshot.as_ref(), Some(&initial));
932        assert!(!fresh.stale);
933
934        write_durable(
935            &store,
936            MemoryScope::Global,
937            None,
938            "Concurrent fact",
939            "Canonical memory changed after synthesis began.",
940        )
941        .await;
942        let stale = store
943            .read_dream_snapshot(MemoryScope::Global, None)
944            .await
945            .expect("read stale Dream");
946        assert!(stale.stale);
947        assert_eq!(stale.snapshot.as_ref(), Some(&initial));
948
949        let error = store
950            .publish_dream_snapshot(
951                MemoryScope::Global,
952                None,
953                &cold.current_generation,
954                "Must not replace the complete snapshot",
955            )
956            .await
957            .expect_err("stale generation must fail CAS");
958        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
959        assert_eq!(
960            store
961                .read_dream_snapshot(MemoryScope::Global, None)
962                .await
963                .expect("read preserved Dream")
964                .snapshot,
965            Some(initial)
966        );
967    }
968
969    #[tokio::test]
970    async fn rebuild_preserves_a_complete_dream_snapshot() {
971        let directory = tempdir().expect("tempdir");
972        let store = MemoryStore::new(directory.path());
973        write_durable(
974            &store,
975            MemoryScope::Global,
976            None,
977            "Rebuild fact",
978            "Derived indexes can rebuild without replacing Dream.",
979        )
980        .await;
981        let generation = store
982            .current_scope_generation(MemoryScope::Global, None)
983            .await
984            .expect("generation");
985        let dream = store
986            .publish_dream_snapshot(
987                MemoryScope::Global,
988                None,
989                &generation,
990                "Stable complete orientation",
991            )
992            .await
993            .expect("publish Dream");
994
995        store
996            .rebuild_scope(MemoryScope::Global, None)
997            .await
998            .expect("rebuild scope");
999        let after = store
1000            .read_dream_snapshot(MemoryScope::Global, None)
1001            .await
1002            .expect("read Dream after rebuild");
1003        assert!(!after.stale);
1004        assert_eq!(after.snapshot, Some(dream));
1005    }
1006}