Skip to main content

clawft_kernel/
profile_store.rs

1//! Per-user profile namespaces (Cognitum Seed Gap #14).
2//!
3//! Each profile gets its own isolated vector storage, allowing
4//! multi-tenant vector isolation on a shared device. Profiles are
5//! persisted to `{storage_path}/{id}/profile.json` and lazily
6//! loaded on boot by scanning the profiles directory.
7//!
8//! This module is compiled only when the `ecc` feature is enabled.
9
10use std::path::{Path, PathBuf};
11use std::sync::RwLock;
12
13use chrono::{DateTime, Utc};
14use dashmap::DashMap;
15use serde::{Deserialize, Serialize};
16use tracing::{debug, info, warn};
17
18use crate::hnsw_service::HnswServiceConfig;
19use crate::vector_backend::{SearchResult, VectorBackend, VectorError, VectorResult};
20use crate::vector_hnsw::HnswBackend;
21
22#[cfg(feature = "exochain")]
23use crate::chain::ChainManager;
24#[cfg(feature = "exochain")]
25use crate::governance::{EffectVector, GovernanceDecision, GovernanceEngine, GovernanceRequest};
26#[cfg(feature = "exochain")]
27use std::sync::Arc;
28
29// ── Profile metadata ────────────────────────────────────────────────────
30
31/// Persisted metadata for a single profile.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ProfileMeta {
34    /// Unique profile identifier (slug, e.g. "alice").
35    pub id: String,
36    /// Human-readable display name.
37    pub name: String,
38    /// When the profile was created.
39    pub created_at: DateTime<Utc>,
40    /// Number of vectors stored (informational snapshot).
41    pub vector_count: usize,
42}
43
44/// A loaded profile: metadata plus its own vector backend.
45pub struct ProfileEntry {
46    pub meta: ProfileMeta,
47    pub backend: Box<dyn VectorBackend>,
48}
49
50impl std::fmt::Debug for ProfileEntry {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("ProfileEntry")
53            .field("meta", &self.meta)
54            .field("backend", &self.meta.id)
55            .finish()
56    }
57}
58
59// ── Profile errors ──────────────────────────────────────────────────────
60
61/// Errors specific to profile operations.
62#[derive(Debug, thiserror::Error)]
63pub enum ProfileError {
64    /// Profile already exists.
65    #[error("profile already exists: '{0}'")]
66    AlreadyExists(String),
67
68    /// Profile not found.
69    #[error("profile not found: '{0}'")]
70    NotFound(String),
71
72    /// Invalid profile ID (must be alphanumeric + hyphens).
73    #[error("invalid profile id: '{0}' -- must be [a-zA-Z0-9_-]+")]
74    InvalidId(String),
75
76    /// I/O error reading/writing profile data.
77    #[error("profile io error: {0}")]
78    Io(#[from] std::io::Error),
79
80    /// JSON serialization error.
81    #[error("profile json error: {0}")]
82    Json(#[from] serde_json::Error),
83
84    /// Governance gate denied the operation.
85    #[error("governance denied: {0}")]
86    GovernanceDenied(String),
87}
88
89pub type ProfileResult<T> = Result<T, ProfileError>;
90
91// ── Profile store ───────────────────────────────────────────────────────
92
93/// Manages multiple named profiles, each with isolated vector storage.
94///
95/// Thread-safe: profiles are stored in a [`DashMap`] and the active
96/// profile is tracked via an [`RwLock`].
97pub struct ProfileStore {
98    /// Base directory for profile storage (e.g., `.weftos/profiles`).
99    storage_path: PathBuf,
100    /// All loaded profiles.
101    profiles: DashMap<String, ProfileEntry>,
102    /// Currently active profile ID.
103    active_profile: RwLock<String>,
104    /// HNSW configuration used when creating new profile backends.
105    hnsw_config: HnswServiceConfig,
106    /// Chain manager for exochain event logging.
107    #[cfg(feature = "exochain")]
108    chain_manager: Option<Arc<ChainManager>>,
109    /// Governance engine for gating destructive operations.
110    #[cfg(feature = "exochain")]
111    governance_engine: Option<Arc<GovernanceEngine>>,
112}
113
114impl ProfileStore {
115    /// Create a new profile store rooted at `storage_path`.
116    ///
117    /// Does **not** scan the directory; call [`load_existing`] for that.
118    pub fn new(storage_path: PathBuf, hnsw_config: HnswServiceConfig) -> Self {
119        Self {
120            storage_path,
121            profiles: DashMap::new(),
122            active_profile: RwLock::new("default".to_owned()),
123            hnsw_config,
124            #[cfg(feature = "exochain")]
125            chain_manager: None,
126            #[cfg(feature = "exochain")]
127            governance_engine: None,
128        }
129    }
130
131    /// Set the chain manager for exochain event logging.
132    #[cfg(feature = "exochain")]
133    pub fn set_chain_manager(&mut self, cm: Arc<ChainManager>) {
134        self.chain_manager = Some(cm);
135    }
136
137    /// Set the governance engine for gating destructive operations.
138    #[cfg(feature = "exochain")]
139    pub fn set_governance_engine(&mut self, engine: Arc<GovernanceEngine>) {
140        self.governance_engine = Some(engine);
141    }
142
143    /// Scan `storage_path` and load existing profiles from disk.
144    ///
145    /// If no profiles exist, creates a "default" profile automatically.
146    pub fn load_existing(&self) -> ProfileResult<usize> {
147        let dir = &self.storage_path;
148        if !dir.exists() {
149            std::fs::create_dir_all(dir)?;
150        }
151
152        let mut count = 0;
153        for entry in std::fs::read_dir(dir)? {
154            let entry = entry?;
155            if !entry.file_type()?.is_dir() {
156                continue;
157            }
158            let profile_dir = entry.path();
159            let meta_path = profile_dir.join("profile.json");
160            if !meta_path.exists() {
161                continue;
162            }
163            match Self::load_profile_meta(&meta_path) {
164                Ok(meta) => {
165                    let backend = Box::new(HnswBackend::new(self.hnsw_config.clone()));
166                    debug!(profile_id = %meta.id, "loaded profile from disk");
167                    self.profiles
168                        .insert(meta.id.clone(), ProfileEntry { meta, backend });
169                    count += 1;
170                }
171                Err(e) => {
172                    warn!(path = %meta_path.display(), error = %e, "failed to load profile");
173                }
174            }
175        }
176
177        // Auto-create default profile if none exist.
178        if self.profiles.is_empty() {
179            info!("no profiles found, creating default profile");
180            self.create_profile_inner("default", "Default Profile")?;
181            count = 1;
182        }
183
184        Ok(count)
185    }
186
187    /// Create a new profile with the given ID and display name.
188    pub fn create_profile(&self, id: &str, name: &str) -> ProfileResult<ProfileMeta> {
189        Self::validate_id(id)?;
190
191        if self.profiles.contains_key(id) {
192            return Err(ProfileError::AlreadyExists(id.to_owned()));
193        }
194
195        // Governance gate: profile creation.
196        #[cfg(feature = "exochain")]
197        if let Some(ref engine) = self.governance_engine {
198            let req =
199                GovernanceRequest::new("system", "profile.create").with_effect(EffectVector {
200                    risk: 0.3,
201                    privacy: 0.2,
202                    ..Default::default()
203                });
204            let result = engine.evaluate(&req);
205            match &result.decision {
206                GovernanceDecision::Deny(reason) | GovernanceDecision::EscalateToHuman(reason) => {
207                    return Err(ProfileError::GovernanceDenied(reason.clone()));
208                }
209                _ => {}
210            }
211        }
212
213        let meta = self.create_profile_inner(id, name)?;
214
215        // Chain logging: profile.create
216        #[cfg(feature = "exochain")]
217        if let Some(ref cm) = self.chain_manager {
218            cm.append(
219                "profile_store",
220                crate::chain::EVENT_KIND_PROFILE_CREATE,
221                Some(serde_json::json!({
222                    "profile_id": id,
223                    "name": name,
224                })),
225            );
226        }
227
228        Ok(meta)
229    }
230
231    /// Delete a profile by ID. Cannot delete the currently active profile.
232    pub fn delete_profile(&self, id: &str) -> ProfileResult<()> {
233        if !self.profiles.contains_key(id) {
234            return Err(ProfileError::NotFound(id.to_owned()));
235        }
236
237        // Prevent deleting the active profile.
238        {
239            let active = self
240                .active_profile
241                .read()
242                .expect("active_profile lock poisoned");
243            if *active == id {
244                return Err(ProfileError::InvalidId(format!(
245                    "cannot delete active profile '{id}'; switch first"
246                )));
247            }
248        }
249
250        // Governance gate: bulk destruction (profile delete).
251        #[cfg(feature = "exochain")]
252        if let Some(ref engine) = self.governance_engine {
253            let req =
254                GovernanceRequest::new("system", "profile.delete").with_effect(EffectVector {
255                    risk: 0.7,
256                    privacy: 0.4,
257                    ..Default::default()
258                });
259            let result = engine.evaluate(&req);
260            match &result.decision {
261                GovernanceDecision::Deny(reason) | GovernanceDecision::EscalateToHuman(reason) => {
262                    return Err(ProfileError::GovernanceDenied(reason.clone()));
263                }
264                _ => {}
265            }
266        }
267
268        self.profiles.remove(id);
269
270        // Remove the directory on disk.
271        let dir = self.storage_path.join(id);
272        if dir.exists() {
273            std::fs::remove_dir_all(&dir)?;
274        }
275
276        // Chain logging: profile.delete
277        #[cfg(feature = "exochain")]
278        if let Some(ref cm) = self.chain_manager {
279            cm.append(
280                "profile_store",
281                crate::chain::EVENT_KIND_PROFILE_DELETE,
282                Some(serde_json::json!({
283                    "profile_id": id,
284                })),
285            );
286        }
287
288        info!(profile_id = %id, "deleted profile");
289        Ok(())
290    }
291
292    /// List all profiles (metadata only).
293    pub fn list_profiles(&self) -> Vec<ProfileMeta> {
294        self.profiles
295            .iter()
296            .map(|entry| {
297                let mut meta = entry.value().meta.clone();
298                meta.vector_count = entry.value().backend.len();
299                meta
300            })
301            .collect()
302    }
303
304    /// Switch the active profile. Subsequent vector operations target
305    /// the new profile.
306    pub fn switch_profile(&self, id: &str) -> ProfileResult<()> {
307        if !self.profiles.contains_key(id) {
308            return Err(ProfileError::NotFound(id.to_owned()));
309        }
310
311        let previous = self.active_profile_id();
312        let mut active = self
313            .active_profile
314            .write()
315            .expect("active_profile lock poisoned");
316        *active = id.to_owned();
317
318        // Chain logging: profile.switch
319        #[cfg(feature = "exochain")]
320        if let Some(ref cm) = self.chain_manager {
321            cm.append(
322                "profile_store",
323                crate::chain::EVENT_KIND_PROFILE_SWITCH,
324                Some(serde_json::json!({
325                    "profile_id": id,
326                    "previous": previous,
327                })),
328            );
329        }
330
331        info!(profile_id = %id, "switched active profile");
332        Ok(())
333    }
334
335    /// Get the currently active profile ID.
336    pub fn active_profile_id(&self) -> String {
337        self.active_profile
338            .read()
339            .expect("active_profile lock poisoned")
340            .clone()
341    }
342
343    /// Get a reference to a specific profile's vector backend.
344    ///
345    /// Returns `None` if the profile does not exist.
346    pub fn get_profile_backend(
347        &self,
348        id: &str,
349    ) -> Option<dashmap::mapref::one::Ref<'_, String, ProfileEntry>> {
350        self.profiles.get(id)
351    }
352
353    /// Get a reference to the active profile's vector backend.
354    pub fn active_backend(&self) -> Option<dashmap::mapref::one::Ref<'_, String, ProfileEntry>> {
355        let id = self.active_profile_id();
356        self.profiles.get(&id)
357    }
358
359    /// Insert a vector into the active profile's backend.
360    pub fn insert(
361        &self,
362        id: u64,
363        key: &str,
364        vector: &[f32],
365        metadata: serde_json::Value,
366    ) -> VectorResult<()> {
367        let profile_id = self.active_profile_id();
368        let entry = self.profiles.get(&profile_id).ok_or_else(|| {
369            VectorError::Other(format!("active profile '{profile_id}' not found"))
370        })?;
371        let result = entry.backend.insert(id, key, vector, metadata);
372
373        // Chain logging: profile.vector.insert
374        #[cfg(feature = "exochain")]
375        if result.is_ok()
376            && let Some(ref cm) = self.chain_manager
377        {
378            cm.append(
379                "profile_store",
380                crate::chain::EVENT_KIND_PROFILE_VECTOR_INSERT,
381                Some(serde_json::json!({
382                    "profile_id": profile_id,
383                    "vector_id": id,
384                    "key": key,
385                })),
386            );
387        }
388
389        result
390    }
391
392    /// Search the active profile's backend.
393    pub fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
394        let profile_id = self.active_profile_id();
395        match self.profiles.get(&profile_id) {
396            Some(entry) => entry.backend.search(query, k),
397            None => Vec::new(),
398        }
399    }
400
401    /// Return the total number of profiles.
402    pub fn len(&self) -> usize {
403        self.profiles.len()
404    }
405
406    /// Return `true` if no profiles are loaded.
407    pub fn is_empty(&self) -> bool {
408        self.profiles.is_empty()
409    }
410
411    /// Persist the metadata for a given profile to disk.
412    pub fn persist_meta(&self, id: &str) -> ProfileResult<()> {
413        let entry = self
414            .profiles
415            .get(id)
416            .ok_or_else(|| ProfileError::NotFound(id.to_owned()))?;
417
418        let mut meta = entry.meta.clone();
419        meta.vector_count = entry.backend.len();
420
421        let dir = self.storage_path.join(id);
422        std::fs::create_dir_all(&dir)?;
423        let meta_path = dir.join("profile.json");
424        let json = serde_json::to_string_pretty(&meta)?;
425        std::fs::write(&meta_path, json)?;
426        Ok(())
427    }
428
429    // ── Internal helpers ────────────────────────────────────────────
430
431    fn create_profile_inner(&self, id: &str, name: &str) -> ProfileResult<ProfileMeta> {
432        let meta = ProfileMeta {
433            id: id.to_owned(),
434            name: name.to_owned(),
435            created_at: Utc::now(),
436            vector_count: 0,
437        };
438
439        let backend = Box::new(HnswBackend::new(self.hnsw_config.clone()));
440
441        // Persist metadata to disk.
442        let dir = self.storage_path.join(id);
443        std::fs::create_dir_all(dir.join("vectors"))?;
444        let meta_path = dir.join("profile.json");
445        let json = serde_json::to_string_pretty(&meta)?;
446        std::fs::write(&meta_path, json)?;
447
448        self.profiles.insert(
449            id.to_owned(),
450            ProfileEntry {
451                meta: meta.clone(),
452                backend,
453            },
454        );
455
456        info!(profile_id = %id, name = %name, "created profile");
457        Ok(meta)
458    }
459
460    fn load_profile_meta(path: &Path) -> ProfileResult<ProfileMeta> {
461        let data = std::fs::read_to_string(path)?;
462        let meta: ProfileMeta = serde_json::from_str(&data)?;
463        Ok(meta)
464    }
465
466    fn validate_id(id: &str) -> ProfileResult<()> {
467        if id.is_empty() {
468            return Err(ProfileError::InvalidId("empty id".to_owned()));
469        }
470        if !id
471            .chars()
472            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
473        {
474            return Err(ProfileError::InvalidId(id.to_owned()));
475        }
476        Ok(())
477    }
478}
479
480impl std::fmt::Debug for ProfileStore {
481    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482        f.debug_struct("ProfileStore")
483            .field("storage_path", &self.storage_path)
484            .field("profiles", &self.profiles.len())
485            .field("active", &self.active_profile_id())
486            .finish()
487    }
488}
489
490// ── Tests ────────────────────────────────────────────────────────────────
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn tmp_dir(name: &str) -> PathBuf {
497        std::env::temp_dir().join(format!(
498            "profile_store_test_{name}_{}",
499            std::time::SystemTime::now()
500                .duration_since(std::time::UNIX_EPOCH)
501                .unwrap()
502                .as_nanos()
503        ))
504    }
505
506    fn make_store(name: &str) -> (ProfileStore, PathBuf) {
507        let dir = tmp_dir(name);
508        let store = ProfileStore::new(dir.clone(), HnswServiceConfig::default());
509        (store, dir)
510    }
511
512    fn cleanup(dir: &Path) {
513        let _ = std::fs::remove_dir_all(dir);
514    }
515
516    #[test]
517    fn create_and_list_profiles() {
518        let (store, dir) = make_store("create_list");
519        store.create_profile("alice", "Alice").unwrap();
520        store.create_profile("bob", "Bob").unwrap();
521
522        let profiles = store.list_profiles();
523        assert_eq!(profiles.len(), 2);
524
525        let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
526        assert!(names.contains(&"Alice"));
527        assert!(names.contains(&"Bob"));
528
529        cleanup(&dir);
530    }
531
532    #[test]
533    fn duplicate_profile_rejected() {
534        let (store, dir) = make_store("duplicate");
535        store.create_profile("alice", "Alice").unwrap();
536        assert!(matches!(
537            store.create_profile("alice", "Alice Again"),
538            Err(ProfileError::AlreadyExists(_))
539        ));
540        cleanup(&dir);
541    }
542
543    #[test]
544    fn invalid_id_rejected() {
545        let (store, dir) = make_store("invalid_id");
546        assert!(matches!(
547            store.create_profile("a b c", "Bad"),
548            Err(ProfileError::InvalidId(_))
549        ));
550        assert!(matches!(
551            store.create_profile("", "Empty"),
552            Err(ProfileError::InvalidId(_))
553        ));
554        assert!(matches!(
555            store.create_profile("a/b", "Slash"),
556            Err(ProfileError::InvalidId(_))
557        ));
558        cleanup(&dir);
559    }
560
561    #[test]
562    fn switch_and_active_profile() {
563        let (store, dir) = make_store("switch");
564        store.create_profile("alice", "Alice").unwrap();
565        store.create_profile("bob", "Bob").unwrap();
566
567        assert_eq!(store.active_profile_id(), "default");
568
569        store.switch_profile("alice").unwrap();
570        assert_eq!(store.active_profile_id(), "alice");
571
572        store.switch_profile("bob").unwrap();
573        assert_eq!(store.active_profile_id(), "bob");
574
575        // Switch to nonexistent fails.
576        assert!(matches!(
577            store.switch_profile("nobody"),
578            Err(ProfileError::NotFound(_))
579        ));
580
581        cleanup(&dir);
582    }
583
584    #[test]
585    fn delete_profile() {
586        let (store, dir) = make_store("delete");
587        store.create_profile("alice", "Alice").unwrap();
588        store.create_profile("bob", "Bob").unwrap();
589
590        // Cannot delete nonexistent.
591        assert!(matches!(
592            store.delete_profile("nobody"),
593            Err(ProfileError::NotFound(_))
594        ));
595
596        // Switch to alice, cannot delete active.
597        store.switch_profile("alice").unwrap();
598        assert!(store.delete_profile("alice").is_err());
599
600        // Can delete bob (not active).
601        store.delete_profile("bob").unwrap();
602        assert_eq!(store.len(), 1);
603
604        cleanup(&dir);
605    }
606
607    #[test]
608    fn insert_and_search_active_profile() {
609        let (store, dir) = make_store("insert_search");
610        store.create_profile("alice", "Alice").unwrap();
611        store.switch_profile("alice").unwrap();
612
613        store
614            .insert(1, "v1", &[1.0, 0.0, 0.0], serde_json::json!({}))
615            .unwrap();
616        store
617            .insert(2, "v2", &[0.0, 1.0, 0.0], serde_json::json!({}))
618            .unwrap();
619
620        let results = store.search(&[1.0, 0.0, 0.0], 1);
621        assert_eq!(results.len(), 1);
622        assert_eq!(results[0].key, "v1");
623
624        cleanup(&dir);
625    }
626
627    #[test]
628    fn profiles_are_isolated() {
629        let (store, dir) = make_store("isolated");
630        store.create_profile("alice", "Alice").unwrap();
631        store.create_profile("bob", "Bob").unwrap();
632
633        // Insert into alice
634        store.switch_profile("alice").unwrap();
635        store
636            .insert(1, "alice-vec", &[1.0, 0.0], serde_json::json!({}))
637            .unwrap();
638
639        // Insert into bob
640        store.switch_profile("bob").unwrap();
641        store
642            .insert(1, "bob-vec", &[0.0, 1.0], serde_json::json!({}))
643            .unwrap();
644
645        // Alice's search returns alice-vec
646        store.switch_profile("alice").unwrap();
647        let results = store.search(&[1.0, 0.0], 1);
648        assert_eq!(results.len(), 1);
649        assert_eq!(results[0].key, "alice-vec");
650
651        // Bob's search returns bob-vec
652        store.switch_profile("bob").unwrap();
653        let results = store.search(&[0.0, 1.0], 1);
654        assert_eq!(results.len(), 1);
655        assert_eq!(results[0].key, "bob-vec");
656
657        cleanup(&dir);
658    }
659
660    #[test]
661    fn load_existing_creates_default() {
662        let (store, dir) = make_store("load_default");
663        let count = store.load_existing().unwrap();
664        assert_eq!(count, 1);
665        assert!(store.profiles.contains_key("default"));
666        cleanup(&dir);
667    }
668
669    #[test]
670    fn load_existing_reads_persisted() {
671        let dir = tmp_dir("load_persisted");
672
673        // First store: create and persist
674        {
675            let store = ProfileStore::new(dir.clone(), HnswServiceConfig::default());
676            store.create_profile("alice", "Alice").unwrap();
677            store.create_profile("bob", "Bob").unwrap();
678            store.persist_meta("alice").unwrap();
679            store.persist_meta("bob").unwrap();
680        }
681
682        // Second store: load from disk
683        {
684            let store = ProfileStore::new(dir.clone(), HnswServiceConfig::default());
685            let count = store.load_existing().unwrap();
686            assert_eq!(count, 2);
687            assert!(store.profiles.contains_key("alice"));
688            assert!(store.profiles.contains_key("bob"));
689        }
690
691        cleanup(&dir);
692    }
693
694    #[test]
695    fn persist_meta_updates_vector_count() {
696        let (store, dir) = make_store("persist_count");
697        store.create_profile("alice", "Alice").unwrap();
698        store.switch_profile("alice").unwrap();
699        store
700            .insert(1, "v1", &[1.0, 0.0], serde_json::json!({}))
701            .unwrap();
702        store.persist_meta("alice").unwrap();
703
704        // Read back the JSON
705        let meta_path = dir.join("alice").join("profile.json");
706        let data = std::fs::read_to_string(&meta_path).unwrap();
707        let meta: ProfileMeta = serde_json::from_str(&data).unwrap();
708        assert_eq!(meta.vector_count, 1);
709
710        cleanup(&dir);
711    }
712}