diff --git a/src/app.rs b/src/app.rs index df246458..bca0f110 100644 --- a/src/app.rs +++ b/src/app.rs @@ -312,15 +312,23 @@ impl AppBuilder { .create_provider(&self.config.llm.nearai.base_url, self.session.clone()); // Register memory tools if database is available + let workspace_user_id = self + .config + .channels + .gateway + .as_ref() + .map(|gw| gw.user_id.as_str()) + .unwrap_or("default"); let workspace = if let Some(ref db) = self.db { let emb_cache_config = EmbeddingCacheConfig { max_entries: self.config.embeddings.cache_size, }; - let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone()) + 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); } + ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone()); let ws = Arc::new(ws); tools.register_memory_tools(Arc::clone(&ws)); Some(ws) diff --git a/src/channels/web/handlers/memory.rs b/src/channels/web/handlers/memory.rs index 8e50f25e..fc0e1fe4 100644 --- a/src/channels/web/handlers/memory.rs +++ b/src/channels/web/handlers/memory.rs @@ -123,25 +123,8 @@ pub async fn memory_read_handler( })) } -pub async fn memory_write_handler( - State(state): State>, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let workspace = state.workspace.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "Workspace not available".to_string(), - ))?; - - workspace - .write(&req.path, &req.content) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - Ok(Json(MemoryWriteResponse { - path: req.path, - status: "written", - })) -} +// memory_write_handler lives in server.rs (layer-aware version with append, +// privacy redirect, and proper error status codes). pub async fn memory_search_handler( State(state): State>, diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 63eafeab..24ce489e 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1822,14 +1822,53 @@ async fn memory_write_handler( "Workspace not available".to_string(), ))?; - workspace - .write(&req.path, &req.content) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // Route through layer-aware methods when a layer is specified + if let Some(ref layer_name) = req.layer { + let result = if req.append { + workspace + .append_to_layer(layer_name, &req.path, &req.content, req.force) + .await + } else { + workspace + .write_to_layer(layer_name, &req.path, &req.content, req.force) + .await + } + .map_err(|e| { + use crate::error::WorkspaceError; + let status = match &e { + WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST, + WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN, + WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, e.to_string()) + })?; + return Ok(Json(MemoryWriteResponse { + path: req.path, + status: "written", + redirected: Some(result.redirected), + actual_layer: Some(result.actual_layer), + })); + } + + // Non-layer path: honor the append field + if req.append { + workspace + .append(&req.path, &req.content) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } else { + workspace + .write(&req.path, &req.content) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } Ok(Json(MemoryWriteResponse { path: req.path, status: "written", + redirected: None, + actual_layer: None, })) } diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 107ee05d..066a6a72 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -302,12 +302,30 @@ pub struct MemoryReadResponse { pub struct MemoryWriteRequest { pub path: String, pub content: String, + /// Optional layer to write to. When present, uses `write_to_layer()` + /// which enables privacy classification and redirect. + pub layer: Option, + /// When true and a layer is specified, appends to existing content + /// instead of replacing it. + #[serde(default)] + pub append: bool, + /// Skip privacy classification and write directly to the specified layer. + #[serde(default)] + pub force: bool, } #[derive(Debug, Serialize)] pub struct MemoryWriteResponse { pub path: String, pub status: &'static str, + /// Whether the write was redirected to a different layer (e.g., sensitive + /// content redirected from shared to private). + #[serde(skip_serializing_if = "Option::is_none")] + pub redirected: Option, + /// The layer the content was actually written to (may differ from requested + /// layer if privacy redirect occurred). + #[serde(skip_serializing_if = "Option::is_none")] + pub actual_layer: Option, } #[derive(Debug, Deserialize)] diff --git a/src/config/channels.rs b/src/config/channels.rs index 6b1058a0..bc704445 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -111,6 +111,10 @@ impl ChannelsConfig { let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?; let gateway = if gateway_enabled { + let user_id = optional_env("GATEWAY_USER_ID")? + .or_else(|| cs.gateway_user_id.clone()) + .unwrap_or_else(|| "default".to_string()); + Some(GatewayConfig { host: optional_env("GATEWAY_HOST")? .or_else(|| cs.gateway_host.clone()) @@ -121,7 +125,7 @@ impl ChannelsConfig { )?, auth_token: optional_env("GATEWAY_AUTH_TOKEN")? .or_else(|| cs.gateway_auth_token.clone()), - user_id: owner_id.to_string(), + user_id, }) } else { None diff --git a/src/config/mod.rs b/src/config/mod.rs index e4834a88..2cbb15db 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -24,6 +24,7 @@ mod skills; mod transcription; mod tunnel; mod wasm; +mod workspace; use std::collections::HashMap; use std::sync::{LazyLock, Mutex, Once}; @@ -53,6 +54,7 @@ pub use self::skills::SkillsConfig; pub use self::transcription::TranscriptionConfig; pub use self::tunnel::TunnelConfig; pub use self::wasm::WasmConfig; +pub use self::workspace::WorkspaceConfig; pub use crate::llm::config::{ BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig, RegistryProviderConfig, @@ -98,6 +100,7 @@ pub struct Config { pub skills: SkillsConfig, pub transcription: TranscriptionConfig, pub search: WorkspaceSearchConfig, + pub workspace: WorkspaceConfig, pub observability: crate::observability::ObservabilityConfig, /// Channel-relay integration (Slack via external relay service). /// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set. @@ -175,6 +178,9 @@ impl Config { }, transcription: TranscriptionConfig::default(), search: WorkspaceSearchConfig::default(), + workspace: WorkspaceConfig { + memory_layers: vec![], + }, observability: crate::observability::ObservabilityConfig::default(), relay: None, } @@ -305,13 +311,21 @@ impl Config { async fn build(settings: &Settings) -> Result { let owner_id = resolve_owner_id(settings)?; + let tunnel = TunnelConfig::resolve(settings)?; + let channels = ChannelsConfig::resolve(settings, &owner_id)?; + let workspace_user_id = channels + .gateway + .as_ref() + .map(|gw| gw.user_id.clone()) + .unwrap_or_else(|| "default".to_string()); + Ok(Self { owner_id: owner_id.clone(), database: DatabaseConfig::resolve()?, llm: LlmConfig::resolve(settings)?, embeddings: EmbeddingsConfig::resolve(settings)?, - tunnel: TunnelConfig::resolve(settings)?, - channels: ChannelsConfig::resolve(settings, &owner_id)?, + tunnel, + channels, agent: AgentConfig::resolve(settings)?, safety: resolve_safety_config(settings)?, wasm: WasmConfig::resolve(settings)?, @@ -325,6 +339,7 @@ impl Config { skills: SkillsConfig::resolve()?, transcription: TranscriptionConfig::resolve(settings)?, search: WorkspaceSearchConfig::resolve()?, + workspace: WorkspaceConfig::resolve(&workspace_user_id)?, 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 new file mode 100644 index 00000000..5f89c655 --- /dev/null +++ b/src/config/workspace.rs @@ -0,0 +1,208 @@ +use crate::config::helpers::optional_env; +use crate::error::ConfigError; +use crate::workspace::layer::MemoryLayer; + +/// Workspace memory configuration. +/// +/// 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)] +pub struct WorkspaceConfig { + pub memory_layers: Vec, +} + +impl WorkspaceConfig { + pub(crate) fn resolve(user_id: &str) -> Result { + let memory_layers: Vec = match optional_env("MEMORY_LAYERS")? { + Some(json_str) => { + serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: format!("must be valid JSON array of layer objects: {e}"), + })? + } + None => MemoryLayer::default_for_user(user_id), + }; + + // Validate layer names and scopes + for layer in &memory_layers { + if layer.name.trim().is_empty() { + return Err(ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: "layer name must not be empty".to_string(), + }); + } + if layer.name.len() > 64 { + return Err(ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: format!("layer name '{}' exceeds 64 characters", layer.name), + }); + } + if !layer + .name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + return Err(ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: format!( + "layer name '{}' contains invalid characters (only alphanumeric, _, - allowed)", + layer.name + ), + }); + } + if layer.scope.trim().is_empty() { + return Err(ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: format!("layer '{}' has an empty scope", layer.name), + }); + } + } + + // Check for duplicate layer names + { + let mut seen = std::collections::HashSet::new(); + for layer in &memory_layers { + if !seen.insert(&layer.name) { + return Err(ConfigError::InvalidValue { + key: "MEMORY_LAYERS".to_string(), + message: format!("duplicate layer name '{}'", layer.name), + }); + } + } + } + + Ok(Self { memory_layers }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Serialize env-var-dependent tests to avoid races. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) { + let _guard = ENV_LOCK.lock().unwrap(); + let prev = std::env::var(key).ok(); + match val { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + f(); + match prev { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } + + #[test] + fn valid_json_parses_correctly() { + let json = r#"[{"name":"private","scope":"alice","writable":true,"sensitivity":"private"},{"name":"shared","scope":"shared","writable":true,"sensitivity":"shared"}]"#; + with_env("MEMORY_LAYERS", Some(json), || { + let config = WorkspaceConfig::resolve("alice").expect("should parse"); + assert_eq!(config.memory_layers.len(), 2); + assert_eq!(config.memory_layers[0].name, "private"); + assert_eq!(config.memory_layers[1].name, "shared"); + }); + } + + #[test] + fn invalid_json_returns_error() { + with_env("MEMORY_LAYERS", Some("not json"), || { + let result = WorkspaceConfig::resolve("alice"); + assert!(result.is_err(), "invalid JSON should fail"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("valid JSON"), + "error should mention JSON: {err}" + ); + }); + } + + #[test] + fn empty_layer_name_returns_error() { + let json = r#"[{"name":"","scope":"alice"}]"#; + with_env("MEMORY_LAYERS", Some(json), || { + let result = WorkspaceConfig::resolve("alice"); + assert!(result.is_err(), "empty layer name should fail"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("empty"), "error should mention empty: {err}"); + }); + } + + #[test] + fn layer_name_exceeding_64_chars_returns_error() { + let long_name = "a".repeat(65); + let json = format!(r#"[{{"name":"{long_name}","scope":"alice"}}]"#); + with_env("MEMORY_LAYERS", Some(&json), || { + let result = WorkspaceConfig::resolve("alice"); + assert!(result.is_err(), "long layer name should fail"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds 64"), + "error should mention 64 chars: {err}" + ); + }); + } + + #[test] + fn layer_name_with_invalid_chars_returns_error() { + for bad_name in ["has space", "has@at", "has.dot", "has/slash"] { + let json = format!(r#"[{{"name":"{bad_name}","scope":"alice"}}]"#); + with_env("MEMORY_LAYERS", Some(&json), || { + let result = WorkspaceConfig::resolve("alice"); + assert!( + result.is_err(), + "layer name '{bad_name}' should fail validation" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("invalid characters"), + "error for '{bad_name}' should mention invalid characters: {err}" + ); + }); + } + } + + #[test] + fn empty_scope_returns_error() { + let json = r#"[{"name":"private","scope":""}]"#; + with_env("MEMORY_LAYERS", Some(json), || { + let result = WorkspaceConfig::resolve("alice"); + assert!(result.is_err(), "empty scope should fail"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("empty scope"), + "error should mention empty scope: {err}" + ); + }); + } + + #[test] + fn duplicate_layer_names_returns_error() { + let json = r#"[{"name":"private","scope":"alice"},{"name":"private","scope":"bob"}]"#; + with_env("MEMORY_LAYERS", Some(json), || { + let result = WorkspaceConfig::resolve("alice"); + assert!(result.is_err(), "duplicate names should fail"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("duplicate"), + "error should mention duplicate: {err}" + ); + }); + } + + #[test] + fn missing_env_defaults_to_single_private_layer() { + with_env("MEMORY_LAYERS", None, || { + let config = WorkspaceConfig::resolve("alice").expect("should default"); + assert_eq!(config.memory_layers.len(), 1); + assert_eq!(config.memory_layers[0].name, "private"); + assert_eq!(config.memory_layers[0].scope, "alice"); + assert!(config.memory_layers[0].writable); + }); + } +} diff --git a/src/error.rs b/src/error.rs index ec378a80..30ec58f4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -304,6 +304,18 @@ 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 }, + + #[error("Layer '{name}' is read-only")] + LayerReadOnly { name: String }, + + #[error("Cannot write sensitive content: no private layer available for redirect")] + PrivacyRedirectFailed, + #[error("Write rejected for '{path}': prompt injection detected ({reason})")] InjectionRejected { path: String, reason: String }, } diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index 327e8c7e..1c27b539 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -194,6 +194,15 @@ impl Tool for MemoryWriteTool { "type": "boolean", "description": "If true, append to existing content. If false, replace entirely.", "default": true + }, + "layer": { + "type": "string", + "description": "Memory layer to write to (e.g. 'private', 'household', 'finance'). When omitted, writes to the workspace's default scope." + }, + "force": { + "type": "boolean", + "description": "Skip privacy classification and write directly to the specified layer without redirect. Use when you're certain the content belongs in the target layer.", + "default": false } }, "required": ["content"] @@ -256,67 +265,86 @@ impl Tool for MemoryWriteTool { .and_then(|v| v.as_bool()) .unwrap_or(true); - // Prompt injection scanning for system-prompt files is handled by - // Workspace::write() / Workspace::append() — no need to duplicate here. + let layer = params.get("layer").and_then(|v| v.as_str()); + let force = params + .get("force") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - let path = match target { - "memory" => { - if append { - self.workspace - .append_memory(content) - .await - .map_err(map_write_err)?; - } else { - self.workspace - .write(paths::MEMORY, content) - .await - .map_err(map_write_err)?; - } - paths::MEMORY.to_string() - } + // 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")) + } + "heartbeat" => paths::HEARTBEAT.to_string(), + path => path.to_string(), + }; + + // When a layer is specified, route through layer-aware methods for ALL targets. + // Otherwise, use default workspace methods (which include injection scanning). + let layer_result = if let Some(layer_name) = layer { + let result = if append { self.workspace - .append_daily_log_tz(content, tz) + .append_to_layer(layer_name, &resolved_path, content, force) .await .map_err(map_write_err)? - } - "heartbeat" => { - if append { + } else { + self.workspace + .write_to_layer(layer_name, &resolved_path, content, force) + .await + .map_err(map_write_err)? + }; + Some((result.actual_layer, result.redirected)) + } else { + // No layer specified — use default workspace methods. + // Prompt injection scanning for system-prompt files is handled by + // Workspace::write() / Workspace::append(). + match target { + "memory" => { + if append { + self.workspace + .append_memory(content) + .await + .map_err(map_write_err)?; + } else { + self.workspace + .write(paths::MEMORY, content) + .await + .map_err(map_write_err)?; + } + } + "daily_log" => { + let tz = crate::timezone::parse_timezone(&ctx.user_timezone) + .unwrap_or(chrono_tz::Tz::UTC); self.workspace - .append(paths::HEARTBEAT, content) - .await - .map_err(map_write_err)?; - } else { - self.workspace - .write(paths::HEARTBEAT, content) + .append_daily_log_tz(content, tz) .await .map_err(map_write_err)?; } - paths::HEARTBEAT.to_string() - } - path => { - if append { - self.workspace - .append(path, content) - .await - .map_err(map_write_err)?; - } else { - self.workspace - .write(path, content) - .await - .map_err(map_write_err)?; + _ => { + if append { + self.workspace + .append(&resolved_path, content) + .await + .map_err(map_write_err)?; + } else { + self.workspace + .write(&resolved_path, content) + .await + .map_err(map_write_err)?; + } } - path.to_string() } + None }; // Sync derived identity documents when the profile is written. - // Normalize the path to match Workspace::normalize_path(): trim, strip - // leading/trailing slashes, collapse all consecutive slashes. let normalized_path = { - let trimmed = path.trim().trim_matches('/'); + let trimmed = resolved_path.trim().trim_matches('/'); let mut result = String::new(); let mut last_was_slash = false; for c in trimmed.chars() { @@ -339,9 +367,6 @@ impl Tool for MemoryWriteTool { tracing::info!("profile write: synced USER.md + assistant-directives.md"); synced_docs.extend_from_slice(&[paths::USER, paths::ASSISTANT_DIRECTIVES]); - // Persist the onboarding-completed flag and set the - // in-memory safety net so BOOTSTRAP.md injection stops - // even if the LLM forgets to delete it. self.workspace.mark_bootstrap_completed(); let toml_path = crate::settings::Settings::default_toml_path(); if let Ok(Some(mut settings)) = crate::settings::Settings::load_toml(&toml_path) @@ -364,10 +389,14 @@ impl Tool for MemoryWriteTool { let mut output = serde_json::json!({ "status": "written", - "path": path, + "path": resolved_path, "append": append, "content_length": content.len(), }); + if let Some((actual_layer, redirected)) = layer_result { + output["layer"] = serde_json::Value::String(actual_layer); + output["redirected"] = serde_json::Value::Bool(redirected); + } if !synced_docs.is_empty() { output["synced"] = serde_json::json!(synced_docs); } diff --git a/src/workspace/layer.rs b/src/workspace/layer.rs new file mode 100644 index 00000000..1025b559 --- /dev/null +++ b/src/workspace/layer.rs @@ -0,0 +1,158 @@ +use serde::Deserialize; + +/// Sensitivity level for a memory layer. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayerSensitivity { + #[default] + Private, + Shared, +} + +/// A named memory layer with read/write permissions and a scope. +/// +/// Layers map to synthetic `user_id` values in the workspace tables. +/// The `scope` field is the user_id used for DB queries on this layer. +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryLayer { + pub name: String, + pub scope: String, + #[serde(default = "default_true")] + pub writable: bool, + #[serde(default)] + pub sensitivity: LayerSensitivity, +} + +fn default_true() -> bool { + true +} + +impl MemoryLayer { + /// Build the default layer set: a single private layer for the given user_id. + pub fn default_for_user(user_id: &str) -> Vec { + vec![MemoryLayer { + name: "private".to_string(), + scope: user_id.to_string(), + writable: true, + sensitivity: LayerSensitivity::Private, + }] + } + + /// Extract read scopes (all layer scope values). + pub fn read_scopes(layers: &[MemoryLayer]) -> Vec { + layers.iter().map(|l| l.scope.clone()).collect() + } + + /// Extract writable scopes only. + pub fn writable_scopes(layers: &[MemoryLayer]) -> Vec { + layers + .iter() + .filter(|l| l.writable) + .map(|l| l.scope.clone()) + .collect() + } + + /// Find a layer by name. Returns None if not found. + pub fn find<'a>(layers: &'a [MemoryLayer], name: &str) -> Option<&'a MemoryLayer> { + layers.iter().find(|l| l.name == name) + } + + /// Find the private layer (first layer with Private sensitivity). + pub fn private_layer(layers: &[MemoryLayer]) -> Option<&MemoryLayer> { + layers + .iter() + .find(|l| l.sensitivity == LayerSensitivity::Private) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_for_user_creates_single_private_layer() { + let layers = MemoryLayer::default_for_user("alice"); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].name, "private"); + assert_eq!(layers[0].scope, "alice"); + assert!(layers[0].writable); + assert_eq!(layers[0].sensitivity, LayerSensitivity::Private); + } + + #[test] + fn read_scopes_collects_all() { + let layers = vec![ + MemoryLayer { + name: "private".into(), + scope: "alice".into(), + writable: true, + sensitivity: LayerSensitivity::Private, + }, + MemoryLayer { + name: "shared".into(), + scope: "shared".into(), + writable: true, + sensitivity: LayerSensitivity::Shared, + }, + MemoryLayer { + name: "reports".into(), + scope: "reports".into(), + writable: false, + sensitivity: LayerSensitivity::Shared, + }, + ]; + let scopes = MemoryLayer::read_scopes(&layers); + assert_eq!(scopes, vec!["alice", "shared", "reports"]); + } + + #[test] + fn writable_scopes_filters_read_only() { + let layers = vec![ + MemoryLayer { + name: "private".into(), + scope: "alice".into(), + writable: true, + sensitivity: LayerSensitivity::Private, + }, + MemoryLayer { + name: "reports".into(), + scope: "reports".into(), + writable: false, + sensitivity: LayerSensitivity::Shared, + }, + ]; + let scopes = MemoryLayer::writable_scopes(&layers); + assert_eq!(scopes, vec!["alice"]); + } + + #[test] + fn find_returns_matching_layer() { + let layers = MemoryLayer::default_for_user("alice"); + assert!(MemoryLayer::find(&layers, "private").is_some()); + assert!(MemoryLayer::find(&layers, "shared").is_none()); + } + + #[test] + fn deserialize_from_json() { + let json = serde_json::json!({ + "name": "shared", + "scope": "shared", + "writable": true, + "sensitivity": "shared" + }); + let layer: MemoryLayer = serde_json::from_value(json).unwrap(); + assert_eq!(layer.name, "shared"); + assert_eq!(layer.sensitivity, LayerSensitivity::Shared); + } + + #[test] + fn deserialize_defaults() { + let json = serde_json::json!({ + "name": "private", + "scope": "alice" + }); + let layer: MemoryLayer = serde_json::from_value(json).unwrap(); + assert!(layer.writable); // default true + assert_eq!(layer.sensitivity, LayerSensitivity::Private); // default + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 02d81418..79437406 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -45,6 +45,8 @@ mod document; mod embedding_cache; mod embeddings; pub mod hygiene; +pub mod layer; +pub mod privacy; #[cfg(feature = "postgres")] mod repository; mod search; @@ -61,6 +63,17 @@ pub use search::{ FusionStrategy, RankedResult, SearchConfig, SearchResult, fuse_results, reciprocal_rank_fusion, }; +/// Result of a layer-aware write operation. +/// +/// Contains the written document plus metadata about whether the write +/// was redirected to a different layer (e.g., sensitive content redirected +/// from shared to private). +pub struct WriteResult { + pub document: MemoryDocument, + pub redirected: bool, + pub actual_layer: String, +} + use std::sync::Arc; use chrono::{NaiveDate, Utc}; @@ -344,20 +357,29 @@ pub struct Workspace { bootstrap_completed: std::sync::atomic::AtomicBool, /// Default search configuration applied to all queries. search_defaults: SearchConfig, + /// Memory layers this workspace has access to. + memory_layers: Vec, + /// Optional privacy classifier for shared layer writes. + /// When None, writes go exactly where requested — no silent redirect. + privacy_classifier: Option>, } impl Workspace { /// Create a new workspace backed by a PostgreSQL connection pool. #[cfg(feature = "postgres")] pub fn new(user_id: impl Into, pool: Pool) -> Self { + let user_id_str = user_id.into(); + let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str); Self { - user_id: user_id.into(), + user_id: user_id_str, agent_id: None, storage: WorkspaceStorage::Repo(Repository::new(pool)), embeddings: None, bootstrap_pending: std::sync::atomic::AtomicBool::new(false), bootstrap_completed: std::sync::atomic::AtomicBool::new(false), search_defaults: SearchConfig::default(), + memory_layers, + privacy_classifier: None, } } @@ -365,14 +387,18 @@ impl Workspace { /// /// Use this for libSQL or any other backend that implements the Database trait. pub fn new_with_db(user_id: impl Into, db: Arc) -> Self { + let user_id_str = user_id.into(); + let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str); Self { - user_id: user_id.into(), + user_id: user_id_str, agent_id: None, storage: WorkspaceStorage::Db(db), embeddings: None, bootstrap_pending: std::sync::atomic::AtomicBool::new(false), bootstrap_completed: std::sync::atomic::AtomicBool::new(false), search_defaults: SearchConfig::default(), + memory_layers, + privacy_classifier: None, } } @@ -444,6 +470,32 @@ impl Workspace { self } + /// Configure memory layers for this workspace. + /// + /// Also updates read_user_ids to include all layer scopes. + pub fn with_memory_layers(mut self, layers: Vec) -> Self { + self.memory_layers = layers; + self + } + + /// Set a privacy classifier for shared layer writes. + /// + /// When set, writes to shared layers are checked against the classifier + /// and redirected to the private layer if sensitive content is detected. + /// When unset (the default), writes go exactly where requested. + pub fn with_privacy_classifier( + mut self, + classifier: Arc, + ) -> Self { + self.privacy_classifier = Some(classifier); + self + } + + /// Get the configured memory layers. + pub fn memory_layers(&self) -> &[crate::workspace::layer::MemoryLayer] { + &self.memory_layers + } + /// Get the user ID. pub fn user_id(&self) -> &str { &self.user_id @@ -501,7 +553,9 @@ impl Workspace { /// Append content to a file. /// /// Creates the file if it doesn't exist. - /// Adds a newline separator between existing and new content. + /// Uses a single `\n` separator (suitable for log-style entries). + /// For semantic separation (e.g., memory entries), use `append_memory()` + /// which uses `\n\n`. pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> { let path = normalize_path(path); let doc = self @@ -526,6 +580,127 @@ impl Workspace { Ok(()) } + /// Resolve the target scope for a layer write, optionally applying privacy guards. + /// + /// Validates that the layer exists and is writable. When a privacy classifier + /// is configured on the workspace AND `force` is false, checks shared-layer + /// writes for sensitive content and redirects to the private layer. + /// + /// By default no classifier is set — writes go exactly where requested. + /// This is intentional: the LLM chooses the correct layer via system prompt + /// guidance, and a regex classifier can't improve on that decision without + /// unacceptable false positive rates in household contexts (e.g., "doctor", + /// "therapy", phone numbers). Operators who want a safety net can configure + /// one via `with_privacy_classifier()`. + /// + /// # Multi-tenant safety (Issue #59) + /// + /// Layer scopes are currently used directly as `user_id` for DB operations. + /// In a multi-tenant deployment, an operator could configure a scope that + /// collides with another user's ID, granting write access to their data. + /// Future work should namespace or validate scopes to prevent this. + /// + /// Returns `(scope, actual_layer_name, redirected)`. + fn resolve_layer_target( + &self, + layer_name: &str, + content: &str, + force: bool, + ) -> Result<(String, String, bool), WorkspaceError> { + use crate::workspace::layer::{LayerSensitivity, MemoryLayer}; + + let layer = MemoryLayer::find(&self.memory_layers, layer_name).ok_or_else(|| { + WorkspaceError::LayerNotFound { + name: layer_name.to_string(), + } + })?; + + if !layer.writable { + return Err(WorkspaceError::LayerReadOnly { + name: layer_name.to_string(), + }); + } + + if !force + && layer.sensitivity == LayerSensitivity::Shared + && let Some(ref classifier) = self.privacy_classifier + && classifier.classify(content).is_sensitive + { + tracing::warn!( + layer = layer_name, + "Redirected sensitive content to private layer" + ); + let private = MemoryLayer::private_layer(&self.memory_layers) + .ok_or(WorkspaceError::PrivacyRedirectFailed)?; + if !private.writable { + return Err(WorkspaceError::PrivacyRedirectFailed); + } + return Ok((private.scope.clone(), private.name.clone(), true)); + } + + Ok((layer.scope.clone(), layer_name.to_string(), false)) + } + + /// Write to a specific memory layer. + /// + /// Checks that the layer exists and is writable. Uses the layer's scope + /// as the user_id for the database write. For shared layers, sensitive + /// content is automatically redirected to the private layer unless + /// `force` is set. + pub async fn write_to_layer( + &self, + layer_name: &str, + path: &str, + content: &str, + force: bool, + ) -> Result { + let (scope, actual_layer, redirected) = + self.resolve_layer_target(layer_name, content, force)?; + let path = normalize_path(path); + let doc = self + .storage + .get_or_create_document_by_path(&scope, self.agent_id, &path) + .await?; + self.storage.update_document(doc.id, content).await?; + self.reindex_document(doc.id).await?; + let document = self.storage.get_document_by_id(doc.id).await?; + Ok(WriteResult { + document, + redirected, + actual_layer, + }) + } + + /// Write to a layer, with append semantics. + pub async fn append_to_layer( + &self, + layer_name: &str, + path: &str, + content: &str, + force: bool, + ) -> Result { + let (scope, actual_layer, redirected) = + self.resolve_layer_target(layer_name, content, force)?; + let path = normalize_path(path); + let doc = self + .storage + .get_or_create_document_by_path(&scope, self.agent_id, &path) + .await?; + let new_content = if doc.content.is_empty() { + content.to_string() + } else { + format!("{}\n\n{}", doc.content, content) + }; + self.storage.update_document(doc.id, &new_content).await?; + self.reindex_document(doc.id).await?; + let document = self.storage.get_document_by_id(doc.id).await?; + Ok(WriteResult { + document, + redirected, + actual_layer, + }) + } + /// Check if a file exists. pub async fn exists(&self, path: &str) -> Result { let path = normalize_path(path); diff --git a/src/workspace/privacy.rs b/src/workspace/privacy.rs new file mode 100644 index 00000000..596a2385 --- /dev/null +++ b/src/workspace/privacy.rs @@ -0,0 +1,276 @@ +use regex::Regex; + +/// Result of privacy classification, including confidence level. +/// +/// Confidence enables downstream callers to apply thresholds (e.g., only +/// redirect above 0.8) and supports future upgrade to LLM-based classifiers +/// that produce probabilistic scores. +#[derive(Debug, Clone)] +pub struct SensitivityResult { + pub is_sensitive: bool, + pub confidence: f32, +} + +/// Classifies content as potentially sensitive for privacy purposes. +/// +/// Used to guard writes to shared memory layers -- if content is flagged +/// as sensitive, it can be redirected to the private layer instead. +pub trait PrivacyClassifier: Send + Sync { + /// Classify content and return sensitivity with confidence score. + fn classify(&self, content: &str) -> SensitivityResult; +} + +/// Pattern-based privacy classifier using regex matching. +/// +/// Default patterns target hard PII (SSN, credit card numbers) where silent +/// redirect is clearly correct. Ambiguous terms (health vocabulary, contact +/// info) are intentionally excluded — they cause false positives in household +/// contexts and silently redirect content the user intended to share. +/// +/// Operators who need broader coverage should use `ConfigurablePrivacyClassifier` +/// with domain-specific patterns. +pub struct PatternPrivacyClassifier { + patterns: Vec, +} + +impl PatternPrivacyClassifier { + pub fn new() -> Result { + let pattern_strs = [ + // SSN — always PII + r"\b\d{3}-\d{2}-\d{4}\b", + // Credit card (basic) — always PII + r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", + // Credentials and auth tokens — high-confidence PII + r"(?i)\b(password|passwd|api[_-]?key|auth[_-]?token|secret[_-]?key)\b", + ]; + let patterns = pattern_strs + .iter() + .map(|p| Regex::new(p)) + .collect::, _>>()?; + Ok(Self { patterns }) + } +} + +impl PrivacyClassifier for PatternPrivacyClassifier { + fn classify(&self, content: &str) -> SensitivityResult { + let is_sensitive = self.patterns.iter().any(|p| p.is_match(content)); + SensitivityResult { + is_sensitive, + // Regex is binary — matched or not. Always full confidence. + confidence: if is_sensitive { 1.0 } else { 0.0 }, + } + } +} + +/// User-configurable privacy classifier. +/// +/// Accepts custom regex patterns at construction time, allowing operators +/// to tune sensitivity for their use case (e.g., drop health terms that +/// cause false positives, add domain-specific patterns). +/// +/// ``` +/// use ironclaw::workspace::privacy::ConfigurablePrivacyClassifier; +/// use ironclaw::workspace::privacy::PrivacyClassifier; +/// +/// let classifier = ConfigurablePrivacyClassifier::new(vec![ +/// r"\b\d{3}-\d{2}-\d{4}\b".into(), // SSN only +/// ]).unwrap(); +/// assert!(classifier.classify("SSN: 123-45-6789").is_sensitive); +/// assert!(!classifier.classify("saw the doctor today").is_sensitive); +/// ``` +pub struct ConfigurablePrivacyClassifier { + patterns: Vec, +} + +impl ConfigurablePrivacyClassifier { + /// Create a classifier from user-supplied regex strings. + /// + /// Returns an error if any pattern fails to compile. + pub fn new(pattern_strs: Vec) -> Result { + let patterns = pattern_strs + .iter() + .map(|p| Regex::new(p)) + .collect::, _>>()?; + Ok(Self { patterns }) + } +} + +impl PrivacyClassifier for ConfigurablePrivacyClassifier { + fn classify(&self, content: &str) -> SensitivityResult { + let is_sensitive = self.patterns.iter().any(|p| p.is_match(content)); + SensitivityResult { + is_sensitive, + confidence: if is_sensitive { 1.0 } else { 0.0 }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn classifier() -> PatternPrivacyClassifier { + PatternPrivacyClassifier::new().unwrap() + } + + // Hard PII — must always trigger + #[test] + fn detects_ssn() { + let result = classifier().classify("My SSN is 123-45-6789"); + assert!(result.is_sensitive); + assert_eq!(result.confidence, 1.0); + } + + #[test] + fn detects_credit_card() { + let result = classifier().classify("Card: 4111 1111 1111 1111"); + assert!(result.is_sensitive); + assert_eq!(result.confidence, 1.0); + } + + #[test] + fn detects_password() { + assert!(classifier().classify("my password is hunter2").is_sensitive); + } + + #[test] + fn detects_api_key() { + assert!( + classifier() + .classify("set the api_key to sk-1234") + .is_sensitive + ); + } + + // Household content — must NOT trigger (previous false positives) + #[test] + fn allows_normal_household_content() { + let result = classifier().classify("We need to buy groceries for dinner Saturday"); + assert!(!result.is_sensitive); + assert_eq!(result.confidence, 0.0); + } + + #[test] + fn allows_doctor_mention() { + assert!( + !classifier() + .classify("the doctor's office called about Saturday") + .is_sensitive + ); + } + + #[test] + fn allows_email_address() { + assert!( + !classifier() + .classify("email joe@plumber.com about the leak") + .is_sensitive + ); + } + + #[test] + fn allows_phone_number() { + assert!( + !classifier() + .classify("call the restaurant at 555-123-4567") + .is_sensitive + ); + } + + #[test] + fn allows_medical_terms_in_context() { + assert!( + !classifier() + .classify("Started new medication for anxiety") + .is_sensitive + ); + } + + #[test] + fn configurable_with_custom_patterns() { + let c = ConfigurablePrivacyClassifier::new(vec![ + r"\b\d{3}-\d{2}-\d{4}\b".into(), // SSN only + ]) + .unwrap(); + assert!(c.classify("SSN: 123-45-6789").is_sensitive); + // Health terms no longer trigger with SSN-only config + assert!(!c.classify("saw the doctor today").is_sensitive); + } + + #[test] + fn configurable_rejects_bad_regex() { + let result = ConfigurablePrivacyClassifier::new(vec!["[invalid".into()]); + assert!(result.is_err()); + } + + #[test] + fn configurable_empty_patterns_allows_everything() { + let c = ConfigurablePrivacyClassifier::new(vec![]).unwrap(); + assert!(!c.classify("My SSN is 123-45-6789").is_sensitive); + } + + // Format variants + #[test] + fn detects_credit_card_no_separators() { + assert!( + classifier() + .classify("card 4111111111111111 on file") + .is_sensitive + ); + } + + #[test] + fn detects_credit_card_with_dashes() { + assert!( + classifier() + .classify("Card: 4111-1111-1111-1111") + .is_sensitive + ); + } + + #[test] + fn detects_ssn_bare() { + assert!(classifier().classify("123-45-6789").is_sensitive); + } + + #[test] + fn detects_auth_token_keyword() { + assert!( + classifier() + .classify("set auth_token to abc123") + .is_sensitive + ); + } + + #[test] + fn detects_secret_key_keyword() { + assert!( + classifier() + .classify("the secret_key is sk-prod-xyz") + .is_sensitive + ); + } + + #[test] + fn detects_pii_in_longer_document() { + let content = "Meeting notes from Thursday.\n\ + Discussed budget and timeline.\n\ + SSN is 999-88-7777 for the insurance form.\n\ + Action items: follow up with vendor."; + assert!(classifier().classify(content).is_sensitive); + } + + #[test] + fn empty_string_is_not_sensitive() { + assert!(!classifier().classify("").is_sensitive); + } + + #[test] + fn partial_ssn_not_sensitive() { + assert!( + !classifier() + .classify("code 123-45 in the system") + .is_sensitive + ); + } +} diff --git a/tests/layered_memory.rs b/tests/layered_memory.rs new file mode 100644 index 00000000..5debce86 --- /dev/null +++ b/tests/layered_memory.rs @@ -0,0 +1,360 @@ +#![cfg(feature = "libsql")] +//! Integration tests for layered memory using file-backed libSQL. + +use std::sync::Arc; + +use ironclaw::db::Database; +use ironclaw::db::libsql::LibSqlBackend; +use ironclaw::workspace::Workspace; +use ironclaw::workspace::layer::{LayerSensitivity, MemoryLayer}; +use ironclaw::workspace::privacy::PatternPrivacyClassifier; + +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) +} + +fn test_layers() -> Vec { + vec![ + MemoryLayer { + name: "private".into(), + scope: "alice".into(), + writable: true, + sensitivity: LayerSensitivity::Private, + }, + MemoryLayer { + name: "shared".into(), + scope: "shared".into(), + writable: true, + sensitivity: LayerSensitivity::Shared, + }, + MemoryLayer { + name: "reports".into(), + scope: "reports".into(), + writable: false, + sensitivity: LayerSensitivity::Shared, + }, + ] +} + +#[tokio::test] +async fn write_to_private_layer() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + let result = ws + .write_to_layer("private", "notes/test.md", "Private note", false) + .await + .expect("write should succeed"); + assert_eq!(result.document.content, "Private note"); + assert!(!result.redirected); + assert_eq!(result.actual_layer, "private"); +} + +#[tokio::test] +async fn write_to_shared_layer() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + let result = ws + .write_to_layer("shared", "plans/dinner.md", "Dinner Saturday at 6", false) + .await + .expect("write should succeed"); + assert_eq!(result.document.content, "Dinner Saturday at 6"); + assert!(!result.redirected); + assert_eq!(result.actual_layer, "shared"); +} + +#[tokio::test] +async fn write_to_read_only_layer_fails() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + let result = ws + .write_to_layer("reports", "notes/budget.md", "Some budget note", false) + .await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn write_to_unknown_layer_fails() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + let result = ws + .write_to_layer("nonexistent", "notes/test.md", "content", false) + .await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn no_redirect_without_classifier() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + // Without a classifier, PII goes exactly where requested + let result = ws + .write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false) + .await + .expect("write should succeed"); + assert!(!result.redirected); + assert_eq!(result.actual_layer, "shared"); +} + +#[tokio::test] +async fn sensitive_content_redirected_to_private() { + let (db, _dir) = setup().await; + let db_clone = db.clone(); + let ws = Workspace::new_with_db("alice", db) + .with_memory_layers(test_layers()) + .with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap())); + + // Write content containing hard PII to shared layer -- should be redirected + let result = ws + .write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false) + .await + .expect("write should succeed (redirected)"); + + // WriteResult should indicate redirect to private layer + assert!(result.redirected, "Should be redirected"); + assert_eq!(result.actual_layer, "private"); + assert_eq!(result.document.content, "My SSN is 123-45-6789"); + + // Content should be in the private scope (alice), not the shared scope + let private_doc = ws.read("notes/pii.md").await; + assert!( + private_doc.is_ok(), + "Should find content in private scope (alice)" + ); + assert_eq!(private_doc.unwrap().content, "My SSN is 123-45-6789"); + + // Verify content is NOT in the shared scope (same DB, different user_id) + let ws_shared = Workspace::new_with_db("shared", db_clone); + let shared_doc = ws_shared.read("notes/pii.md").await; + assert!( + shared_doc.is_err(), + "Should NOT find content in shared scope" + ); +} + +#[tokio::test] +async fn default_write_still_works() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + // Regular write (no layer) should still work + let doc = ws + .write("notes/test.md", "Regular note") + .await + .expect("write should succeed"); + assert_eq!(doc.content, "Regular note"); +} + +#[tokio::test] +async fn append_to_layer_works() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + // Write initial content to a layer + ws.write_to_layer("private", "notes/log.md", "Entry one", false) + .await + .expect("initial write should succeed"); + + // Append to the same layer path + let result = ws + .append_to_layer("private", "notes/log.md", "Entry two", false) + .await + .expect("append should succeed"); + + // Content should be concatenated with double newline + assert!( + result.document.content.contains("Entry one"), + "Should contain first entry" + ); + assert!( + result.document.content.contains("Entry two"), + "Should contain second entry" + ); +} + +#[tokio::test] +async fn sensitive_content_fails_without_private_layer() { + let (db, _dir) = setup().await; + + // Workspace with classifier but only shared layers (no private layer for redirect) + let shared_only_layers = vec![MemoryLayer { + name: "shared".into(), + scope: "shared".into(), + writable: true, + sensitivity: LayerSensitivity::Shared, + }]; + let ws = Workspace::new_with_db("alice", db) + .with_memory_layers(shared_only_layers) + .with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap())); + + // Writing PII content should fail (no private layer to redirect to) + let result = ws + .write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false) + .await; + assert!( + result.is_err(), + "Should fail when no private layer available for redirect" + ); +} + +#[tokio::test] +async fn append_sensitive_to_shared_redirects() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db) + .with_memory_layers(test_layers()) + .with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap())); + + // Append PII content to shared layer -- should be redirected + let result = ws + .append_to_layer( + "shared", + "notes/pii.md", + "Card number is 4111 1111 1111 1111", + false, + ) + .await + .expect("append should succeed (redirected)"); + + assert!(result.redirected, "Should be redirected"); + assert_eq!(result.actual_layer, "private"); + assert!(result.document.content.contains("4111")); +} + +#[tokio::test] +async fn force_skips_privacy_redirect() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db) + .with_memory_layers(test_layers()) + .with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap())); + + // PII content with force=true should stay in shared layer + let result = ws + .write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", true) + .await + .expect("write should succeed without redirect"); + + assert!( + !result.redirected, + "Should NOT be redirected with force=true" + ); + assert_eq!(result.actual_layer, "shared"); +} + +#[tokio::test] +async fn search_finds_private_layer_content() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + // Write to the private layer (scope = "alice" = user_id) + ws.write_to_layer( + "private", + "notes/private.md", + "My private thought about waffles", + false, + ) + .await + .unwrap(); + + // Search should find content in the primary scope + let results = ws.search("waffles", 10).await.unwrap(); + assert!( + !results.is_empty(), + "Should find results in the private layer" + ); +} + +#[tokio::test] +async fn write_to_private_invisible_from_shared_scope() { + let (db, _dir) = setup().await; + let db_clone = db.clone(); + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + ws.write_to_layer("private", "notes/secret.md", "Private data", false) + .await + .expect("write should succeed"); + + let ws_shared = Workspace::new_with_db("shared", db_clone); + let result = ws_shared.read("notes/secret.md").await; + assert!( + result.is_err(), + "Shared scope must not read private layer content" + ); +} + +#[tokio::test] +async fn write_to_shared_invisible_from_private_scope() { + let (db, _dir) = setup().await; + let db_clone = db.clone(); + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + ws.write_to_layer("shared", "plans/visible.md", "Shared plan", false) + .await + .expect("write should succeed"); + + let ws_alice = Workspace::new_with_db("alice", db_clone); + let result = ws_alice.read("plans/visible.md").await; + assert!( + result.is_err(), + "Private scope must not read shared layer content without multi-scope" + ); +} + +#[tokio::test] +async fn write_empty_path_to_layer() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + let result = ws.write_to_layer("private", "", "content", false).await; + // normalize_path("") returns "" — the write succeeds with an empty-string path + assert!(result.is_ok(), "write with empty path should succeed"); + let write_result = result.unwrap(); + assert_eq!(write_result.document.content, "content"); + assert!(!write_result.redirected); + assert_eq!(write_result.actual_layer, "private"); +} + +#[tokio::test] +async fn overwrite_existing_content_in_layer() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers()); + + ws.write_to_layer("private", "notes/evolving.md", "Version 1", false) + .await + .expect("first write"); + + let result = ws + .write_to_layer("private", "notes/evolving.md", "Version 2", false) + .await + .expect("overwrite should succeed"); + + assert_eq!(result.document.content, "Version 2"); + assert!(!result.redirected); +} + +#[tokio::test] +async fn sensitive_write_to_private_layer_not_redirected() { + let (db, _dir) = setup().await; + let ws = Workspace::new_with_db("alice", db) + .with_memory_layers(test_layers()) + .with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap())); + + let result = ws + .write_to_layer("private", "notes/pii.md", "My SSN is 123-45-6789", false) + .await + .expect("write to private should succeed"); + + assert!( + !result.redirected, + "Private layer writes should not redirect" + ); + assert_eq!(result.actual_layer, "private"); +}