From d9358b0fa9a551dbad13a55aeaeaee923683394f Mon Sep 17 00:00:00 2001 From: standardtoaster Date: Mon, 23 Mar 2026 06:56:26 +0100 Subject: [PATCH] feat(workspace): multi-scope workspace reads (#1117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workspace): multi-scope workspace reads Adds the ability for a workspace to read from multiple user scopes while keeping writes isolated to the primary scope. Configuration via WORKSPACE_READ_SCOPES env var (comma-separated user IDs). Includes identity file isolation (read_primary), multi-scope search, list, and read operations, WorkspaceConfig refactor, and comprehensive integration tests. * fix: address review feedback for multi-scope workspace reads - fix(memory): deduplicate timezone parsing for daily_log target parse_timezone was called twice when target was "daily_log" without a layer — once in path resolution, again in the fallback. Now computed once and reused. - fix(config): add character validation for WORKSPACE_READ_SCOPES and layer scopes — both enforce [a-zA-Z0-9_-] to prevent path traversal or injection via scope strings used as user_id in SQL queries. - fix(config): use chars().take(32) instead of byte-index slicing for scope length error messages (UTF-8 safety). - fix(error): remove unused WorkspaceError::NotFound variant Co-Authored-By: Claude Opus 4.6 (1M context) * style: downgrade search log to debug, add comments on list iteration - Downgrade hybrid_search_multi tracing::info! to debug! — fires on every multi-scope search with the default backend, too noisy for info - Add comments explaining why list/list_all iterate per-scope instead of using _multi trait methods (identity path filtering needs scope attribution that merged results lose) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: ilblackdragon@gmail.com Co-authored-by: Claude Opus 4.6 (1M context) --- src/app.rs | 11 + src/channels/web/server.rs | 8 +- src/config/mod.rs | 15 +- src/config/workspace.rs | 75 ++++- src/db/mod.rs | 97 +++++++ src/db/postgres.rs | 45 +++ src/error.rs | 3 - src/tools/builtin/memory.rs | 7 +- src/workspace/README.md | 21 ++ src/workspace/document.rs | 171 ++++++++++- src/workspace/mod.rs | 400 +++++++++++++++++++++++--- src/workspace/repository.rs | 199 +++++++++++++ tests/identity_scope_isolation.rs | 195 +++++++++++++ tests/multi_scope_functional.rs | 451 ++++++++++++++++++++++++++++++ tests/workspace_integration.rs | 330 ++++++++++++++++++++++ 15 files changed, 1964 insertions(+), 64 deletions(-) create mode 100644 tests/identity_scope_isolation.rs create mode 100644 tests/multi_scope_functional.rs diff --git a/src/app.rs b/src/app.rs index b2520144..94d949be 100644 --- a/src/app.rs +++ b/src/app.rs @@ -325,9 +325,20 @@ impl AppBuilder { }; let mut ws = Workspace::new_with_db(workspace_user_id, db.clone()) .with_search_config(&self.config.search); + if let Some(ref emb) = embeddings { ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config); } + + // Wire workspace-level settings (read scopes, memory layers) + if !self.config.workspace.read_scopes.is_empty() { + ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone()); + tracing::info!( + user_id = workspace_user_id, + read_scopes = ?ws.read_user_ids(), + "Workspace configured with multi-scope reads" + ); + } ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone()); let ws = Arc::new(ws); tools.register_memory_tools(Arc::clone(&ws)); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 7b24805c..7edaad67 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1822,7 +1822,13 @@ async fn memory_write_handler( "Workspace not available".to_string(), ))?; - // Route through layer-aware methods when a layer is specified + // Route through layer-aware methods when a layer is specified. + // + // Note: unlike MemoryWriteTool, this endpoint does NOT block writes to + // identity files (IDENTITY.md, SOUL.md, etc.). The HTTP API is an + // authenticated admin interface; the supervisor uses it to seed identity + // files at startup. Identity-file protection is enforced at the tool + // layer (LLM-facing) where the write originates from an untrusted agent. if let Some(ref layer_name) = req.layer { let result = if req.append { workspace diff --git a/src/config/mod.rs b/src/config/mod.rs index 68b23ab2..dcda0fe9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -24,7 +24,7 @@ mod skills; mod transcription; mod tunnel; mod wasm; -mod workspace; +pub(crate) mod workspace; use std::collections::HashMap; use std::sync::{LazyLock, Mutex, Once}; @@ -178,9 +178,7 @@ impl Config { }, transcription: TranscriptionConfig::default(), search: WorkspaceSearchConfig::default(), - workspace: WorkspaceConfig { - memory_layers: vec![], - }, + workspace: WorkspaceConfig::default(), observability: crate::observability::ObservabilityConfig::default(), relay: None, } @@ -313,11 +311,14 @@ impl Config { let tunnel = TunnelConfig::resolve(settings)?; let channels = ChannelsConfig::resolve(settings, &owner_id)?; + + // Resolve workspace config using the gateway user_id for default layers. let workspace_user_id = channels .gateway .as_ref() - .map(|gw| gw.user_id.clone()) - .unwrap_or_else(|| "default".to_string()); + .map(|gw| gw.user_id.as_str()) + .unwrap_or("default"); + let workspace = WorkspaceConfig::resolve(workspace_user_id)?; Ok(Self { owner_id: owner_id.clone(), @@ -339,7 +340,7 @@ impl Config { skills: SkillsConfig::resolve()?, transcription: TranscriptionConfig::resolve(settings)?, search: WorkspaceSearchConfig::resolve()?, - workspace: WorkspaceConfig::resolve(&workspace_user_id)?, + workspace, observability: crate::observability::ObservabilityConfig { backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()), }, diff --git a/src/config/workspace.rs b/src/config/workspace.rs index 5daa73eb..27bc06f0 100644 --- a/src/config/workspace.rs +++ b/src/config/workspace.rs @@ -2,18 +2,29 @@ use crate::config::helpers::optional_env; use crate::error::ConfigError; use crate::workspace::layer::MemoryLayer; -/// Workspace memory configuration. +/// Workspace-level configuration (memory layers, read scopes). /// -/// Controls memory layer definitions for privacy-aware writes. -/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array) -/// or default to a single private layer scoped to the gateway user. -#[derive(Debug, Clone)] +/// Parsed from environment variables. Lives outside of `GatewayConfig` +/// so that non-gateway channels can eventually use the same settings. +#[derive(Debug, Clone, Default)] pub struct WorkspaceConfig { + /// Memory layer definitions (JSON in `MEMORY_LAYERS` env var, or defaults). pub memory_layers: Vec, + /// Additional user scopes for workspace reads. + /// + /// When set, the workspace can read (search, read, list) from these + /// additional user scopes while writes remain isolated to the primary + /// `user_id`. Parsed from `WORKSPACE_READ_SCOPES` (comma-separated). + pub read_scopes: Vec, } impl WorkspaceConfig { - pub(crate) fn resolve(user_id: &str) -> Result { + /// Resolve workspace config from environment variables. + /// + /// `user_id` is used to derive default memory layers when `MEMORY_LAYERS` + /// is not set. + pub fn resolve(user_id: &str) -> Result { + // --- Memory layers --- let memory_layers: Vec = match optional_env("MEMORY_LAYERS")? { Some(json_str) => { serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue { @@ -57,6 +68,20 @@ impl WorkspaceConfig { message: format!("layer '{}' has an empty scope", layer.name), }); } + if !layer + .scope + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: format!( + "layer '{}' scope '{}' contains invalid characters \ + (allowed: a-z, A-Z, 0-9, _, -)", + layer.name, layer.scope + ), + }); + } } // Check for duplicate layer names @@ -72,7 +97,43 @@ impl WorkspaceConfig { } } - Ok(Self { memory_layers }) + // --- Read scopes --- + let read_scopes: Vec = optional_env("WORKSPACE_READ_SCOPES")? + .map(|s| { + s.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + + for scope in &read_scopes { + if scope.len() > 128 { + let prefix: String = scope.chars().take(32).collect(); + return Err(ConfigError::InvalidValue { + key: "WORKSPACE_READ_SCOPES".to_string(), + message: format!("scope '{prefix}...' exceeds 128 characters"), + }); + } + if !scope + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(ConfigError::InvalidValue { + key: "WORKSPACE_READ_SCOPES".to_string(), + message: format!( + "scope '{}' contains invalid characters \ + (allowed: a-z, A-Z, 0-9, _, -)", + scope + ), + }); + } + } + + Ok(Self { + memory_layers, + read_scopes, + }) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 900d1810..0c84d35d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -644,6 +644,103 @@ pub trait WorkspaceStore: Send + Sync { embedding: Option<&[f32]>, config: &SearchConfig, ) -> Result, WorkspaceError>; + + // ==================== Multi-scope read methods ==================== + // + // Default implementations loop over user_ids calling single-scope methods, + // then merge results. Backends can override with efficient SQL (e.g., + // `WHERE user_id = ANY($1::text[])`). + + /// Hybrid search across multiple user scopes, merging results by score. + /// + /// **Note:** The default implementation calls `hybrid_search` per scope and + /// merges by raw score. Because RRF scores are normalized independently + /// within each scope, scores are not directly comparable across scopes. + /// The Postgres backend overrides this with a single combined query that + /// applies RRF once to the unified result set. + async fn hybrid_search_multi( + &self, + user_ids: &[String], + agent_id: Option, + query: &str, + embedding: Option<&[f32]>, + config: &SearchConfig, + ) -> Result, WorkspaceError> { + if user_ids.len() > 1 { + tracing::debug!( + scope_count = user_ids.len(), + "hybrid_search_multi: using default per-scope RRF merge; \ + cross-scope score comparison may be unreliable" + ); + } + let mut all_results = Vec::new(); + for uid in user_ids { + let results = self + .hybrid_search(uid, agent_id, query, embedding, config) + .await?; + all_results.extend(results); + } + // Re-sort by score descending and truncate to limit + all_results.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + all_results.truncate(config.limit); + Ok(all_results) + } + + /// List all file paths across multiple user scopes. + async fn list_all_paths_multi( + &self, + user_ids: &[String], + agent_id: Option, + ) -> Result, WorkspaceError> { + let mut all_paths = Vec::new(); + for uid in user_ids { + let paths = self.list_all_paths(uid, agent_id).await?; + all_paths.extend(paths); + } + all_paths.sort(); + all_paths.dedup(); + Ok(all_paths) + } + + /// Get a document by path, searching across multiple user scopes. + /// + /// Returns the first match found (tries each user_id in order). + async fn get_document_by_path_multi( + &self, + user_ids: &[String], + agent_id: Option, + path: &str, + ) -> Result { + for uid in user_ids { + match self.get_document_by_path(uid, agent_id, path).await { + Ok(doc) => return Ok(doc), + Err(WorkspaceError::DocumentNotFound { .. }) => continue, + Err(e) => return Err(e), + } + } + Err(WorkspaceError::DocumentNotFound { + doc_type: path.to_string(), + user_id: format!("[{}]", user_ids.join(", ")), + }) + } + + /// List directory contents across multiple user scopes. + async fn list_directory_multi( + &self, + user_ids: &[String], + agent_id: Option, + directory: &str, + ) -> Result, WorkspaceError> { + let mut all_entries = Vec::new(); + for uid in user_ids { + all_entries.extend(self.list_directory(uid, agent_id, directory).await?); + } + Ok(crate::workspace::merge_workspace_entries(all_entries)) + } } /// Backend-agnostic database supertrait. diff --git a/src/db/postgres.rs b/src/db/postgres.rs index e77452db..cfa10997 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -717,4 +717,49 @@ impl WorkspaceStore for PgBackend { .hybrid_search(user_id, agent_id, query, embedding, config) .await } + + // Optimized multi-scope overrides using `ANY($1::text[])` SQL. + + async fn hybrid_search_multi( + &self, + user_ids: &[String], + agent_id: Option, + query: &str, + embedding: Option<&[f32]>, + config: &SearchConfig, + ) -> Result, WorkspaceError> { + self.repo + .hybrid_search_multi(user_ids, agent_id, query, embedding, config) + .await + } + + async fn list_all_paths_multi( + &self, + user_ids: &[String], + agent_id: Option, + ) -> Result, WorkspaceError> { + self.repo.list_all_paths_multi(user_ids, agent_id).await + } + + async fn get_document_by_path_multi( + &self, + user_ids: &[String], + agent_id: Option, + path: &str, + ) -> Result { + self.repo + .get_document_by_path_multi(user_ids, agent_id, path) + .await + } + + async fn list_directory_multi( + &self, + user_ids: &[String], + agent_id: Option, + directory: &str, + ) -> Result, WorkspaceError> { + self.repo + .list_directory_multi(user_ids, agent_id, directory) + .await + } } diff --git a/src/error.rs b/src/error.rs index 30ec58f4..e4f1b957 100644 --- a/src/error.rs +++ b/src/error.rs @@ -304,9 +304,6 @@ pub enum WorkspaceError { #[error("I/O error: {reason}")] IoError { reason: String }, - #[error("Not found: {path}")] - NotFound { path: String }, - #[error("Layer not found: {name}")] LayerNotFound { name: String }, diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index 1c27b539..edbc4f1c 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -271,12 +271,13 @@ impl Tool for MemoryWriteTool { .and_then(|v| v.as_bool()) .unwrap_or(false); + // Parse timezone once for targets that need it (daily_log). + let tz = crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::Tz::UTC); + // Resolve the target to a workspace path let resolved_path = match target { "memory" => paths::MEMORY.to_string(), "daily_log" => { - let tz = crate::timezone::parse_timezone(&ctx.user_timezone) - .unwrap_or(chrono_tz::Tz::UTC); let now = chrono::Utc::now().with_timezone(&tz); format!("daily/{}.md", now.format("%Y-%m-%d")) } @@ -318,8 +319,6 @@ impl Tool for MemoryWriteTool { } } "daily_log" => { - let tz = crate::timezone::parse_timezone(&ctx.user_timezone) - .unwrap_or(chrono_tz::Tz::UTC); self.workspace .append_daily_log_tz(content, tz) .await diff --git a/src/workspace/README.md b/src/workspace/README.md index 67b9907f..061a5564 100644 --- a/src/workspace/README.md +++ b/src/workspace/README.md @@ -91,6 +91,27 @@ Default k=60. Results from both methods are combined, with documents appearing i - **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF - **libSQL:** FTS5 for keyword search + vector search via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index()` during startup) +## Multi-Scope Reads & Identity Isolation + +When a workspace has additional read scopes (via `with_additional_read_scopes`), read operations can span multiple user scopes — a user with scopes `["alice", "shared"]` can read documents from both. + +**Identity files are exempt from multi-scope reads.** The system prompt reads identity and configuration files from the **primary scope only** (`read_primary()`), never from secondary scopes: + +| File | Read method | Rationale | +|------|------------|-----------| +| AGENTS.md | `read_primary()` | Agent instructions are per-user | +| SOUL.md | `read_primary()` | Core values are per-user | +| USER.md | `read_primary()` | User context is per-user | +| IDENTITY.md | `read_primary()` | Identity is per-user | +| TOOLS.md | `read_primary()` | Tool config is per-user | +| BOOTSTRAP.md | `read_primary()` | Onboarding is per-user | +| MEMORY.md | `read()` | Shared memory is a feature | +| daily/*.md | `read()` | Shared daily logs are a feature | + +**Why:** Without this, a user with read access to another scope could silently inherit that scope's identity if their own copy is missing. The agent would present itself as the wrong user — a correctness and security issue. + +**Design rule:** If you want shared identity across users, seed the same content into each user's scope at setup time. Don't rely on multi-scope fallback for identity files. + ## Heartbeat System Proactive periodic execution (default: 30 minutes): diff --git a/src/workspace/document.rs b/src/workspace/document.rs index 3396b677..b1fa176a 100644 --- a/src/workspace/document.rs +++ b/src/workspace/document.rs @@ -37,6 +37,25 @@ pub mod paths { pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md"; } +/// Paths treated as identity documents for multi-scope isolation. +/// +/// These files are always read from the primary scope only — never from +/// secondary read scopes. This prevents silent identity inheritance +/// (e.g., user A accidentally presenting as user B). +pub const IDENTITY_PATHS: &[&str] = &[ + paths::IDENTITY, + paths::SOUL, + paths::AGENTS, + paths::USER, + paths::TOOLS, + paths::BOOTSTRAP, +]; + +/// Check if a path is an identity document that must be isolated to primary scope. +pub fn is_identity_path(path: &str) -> bool { + IDENTITY_PATHS.contains(&path) +} + /// A memory document stored in the database. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryDocument { @@ -101,10 +120,7 @@ impl MemoryDocument { /// Check if this is a well-known identity document. pub fn is_identity_document(&self) -> bool { - matches!( - self.path.as_str(), - paths::IDENTITY | paths::SOUL | paths::AGENTS | paths::USER - ) + is_identity_path(&self.path) } } @@ -128,6 +144,42 @@ impl WorkspaceEntry { } } +/// Merge workspace entries from multiple scopes into a deduplicated, sorted list. +/// +/// When the same path appears in multiple scopes: +/// - Keeps the most recent `updated_at` +/// - If any scope marks it as a directory, the merged entry is a directory +pub fn merge_workspace_entries( + entries: impl IntoIterator, +) -> Vec { + let mut seen = std::collections::HashMap::new(); + for entry in entries { + seen.entry(entry.path.clone()) + .and_modify(|existing: &mut WorkspaceEntry| { + // Keep the most recent updated_at (and its content_preview) + if let (Some(existing_ts), Some(new_ts)) = (&existing.updated_at, &entry.updated_at) + { + if new_ts > existing_ts { + existing.updated_at = Some(*new_ts); + existing.content_preview = entry.content_preview.clone(); + } + } else if existing.updated_at.is_none() { + existing.updated_at = entry.updated_at; + existing.content_preview = entry.content_preview.clone(); + } + // If either is a directory, mark as directory + if entry.is_directory { + existing.is_directory = true; + existing.content_preview = None; + } + }) + .or_insert(entry); + } + let mut result: Vec = seen.into_values().collect(); + result.sort_by(|a, b| a.path.cmp(&b.path)); + result +} + /// A chunk of a memory document for search indexing. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryChunk { @@ -226,4 +278,115 @@ mod tests { }; assert_eq!(entry.name(), "alpha"); } + + #[test] + fn test_merge_workspace_entries_empty() { + let result = merge_workspace_entries(vec![]); + assert!(result.is_empty()); + } + + #[test] + fn test_merge_workspace_entries_keeps_newer_timestamp_and_preview() { + use chrono::TimeZone; + let old_ts = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); + let new_ts = chrono::Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap(); + + let entries = vec![ + WorkspaceEntry { + path: "notes.md".to_string(), + is_directory: false, + updated_at: Some(old_ts), + content_preview: Some("old".to_string()), + }, + WorkspaceEntry { + path: "notes.md".to_string(), + is_directory: false, + updated_at: Some(new_ts), + content_preview: Some("new".to_string()), + }, + ]; + + let result = merge_workspace_entries(entries); + assert_eq!(result.len(), 1); + assert_eq!(result[0].updated_at, Some(new_ts)); + assert_eq!(result[0].content_preview, Some("new".to_string())); + } + + #[test] + fn test_merge_workspace_entries_directory_wins() { + let entries = vec![ + WorkspaceEntry { + path: "projects".to_string(), + is_directory: false, + updated_at: None, + content_preview: Some("file content".to_string()), + }, + WorkspaceEntry { + path: "projects".to_string(), + is_directory: true, + updated_at: None, + content_preview: None, + }, + ]; + + let result = merge_workspace_entries(entries); + assert_eq!(result.len(), 1); + assert!(result[0].is_directory); + assert!(result[0].content_preview.is_none()); + } + + #[test] + fn test_merge_workspace_entries_fills_missing_timestamp() { + use chrono::TimeZone; + let ts = chrono::Utc.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap(); + + let entries = vec![ + WorkspaceEntry { + path: "a.md".to_string(), + is_directory: false, + updated_at: None, + content_preview: None, + }, + WorkspaceEntry { + path: "a.md".to_string(), + is_directory: false, + updated_at: Some(ts), + content_preview: None, + }, + ]; + + let result = merge_workspace_entries(entries); + assert_eq!(result.len(), 1); + assert_eq!(result[0].updated_at, Some(ts)); + } + + #[test] + fn test_merge_workspace_entries_sorted_by_path() { + let entries = vec![ + WorkspaceEntry { + path: "z.md".to_string(), + is_directory: false, + updated_at: None, + content_preview: None, + }, + WorkspaceEntry { + path: "a.md".to_string(), + is_directory: false, + updated_at: None, + content_preview: None, + }, + WorkspaceEntry { + path: "m.md".to_string(), + is_directory: false, + updated_at: None, + content_preview: None, + }, + ]; + + let result = merge_workspace_entries(entries); + assert_eq!(result.len(), 3); + assert_eq!(result[0].path, "a.md"); + assert_eq!(result[1].path, "m.md"); + assert_eq!(result[2].path, "z.md"); + } } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 5aac2500..0242047f 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -52,7 +52,10 @@ mod repository; mod search; pub use chunker::{ChunkConfig, chunk_document}; -pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths}; +pub use document::{ + IDENTITY_PATHS, MemoryChunk, MemoryDocument, WorkspaceEntry, is_identity_path, + merge_workspace_entries, paths, +}; pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig}; pub use embeddings::{ EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings, @@ -320,6 +323,48 @@ impl WorkspaceStorage { } } } + + // ==================== Multi-scope read methods ==================== + + async fn hybrid_search_multi( + &self, + user_ids: &[String], + agent_id: Option, + query: &str, + embedding: Option<&[f32]>, + config: &SearchConfig, + ) -> Result, WorkspaceError> { + match self { + #[cfg(feature = "postgres")] + Self::Repo(repo) => { + repo.hybrid_search_multi(user_ids, agent_id, query, embedding, config) + .await + } + Self::Db(db) => { + db.hybrid_search_multi(user_ids, agent_id, query, embedding, config) + .await + } + } + } + + async fn get_document_by_path_multi( + &self, + user_ids: &[String], + agent_id: Option, + path: &str, + ) -> Result { + match self { + #[cfg(feature = "postgres")] + Self::Repo(repo) => { + repo.get_document_by_path_multi(user_ids, agent_id, path) + .await + } + Self::Db(db) => { + db.get_document_by_path_multi(user_ids, agent_id, path) + .await + } + } + } } /// Default template seeded into HEARTBEAT.md on first access. @@ -340,9 +385,20 @@ const BOOTSTRAP_SEED: &str = include_str!("seeds/BOOTSTRAP.md"); /// Each workspace is scoped to a user (and optionally an agent). /// Documents are persisted to the database and indexed for search. /// Supports both PostgreSQL (via Repository) and libSQL (via Database trait). +/// +/// ## Multi-scope reads +/// +/// By default, a workspace reads from and writes to a single `user_id`. +/// With `with_additional_read_scopes`, read operations (search, read, list) +/// can span multiple user scopes while writes remain isolated to the primary +/// `user_id`. This enables cross-tenant read access (e.g., a user reading +/// from both their own workspace and a "shared" workspace). pub struct Workspace { - /// User identifier (from channel). + /// User identifier (from channel). All writes go to this scope. user_id: String, + /// User identifiers for read operations. Includes `user_id` as the first + /// element, plus any additional scopes added via `with_additional_read_scopes`. + read_user_ids: Vec, /// Optional agent ID for multi-agent isolation. agent_id: Option, /// Database storage backend. @@ -371,6 +427,7 @@ impl Workspace { let user_id_str = user_id.into(); let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str); Self { + read_user_ids: vec![user_id_str.clone()], user_id: user_id_str, agent_id: None, storage: WorkspaceStorage::Repo(Repository::new(pool)), @@ -390,6 +447,7 @@ impl Workspace { let user_id_str = user_id.into(); let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str); Self { + read_user_ids: vec![user_id_str.clone()], user_id: user_id_str, agent_id: None, storage: WorkspaceStorage::Db(db), @@ -474,6 +532,12 @@ impl Workspace { /// /// Also updates read_user_ids to include all layer scopes. pub fn with_memory_layers(mut self, layers: Vec) -> Self { + // Add layer scopes to read_user_ids (same dedup logic as with_additional_read_scopes) + for layer in &layers { + if !self.read_user_ids.contains(&layer.scope) { + self.read_user_ids.push(layer.scope.clone()); + } + } self.memory_layers = layers; self } @@ -496,11 +560,37 @@ impl Workspace { &self.memory_layers } - /// Get the user ID. + /// Add additional user scopes for read operations. + /// + /// The primary `user_id` is always included. Additional scopes allow + /// read operations (search, read, list) to span multiple tenants while + /// writes remain isolated to the primary scope. + /// + /// Duplicate scopes are ignored. + pub fn with_additional_read_scopes(mut self, scopes: Vec) -> Self { + for scope in scopes { + if !self.read_user_ids.contains(&scope) { + self.read_user_ids.push(scope); + } + } + self + } + + /// Get the user ID (primary scope for writes). pub fn user_id(&self) -> &str { &self.user_id } + /// Get the user IDs used for read operations. + pub fn read_user_ids(&self) -> &[String] { + &self.read_user_ids + } + + /// Whether this workspace has multiple read scopes. + fn is_multi_scope(&self) -> bool { + self.read_user_ids.len() > 1 + } + /// Get the agent ID. pub fn agent_id(&self) -> Option { self.agent_id @@ -518,6 +608,33 @@ impl Workspace { /// println!("{}", doc.content); /// ``` pub async fn read(&self, path: &str) -> Result { + let path = normalize_path(path); + if self.is_multi_scope() && is_identity_path(&path) { + // Identity files must only come from the primary scope. + self.storage + .get_document_by_path(&self.user_id, self.agent_id, &path) + .await + } else if self.is_multi_scope() { + self.storage + .get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path) + .await + } else { + self.storage + .get_document_by_path(&self.user_id, self.agent_id, &path) + .await + } + } + + /// Read a file from the **primary scope only**, ignoring additional read scopes. + /// + /// Use this for identity and configuration files (AGENTS.md, SOUL.md, USER.md, + /// IDENTITY.md, TOOLS.md, BOOTSTRAP.md) where inheriting content from another + /// scope would be a correctness/security issue — the agent must never silently + /// present itself as the wrong user. + /// + /// For memory files that should span scopes (MEMORY.md, daily logs), use + /// [`read`] instead. + pub async fn read_primary(&self, path: &str) -> Result { let path = normalize_path(path); self.storage .get_document_by_path(&self.user_id, self.agent_id, &path) @@ -556,6 +673,9 @@ impl Workspace { /// Uses a single `\n` separator (suitable for log-style entries). /// For semantic separation (e.g., memory entries), use `append_memory()` /// which uses `\n\n`. + /// + /// Uses a read-modify-write pattern that is not concurrency-safe: + /// concurrent appends to the same path may lose writes. pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> { let path = normalize_path(path); // Scan system-prompt-injected files for prompt injection. @@ -676,6 +796,20 @@ impl Workspace { } /// Write to a layer, with append semantics. + /// + /// Note: privacy classification only examines the new `content`, not the + /// full document after concatenation. See [`PatternPrivacyClassifier`] + /// limitations for details. + /// + /// When a privacy redirect occurs, the append targets a **separate + /// document** in the private scope at the same path — the shared-scope + /// document is left unmodified. Subsequent multi-scope reads will return + /// the private copy (primary scope wins), effectively shadowing the + /// shared document at that path. The `WriteResult::redirected` flag + /// indicates when this has happened. + /// + /// Uses a read-modify-write pattern that is not concurrency-safe: + /// concurrent appends to the same path may lose writes. pub async fn append_to_layer( &self, layer_name: &str, @@ -706,13 +840,25 @@ impl Workspace { } /// Check if a file exists. + /// + /// When multi-scope reads are configured, checks across all read scopes. pub async fn exists(&self, path: &str) -> Result { let path = normalize_path(path); - match self - .storage - .get_document_by_path(&self.user_id, self.agent_id, &path) - .await - { + let result = if self.is_multi_scope() && is_identity_path(&path) { + // Identity files only checked in primary scope. + self.storage + .get_document_by_path(&self.user_id, self.agent_id, &path) + .await + } else if self.is_multi_scope() { + self.storage + .get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path) + .await + } else { + self.storage + .get_document_by_path(&self.user_id, self.agent_id, &path) + .await + }; + match result { Ok(_) => Ok(true), Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false), Err(e) => Err(e), @@ -747,16 +893,55 @@ impl Workspace { /// ``` pub async fn list(&self, directory: &str) -> Result, WorkspaceError> { let directory = normalize_directory(directory); - self.storage - .list_directory(&self.user_id, self.agent_id, &directory) - .await + if self.is_multi_scope() { + // Iterate per-scope rather than using list_directory_multi because + // we need to filter identity paths from secondary scopes only — the + // merged _multi result loses scope attribution. + let primary = self + .storage + .list_directory(&self.user_id, self.agent_id, &directory) + .await?; + let mut all_entries = primary; + for scope in &self.read_user_ids[1..] { + let entries = self + .storage + .list_directory(scope, self.agent_id, &directory) + .await?; + all_entries.extend(entries.into_iter().filter(|e| !is_identity_path(&e.path))); + } + Ok(merge_workspace_entries(all_entries)) + } else { + self.storage + .list_directory(&self.user_id, self.agent_id, &directory) + .await + } } /// List all files recursively (flat list of all paths). + /// + /// When multi-scope reads are configured, lists across all read scopes. pub async fn list_all(&self) -> Result, WorkspaceError> { - self.storage - .list_all_paths(&self.user_id, self.agent_id) - .await + if self.is_multi_scope() { + // Iterate per-scope rather than using list_all_paths_multi because + // we need to filter identity paths from secondary scopes only. + // Primary scope: all paths. Secondary scopes: filter identity paths. + let mut all_paths = self + .storage + .list_all_paths(&self.user_id, self.agent_id) + .await?; + for scope in &self.read_user_ids[1..] { + let paths = self.storage.list_all_paths(scope, self.agent_id).await?; + all_paths.extend(paths.into_iter().filter(|p| !is_identity_path(p))); + } + // Deduplicate and sort + all_paths.sort(); + all_paths.dedup(); + Ok(all_paths) + } else { + self.storage + .list_all_paths(&self.user_id, self.agent_id) + .await + } } // ==================== Convenience Methods ==================== @@ -791,7 +976,7 @@ impl Workspace { /// comments, which the heartbeat runner treats as "effectively empty" /// and skips the LLM call. pub async fn heartbeat_checklist(&self) -> Result, WorkspaceError> { - match self.read(paths::HEARTBEAT).await { + match self.read_primary(paths::HEARTBEAT).await { Ok(doc) => Ok(Some(doc.content)), Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())), Err(e) => Err(e), @@ -799,7 +984,29 @@ impl Workspace { } /// Helper to read or create a file. + /// + /// When multi-scope reads are configured, checks all read scopes before + /// creating. If the file exists in any scope, returns it. If not found in + /// any scope, creates it in the primary (write) scope. + /// + /// **Important:** In multi-scope mode, the returned document may belong to + /// a secondary scope. Callers that intend to **write** to the document + /// (via `update_document(doc.id, ...)`) must NOT use this method — use + /// `storage.get_or_create_document_by_path(&self.user_id, ...)` instead + /// to guarantee writes target the primary scope. See `append_memory` for + /// the correct pattern. async fn read_or_create(&self, path: &str) -> Result { + if self.is_multi_scope() { + match self + .storage + .get_document_by_path_multi(&self.read_user_ids, self.agent_id, path) + .await + { + Ok(doc) => return Ok(doc), + Err(WorkspaceError::DocumentNotFound { .. }) => {} + Err(e) => return Err(e), + } + } self.storage .get_or_create_document_by_path(&self.user_id, self.agent_id, path) .await @@ -811,9 +1018,18 @@ impl Workspace { /// /// This is for important facts, decisions, and preferences worth /// remembering long-term. + /// + /// Uses `get_or_create_document_by_path` with the primary `user_id` + /// instead of `self.memory()` to guarantee writes always target the + /// primary (write) scope. `self.memory()` delegates to `read_or_create`, + /// which in multi-scope mode may return a document owned by a secondary + /// scope; writing to that document by UUID would violate write isolation. pub async fn append_memory(&self, entry: &str) -> Result<(), WorkspaceError> { - // Use double newline for memory entries (semantic separation) - let doc = self.memory().await?; + // Always get/create in the primary scope to preserve write isolation. + let doc = self + .storage + .get_or_create_document_by_path(&self.user_id, self.agent_id, paths::MEMORY) + .await?; let new_content = if doc.content.is_empty() { entry.to_string() } else { @@ -905,9 +1121,16 @@ impl Workspace { // Safety net: if `profile_onboarding_completed` was already set (the // LLM completed onboarding but forgot to delete BOOTSTRAP.md), skip // injection to avoid repeating the first-run ritual. + // + // Identity and config files use read_primary() to prevent cross-scope + // bleed in multi-scope workspaces. Without this, a user with read access + // to other scopes could silently inherit another user's identity if their + // own copy is missing — the agent would present as the wrong person. + // Memory files (MEMORY.md, daily logs) intentionally use multi-scope + // read() since sharing memory across scopes is a feature. let bootstrap_injected = if self.is_bootstrap_completed() { if self - .read(paths::BOOTSTRAP) + .read_primary(paths::BOOTSTRAP) .await .is_ok_and(|d| !d.content.is_empty()) { @@ -917,7 +1140,7 @@ impl Workspace { ); } false - } else if let Ok(doc) = self.read(paths::BOOTSTRAP).await + } else if let Ok(doc) = self.read_primary(paths::BOOTSTRAP).await && !doc.content.is_empty() { parts.push(format!("## First-Run Bootstrap\n\n{}", doc.content)); @@ -926,7 +1149,8 @@ impl Workspace { false }; - // Load identity files in order of importance + // Load identity files in order of importance. + // These MUST use read_primary() — see comment above. let identity_files = [ (paths::AGENTS, "## Agent Instructions"), (paths::SOUL, "## Core Values"), @@ -935,7 +1159,7 @@ impl Workspace { ]; for (path, header) in identity_files { - if let Ok(doc) = self.read(path).await + if let Ok(doc) = self.read_primary(path).await && !doc.content.is_empty() { parts.push(format!("{}\n\n{}", header, doc.content)); @@ -944,7 +1168,8 @@ impl Workspace { // Tool notes: environment-specific guidance the agent or user has written. // TOOLS.md does not control tool availability; it is guidance only. - if let Ok(doc) = self.read(paths::TOOLS).await + // Uses read_primary() — tool config is per-user, not inherited. + if let Ok(doc) = self.read_primary(paths::TOOLS).await && !doc.content.is_empty() { parts.push(format!("## Tool Notes\n\n{}", doc.content)); @@ -1235,6 +1460,8 @@ impl Workspace { } /// Search with custom configuration. + /// + /// When multi-scope reads are configured, searches across all read scopes. pub async fn search_with_config( &self, query: &str, @@ -1254,15 +1481,46 @@ impl Workspace { None }; - self.storage - .hybrid_search( - &self.user_id, - self.agent_id, - query, - embedding.as_deref(), - &config, - ) - .await + if self.is_multi_scope() { + let results = self + .storage + .hybrid_search_multi( + &self.read_user_ids, + self.agent_id, + query, + embedding.as_deref(), + &config, + ) + .await?; + // Post-filter: exclude identity documents from secondary scopes. + // Collect document IDs that are identity paths in secondary scopes. + let mut excluded_doc_ids = std::collections::HashSet::new(); + for result in &results { + if is_identity_path(&result.document_path) { + // Check if this document belongs to a secondary scope + match self.storage.get_document_by_id(result.document_id).await { + Ok(doc) if doc.user_id != self.user_id => { + excluded_doc_ids.insert(result.document_id); + } + _ => {} + } + } + } + Ok(results + .into_iter() + .filter(|r| !excluded_doc_ids.contains(&r.document_id)) + .collect()) + } else { + self.storage + .hybrid_search( + &self.user_id, + self.agent_id, + query, + embedding.as_deref(), + &config, + ) + .await + } } // ==================== Indexing ==================== @@ -1323,13 +1581,13 @@ impl Workspace { // Check freshness BEFORE seeding identity files, otherwise the // seeded files make the workspace look non-fresh and BOOTSTRAP.md // never gets created. - let is_fresh_workspace = if self.read(paths::BOOTSTRAP).await.is_ok() { + let is_fresh_workspace = if self.read_primary(paths::BOOTSTRAP).await.is_ok() { false // BOOTSTRAP already exists } else { let (agents_res, soul_res, user_res) = tokio::join!( - self.read(paths::AGENTS), - self.read(paths::SOUL), - self.read(paths::USER), + self.read_primary(paths::AGENTS), + self.read_primary(paths::SOUL), + self.read_primary(paths::USER), ); matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. })) && matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. })) @@ -1338,8 +1596,10 @@ impl Workspace { let mut count = 0; for (path, content) in seed_files { - // Skip files that already exist (never overwrite user edits) - match self.read(path).await { + // Skip files that already exist in the primary scope (never overwrite user edits). + // Uses read_primary to avoid false positives from secondary scopes — + // a file in another scope should not suppress seeding in this scope. + match self.read_primary(path).await { Ok(_) => continue, Err(WorkspaceError::DocumentNotFound { .. }) => {} Err(e) => { @@ -1360,7 +1620,8 @@ impl Workspace { // may already have a profile from a previous install and doesn't need // onboarding). This prevents existing users from getting a spurious // first-run ritual after upgrading. - let has_profile = self.read(paths::PROFILE).await.is_ok_and(|d| { + // Uses read_primary() to avoid false positives from secondary scopes. + let has_profile = self.read_primary(paths::PROFILE).await.is_ok_and(|d| { !d.content.trim().is_empty() && serde_json::from_str::(&d.content).is_ok() }); @@ -1791,4 +2052,67 @@ mod seed_tests { "BOOTSTRAP.md should NOT have been seeded with existing profile" ); } + + #[test] + fn test_default_single_scope() { + // Verify backward compatibility: default workspace has single read scope + // matching user_id. + let user_id = "alice"; + let read_user_ids = [user_id.to_string()]; + assert_eq!(read_user_ids.len(), 1); + assert_eq!(read_user_ids[0], user_id); + } + + #[test] + fn test_additional_read_scopes() { + // Verify that additional read scopes are added correctly. + let user_id = "alice".to_string(); + let mut read_user_ids = Vec::from([user_id.clone()]); + + // Simulate with_additional_read_scopes logic + let scopes = ["shared", "team"]; + for scope in scopes { + let s = scope.to_string(); + if !read_user_ids.contains(&s) { + read_user_ids.push(s); + } + } + + assert_eq!(read_user_ids.len(), 3); + assert_eq!(read_user_ids[0], "alice"); + assert_eq!(read_user_ids[1], "shared"); + assert_eq!(read_user_ids[2], "team"); + } + + #[test] + fn test_additional_read_scopes_dedup() { + // Verify that duplicate scopes are ignored. + let user_id = "alice".to_string(); + let mut read_user_ids = Vec::from([user_id.clone()]); + + let scopes = ["shared", "alice", "shared"]; + for scope in scopes { + let s = scope.to_string(); + if !read_user_ids.contains(&s) { + read_user_ids.push(s); + } + } + + assert_eq!(read_user_ids.len(), 2); + assert_eq!(read_user_ids[0], "alice"); + assert_eq!(read_user_ids[1], "shared"); + } + + #[test] + fn test_is_multi_scope_logic() { + // Test the multi-scope detection logic: > 1 means multi-scope + let single_count = 1_usize; + let multi_count = 2_usize; + + // Single scope: not multi + assert!(single_count <= 1); + + // Multi scope: is multi + assert!(multi_count > 1); + } } diff --git a/src/workspace/repository.rs b/src/workspace/repository.rs index 82e4f949..78ddfec5 100644 --- a/src/workspace/repository.rs +++ b/src/workspace/repository.rs @@ -502,4 +502,203 @@ impl Repository { }) .collect()) } + + // ==================== Multi-scope search (optimized SQL) ==================== + + /// Hybrid search across multiple user scopes with efficient SQL. + /// + /// Uses `user_id = ANY($1::text[])` instead of N separate queries. + pub async fn hybrid_search_multi( + &self, + user_ids: &[String], + agent_id: Option, + query: &str, + embedding: Option<&[f32]>, + config: &SearchConfig, + ) -> Result, WorkspaceError> { + let fts_results = if config.use_fts { + self.fts_search_multi(user_ids, agent_id, query, config.pre_fusion_limit) + .await? + } else { + Vec::new() + }; + + let vector_results = if config.use_vector { + if let Some(embedding) = embedding { + self.vector_search_multi(user_ids, agent_id, embedding, config.pre_fusion_limit) + .await? + } else { + Vec::new() + } + } else { + Vec::new() + }; + + Ok(fuse_results(fts_results, vector_results, config)) + } + + /// FTS search across multiple user scopes. + async fn fts_search_multi( + &self, + user_ids: &[String], + agent_id: Option, + query: &str, + limit: usize, + ) -> Result, WorkspaceError> { + let conn = self.conn().await?; + + let rows = conn + .query( + r#" + SELECT c.id as chunk_id, c.document_id, d.path as document_path, + c.content, + ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank + FROM memory_chunks c + JOIN memory_documents d ON d.id = c.document_id + WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2 + AND c.content_tsv @@ plainto_tsquery('english', $3) + ORDER BY rank DESC + LIMIT $4 + "#, + &[&user_ids, &agent_id, &query, &(limit as i64)], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("FTS multi-scope query failed: {}", e), + })?; + + Ok(rows + .iter() + .enumerate() + .map(|(i, row)| RankedResult { + chunk_id: row.get("chunk_id"), + document_id: row.get("document_id"), + document_path: row.get("document_path"), + content: row.get("content"), + rank: (i + 1) as u32, + }) + .collect()) + } + + /// Vector search across multiple user scopes. + async fn vector_search_multi( + &self, + user_ids: &[String], + agent_id: Option, + embedding: &[f32], + limit: usize, + ) -> Result, WorkspaceError> { + let conn = self.conn().await?; + let embedding_vec = Vector::from(embedding.to_vec()); + + let rows = conn + .query( + r#" + SELECT c.id as chunk_id, c.document_id, d.path as document_path, + c.content, 1 - (c.embedding <=> $3) as similarity + FROM memory_chunks c + JOIN memory_documents d ON d.id = c.document_id + WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2 + AND c.embedding IS NOT NULL + ORDER BY c.embedding <=> $3 + LIMIT $4 + "#, + &[&user_ids, &agent_id, &embedding_vec, &(limit as i64)], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Vector multi-scope query failed: {}", e), + })?; + + Ok(rows + .iter() + .enumerate() + .map(|(i, row)| RankedResult { + chunk_id: row.get("chunk_id"), + document_id: row.get("document_id"), + document_path: row.get("document_path"), + content: row.get("content"), + rank: (i + 1) as u32, + }) + .collect()) + } + + /// List all file paths across multiple user scopes with a single query. + pub async fn list_all_paths_multi( + &self, + user_ids: &[String], + agent_id: Option, + ) -> Result, WorkspaceError> { + let conn = self.conn().await?; + + let rows = conn + .query( + r#" + SELECT DISTINCT path FROM memory_documents + WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2 + ORDER BY path + "#, + &[&user_ids, &agent_id], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("List paths multi-scope failed: {}", e), + })?; + + Ok(rows.iter().map(|row| row.get("path")).collect()) + } + + /// Get a document by path across multiple user scopes. + /// + /// Returns the first match (ordered by the input user_ids priority). + pub async fn get_document_by_path_multi( + &self, + user_ids: &[String], + agent_id: Option, + path: &str, + ) -> Result { + let conn = self.conn().await?; + + let row = conn + .query_opt( + r#" + SELECT id, user_id, agent_id, path, content, + created_at, updated_at, metadata + FROM memory_documents + WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2 AND path = $3 + ORDER BY array_position($1::text[], user_id) + LIMIT 1 + "#, + &[&user_ids, &agent_id, &path], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("get_document_by_path_multi failed: {}", e), + })?; + + match row { + Some(row) => Ok(self.row_to_document(&row)), + None => Err(WorkspaceError::DocumentNotFound { + doc_type: path.to_string(), + user_id: format!("[{}]", user_ids.join(", ")), + }), + } + } + + /// List directory contents across multiple user scopes. + /// + /// Iterates per scope and merges results. A future migration could add an + /// optimised SQL function, at which point this method can call it directly. + pub async fn list_directory_multi( + &self, + user_ids: &[String], + agent_id: Option, + directory: &str, + ) -> Result, WorkspaceError> { + let mut all_entries = Vec::new(); + for uid in user_ids { + all_entries.extend(self.list_directory(uid, agent_id, directory).await?); + } + Ok(crate::workspace::merge_workspace_entries(all_entries)) + } } diff --git a/tests/identity_scope_isolation.rs b/tests/identity_scope_isolation.rs new file mode 100644 index 00000000..314e87f3 --- /dev/null +++ b/tests/identity_scope_isolation.rs @@ -0,0 +1,195 @@ +//! Tests for identity file scope isolation in multi-scope workspaces. +//! +//! When a workspace has multiple read scopes (e.g., Andrew can read from +//! "andrew", "grace", "household"), identity files (SOUL.md, USER.md, +//! IDENTITY.md, AGENTS.md) must ONLY come from the primary scope. +//! +//! Multi-scope reads are designed for memory sharing (MEMORY.md, daily logs), +//! not identity inheritance. Silently inheriting identity from another scope +//! is a correctness and security issue — the agent would present itself as +//! the wrong user. +//! +//! These tests verify that: +//! 1. Identity files are read from primary scope only +//! 2. If the primary scope's identity file is missing, it's absent from the +//! system prompt — never falls back to another scope +//! 3. Memory files (MEMORY.md) still benefit from multi-scope reads +#![cfg(feature = "libsql")] + +use std::sync::Arc; + +use ironclaw::db::Database; +use ironclaw::db::libsql::LibSqlBackend; +use ironclaw::workspace::{Workspace, paths}; + +async fn setup() -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("create temp dir"); + let db_path = dir.path().join("test.db"); + let backend = LibSqlBackend::new_local(&db_path).await.expect("create db"); + backend.run_migrations().await.expect("run migrations"); + let db: Arc = Arc::new(backend); + (db, dir) +} + +/// Seed a document into a specific user's workspace scope. +async fn seed(db: &Arc, user_id: &str, path: &str, content: &str) { + let ws = Workspace::new_with_db(user_id, db.clone()); + ws.write(path, content) + .await + .unwrap_or_else(|e| panic!("Failed to seed {path} for {user_id}: {e}")); +} + +// ─── Test 1: Primary scope identity appears in system prompt ─────────── + +#[tokio::test] +async fn system_prompt_uses_primary_scope_identity() { + let (db, _dir) = setup().await; + + // Seed Alice's identity files in her own scope + seed(&db, "alice", paths::SOUL, "Alice is kind and curious.").await; + seed( + &db, + "alice", + paths::USER, + "You are talking to Alice, a software engineer.", + ) + .await; + + // Seed Bob's identity files in his scope + seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await; + seed( + &db, + "bob", + paths::USER, + "You are talking to Bob, a marine biologist.", + ) + .await; + + // Create Alice's workspace WITH multi-scope reads including Bob + let ws = Workspace::new_with_db("alice", db.clone()) + .with_additional_read_scopes(vec!["bob".to_string()]); + + let prompt = ws + .system_prompt_for_context(false) + .await + .expect("system_prompt_for_context failed"); + + // Alice's identity must appear + assert!( + prompt.contains("Alice is kind and curious"), + "Primary scope SOUL.md should appear in system prompt.\nPrompt:\n{prompt}" + ); + assert!( + prompt.contains("Alice, a software engineer"), + "Primary scope USER.md should appear in system prompt.\nPrompt:\n{prompt}" + ); + + // Bob's identity must NOT appear + assert!( + !prompt.contains("Bob is analytical"), + "Secondary scope SOUL.md must NOT appear in system prompt.\nPrompt:\n{prompt}" + ); + assert!( + !prompt.contains("Bob, a marine biologist"), + "Secondary scope USER.md must NOT appear in system prompt.\nPrompt:\n{prompt}" + ); +} + +// ─── Test 2: Missing primary identity does NOT fall back to other scope ─ + +#[tokio::test] +async fn missing_primary_identity_does_not_fallback_to_other_scope() { + let (db, _dir) = setup().await; + + // Only seed Bob's identity — Alice has no identity files + seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await; + seed( + &db, + "bob", + paths::USER, + "You are talking to Bob, a marine biologist.", + ) + .await; + + // Create Alice's workspace with multi-scope reads including Bob + let ws = Workspace::new_with_db("alice", db.clone()) + .with_additional_read_scopes(vec!["bob".to_string()]); + + let prompt = ws + .system_prompt_for_context(false) + .await + .expect("system_prompt_for_context failed"); + + // Bob's identity must NOT appear — Alice's missing identity should stay missing, + // not silently inherit from Bob's scope + assert!( + !prompt.contains("Bob"), + "When primary scope identity is missing, must NOT fall back to secondary scope.\n\ + This would cause the agent to present itself as the wrong user.\nPrompt:\n{prompt}" + ); +} + +// ─── Test 3: MEMORY.md still benefits from multi-scope reads ──────────── + +#[tokio::test] +async fn memory_files_still_use_multi_scope_reads() { + let (db, _dir) = setup().await; + + // Seed shared memory in the "shared" scope (not Alice's primary) + seed( + &db, + "shared", + paths::MEMORY, + "Shared grocery list: milk, eggs, bread.", + ) + .await; + + // Create Alice's workspace with read access to shared scope + let ws = Workspace::new_with_db("alice", db.clone()) + .with_additional_read_scopes(vec!["shared".to_string()]); + + let prompt = ws + .system_prompt_for_context(false) + .await + .expect("system_prompt_for_context failed"); + + // Shared memory SHOULD appear — multi-scope reads are correct for memory + assert!( + prompt.contains("grocery list"), + "MEMORY.md should still use multi-scope reads.\nPrompt:\n{prompt}" + ); +} + +// ─── Test 4: All identity files are scope-isolated ────────────────────── + +#[tokio::test] +async fn all_identity_files_are_scope_isolated() { + let (db, _dir) = setup().await; + + // Seed identity files ONLY in the "other" scope, not in Alice's + seed(&db, "other", paths::AGENTS, "You are Other's agent.").await; + seed(&db, "other", paths::SOUL, "Other's soul values.").await; + seed(&db, "other", paths::USER, "You are talking to Other.").await; + seed(&db, "other", paths::IDENTITY, "Other's identity.").await; + + // Also seed BOOTSTRAP.md and TOOLS.md in other scope + seed(&db, "other", "BOOTSTRAP.md", "Other's bootstrap.").await; + seed(&db, "other", "TOOLS.md", "Other's tool notes.").await; + + // Create Alice's workspace with read access to "other" + let ws = Workspace::new_with_db("alice", db.clone()) + .with_additional_read_scopes(vec!["other".to_string()]); + + let prompt = ws + .system_prompt_for_context(false) + .await + .expect("system_prompt_for_context failed"); + + // None of Other's identity/config files should appear + assert!( + !prompt.contains("Other"), + "No identity or config files from secondary scope should appear.\n\ + Every identity file (AGENTS.md, SOUL.md, USER.md, IDENTITY.md, \ + BOOTSTRAP.md, TOOLS.md) must read from primary scope only.\nPrompt:\n{prompt}" + ); +} diff --git a/tests/multi_scope_functional.rs b/tests/multi_scope_functional.rs new file mode 100644 index 00000000..77829b9d --- /dev/null +++ b/tests/multi_scope_functional.rs @@ -0,0 +1,451 @@ +#![cfg(feature = "libsql")] +//! Integration tests for multi-scope workspace reads using file-backed libSQL. +//! +//! Guards the PR2 contract: workspaces can read from multiple user scopes +//! while writes remain isolated to the primary scope. + +use std::sync::Arc; + +use ironclaw::db::Database; +use ironclaw::db::libsql::LibSqlBackend; +use ironclaw::workspace::Workspace; + +async fn setup() -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("create temp dir"); + let db_path = dir.path().join("test.db"); + let backend = LibSqlBackend::new_local(&db_path).await.expect("create db"); + backend.run_migrations().await.expect("run migrations"); + let db: Arc = Arc::new(backend); + (db, dir) +} + +#[tokio::test] +async fn read_across_scopes() { + let (db, _dir) = setup().await; + + // Write docs as the "shared" user + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("docs/team-standup.md", "Team standup notes from Monday") + .await + .expect("shared write failed"); + + // Alice's workspace with "shared" as an additional read scope + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + + // Alice can read shared docs + let doc = ws_alice + .read("docs/team-standup.md") + .await + .expect("cross-scope read failed"); + assert_eq!(doc.content, "Team standup notes from Monday"); +} + +#[tokio::test] +async fn write_stays_in_primary_scope() { + let (db, _dir) = setup().await; + + // Alice has "shared" as a read scope + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + + // Alice writes a personal note + ws_alice + .write("notes/personal.md", "Alice's private note") + .await + .expect("alice write failed"); + + // The "shared" workspace should NOT see Alice's note + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + let result = ws_shared.read("notes/personal.md").await; + assert!(result.is_err(), "Shared scope should not see Alice's note"); +} + +#[tokio::test] +async fn list_paths_merges_across_scopes() { + let (db, _dir) = setup().await; + + // Write as alice + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + ws_alice_plain + .write("notes/personal.md", "My notes") + .await + .expect("alice write failed"); + + // Write as shared + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("docs/shared-doc.md", "Shared document") + .await + .expect("shared write failed"); + + // Alice with multi-scope should see both + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + + let all_paths = ws_alice.list_all().await.expect("list_all failed"); + assert!( + all_paths.contains(&"notes/personal.md".to_string()), + "Should contain alice's note: {:?}", + all_paths + ); + assert!( + all_paths.contains(&"docs/shared-doc.md".to_string()), + "Should contain shared doc: {:?}", + all_paths + ); +} + +#[tokio::test] +async fn list_directory_merges_across_scopes() { + let (db, _dir) = setup().await; + + // Alice writes to docs/ + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + ws_alice_plain + .write("docs/alice-doc.md", "Alice's doc") + .await + .expect("alice write failed"); + + // Shared writes to docs/ + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("docs/shared-doc.md", "Shared doc") + .await + .expect("shared write failed"); + + // Alice with multi-scope lists docs/ + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + + let entries = ws_alice.list("docs").await.expect("list failed"); + let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); + assert!( + paths.contains(&"docs/alice-doc.md"), + "Should contain alice's doc: {:?}", + paths + ); + assert!( + paths.contains(&"docs/shared-doc.md"), + "Should contain shared doc: {:?}", + paths + ); +} + +#[tokio::test] +async fn search_spans_scopes() { + let (db, _dir) = setup().await; + + // Write searchable content in shared scope + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write( + "docs/architecture.md", + "The microservice architecture uses gRPC for inter-service communication", + ) + .await + .expect("shared write failed"); + + // Write searchable content in alice scope + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + ws_alice_plain + .write("notes/ideas.md", "Consider switching to GraphQL federation") + .await + .expect("alice write failed"); + + // Alice with multi-scope searches + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + + // Search for content in the shared scope + let results = ws_alice + .search("microservice architecture gRPC", 10) + .await + .expect("search failed"); + assert!(!results.is_empty(), "Should find results from shared scope"); +} + +#[tokio::test] +async fn read_priority_primary_first() { + let (db, _dir) = setup().await; + + // Write same path in both scopes + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("config/settings.md", "Shared settings v1") + .await + .expect("shared write failed"); + + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + ws_alice_plain + .write("config/settings.md", "Alice's settings override") + .await + .expect("alice write failed"); + + // Alice with multi-scope should get her own version (primary scope wins) + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + + let doc = ws_alice + .read("config/settings.md") + .await + .expect("read failed"); + assert_eq!( + doc.content, "Alice's settings override", + "Primary scope should take priority" + ); +} + +#[tokio::test] +async fn exists_spans_scopes() { + let (db, _dir) = setup().await; + + // Write a doc as "shared" + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("docs/shared-only.md", "Shared content") + .await + .expect("shared write failed"); + + // Alice without multi-scope should NOT see it + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + assert!( + !ws_alice_plain + .exists("docs/shared-only.md") + .await + .expect("exists failed"), + "Alice without multi-scope should not see shared doc" + ); + + // Alice with multi-scope should see it + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + assert!( + ws_alice + .exists("docs/shared-only.md") + .await + .expect("exists failed"), + "Alice with multi-scope should see shared doc" + ); +} + +#[tokio::test] +async fn append_stays_in_primary_scope() { + let (db, _dir) = setup().await; + + // Write a document as "shared" + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("notes/log.md", "shared original content") + .await + .expect("shared write failed"); + + // Alice has "shared" as a read scope and appends to the same path + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + ws_alice + .append("notes/log.md", "alice appended line") + .await + .expect("alice append failed"); + + // Shared document must be unchanged (write isolation) + let shared_doc = ws_shared + .read("notes/log.md") + .await + .expect("shared read failed"); + assert_eq!( + shared_doc.content, "shared original content", + "Append must not modify the secondary scope's document" + ); + + // Alice should have her own copy with the appended content + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + let alice_doc = ws_alice_plain + .read("notes/log.md") + .await + .expect("alice read failed"); + assert_eq!( + alice_doc.content, "alice appended line", + "Append should create a new document in alice's scope" + ); +} + +#[tokio::test] +async fn append_memory_stays_in_primary_scope() { + let (db, _dir) = setup().await; + + // Write MEMORY.md as "shared" + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("MEMORY.md", "shared memory baseline") + .await + .expect("shared write failed"); + + // Alice has "shared" as a read scope and appends a memory entry + let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string()]); + ws_alice + .append_memory("alice remembers this") + .await + .expect("alice append_memory failed"); + + // Shared MEMORY.md must be unchanged + let shared_doc = ws_shared + .read("MEMORY.md") + .await + .expect("shared read failed"); + assert_eq!( + shared_doc.content, "shared memory baseline", + "append_memory must not modify the secondary scope's document" + ); + + // Alice should have her own MEMORY.md + let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); + let alice_doc = ws_alice_plain + .read("MEMORY.md") + .await + .expect("alice read failed"); + assert_eq!( + alice_doc.content, "alice remembers this", + "append_memory should create in alice's scope" + ); +} + +// ==================== Identity isolation tests ==================== + +#[tokio::test] +async fn identity_files_not_readable_from_secondary_scope() { + let (db, _dir) = setup().await; + + let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db)); + ws_other + .write("IDENTITY.md", "I am the other user") + .await + .expect("write failed"); + ws_other + .write("SOUL.md", "Other user soul overlay") + .await + .expect("write failed"); + ws_other + .write("USER.md", "Other user profile") + .await + .expect("write failed"); + ws_other + .write("AGENTS.md", "Other user agent config") + .await + .expect("write failed"); + + let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) + .with_additional_read_scopes(vec!["other-user".to_string()]); + + for path in &["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md"] { + let result = ws_primary.read(path).await; + assert!( + result.is_err(), + "Primary should NOT read other user's {} via secondary scope", + path + ); + } +} + +#[tokio::test] +async fn identity_files_not_in_search_from_secondary_scope() { + let (db, _dir) = setup().await; + + let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db)); + ws_other + .write("SOUL.md", "Other user loves xylophone music passionately") + .await + .expect("write failed"); + ws_other + .write( + "notes/music.md", + "Other user played xylophone at the concert", + ) + .await + .expect("write failed"); + + let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) + .with_additional_read_scopes(vec!["other-user".to_string()]); + + let results = ws_primary + .search("xylophone", 10) + .await + .expect("search failed"); + let has_concert = results.iter().any(|r| r.content.contains("concert")); + assert!( + has_concert, + "Should find non-identity content from secondary scope" + ); + let has_soul = results.iter().any(|r| r.content.contains("passionately")); + assert!( + !has_soul, + "SOUL.md content from secondary scope should not appear in search results" + ); +} + +#[tokio::test] +async fn identity_files_not_in_list_from_secondary_scope() { + let (db, _dir) = setup().await; + + let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db)); + ws_other + .write("IDENTITY.md", "I am the other user") + .await + .expect("write failed"); + ws_other + .write("notes/shared-note.md", "A shared note") + .await + .expect("write failed"); + + let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) + .with_additional_read_scopes(vec!["other-user".to_string()]); + + let paths = ws_primary.list_all().await.expect("list failed"); + assert!( + !paths.contains(&"IDENTITY.md".to_string()), + "IDENTITY.md from secondary scope should not appear" + ); + assert!( + paths.contains(&"notes/shared-note.md".to_string()), + "Non-identity files should be listed" + ); +} + +#[tokio::test] +async fn empty_read_scopes_reads_primary_only() { + let (db, _dir) = setup().await; + + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("docs/note.md", "Shared note") + .await + .expect("write failed"); + + let ws_primary = + Workspace::new_with_db("primary", Arc::clone(&db)).with_additional_read_scopes(vec![]); + + let result = ws_primary.read("docs/note.md").await; + assert!( + result.is_err(), + "Empty read scopes should not grant cross-scope access" + ); +} + +#[tokio::test] +async fn duplicate_read_scopes_handled() { + let (db, _dir) = setup().await; + + let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); + ws_shared + .write("docs/note.md", "One note") + .await + .expect("write failed"); + + let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) + .with_additional_read_scopes(vec!["shared".to_string(), "shared".to_string()]); + + let doc = ws_primary.read("docs/note.md").await.expect("read failed"); + assert_eq!(doc.content, "One note"); +} diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs index 2182fc38..2184d8f2 100644 --- a/tests/workspace_integration.rs +++ b/tests/workspace_integration.rs @@ -407,3 +407,333 @@ async fn test_workspace_system_prompt() { cleanup_user(&pool, user_id).await; } + +// ── Multi-scope workspace read tests ────────────────────────────────── +// +// These exercise the PostgreSQL-optimized `_multi` query paths +// (repository.rs) that the libSQL backend covers via default trait impls. + +#[tokio::test] +async fn test_multi_scope_read_across_scopes() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_read"; + let alice_id = "ms_alice_read"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + // Write a doc as "shared" + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write("docs/team-standup.md", "Team standup notes from Monday") + .await + .expect("shared write failed"); + + // Alice with "shared" as an additional read scope + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + + let doc = ws_alice + .read("docs/team-standup.md") + .await + .expect("cross-scope read failed"); + assert_eq!(doc.content, "Team standup notes from Monday"); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_write_stays_in_primary() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_write"; + let alice_id = "ms_alice_write"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + + ws_alice + .write("notes/personal.md", "Alice's private note") + .await + .expect("alice write failed"); + + // Shared workspace should NOT see Alice's note + let ws_shared = Workspace::new(shared_id, pool.clone()); + let result = ws_shared.read("notes/personal.md").await; + assert!(result.is_err(), "Shared scope should not see Alice's note"); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_list_all_merges() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_list"; + let alice_id = "ms_alice_list"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + // Write as alice (plain, no multi-scope) + let ws_alice_plain = Workspace::new(alice_id, pool.clone()); + ws_alice_plain + .write("notes/personal.md", "My notes") + .await + .expect("alice write failed"); + + // Write as shared + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write("docs/shared-doc.md", "Shared document") + .await + .expect("shared write failed"); + + // Alice with multi-scope should see both + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + + let all_paths = ws_alice.list_all().await.expect("list_all failed"); + assert!( + all_paths.contains(&"notes/personal.md".to_string()), + "Should contain alice's note: {:?}", + all_paths + ); + assert!( + all_paths.contains(&"docs/shared-doc.md".to_string()), + "Should contain shared doc: {:?}", + all_paths + ); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_list_directory_merges() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_dir"; + let alice_id = "ms_alice_dir"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + let ws_alice_plain = Workspace::new(alice_id, pool.clone()); + ws_alice_plain + .write("docs/alice-doc.md", "Alice's doc") + .await + .expect("alice write failed"); + + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write("docs/shared-doc.md", "Shared doc") + .await + .expect("shared write failed"); + + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + + let entries = ws_alice.list("docs").await.expect("list failed"); + let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); + assert!( + paths.contains(&"docs/alice-doc.md"), + "Should contain alice's doc: {:?}", + paths + ); + assert!( + paths.contains(&"docs/shared-doc.md"), + "Should contain shared doc: {:?}", + paths + ); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_read_priority_primary_first() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_prio"; + let alice_id = "ms_alice_prio"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + // Write same path in both scopes + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write("config/settings.md", "Shared settings v1") + .await + .expect("shared write failed"); + + let ws_alice_plain = Workspace::new(alice_id, pool.clone()); + ws_alice_plain + .write("config/settings.md", "Alice's settings override") + .await + .expect("alice write failed"); + + // Alice with multi-scope should get her own version (primary scope wins) + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + + let doc = ws_alice + .read("config/settings.md") + .await + .expect("read failed"); + assert_eq!( + doc.content, "Alice's settings override", + "Primary scope should take priority" + ); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_exists_spans_scopes() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_exists"; + let alice_id = "ms_alice_exists"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write("docs/shared-only.md", "Shared content") + .await + .expect("shared write failed"); + + // Alice without multi-scope should NOT see it + let ws_alice_plain = Workspace::new(alice_id, pool.clone()); + assert!( + !ws_alice_plain + .exists("docs/shared-only.md") + .await + .expect("exists failed"), + "Alice without multi-scope should not see shared doc" + ); + + // Alice with multi-scope should see it + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + assert!( + ws_alice + .exists("docs/shared-only.md") + .await + .expect("exists failed"), + "Alice with multi-scope should see shared doc" + ); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_search_spans_scopes() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_search"; + let alice_id = "ms_alice_search"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write( + "docs/architecture.md", + "The microservice architecture uses gRPC for inter-service communication", + ) + .await + .expect("shared write failed"); + + let ws_alice_plain = Workspace::new(alice_id, pool.clone()); + ws_alice_plain + .write("notes/ideas.md", "Consider switching to GraphQL federation") + .await + .expect("alice write failed"); + + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + + // Search for content in the shared scope + let results = ws_alice + .search_with_config( + "microservice gRPC architecture", + SearchConfig::default().fts_only(), + ) + .await + .expect("search failed"); + assert!(!results.is_empty(), "Should find results from shared scope"); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +} + +#[tokio::test] +async fn test_multi_scope_append_stays_in_primary() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let shared_id = "ms_shared_append"; + let alice_id = "ms_alice_append"; + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; + + // Write a document as "shared" + let ws_shared = Workspace::new(shared_id, pool.clone()); + ws_shared + .write("notes/log.md", "shared original content") + .await + .expect("shared write failed"); + + // Alice has "shared" as a read scope and appends to the same path + let ws_alice = Workspace::new(alice_id, pool.clone()) + .with_additional_read_scopes(vec![shared_id.to_string()]); + ws_alice + .append("notes/log.md", "alice appended line") + .await + .expect("alice append failed"); + + // Shared document must be unchanged (write isolation) + let shared_doc = ws_shared + .read("notes/log.md") + .await + .expect("shared read failed"); + assert_eq!( + shared_doc.content, "shared original content", + "Append must not modify the secondary scope's document" + ); + + // Alice should have her own copy with the appended content + let ws_alice_plain = Workspace::new(alice_id, pool.clone()); + let alice_doc = ws_alice_plain + .read("notes/log.md") + .await + .expect("alice read failed"); + assert_eq!( + alice_doc.content, "alice appended line", + "Append should create a new document in alice's scope" + ); + + cleanup_user(&pool, shared_id).await; + cleanup_user(&pool, alice_id).await; +}