Skip to main content

tandem_memory/
db.rs

1#![allow(clippy::all)]
2
3// Database Layer Module
4// SQLite + sqlite-vec for vector storage
5
6use crate::types::{
7    ClearFileIndexResult, GlobalMemoryRecord, GlobalMemorySearchHit, GlobalMemoryWriteResult,
8    KnowledgeCoverageRecord, KnowledgeItemRecord, KnowledgeItemStatus, KnowledgePromotionRequest,
9    KnowledgePromotionResult, KnowledgeSpaceRecord, MemoryChunk, MemoryConfig, MemoryError,
10    MemoryResult, MemoryStats, MemoryTenantScope, MemoryTier, ProjectMemoryStats,
11    SourceObjectLifecycleRecord, SourceObjectLifecycleState, DEFAULT_EMBEDDING_DIMENSION,
12};
13use chrono::{DateTime, Utc};
14use rusqlite::{ffi::sqlite3_auto_extension, params, Connection, OptionalExtension, Row};
15use sqlite_vec::sqlite3_vec_init;
16use std::collections::HashSet;
17use std::path::Path;
18use std::sync::{Arc, LazyLock};
19use std::time::Duration;
20use tokio::sync::Mutex;
21
22type ProjectIndexStatusRow = (
23    Option<String>,
24    Option<i64>,
25    Option<i64>,
26    Option<i64>,
27    Option<i64>,
28    Option<i64>,
29);
30
31/// Database connection manager
32pub struct MemoryDatabase {
33    conn: Arc<Mutex<Connection>>,
34    db_path: std::path::PathBuf,
35}
36
37static SCHEMA_INIT_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
38
39include!("memory_database_impl_parts/part01.rs");
40include!("memory_database_impl_parts/part02.rs");
41
42/// Convert a database row to a MemoryChunk
43fn row_to_chunk(row: &Row, tier: MemoryTier) -> Result<MemoryChunk, rusqlite::Error> {
44    let id: String = row.get(0)?;
45    let content: String = row.get(1)?;
46    let (session_id, project_id, source_idx, created_at_idx, token_count_idx, metadata_idx) =
47        match tier {
48            MemoryTier::Session => (
49                Some(row.get(2)?),
50                row.get(3)?,
51                4usize,
52                5usize,
53                6usize,
54                7usize,
55            ),
56            MemoryTier::Project => (
57                row.get(2)?,
58                Some(row.get(3)?),
59                4usize,
60                5usize,
61                6usize,
62                7usize,
63            ),
64            MemoryTier::Global => (None, None, 2usize, 3usize, 4usize, 5usize),
65        };
66
67    let source: String = row.get(source_idx)?;
68    let created_at_str: String = row.get(created_at_idx)?;
69    let token_count: i64 = row.get(token_count_idx)?;
70    let metadata_str: Option<String> = row.get(metadata_idx)?;
71
72    let created_at = DateTime::parse_from_rfc3339(&created_at_str)
73        .map_err(|e| {
74            rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e))
75        })?
76        .with_timezone(&Utc);
77
78    let metadata = metadata_str
79        .filter(|s| !s.is_empty())
80        .and_then(|s| serde_json::from_str(&s).ok());
81
82    let source_path = row.get::<_, Option<String>>("source_path").ok().flatten();
83    let source_mtime = row.get::<_, Option<i64>>("source_mtime").ok().flatten();
84    let source_size = row.get::<_, Option<i64>>("source_size").ok().flatten();
85    let source_hash = row.get::<_, Option<String>>("source_hash").ok().flatten();
86    let tenant_scope = MemoryTenantScope {
87        org_id: row
88            .get::<_, Option<String>>("tenant_org_id")
89            .ok()
90            .flatten()
91            .filter(|value| !value.trim().is_empty())
92            .unwrap_or_else(|| LOCAL_TENANT_ORG_ID.to_string()),
93        workspace_id: row
94            .get::<_, Option<String>>("tenant_workspace_id")
95            .ok()
96            .flatten()
97            .filter(|value| !value.trim().is_empty())
98            .unwrap_or_else(|| LOCAL_TENANT_WORKSPACE_ID.to_string()),
99        deployment_id: row
100            .get::<_, Option<String>>("tenant_deployment_id")
101            .ok()
102            .flatten()
103            .filter(|value| !value.trim().is_empty()),
104    };
105
106    Ok(MemoryChunk {
107        id,
108        content,
109        tier,
110        session_id,
111        project_id,
112        source,
113        source_path,
114        source_mtime,
115        source_size,
116        source_hash,
117        tenant_scope,
118        created_at,
119        token_count,
120        metadata,
121    })
122}
123
124fn require_scope_id<'a>(tier: MemoryTier, scope: Option<&'a str>) -> MemoryResult<&'a str> {
125    scope
126        .filter(|value| !value.trim().is_empty())
127        .ok_or_else(|| {
128            crate::types::MemoryError::InvalidConfig(match tier {
129                MemoryTier::Session => "tier=session requires session_id".to_string(),
130                MemoryTier::Project => "tier=project requires project_id".to_string(),
131                MemoryTier::Global => "tier=global does not require a scope id".to_string(),
132            })
133        })
134}
135
136const LOCAL_TENANT_ORG_ID: &str = "local";
137const LOCAL_TENANT_WORKSPACE_ID: &str = "local";
138
139fn tenant_scope_matches_sql_clause(prefix: &str, first_param: usize) -> String {
140    format!(
141        "{prefix}.tenant_org_id = ?{first_param} AND {prefix}.tenant_workspace_id = ?{} AND IFNULL({prefix}.tenant_deployment_id, '') = IFNULL(?{}, '')",
142        first_param + 1,
143        first_param + 2
144    )
145}
146
147fn global_memory_record_tenant_scope(
148    record: &GlobalMemoryRecord,
149) -> (String, String, Option<String>) {
150    record
151        .provenance
152        .as_ref()
153        .and_then(|value| value.get("tenant_context"))
154        .and_then(memory_tenant_scope_from_value)
155        .unwrap_or_else(|| {
156            (
157                LOCAL_TENANT_ORG_ID.to_string(),
158                LOCAL_TENANT_WORKSPACE_ID.to_string(),
159                None,
160            )
161        })
162}
163
164fn memory_tenant_scope_from_value(
165    value: &serde_json::Value,
166) -> Option<(String, String, Option<String>)> {
167    let org_id = value.get("org_id")?.as_str()?.to_string();
168    let workspace_id = value.get("workspace_id")?.as_str()?.to_string();
169    let deployment_id = value
170        .get("deployment_id")
171        .and_then(|value| value.as_str())
172        .map(ToString::to_string);
173    Some((org_id, workspace_id, deployment_id))
174}
175
176fn row_to_global_record(row: &Row) -> Result<GlobalMemoryRecord, rusqlite::Error> {
177    let metadata_str: Option<String> = row.get(12)?;
178    let provenance_str: Option<String> = row.get(13)?;
179    Ok(GlobalMemoryRecord {
180        id: row.get(0)?,
181        user_id: row.get(1)?,
182        source_type: row.get(2)?,
183        content: row.get(3)?,
184        content_hash: row.get(4)?,
185        run_id: row.get(5)?,
186        session_id: row.get(6)?,
187        message_id: row.get(7)?,
188        tool_name: row.get(8)?,
189        project_tag: row.get(9)?,
190        channel_tag: row.get(10)?,
191        host_tag: row.get(11)?,
192        metadata: metadata_str
193            .filter(|s| !s.is_empty())
194            .and_then(|s| serde_json::from_str(&s).ok()),
195        provenance: provenance_str
196            .filter(|s| !s.is_empty())
197            .and_then(|s| serde_json::from_str(&s).ok()),
198        redaction_status: row.get(14)?,
199        redaction_count: row.get::<_, i64>(15)? as u32,
200        visibility: row.get(16)?,
201        demoted: row.get::<_, i64>(17)? != 0,
202        score_boost: row.get(18)?,
203        created_at_ms: row.get::<_, i64>(19)? as u64,
204        updated_at_ms: row.get::<_, i64>(20)? as u64,
205        expires_at_ms: row.get::<_, Option<i64>>(21)?.map(|v| v as u64),
206    })
207}
208
209fn row_to_source_object_lifecycle(
210    row: &Row,
211) -> Result<SourceObjectLifecycleRecord, rusqlite::Error> {
212    let metadata_str: Option<String> = row.get("metadata")?;
213    let resource_ref_str: String = row.get("resource_ref")?;
214    let tenant_scope = MemoryTenantScope {
215        org_id: row.get("tenant_org_id")?,
216        workspace_id: row.get("tenant_workspace_id")?,
217        deployment_id: row
218            .get::<_, Option<String>>("tenant_deployment_id")?
219            .filter(|value| !value.is_empty()),
220    };
221    let tier = match row.get::<_, String>("tier")?.as_str() {
222        "session" => MemoryTier::Session,
223        "project" => MemoryTier::Project,
224        _ => MemoryTier::Global,
225    };
226    Ok(SourceObjectLifecycleRecord {
227        source_object_id: row.get("source_object_id")?,
228        tenant_scope,
229        source_binding_id: row.get("source_binding_id")?,
230        connector_id: row.get("connector_id")?,
231        state: SourceObjectLifecycleState::parse(&row.get::<_, String>("state")?),
232        tier,
233        session_id: row.get("session_id")?,
234        project_id: row.get("project_id")?,
235        import_namespace: row.get("import_namespace")?,
236        indexed_path: row.get("indexed_path")?,
237        native_object_id: row.get("native_object_id")?,
238        resource_ref: serde_json::from_str(&resource_ref_str).unwrap_or(serde_json::Value::Null),
239        data_class: row.get("data_class")?,
240        content_hash: row.get("content_hash")?,
241        source_hash: row.get("source_hash")?,
242        first_seen_at_ms: row.get::<_, i64>("first_seen_at_ms")? as u64,
243        last_seen_at_ms: row.get::<_, i64>("last_seen_at_ms")? as u64,
244        tombstoned_at_ms: row
245            .get::<_, Option<i64>>("tombstoned_at_ms")?
246            .map(|value| value as u64),
247        metadata: metadata_str
248            .filter(|value| !value.is_empty())
249            .and_then(|value| serde_json::from_str(&value).ok()),
250    })
251}
252
253impl MemoryDatabase {
254    pub async fn get_node_by_uri(
255        &self,
256        uri: &str,
257    ) -> MemoryResult<Option<crate::types::MemoryNode>> {
258        let conn = self.conn.lock().await;
259        let mut stmt = conn.prepare(
260            "SELECT id, uri, parent_uri, node_type, created_at, updated_at, metadata
261             FROM memory_nodes WHERE uri = ?1",
262        )?;
263
264        let result = stmt.query_row(params![uri], |row| {
265            let node_type_str: String = row.get(3)?;
266            let node_type = node_type_str
267                .parse()
268                .unwrap_or(crate::types::NodeType::File);
269            let metadata_str: Option<String> = row.get(6)?;
270            Ok(crate::types::MemoryNode {
271                id: row.get(0)?,
272                uri: row.get(1)?,
273                parent_uri: row.get(2)?,
274                node_type,
275                created_at: row.get::<_, String>(4)?.parse().unwrap_or_default(),
276                updated_at: row.get::<_, String>(5)?.parse().unwrap_or_default(),
277                metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
278            })
279        });
280
281        match result {
282            Ok(node) => Ok(Some(node)),
283            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
284            Err(e) => Err(MemoryError::Database(e)),
285        }
286    }
287
288    pub async fn create_node(
289        &self,
290        uri: &str,
291        parent_uri: Option<&str>,
292        node_type: crate::types::NodeType,
293        metadata: Option<&serde_json::Value>,
294    ) -> MemoryResult<String> {
295        let id = uuid::Uuid::new_v4().to_string();
296        let now = Utc::now().to_rfc3339();
297        let metadata_str = metadata.map(|m| serde_json::to_string(m)).transpose()?;
298
299        let conn = self.conn.lock().await;
300        conn.execute(
301            "INSERT INTO memory_nodes (id, uri, parent_uri, node_type, created_at, updated_at, metadata)
302             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
303            params![id, uri, parent_uri, node_type.to_string(), now, now, metadata_str],
304        )?;
305
306        Ok(id)
307    }
308
309    pub async fn list_directory(&self, uri: &str) -> MemoryResult<Vec<crate::types::MemoryNode>> {
310        let conn = self.conn.lock().await;
311        let mut stmt = conn.prepare(
312            "SELECT id, uri, parent_uri, node_type, created_at, updated_at, metadata
313             FROM memory_nodes WHERE parent_uri = ?1 ORDER BY node_type DESC, uri ASC",
314        )?;
315
316        let rows = stmt.query_map(params![uri], |row| {
317            let node_type_str: String = row.get(3)?;
318            let node_type = node_type_str
319                .parse()
320                .unwrap_or(crate::types::NodeType::File);
321            let metadata_str: Option<String> = row.get(6)?;
322            Ok(crate::types::MemoryNode {
323                id: row.get(0)?,
324                uri: row.get(1)?,
325                parent_uri: row.get(2)?,
326                node_type,
327                created_at: row.get::<_, String>(4)?.parse().unwrap_or_default(),
328                updated_at: row.get::<_, String>(5)?.parse().unwrap_or_default(),
329                metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
330            })
331        })?;
332
333        rows.collect::<Result<Vec<_>, _>>()
334            .map_err(MemoryError::Database)
335    }
336
337    pub async fn get_layer(
338        &self,
339        node_id: &str,
340        layer_type: crate::types::LayerType,
341    ) -> MemoryResult<Option<crate::types::MemoryLayer>> {
342        let conn = self.conn.lock().await;
343        let mut stmt = conn.prepare(
344            "SELECT id, node_id, layer_type, content, token_count, embedding_id, created_at, source_chunk_id
345             FROM memory_layers WHERE node_id = ?1 AND layer_type = ?2"
346        )?;
347
348        let result = stmt.query_row(params![node_id, layer_type.to_string()], |row| {
349            let layer_type_str: String = row.get(2)?;
350            let layer_type = layer_type_str
351                .parse()
352                .unwrap_or(crate::types::LayerType::L2);
353            Ok(crate::types::MemoryLayer {
354                id: row.get(0)?,
355                node_id: row.get(1)?,
356                layer_type,
357                content: row.get(3)?,
358                token_count: row.get(4)?,
359                embedding_id: row.get(5)?,
360                created_at: row.get::<_, String>(6)?.parse().unwrap_or_default(),
361                source_chunk_id: row.get(7)?,
362            })
363        });
364
365        match result {
366            Ok(layer) => Ok(Some(layer)),
367            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
368            Err(e) => Err(MemoryError::Database(e)),
369        }
370    }
371
372    pub async fn create_layer(
373        &self,
374        node_id: &str,
375        layer_type: crate::types::LayerType,
376        content: &str,
377        token_count: i64,
378        source_chunk_id: Option<&str>,
379    ) -> MemoryResult<String> {
380        let id = uuid::Uuid::new_v4().to_string();
381        let now = Utc::now().to_rfc3339();
382
383        let conn = self.conn.lock().await;
384        conn.execute(
385            "INSERT INTO memory_layers (id, node_id, layer_type, content, token_count, created_at, source_chunk_id)
386             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
387            params![id, node_id, layer_type.to_string(), content, token_count, now, source_chunk_id],
388        )?;
389
390        Ok(id)
391    }
392
393    pub async fn get_children_tree(
394        &self,
395        parent_uri: &str,
396        max_depth: usize,
397    ) -> MemoryResult<Vec<crate::types::TreeNode>> {
398        if max_depth == 0 {
399            return Ok(Vec::new());
400        }
401
402        let children = self.list_directory(parent_uri).await?;
403        let mut tree_nodes = Vec::new();
404
405        for child in children {
406            let layer_summary = self.get_layer_summary(&child.id).await?;
407
408            let grandchildren = if child.node_type == crate::types::NodeType::Directory {
409                Box::pin(self.get_children_tree(&child.uri, max_depth.saturating_sub(1))).await?
410            } else {
411                Vec::new()
412            };
413
414            tree_nodes.push(crate::types::TreeNode {
415                node: child,
416                children: grandchildren,
417                layer_summary,
418            });
419        }
420
421        Ok(tree_nodes)
422    }
423
424    async fn get_layer_summary(
425        &self,
426        node_id: &str,
427    ) -> MemoryResult<Option<crate::types::LayerSummary>> {
428        let l0 = self.get_layer(node_id, crate::types::LayerType::L0).await?;
429        let l1 = self.get_layer(node_id, crate::types::LayerType::L1).await?;
430        let has_l2 = self
431            .get_layer(node_id, crate::types::LayerType::L2)
432            .await?
433            .is_some();
434
435        if l0.is_none() && l1.is_none() && !has_l2 {
436            return Ok(None);
437        }
438
439        Ok(Some(crate::types::LayerSummary {
440            l0_preview: l0.map(|l| truncate_string(&l.content, 100)),
441            l1_preview: l1.map(|l| truncate_string(&l.content, 200)),
442            has_l2,
443        }))
444    }
445
446    pub async fn node_exists(&self, uri: &str) -> MemoryResult<bool> {
447        let conn = self.conn.lock().await;
448        let count: i64 = conn.query_row(
449            "SELECT COUNT(*) FROM memory_nodes WHERE uri = ?1",
450            params![uri],
451            |row| row.get(0),
452        )?;
453        Ok(count > 0)
454    }
455}
456
457fn row_to_knowledge_space(row: &Row) -> Result<KnowledgeSpaceRecord, rusqlite::Error> {
458    let scope = row
459        .get::<_, String>(1)?
460        .parse()
461        .unwrap_or(tandem_orchestrator::KnowledgeScope::Project);
462    let trust_level = row
463        .get::<_, String>(6)?
464        .parse()
465        .unwrap_or(tandem_orchestrator::KnowledgeTrustLevel::Promoted);
466    let metadata = row
467        .get::<_, Option<String>>(7)?
468        .and_then(|raw| serde_json::from_str(&raw).ok());
469    Ok(KnowledgeSpaceRecord {
470        id: row.get(0)?,
471        scope,
472        project_id: row.get(2)?,
473        namespace: row.get(3)?,
474        title: row.get(4)?,
475        description: row.get(5)?,
476        trust_level,
477        metadata,
478        created_at_ms: row.get::<_, i64>(8)? as u64,
479        updated_at_ms: row.get::<_, i64>(9)? as u64,
480    })
481}
482
483fn row_to_knowledge_item(row: &Row) -> Result<KnowledgeItemRecord, rusqlite::Error> {
484    let trust_level = row
485        .get::<_, String>(8)?
486        .parse()
487        .unwrap_or(tandem_orchestrator::KnowledgeTrustLevel::Promoted);
488    let status = row
489        .get::<_, String>(9)?
490        .parse()
491        .unwrap_or(KnowledgeItemStatus::Working);
492    let payload = row
493        .get::<_, String>(7)
494        .ok()
495        .and_then(|raw| serde_json::from_str(&raw).ok())
496        .unwrap_or(serde_json::Value::Null);
497    let artifact_refs = row
498        .get::<_, String>(11)
499        .ok()
500        .and_then(|raw| serde_json::from_str(&raw).ok())
501        .unwrap_or_default();
502    let source_memory_ids = row
503        .get::<_, String>(12)
504        .ok()
505        .and_then(|raw| serde_json::from_str(&raw).ok())
506        .unwrap_or_default();
507    let metadata = row
508        .get::<_, Option<String>>(14)?
509        .and_then(|raw| serde_json::from_str(&raw).ok());
510    Ok(KnowledgeItemRecord {
511        id: row.get(0)?,
512        space_id: row.get(1)?,
513        coverage_key: row.get(2)?,
514        dedupe_key: row.get(3)?,
515        item_type: row.get(4)?,
516        title: row.get(5)?,
517        summary: row.get(6)?,
518        payload,
519        trust_level,
520        status,
521        run_id: row.get(10)?,
522        artifact_refs,
523        source_memory_ids,
524        freshness_expires_at_ms: row.get::<_, Option<i64>>(13)?.map(|value| value as u64),
525        metadata,
526        created_at_ms: row.get::<_, i64>(15)? as u64,
527        updated_at_ms: row.get::<_, i64>(16)? as u64,
528    })
529}
530
531fn row_to_knowledge_coverage(row: &Row) -> Result<KnowledgeCoverageRecord, rusqlite::Error> {
532    let metadata = row
533        .get::<_, Option<String>>(7)?
534        .and_then(|raw| serde_json::from_str(&raw).ok());
535    Ok(KnowledgeCoverageRecord {
536        coverage_key: row.get(0)?,
537        space_id: row.get(1)?,
538        latest_item_id: row.get(2)?,
539        latest_dedupe_key: row.get(3)?,
540        last_seen_at_ms: row.get::<_, i64>(4)? as u64,
541        last_promoted_at_ms: row.get::<_, Option<i64>>(5)?.map(|value| value as u64),
542        freshness_expires_at_ms: row.get::<_, Option<i64>>(6)?.map(|value| value as u64),
543        metadata,
544    })
545}
546
547fn truncate_string(s: &str, max_len: usize) -> String {
548    if s.len() <= max_len {
549        s.to_string()
550    } else {
551        format!("{}...", &s[..max_len.saturating_sub(3)])
552    }
553}
554
555fn build_fts_query(query: &str) -> String {
556    let tokens = query
557        .split_whitespace()
558        .filter_map(|tok| {
559            let cleaned =
560                tok.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
561            if cleaned.is_empty() {
562                None
563            } else {
564                Some(format!("\"{}\"", cleaned))
565            }
566        })
567        .collect::<Vec<_>>();
568    if tokens.is_empty() {
569        "\"\"".to_string()
570    } else {
571        tokens.join(" OR ")
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serde_json::Value;
579    use tandem_orchestrator::{KnowledgeScope, KnowledgeTrustLevel};
580    use tempfile::TempDir;
581
582    async fn setup_test_db() -> (MemoryDatabase, TempDir) {
583        let temp_dir = TempDir::new().unwrap();
584        let db_path = temp_dir.path().join("test_memory.db");
585        let db = MemoryDatabase::new(&db_path).await.unwrap();
586        (db, temp_dir)
587    }
588
589    fn tenant_scope(org_id: &str, workspace_id: &str) -> MemoryTenantScope {
590        MemoryTenantScope {
591            org_id: org_id.to_string(),
592            workspace_id: workspace_id.to_string(),
593            deployment_id: Some("deployment-1".to_string()),
594        }
595    }
596
597    fn test_vector_chunk(
598        id: &str,
599        tier: MemoryTier,
600        tenant_scope: MemoryTenantScope,
601        content: &str,
602        source_hash: Option<&str>,
603    ) -> MemoryChunk {
604        MemoryChunk {
605            id: id.to_string(),
606            content: content.to_string(),
607            tier,
608            session_id: Some("shared-session".to_string()),
609            project_id: Some("shared-project".to_string()),
610            source: "test_vector".to_string(),
611            source_path: None,
612            source_mtime: None,
613            source_size: None,
614            source_hash: source_hash.map(ToString::to_string),
615            tenant_scope,
616            created_at: Utc::now(),
617            token_count: 4,
618            metadata: None,
619        }
620    }
621
622    fn embedding(first: f32, second: f32) -> Vec<f32> {
623        let mut values = vec![0.0f32; DEFAULT_EMBEDDING_DIMENSION];
624        values[0] = first;
625        values[1] = second;
626        values
627    }
628
629    fn source_object_record(
630        source_object_id: &str,
631        tenant_scope: MemoryTenantScope,
632    ) -> SourceObjectLifecycleRecord {
633        SourceObjectLifecycleRecord {
634            source_object_id: source_object_id.to_string(),
635            tenant_scope,
636            source_binding_id: "shared-binding".to_string(),
637            connector_id: "manual_upload".to_string(),
638            state: SourceObjectLifecycleState::Active,
639            tier: MemoryTier::Global,
640            session_id: None,
641            project_id: None,
642            import_namespace: "shared-import".to_string(),
643            indexed_path: "shared-import/note.md".to_string(),
644            native_object_id: "shared-import/note.md".to_string(),
645            resource_ref: serde_json::json!({
646                "organization_id": "org-a",
647                "workspace_id": "workspace-a",
648                "resource_kind": "document_collection",
649                "resource_id": "shared-docs"
650            }),
651            data_class: "internal".to_string(),
652            content_hash: Some("content-hash".to_string()),
653            source_hash: Some("source-hash".to_string()),
654            first_seen_at_ms: 1_000,
655            last_seen_at_ms: 1_000,
656            tombstoned_at_ms: None,
657            metadata: None,
658        }
659    }
660
661    #[tokio::test]
662    async fn test_init_schema() {
663        let (db, _temp) = setup_test_db().await;
664        // If we get here, schema was initialized successfully
665        let stats = db.get_stats().await.unwrap();
666        assert_eq!(stats.total_chunks, 0);
667    }
668
669    #[tokio::test]
670    async fn source_object_lifecycle_native_ids_are_tenant_scoped() {
671        let (db, _temp) = setup_test_db().await;
672        let tenant_a = tenant_scope("org-a", "workspace-a");
673        let tenant_b = tenant_scope("org-b", "workspace-b");
674
675        db.upsert_source_object_active_for_tenant(&source_object_record(
676            "source-object-a",
677            tenant_a.clone(),
678        ))
679        .await
680        .unwrap();
681        db.upsert_source_object_active_for_tenant(&source_object_record(
682            "source-object-b",
683            tenant_b.clone(),
684        ))
685        .await
686        .unwrap();
687
688        let object_a = db
689            .get_source_object_lifecycle_by_native_for_tenant(
690                &tenant_a,
691                "shared-binding",
692                "shared-import/note.md",
693            )
694            .await
695            .unwrap()
696            .expect("tenant A source object");
697        let object_b = db
698            .get_source_object_lifecycle_by_native_for_tenant(
699                &tenant_b,
700                "shared-binding",
701                "shared-import/note.md",
702            )
703            .await
704            .unwrap()
705            .expect("tenant B source object");
706
707        assert_eq!(object_a.source_object_id, "source-object-a");
708        assert_eq!(object_b.source_object_id, "source-object-b");
709        assert_ne!(object_a.source_object_id, object_b.source_object_id);
710    }
711
712    #[tokio::test]
713    async fn test_knowledge_registry_roundtrip() {
714        let (db, _temp) = setup_test_db().await;
715
716        let space = KnowledgeSpaceRecord {
717            id: "space-1".to_string(),
718            scope: tandem_orchestrator::KnowledgeScope::Project,
719            project_id: Some("project-1".to_string()),
720            namespace: Some("support".to_string()),
721            title: Some("Support Knowledge".to_string()),
722            description: Some("Reusable support guidance".to_string()),
723            trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
724            metadata: Some(serde_json::json!({"owner": "ops"})),
725            created_at_ms: 1,
726            updated_at_ms: 2,
727        };
728        db.upsert_knowledge_space(&space).await.unwrap();
729
730        let item = KnowledgeItemRecord {
731            id: "item-1".to_string(),
732            space_id: space.id.clone(),
733            coverage_key: "project-1/support/debugging/slow-start".to_string(),
734            dedupe_key: "dedupe-1".to_string(),
735            item_type: "decision".to_string(),
736            title: "Restart service before retry".to_string(),
737            summary: Some("When the service is stale, restart before retrying.".to_string()),
738            payload: serde_json::json!({"action": "restart"}),
739            trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
740            status: KnowledgeItemStatus::Promoted,
741            run_id: Some("run-1".to_string()),
742            artifact_refs: vec!["artifact://run-1/report".to_string()],
743            source_memory_ids: vec!["memory-1".to_string()],
744            freshness_expires_at_ms: Some(10),
745            metadata: Some(serde_json::json!({"source": "run"})),
746            created_at_ms: 3,
747            updated_at_ms: 4,
748        };
749        db.upsert_knowledge_item(&item).await.unwrap();
750
751        let coverage = KnowledgeCoverageRecord {
752            coverage_key: item.coverage_key.clone(),
753            space_id: space.id.clone(),
754            latest_item_id: Some(item.id.clone()),
755            latest_dedupe_key: Some(item.dedupe_key.clone()),
756            last_seen_at_ms: 5,
757            last_promoted_at_ms: Some(6),
758            freshness_expires_at_ms: Some(10),
759            metadata: Some(serde_json::json!({"coverage": true})),
760        };
761        db.upsert_knowledge_coverage(&coverage).await.unwrap();
762
763        let loaded_space = db.get_knowledge_space(&space.id).await.unwrap().unwrap();
764        assert_eq!(loaded_space.namespace.as_deref(), Some("support"));
765
766        let loaded_items = db
767            .list_knowledge_items(&space.id, Some(&item.coverage_key))
768            .await
769            .unwrap();
770        assert_eq!(loaded_items.len(), 1);
771        assert_eq!(loaded_items[0].title, item.title);
772
773        let loaded_coverage = db
774            .get_knowledge_coverage(&item.coverage_key, &space.id)
775            .await
776            .unwrap()
777            .unwrap();
778        assert_eq!(loaded_coverage.latest_item_id.as_deref(), Some("item-1"));
779    }
780
781    #[tokio::test]
782    async fn test_knowledge_registry_is_tenant_scoped() {
783        let (db, _temp) = setup_test_db().await;
784        let tenant_a = tenant_scope("org-a", "workspace-a");
785        let tenant_b = tenant_scope("org-b", "workspace-b");
786
787        let mut space_a = KnowledgeSpaceRecord {
788            id: "tenant-a-space".to_string(),
789            scope: tandem_orchestrator::KnowledgeScope::Project,
790            project_id: Some("shared-project".to_string()),
791            namespace: Some("shared-namespace".to_string()),
792            title: Some("Tenant A Knowledge".to_string()),
793            description: None,
794            trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
795            metadata: None,
796            created_at_ms: 1,
797            updated_at_ms: 2,
798        };
799        let mut space_b = space_a.clone();
800        space_b.id = "tenant-b-space".to_string();
801        space_b.title = Some("Tenant B Knowledge".to_string());
802
803        db.upsert_knowledge_space_for_tenant(&space_a, &tenant_a)
804            .await
805            .unwrap();
806        db.upsert_knowledge_space_for_tenant(&space_b, &tenant_b)
807            .await
808            .unwrap();
809
810        let spaces_a = db
811            .list_knowledge_spaces_for_tenant(Some("shared-project"), &tenant_a)
812            .await
813            .unwrap();
814        let spaces_b = db
815            .list_knowledge_spaces_for_tenant(Some("shared-project"), &tenant_b)
816            .await
817            .unwrap();
818        assert_eq!(spaces_a.len(), 1);
819        assert_eq!(spaces_a[0].id, "tenant-a-space");
820        assert_eq!(spaces_b.len(), 1);
821        assert_eq!(spaces_b[0].id, "tenant-b-space");
822        assert!(db
823            .get_knowledge_space_for_tenant("tenant-b-space", &tenant_a)
824            .await
825            .unwrap()
826            .is_none());
827
828        let item_b = KnowledgeItemRecord {
829            id: "tenant-b-item".to_string(),
830            space_id: space_b.id.clone(),
831            coverage_key: "shared-project/topic/debugging".to_string(),
832            dedupe_key: "shared-dedupe".to_string(),
833            item_type: "decision".to_string(),
834            title: "Tenant B item".to_string(),
835            summary: None,
836            payload: serde_json::json!({"tenant": "b"}),
837            trust_level: tandem_orchestrator::KnowledgeTrustLevel::Working,
838            status: KnowledgeItemStatus::Working,
839            run_id: Some("run-b".to_string()),
840            artifact_refs: Vec::new(),
841            source_memory_ids: Vec::new(),
842            freshness_expires_at_ms: None,
843            metadata: None,
844            created_at_ms: 3,
845            updated_at_ms: 4,
846        };
847        db.upsert_knowledge_item_for_tenant(&item_b, &tenant_b)
848            .await
849            .unwrap();
850        assert!(db
851            .upsert_knowledge_item_for_tenant(&item_b, &tenant_a)
852            .await
853            .is_err());
854        assert!(db
855            .get_knowledge_item_for_tenant("tenant-b-item", &tenant_a)
856            .await
857            .unwrap()
858            .is_none());
859        assert!(db
860            .list_knowledge_items_for_tenant(&space_b.id, None, &tenant_a)
861            .await
862            .unwrap()
863            .is_empty());
864        assert_eq!(
865            db.list_knowledge_items_for_tenant(&space_b.id, None, &tenant_b)
866                .await
867                .unwrap()
868                .len(),
869            1
870        );
871
872        let coverage_b = KnowledgeCoverageRecord {
873            coverage_key: item_b.coverage_key.clone(),
874            space_id: space_b.id.clone(),
875            latest_item_id: Some(item_b.id.clone()),
876            latest_dedupe_key: Some(item_b.dedupe_key.clone()),
877            last_seen_at_ms: 5,
878            last_promoted_at_ms: None,
879            freshness_expires_at_ms: None,
880            metadata: None,
881        };
882        db.upsert_knowledge_coverage_for_tenant(&coverage_b, &tenant_b)
883            .await
884            .unwrap();
885        assert!(db
886            .upsert_knowledge_coverage_for_tenant(&coverage_b, &tenant_a)
887            .await
888            .is_err());
889        assert!(db
890            .get_knowledge_coverage_for_tenant(&coverage_b.coverage_key, &space_b.id, &tenant_a)
891            .await
892            .unwrap()
893            .is_none());
894        assert!(db
895            .get_knowledge_coverage_for_tenant(&coverage_b.coverage_key, &space_b.id, &tenant_b)
896            .await
897            .unwrap()
898            .is_some());
899
900        let promote = KnowledgePromotionRequest {
901            item_id: item_b.id.clone(),
902            target_status: KnowledgeItemStatus::Promoted,
903            promoted_at_ms: 10,
904            freshness_expires_at_ms: None,
905            reviewer_id: None,
906            approval_id: None,
907            reason: None,
908        };
909        assert!(db
910            .promote_knowledge_item_for_tenant(&promote, &tenant_a)
911            .await
912            .unwrap()
913            .is_none());
914        assert!(db
915            .promote_knowledge_item_for_tenant(&promote, &tenant_b)
916            .await
917            .unwrap()
918            .is_some());
919
920        space_a.updated_at_ms = 11;
921        db.upsert_knowledge_space_for_tenant(&space_a, &tenant_a)
922            .await
923            .unwrap();
924    }
925
926    #[tokio::test]
927    async fn test_store_and_retrieve_chunk() {
928        let (db, _temp) = setup_test_db().await;
929
930        let chunk = MemoryChunk {
931            id: "test-1".to_string(),
932            content: "Test content".to_string(),
933            tier: MemoryTier::Session,
934            session_id: Some("session-1".to_string()),
935            project_id: Some("project-1".to_string()),
936            source: "user_message".to_string(),
937            source_path: None,
938            source_mtime: None,
939            source_size: None,
940            source_hash: None,
941            tenant_scope: MemoryTenantScope::local(),
942            created_at: Utc::now(),
943            token_count: 10,
944            metadata: None,
945        };
946
947        let embedding = vec![0.1f32; DEFAULT_EMBEDDING_DIMENSION];
948        db.store_chunk(&chunk, &embedding).await.unwrap();
949
950        let chunks = db.get_session_chunks("session-1").await.unwrap();
951        assert_eq!(chunks.len(), 1);
952        assert_eq!(chunks[0].content, "Test content");
953    }
954
955    #[tokio::test]
956    async fn test_store_and_retrieve_global_chunk() {
957        let (db, _temp) = setup_test_db().await;
958
959        let chunk = MemoryChunk {
960            id: "global-1".to_string(),
961            content: "Global note".to_string(),
962            tier: MemoryTier::Global,
963            session_id: None,
964            project_id: None,
965            source: "agent_note".to_string(),
966            source_path: None,
967            source_mtime: None,
968            source_size: None,
969            source_hash: None,
970            tenant_scope: MemoryTenantScope::local(),
971            created_at: Utc::now(),
972            token_count: 7,
973            metadata: Some(serde_json::json!({"kind":"test"})),
974        };
975
976        let embedding = vec![0.2f32; DEFAULT_EMBEDDING_DIMENSION];
977        db.store_chunk(&chunk, &embedding).await.unwrap();
978
979        let chunks = db.get_global_chunks(10).await.unwrap();
980        assert_eq!(chunks.len(), 1);
981        assert_eq!(chunks[0].content, "Global note");
982        assert_eq!(chunks[0].source, "agent_note");
983        assert_eq!(chunks[0].token_count, 7);
984        assert_eq!(chunks[0].tier, MemoryTier::Global);
985    }
986
987    #[tokio::test]
988    async fn test_global_chunk_exists_by_source_hash() {
989        let (db, _temp) = setup_test_db().await;
990
991        let chunk = MemoryChunk {
992            id: "global-hash".to_string(),
993            content: "Global hash note".to_string(),
994            tier: MemoryTier::Global,
995            session_id: None,
996            project_id: None,
997            source: "chat_exchange".to_string(),
998            source_path: None,
999            source_mtime: None,
1000            source_size: None,
1001            source_hash: Some("hash-123".to_string()),
1002            tenant_scope: MemoryTenantScope::local(),
1003            created_at: Utc::now(),
1004            token_count: 5,
1005            metadata: None,
1006        };
1007
1008        let embedding = vec![0.3f32; DEFAULT_EMBEDDING_DIMENSION];
1009        db.store_chunk(&chunk, &embedding).await.unwrap();
1010
1011        assert!(db
1012            .global_chunk_exists_by_source_hash("hash-123")
1013            .await
1014            .unwrap());
1015        assert!(!db
1016            .global_chunk_exists_by_source_hash("missing-hash")
1017            .await
1018            .unwrap());
1019    }
1020
1021    #[tokio::test]
1022    async fn test_vector_search_is_tenant_partitioned_before_top_k() {
1023        let (db, _temp) = setup_test_db().await;
1024        let tenant_a = tenant_scope("org-a", "workspace-a");
1025        let tenant_b = tenant_scope("org-b", "workspace-b");
1026        let query = embedding(1.0, 0.0);
1027
1028        db.store_chunk(
1029            &test_vector_chunk(
1030                "tenant-a-vector",
1031                MemoryTier::Project,
1032                tenant_a.clone(),
1033                "tenant a memory",
1034                None,
1035            ),
1036            &embedding(0.8, 0.2),
1037        )
1038        .await
1039        .unwrap();
1040        db.store_chunk(
1041            &test_vector_chunk(
1042                "tenant-b-vector",
1043                MemoryTier::Project,
1044                tenant_b.clone(),
1045                "tenant b closer memory",
1046                None,
1047            ),
1048            &query,
1049        )
1050        .await
1051        .unwrap();
1052
1053        let results = db
1054            .search_similar_for_tenant(
1055                &query,
1056                MemoryTier::Project,
1057                Some("shared-project"),
1058                None,
1059                &tenant_a,
1060                1,
1061            )
1062            .await
1063            .unwrap();
1064
1065        assert_eq!(results.len(), 1);
1066        assert_eq!(results[0].0.id, "tenant-a-vector");
1067        assert_eq!(results[0].0.tenant_scope, tenant_a);
1068    }
1069
1070    #[tokio::test]
1071    async fn test_identical_vector_content_only_returns_request_tenant() {
1072        let (db, _temp) = setup_test_db().await;
1073        let tenant_a = tenant_scope("org-a", "workspace-a");
1074        let tenant_b = tenant_scope("org-b", "workspace-b");
1075        let vector = embedding(0.4, 0.6);
1076
1077        db.store_chunk(
1078            &test_vector_chunk(
1079                "tenant-a-identical",
1080                MemoryTier::Global,
1081                tenant_a.clone(),
1082                "identical memory body",
1083                Some("same-source-hash"),
1084            ),
1085            &vector,
1086        )
1087        .await
1088        .unwrap();
1089        db.store_chunk(
1090            &test_vector_chunk(
1091                "tenant-b-identical",
1092                MemoryTier::Global,
1093                tenant_b,
1094                "identical memory body",
1095                Some("same-source-hash"),
1096            ),
1097            &vector,
1098        )
1099        .await
1100        .unwrap();
1101
1102        let results = db
1103            .search_similar_for_tenant(&vector, MemoryTier::Global, None, None, &tenant_a, 10)
1104            .await
1105            .unwrap();
1106
1107        assert_eq!(results.len(), 1);
1108        assert_eq!(results[0].0.id, "tenant-a-identical");
1109    }
1110
1111    #[tokio::test]
1112    async fn test_tenant_delete_does_not_remove_other_tenant_vector_memory() {
1113        let (db, _temp) = setup_test_db().await;
1114        let tenant_a = tenant_scope("org-a", "workspace-a");
1115        let tenant_b = tenant_scope("org-b", "workspace-b");
1116        let vector = embedding(0.2, 0.8);
1117
1118        db.store_chunk(
1119            &test_vector_chunk(
1120                "tenant-a-delete",
1121                MemoryTier::Global,
1122                tenant_a.clone(),
1123                "tenant a delete target",
1124                None,
1125            ),
1126            &vector,
1127        )
1128        .await
1129        .unwrap();
1130        db.store_chunk(
1131            &test_vector_chunk(
1132                "tenant-b-delete",
1133                MemoryTier::Global,
1134                tenant_b.clone(),
1135                "tenant b delete target",
1136                None,
1137            ),
1138            &vector,
1139        )
1140        .await
1141        .unwrap();
1142
1143        let cross_delete = db
1144            .delete_chunk_for_tenant(MemoryTier::Global, "tenant-b-delete", None, None, &tenant_a)
1145            .await
1146            .unwrap();
1147        assert_eq!(cross_delete, 0);
1148
1149        let tenant_b_results = db
1150            .search_similar_for_tenant(&vector, MemoryTier::Global, None, None, &tenant_b, 10)
1151            .await
1152            .unwrap();
1153        assert_eq!(tenant_b_results.len(), 1);
1154        assert_eq!(tenant_b_results[0].0.id, "tenant-b-delete");
1155
1156        let own_delete = db
1157            .delete_chunk_for_tenant(MemoryTier::Global, "tenant-a-delete", None, None, &tenant_a)
1158            .await
1159            .unwrap();
1160        assert_eq!(own_delete, 1);
1161        assert_eq!(
1162            db.search_similar_for_tenant(&vector, MemoryTier::Global, None, None, &tenant_b, 10)
1163                .await
1164                .unwrap()
1165                .len(),
1166            1
1167        );
1168    }
1169
1170    #[tokio::test]
1171    async fn test_same_source_hash_does_not_dedupe_across_tenants() {
1172        let (db, _temp) = setup_test_db().await;
1173        let tenant_a = tenant_scope("org-a", "workspace-a");
1174        let tenant_b = tenant_scope("org-b", "workspace-b");
1175        let source_hash = "shared-source-hash";
1176
1177        db.store_chunk(
1178            &test_vector_chunk(
1179                "tenant-a-hash",
1180                MemoryTier::Global,
1181                tenant_a.clone(),
1182                "same source hash",
1183                Some(source_hash),
1184            ),
1185            &embedding(0.7, 0.1),
1186        )
1187        .await
1188        .unwrap();
1189        db.store_chunk(
1190            &test_vector_chunk(
1191                "tenant-b-hash",
1192                MemoryTier::Global,
1193                tenant_b.clone(),
1194                "same source hash",
1195                Some(source_hash),
1196            ),
1197            &embedding(0.7, 0.1),
1198        )
1199        .await
1200        .unwrap();
1201
1202        assert!(db
1203            .global_chunk_exists_by_source_hash_for_tenant(source_hash, &tenant_a)
1204            .await
1205            .unwrap());
1206        assert!(db
1207            .global_chunk_exists_by_source_hash_for_tenant(source_hash, &tenant_b)
1208            .await
1209            .unwrap());
1210
1211        let tenant_a_chunks = db
1212            .get_global_chunks_for_tenant(&tenant_a, 10)
1213            .await
1214            .unwrap();
1215        let tenant_b_chunks = db
1216            .get_global_chunks_for_tenant(&tenant_b, 10)
1217            .await
1218            .unwrap();
1219        assert_eq!(tenant_a_chunks.len(), 1);
1220        assert_eq!(tenant_b_chunks.len(), 1);
1221        assert_ne!(tenant_a_chunks[0].id, tenant_b_chunks[0].id);
1222    }
1223
1224    #[tokio::test]
1225    async fn test_memory_stats_are_tenant_scoped() {
1226        let (db, _temp) = setup_test_db().await;
1227        let tenant_a = tenant_scope("org-a", "workspace-a");
1228        let tenant_b = tenant_scope("org-b", "workspace-b");
1229
1230        db.store_chunk(
1231            &test_vector_chunk(
1232                "tenant-a-session-stat",
1233                MemoryTier::Session,
1234                tenant_a.clone(),
1235                "tenant a session stats",
1236                None,
1237            ),
1238            &embedding(0.1, 0.2),
1239        )
1240        .await
1241        .unwrap();
1242        db.store_chunk(
1243            &test_vector_chunk(
1244                "tenant-a-project-stat",
1245                MemoryTier::Project,
1246                tenant_a.clone(),
1247                "tenant a project stats",
1248                None,
1249            ),
1250            &embedding(0.2, 0.3),
1251        )
1252        .await
1253        .unwrap();
1254        db.store_chunk(
1255            &test_vector_chunk(
1256                "tenant-a-global-stat",
1257                MemoryTier::Global,
1258                tenant_a.clone(),
1259                "tenant a global stats",
1260                None,
1261            ),
1262            &embedding(0.3, 0.4),
1263        )
1264        .await
1265        .unwrap();
1266        db.store_chunk(
1267            &test_vector_chunk(
1268                "tenant-b-project-stat",
1269                MemoryTier::Project,
1270                tenant_b.clone(),
1271                "tenant b project stats should not count",
1272                None,
1273            ),
1274            &embedding(0.4, 0.5),
1275        )
1276        .await
1277        .unwrap();
1278
1279        db.log_cleanup_for_tenant(
1280            "test",
1281            MemoryTier::Project,
1282            Some("shared-project"),
1283            None,
1284            1,
1285            10,
1286            &tenant_b,
1287        )
1288        .await
1289        .unwrap();
1290
1291        let tenant_a_stats = db.get_stats_for_tenant(&tenant_a).await.unwrap();
1292        assert_eq!(tenant_a_stats.session_chunks, 1);
1293        assert_eq!(tenant_a_stats.project_chunks, 1);
1294        assert_eq!(tenant_a_stats.global_chunks, 1);
1295        assert_eq!(tenant_a_stats.total_chunks, 3);
1296        assert!(tenant_a_stats.total_bytes > 0);
1297        assert!(tenant_a_stats.last_cleanup.is_none());
1298
1299        let tenant_b_stats = db.get_stats_for_tenant(&tenant_b).await.unwrap();
1300        assert_eq!(tenant_b_stats.session_chunks, 0);
1301        assert_eq!(tenant_b_stats.project_chunks, 1);
1302        assert_eq!(tenant_b_stats.global_chunks, 0);
1303        assert_eq!(tenant_b_stats.total_chunks, 1);
1304        assert!(tenant_b_stats.last_cleanup.is_some());
1305    }
1306
1307    #[tokio::test]
1308    async fn test_project_stats_are_tenant_scoped_for_vector_chunks() {
1309        let (db, _temp) = setup_test_db().await;
1310        let tenant_a = tenant_scope("org-a", "workspace-a");
1311        let tenant_b = tenant_scope("org-b", "workspace-b");
1312
1313        db.store_chunk(
1314            &test_vector_chunk(
1315                "tenant-a-project-stat-1",
1316                MemoryTier::Project,
1317                tenant_a.clone(),
1318                "tenant a project stat one",
1319                None,
1320            ),
1321            &embedding(0.5, 0.1),
1322        )
1323        .await
1324        .unwrap();
1325        let mut tenant_a_file = test_vector_chunk(
1326            "tenant-a-project-file-stat",
1327            MemoryTier::Project,
1328            tenant_a.clone(),
1329            "tenant a file stat",
1330            None,
1331        );
1332        tenant_a_file.source = "file".to_string();
1333        db.store_chunk(&tenant_a_file, &embedding(0.6, 0.1))
1334            .await
1335            .unwrap();
1336        db.store_chunk(
1337            &test_vector_chunk(
1338                "tenant-b-project-stat-1",
1339                MemoryTier::Project,
1340                tenant_b,
1341                "tenant b project stat",
1342                None,
1343            ),
1344            &embedding(0.7, 0.1),
1345        )
1346        .await
1347        .unwrap();
1348
1349        let stats = db
1350            .get_project_stats_for_tenant("shared-project", &tenant_a)
1351            .await
1352            .unwrap();
1353        assert_eq!(stats.project_chunks, 2);
1354        assert_eq!(stats.file_index_chunks, 1);
1355        assert!(stats.project_bytes > 0);
1356        assert!(stats.file_index_bytes > 0);
1357    }
1358
1359    #[tokio::test]
1360    async fn test_import_index_paths_are_tenant_scoped() {
1361        let (db, _temp) = setup_test_db().await;
1362        let tenant_a = tenant_scope("org-a", "workspace-a");
1363        let tenant_b = tenant_scope("org-b", "workspace-b");
1364
1365        db.upsert_import_index_entry_for_tenant(
1366            MemoryTier::Project,
1367            None,
1368            Some("shared-project"),
1369            "repo/README.md",
1370            10,
1371            100,
1372            "hash-a",
1373            &tenant_a,
1374        )
1375        .await
1376        .unwrap();
1377        db.upsert_import_index_entry_for_tenant(
1378            MemoryTier::Project,
1379            None,
1380            Some("shared-project"),
1381            "repo/README.md",
1382            20,
1383            200,
1384            "hash-b",
1385            &tenant_b,
1386        )
1387        .await
1388        .unwrap();
1389
1390        let tenant_a_paths = db
1391            .list_import_index_paths_for_tenant(
1392                MemoryTier::Project,
1393                None,
1394                Some("shared-project"),
1395                &tenant_a,
1396            )
1397            .await
1398            .unwrap();
1399        assert_eq!(tenant_a_paths, vec!["repo/README.md".to_string()]);
1400
1401        let tenant_a_entry = db
1402            .get_import_index_entry_for_tenant(
1403                MemoryTier::Project,
1404                None,
1405                Some("shared-project"),
1406                "repo/README.md",
1407                &tenant_a,
1408            )
1409            .await
1410            .unwrap()
1411            .unwrap();
1412        let tenant_b_entry = db
1413            .get_import_index_entry_for_tenant(
1414                MemoryTier::Project,
1415                None,
1416                Some("shared-project"),
1417                "repo/README.md",
1418                &tenant_b,
1419            )
1420            .await
1421            .unwrap()
1422            .unwrap();
1423        assert_eq!(tenant_a_entry.2, "hash-a");
1424        assert_eq!(tenant_b_entry.2, "hash-b");
1425    }
1426
1427    #[tokio::test]
1428    async fn test_delete_import_index_entry_is_tenant_scoped() {
1429        let (db, _temp) = setup_test_db().await;
1430        let tenant_a = tenant_scope("org-a", "workspace-a");
1431        let tenant_b = tenant_scope("org-b", "workspace-b");
1432
1433        for (tenant, hash) in [(&tenant_a, "hash-a"), (&tenant_b, "hash-b")] {
1434            db.upsert_import_index_entry_for_tenant(
1435                MemoryTier::Global,
1436                None,
1437                None,
1438                "shared/path.md",
1439                1,
1440                10,
1441                hash,
1442                tenant,
1443            )
1444            .await
1445            .unwrap();
1446        }
1447
1448        db.delete_import_index_entry_for_tenant(
1449            MemoryTier::Global,
1450            None,
1451            None,
1452            "shared/path.md",
1453            &tenant_a,
1454        )
1455        .await
1456        .unwrap();
1457
1458        assert!(db
1459            .get_import_index_entry_for_tenant(
1460                MemoryTier::Global,
1461                None,
1462                None,
1463                "shared/path.md",
1464                &tenant_a
1465            )
1466            .await
1467            .unwrap()
1468            .is_none());
1469        let tenant_b_entry = db
1470            .get_import_index_entry_for_tenant(
1471                MemoryTier::Global,
1472                None,
1473                None,
1474                "shared/path.md",
1475                &tenant_b,
1476            )
1477            .await
1478            .unwrap()
1479            .unwrap();
1480        assert_eq!(tenant_b_entry.2, "hash-b");
1481    }
1482
1483    #[tokio::test]
1484    async fn test_file_chunk_delete_by_path_is_tenant_scoped() {
1485        let (db, _temp) = setup_test_db().await;
1486        let tenant_a = tenant_scope("org-a", "workspace-a");
1487        let tenant_b = tenant_scope("org-b", "workspace-b");
1488
1489        let mut chunk_a = test_vector_chunk(
1490            "tenant-a-file-delete",
1491            MemoryTier::Project,
1492            tenant_a.clone(),
1493            "same file content",
1494            Some("same-hash"),
1495        );
1496        chunk_a.source = "file".to_string();
1497        chunk_a.source_path = Some("repo/file.md".to_string());
1498        let mut chunk_b = test_vector_chunk(
1499            "tenant-b-file-delete",
1500            MemoryTier::Project,
1501            tenant_b.clone(),
1502            "same file content",
1503            Some("same-hash"),
1504        );
1505        chunk_b.source = "file".to_string();
1506        chunk_b.source_path = Some("repo/file.md".to_string());
1507
1508        db.store_chunk(&chunk_a, &embedding(0.1, 0.2))
1509            .await
1510            .unwrap();
1511        db.store_chunk(&chunk_b, &embedding(0.1, 0.2))
1512            .await
1513            .unwrap();
1514
1515        let (deleted, _) = db
1516            .delete_file_chunks_by_path_for_tenant(
1517                MemoryTier::Project,
1518                None,
1519                Some("shared-project"),
1520                "repo/file.md",
1521                &tenant_a,
1522            )
1523            .await
1524            .unwrap();
1525        assert_eq!(deleted, 1);
1526
1527        assert!(db
1528            .get_project_chunks_for_tenant("shared-project", &tenant_a)
1529            .await
1530            .unwrap()
1531            .is_empty());
1532        let tenant_b_chunks = db
1533            .get_project_chunks_for_tenant("shared-project", &tenant_b)
1534            .await
1535            .unwrap();
1536        assert_eq!(tenant_b_chunks.len(), 1);
1537        assert_eq!(tenant_b_chunks[0].id, "tenant-b-file-delete");
1538    }
1539
1540    #[tokio::test]
1541    async fn test_project_file_index_clear_is_tenant_scoped() {
1542        let (db, _temp) = setup_test_db().await;
1543        let tenant_a = tenant_scope("org-a", "workspace-a");
1544        let tenant_b = tenant_scope("org-b", "workspace-b");
1545
1546        for (tenant, id, hash) in [
1547            (&tenant_a, "tenant-a-clear-file-index", "hash-a"),
1548            (&tenant_b, "tenant-b-clear-file-index", "hash-b"),
1549        ] {
1550            db.upsert_file_index_entry_for_tenant(
1551                "shared-project",
1552                "repo/file.md",
1553                1,
1554                10,
1555                hash,
1556                tenant,
1557            )
1558            .await
1559            .unwrap();
1560            db.upsert_project_index_status_for_tenant("shared-project", 5, 4, 3, 2, 1, tenant)
1561                .await
1562                .unwrap();
1563            let mut chunk = test_vector_chunk(
1564                id,
1565                MemoryTier::Project,
1566                tenant.clone(),
1567                "file index clear content",
1568                Some(hash),
1569            );
1570            chunk.source = "file".to_string();
1571            chunk.source_path = Some("repo/file.md".to_string());
1572            db.store_chunk(&chunk, &embedding(0.4, 0.5)).await.unwrap();
1573        }
1574
1575        let result = db
1576            .clear_project_file_index_for_tenant("shared-project", false, &tenant_a)
1577            .await
1578            .unwrap();
1579        assert_eq!(result.chunks_deleted, 1);
1580
1581        assert_eq!(
1582            db.project_file_index_count_for_tenant("shared-project", &tenant_a)
1583                .await
1584                .unwrap(),
1585            0
1586        );
1587        assert!(db
1588            .get_project_chunks_for_tenant("shared-project", &tenant_a)
1589            .await
1590            .unwrap()
1591            .is_empty());
1592
1593        assert_eq!(
1594            db.project_file_index_count_for_tenant("shared-project", &tenant_b)
1595                .await
1596                .unwrap(),
1597            1
1598        );
1599        assert_eq!(
1600            db.get_project_chunks_for_tenant("shared-project", &tenant_b)
1601                .await
1602                .unwrap()
1603                .len(),
1604            1
1605        );
1606        let tenant_b_stats = db
1607            .get_project_stats_for_tenant("shared-project", &tenant_b)
1608            .await
1609            .unwrap();
1610        assert_eq!(tenant_b_stats.last_indexed_files, Some(3));
1611    }
1612
1613    #[tokio::test]
1614    async fn test_project_stats_file_index_is_tenant_scoped() {
1615        let (db, _temp) = setup_test_db().await;
1616        let tenant_a = tenant_scope("org-a", "workspace-a");
1617        let tenant_b = tenant_scope("org-b", "workspace-b");
1618
1619        db.upsert_file_index_entry_for_tenant(
1620            "shared-project",
1621            "repo/a.md",
1622            1,
1623            10,
1624            "hash-a",
1625            &tenant_a,
1626        )
1627        .await
1628        .unwrap();
1629        db.upsert_project_index_status_for_tenant("shared-project", 10, 9, 8, 1, 0, &tenant_a)
1630            .await
1631            .unwrap();
1632        db.upsert_file_index_entry_for_tenant(
1633            "shared-project",
1634            "repo/b.md",
1635            2,
1636            20,
1637            "hash-b",
1638            &tenant_b,
1639        )
1640        .await
1641        .unwrap();
1642        db.upsert_project_index_status_for_tenant("shared-project", 3, 2, 1, 1, 0, &tenant_b)
1643            .await
1644            .unwrap();
1645
1646        let stats_a = db
1647            .get_project_stats_for_tenant("shared-project", &tenant_a)
1648            .await
1649            .unwrap();
1650        let stats_b = db
1651            .get_project_stats_for_tenant("shared-project", &tenant_b)
1652            .await
1653            .unwrap();
1654
1655        assert_eq!(stats_a.indexed_files, 1);
1656        assert_eq!(stats_a.last_total_files, Some(10));
1657        assert_eq!(stats_a.last_indexed_files, Some(8));
1658        assert_eq!(stats_b.indexed_files, 1);
1659        assert_eq!(stats_b.last_total_files, Some(3));
1660        assert_eq!(stats_b.last_indexed_files, Some(1));
1661    }
1662
1663    #[tokio::test]
1664    async fn test_clear_session_and_project_memory_are_tenant_scoped() {
1665        let (db, _temp) = setup_test_db().await;
1666        let tenant_a = tenant_scope("org-a", "workspace-a");
1667        let tenant_b = tenant_scope("org-b", "workspace-b");
1668
1669        db.store_chunk(
1670            &test_vector_chunk(
1671                "tenant-a-clear-session",
1672                MemoryTier::Session,
1673                tenant_a.clone(),
1674                "tenant a session clear target",
1675                None,
1676            ),
1677            &embedding(0.1, 0.9),
1678        )
1679        .await
1680        .unwrap();
1681        db.store_chunk(
1682            &test_vector_chunk(
1683                "tenant-b-clear-session",
1684                MemoryTier::Session,
1685                tenant_b.clone(),
1686                "tenant b session must remain",
1687                None,
1688            ),
1689            &embedding(0.1, 0.9),
1690        )
1691        .await
1692        .unwrap();
1693        db.store_chunk(
1694            &test_vector_chunk(
1695                "tenant-a-clear-project",
1696                MemoryTier::Project,
1697                tenant_a.clone(),
1698                "tenant a project clear target",
1699                None,
1700            ),
1701            &embedding(0.2, 0.8),
1702        )
1703        .await
1704        .unwrap();
1705        db.store_chunk(
1706            &test_vector_chunk(
1707                "tenant-b-clear-project",
1708                MemoryTier::Project,
1709                tenant_b.clone(),
1710                "tenant b project must remain",
1711                None,
1712            ),
1713            &embedding(0.2, 0.8),
1714        )
1715        .await
1716        .unwrap();
1717
1718        assert_eq!(
1719            db.clear_session_memory_for_tenant("shared-session", &tenant_a)
1720                .await
1721                .unwrap(),
1722            1
1723        );
1724        assert_eq!(
1725            db.clear_project_memory_for_tenant("shared-project", &tenant_a)
1726                .await
1727                .unwrap(),
1728            1
1729        );
1730
1731        let tenant_b_session = db
1732            .get_session_chunks_for_tenant("shared-session", &tenant_b)
1733            .await
1734            .unwrap();
1735        let tenant_b_project = db
1736            .get_project_chunks_for_tenant("shared-project", &tenant_b)
1737            .await
1738            .unwrap();
1739        assert_eq!(tenant_b_session.len(), 1);
1740        assert_eq!(tenant_b_project.len(), 1);
1741    }
1742
1743    #[tokio::test]
1744    async fn test_old_session_cleanup_is_tenant_scoped() {
1745        let (db, _temp) = setup_test_db().await;
1746        let tenant_a = tenant_scope("org-a", "workspace-a");
1747        let tenant_b = tenant_scope("org-b", "workspace-b");
1748        let old = Utc::now() - chrono::Duration::days(90);
1749
1750        let mut tenant_a_old = test_vector_chunk(
1751            "tenant-a-old-session",
1752            MemoryTier::Session,
1753            tenant_a.clone(),
1754            "tenant a old session",
1755            None,
1756        );
1757        tenant_a_old.created_at = old;
1758        db.store_chunk(&tenant_a_old, &embedding(0.3, 0.7))
1759            .await
1760            .unwrap();
1761
1762        let mut tenant_b_old = test_vector_chunk(
1763            "tenant-b-old-session",
1764            MemoryTier::Session,
1765            tenant_b.clone(),
1766            "tenant b old session",
1767            None,
1768        );
1769        tenant_b_old.created_at = old;
1770        db.store_chunk(&tenant_b_old, &embedding(0.3, 0.7))
1771            .await
1772            .unwrap();
1773
1774        assert_eq!(
1775            db.cleanup_old_sessions_for_tenant(30, &tenant_a)
1776                .await
1777                .unwrap(),
1778            1
1779        );
1780        assert!(db
1781            .get_session_chunks_for_tenant("shared-session", &tenant_a)
1782            .await
1783            .unwrap()
1784            .is_empty());
1785        assert_eq!(
1786            db.get_session_chunks_for_tenant("shared-session", &tenant_b)
1787                .await
1788                .unwrap()
1789                .len(),
1790            1
1791        );
1792    }
1793
1794    #[tokio::test]
1795    async fn test_config_crud() {
1796        let (db, _temp) = setup_test_db().await;
1797
1798        let config = db.get_or_create_config("project-1").await.unwrap();
1799        assert_eq!(config.max_chunks, 10000);
1800
1801        let new_config = MemoryConfig {
1802            max_chunks: 5000,
1803            ..Default::default()
1804        };
1805        db.update_config("project-1", &new_config).await.unwrap();
1806
1807        let updated = db.get_or_create_config("project-1").await.unwrap();
1808        assert_eq!(updated.max_chunks, 5000);
1809    }
1810
1811    #[tokio::test]
1812    async fn test_config_crud_is_tenant_scoped() {
1813        let (db, _temp) = setup_test_db().await;
1814        let tenant_a = tenant_scope("org-a", "workspace-a");
1815        let tenant_b = tenant_scope("org-b", "workspace-b");
1816
1817        let config_a = MemoryConfig {
1818            max_chunks: 111,
1819            session_retention_days: 7,
1820            ..Default::default()
1821        };
1822        let config_b = MemoryConfig {
1823            max_chunks: 222,
1824            session_retention_days: 14,
1825            ..Default::default()
1826        };
1827        db.update_config_for_tenant("shared-project", &config_a, &tenant_a)
1828            .await
1829            .unwrap();
1830        db.update_config_for_tenant("shared-project", &config_b, &tenant_b)
1831            .await
1832            .unwrap();
1833
1834        let loaded_a = db
1835            .get_or_create_config_for_tenant("shared-project", &tenant_a)
1836            .await
1837            .unwrap();
1838        let loaded_b = db
1839            .get_or_create_config_for_tenant("shared-project", &tenant_b)
1840            .await
1841            .unwrap();
1842
1843        assert_eq!(loaded_a.max_chunks, 111);
1844        assert_eq!(loaded_a.session_retention_days, 7);
1845        assert_eq!(loaded_b.max_chunks, 222);
1846        assert_eq!(loaded_b.session_retention_days, 14);
1847    }
1848
1849    #[tokio::test]
1850    async fn test_prune_old_session_chunks_is_tenant_scoped() {
1851        let (db, _temp) = setup_test_db().await;
1852        let tenant_a = tenant_scope("org-a", "workspace-a");
1853        let tenant_b = tenant_scope("org-b", "workspace-b");
1854        let old = Utc::now() - chrono::Duration::days(10);
1855
1856        let mut chunk_a = test_vector_chunk(
1857            "tenant-a-old-session-prune",
1858            MemoryTier::Session,
1859            tenant_a.clone(),
1860            "old tenant a session chunk",
1861            None,
1862        );
1863        chunk_a.created_at = old;
1864        let mut chunk_b = test_vector_chunk(
1865            "tenant-b-old-session-prune",
1866            MemoryTier::Session,
1867            tenant_b.clone(),
1868            "old tenant b session chunk",
1869            None,
1870        );
1871        chunk_b.created_at = old;
1872
1873        db.store_chunk(&chunk_a, &embedding(0.2, 0.8))
1874            .await
1875            .unwrap();
1876        db.store_chunk(&chunk_b, &embedding(0.2, 0.8))
1877            .await
1878            .unwrap();
1879
1880        let deleted = db
1881            .prune_old_session_chunks_for_tenant(1, &tenant_a)
1882            .await
1883            .unwrap();
1884        assert_eq!(deleted, 1);
1885        assert!(db
1886            .get_session_chunks_for_tenant("shared-session", &tenant_a)
1887            .await
1888            .unwrap()
1889            .is_empty());
1890        assert_eq!(
1891            db.get_session_chunks_for_tenant("shared-session", &tenant_b)
1892                .await
1893                .unwrap()
1894                .len(),
1895            1
1896        );
1897    }
1898
1899    #[tokio::test]
1900    async fn test_run_hygiene_reads_tenant_scoped_global_config() {
1901        let (db, _temp) = setup_test_db().await;
1902        let tenant_a = tenant_scope("org-a", "workspace-a");
1903        let tenant_b = tenant_scope("org-b", "workspace-b");
1904        let old = Utc::now() - chrono::Duration::days(10);
1905
1906        let config_a = MemoryConfig {
1907            session_retention_days: 1,
1908            ..Default::default()
1909        };
1910        let config_b = MemoryConfig {
1911            session_retention_days: 0,
1912            ..Default::default()
1913        };
1914        db.update_config_for_tenant("__global__", &config_a, &tenant_a)
1915            .await
1916            .unwrap();
1917        db.update_config_for_tenant("__global__", &config_b, &tenant_b)
1918            .await
1919            .unwrap();
1920
1921        let mut chunk_a = test_vector_chunk(
1922            "tenant-a-hygiene",
1923            MemoryTier::Session,
1924            tenant_a.clone(),
1925            "tenant a old hygiene chunk",
1926            None,
1927        );
1928        chunk_a.created_at = old;
1929        let mut chunk_b = test_vector_chunk(
1930            "tenant-b-hygiene",
1931            MemoryTier::Session,
1932            tenant_b.clone(),
1933            "tenant b old hygiene chunk",
1934            None,
1935        );
1936        chunk_b.created_at = old;
1937
1938        db.store_chunk(&chunk_a, &embedding(0.3, 0.7))
1939            .await
1940            .unwrap();
1941        db.store_chunk(&chunk_b, &embedding(0.3, 0.7))
1942            .await
1943            .unwrap();
1944
1945        let deleted = db.run_hygiene_for_tenant(0, &tenant_a).await.unwrap();
1946        assert_eq!(deleted, 1);
1947        assert!(db
1948            .get_session_chunks_for_tenant("shared-session", &tenant_a)
1949            .await
1950            .unwrap()
1951            .is_empty());
1952        assert_eq!(
1953            db.get_session_chunks_for_tenant("shared-session", &tenant_b)
1954                .await
1955                .unwrap()
1956                .len(),
1957            1
1958        );
1959    }
1960
1961    #[tokio::test]
1962    async fn test_global_memory_put_search_and_dedup() {
1963        let (db, _temp) = setup_test_db().await;
1964        let now = chrono::Utc::now().timestamp_millis() as u64;
1965        let record = GlobalMemoryRecord {
1966            id: "gm-1".to_string(),
1967            user_id: "user-a".to_string(),
1968            source_type: "user_message".to_string(),
1969            content: "remember rust workspace layout".to_string(),
1970            content_hash: "h1".to_string(),
1971            run_id: "run-1".to_string(),
1972            session_id: Some("s1".to_string()),
1973            message_id: Some("m1".to_string()),
1974            tool_name: None,
1975            project_tag: Some("proj-x".to_string()),
1976            channel_tag: None,
1977            host_tag: None,
1978            metadata: None,
1979            provenance: None,
1980            redaction_status: "passed".to_string(),
1981            redaction_count: 0,
1982            visibility: "private".to_string(),
1983            demoted: false,
1984            score_boost: 0.0,
1985            created_at_ms: now,
1986            updated_at_ms: now,
1987            expires_at_ms: None,
1988        };
1989        let first = db.put_global_memory_record(&record).await.unwrap();
1990        assert!(first.stored);
1991        let second = db.put_global_memory_record(&record).await.unwrap();
1992        assert!(second.deduped);
1993
1994        let hits = db
1995            .search_global_memory("user-a", "rust workspace", 5, Some("proj-x"), None, None)
1996            .await
1997            .unwrap();
1998        assert!(!hits.is_empty());
1999        assert_eq!(hits[0].record.id, "gm-1");
2000    }
2001
2002    #[tokio::test]
2003    async fn test_global_memory_tenant_filtered_fts_list_get_and_delete() {
2004        let (db, _temp) = setup_test_db().await;
2005        let now = chrono::Utc::now().timestamp_millis() as u64;
2006        let tenant_a = GlobalMemoryRecord {
2007            id: "gm-tenant-a".to_string(),
2008            user_id: "same-user".to_string(),
2009            source_type: "note".to_string(),
2010            content: "shared tenant phrase".to_string(),
2011            content_hash: "same-hash".to_string(),
2012            run_id: "same-run".to_string(),
2013            session_id: Some("same-session".to_string()),
2014            message_id: Some("same-message".to_string()),
2015            tool_name: None,
2016            project_tag: Some("same-project".to_string()),
2017            channel_tag: None,
2018            host_tag: None,
2019            metadata: None,
2020            provenance: Some(serde_json::json!({
2021                "tenant_context": {
2022                    "org_id": "org-a",
2023                    "workspace_id": "workspace-a",
2024                    "source": "explicit"
2025                }
2026            })),
2027            redaction_status: "passed".to_string(),
2028            redaction_count: 0,
2029            visibility: "private".to_string(),
2030            demoted: false,
2031            score_boost: 0.0,
2032            created_at_ms: now,
2033            updated_at_ms: now,
2034            expires_at_ms: None,
2035        };
2036        let mut tenant_b = tenant_a.clone();
2037        tenant_b.id = "gm-tenant-b".to_string();
2038        tenant_b.provenance = Some(serde_json::json!({
2039            "tenant_context": {
2040                "org_id": "org-b",
2041                "workspace_id": "workspace-b",
2042                "source": "explicit"
2043            }
2044        }));
2045
2046        assert!(db.put_global_memory_record(&tenant_a).await.unwrap().stored);
2047        assert!(db.put_global_memory_record(&tenant_b).await.unwrap().stored);
2048
2049        let hits_a = db
2050            .search_global_memory_for_tenant(
2051                "org-a",
2052                "workspace-a",
2053                None,
2054                "same-user",
2055                "shared tenant phrase",
2056                10,
2057                Some("same-project"),
2058                None,
2059                None,
2060            )
2061            .await
2062            .unwrap();
2063        assert_eq!(hits_a.len(), 1);
2064        assert_eq!(hits_a[0].record.id, "gm-tenant-a");
2065
2066        let rows_b = db
2067            .list_global_memory_for_tenant(
2068                "org-b",
2069                "workspace-b",
2070                None,
2071                "same-user",
2072                Some("shared tenant"),
2073                Some("same-project"),
2074                None,
2075                10,
2076                0,
2077            )
2078            .await
2079            .unwrap();
2080        assert_eq!(rows_b.len(), 1);
2081        assert_eq!(rows_b[0].id, "gm-tenant-b");
2082
2083        assert!(db
2084            .get_global_memory_for_tenant("gm-tenant-b", "org-a", "workspace-a", None)
2085            .await
2086            .unwrap()
2087            .is_none());
2088        assert!(!db
2089            .delete_global_memory_for_tenant("gm-tenant-b", "org-a", "workspace-a", None)
2090            .await
2091            .unwrap());
2092        assert!(db
2093            .delete_global_memory_for_tenant("gm-tenant-b", "org-b", "workspace-b", None)
2094            .await
2095            .unwrap());
2096    }
2097
2098    #[tokio::test]
2099    async fn test_knowledge_registry_round_trip() {
2100        let (db, _temp) = setup_test_db().await;
2101        let now = chrono::Utc::now().timestamp_millis() as u64;
2102
2103        let space = KnowledgeSpaceRecord {
2104            id: "space-1".to_string(),
2105            scope: KnowledgeScope::Project,
2106            project_id: Some("project-1".to_string()),
2107            namespace: Some("marketing/positioning".to_string()),
2108            title: Some("Marketing positioning".to_string()),
2109            description: Some("Reusable positioning guidance".to_string()),
2110            trust_level: KnowledgeTrustLevel::ApprovedDefault,
2111            metadata: Some(serde_json::json!({"owner":"marketing"})),
2112            created_at_ms: now,
2113            updated_at_ms: now,
2114        };
2115        db.upsert_knowledge_space(&space).await.unwrap();
2116
2117        let loaded_space = db.get_knowledge_space("space-1").await.unwrap().unwrap();
2118        assert_eq!(loaded_space.id, "space-1");
2119        assert_eq!(loaded_space.scope, KnowledgeScope::Project);
2120        assert_eq!(loaded_space.project_id.as_deref(), Some("project-1"));
2121        assert_eq!(
2122            loaded_space.namespace.as_deref(),
2123            Some("marketing/positioning")
2124        );
2125
2126        let item = KnowledgeItemRecord {
2127            id: "item-1".to_string(),
2128            space_id: "space-1".to_string(),
2129            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
2130            dedupe_key: "item-1-dedupe".to_string(),
2131            item_type: "evidence".to_string(),
2132            title: "Pricing sensitivity observation".to_string(),
2133            summary: Some("Customers reacted to annual pricing changes".to_string()),
2134            payload: serde_json::json!({"claim":"Annual pricing changes created friction"}),
2135            trust_level: KnowledgeTrustLevel::Promoted,
2136            status: KnowledgeItemStatus::Promoted,
2137            run_id: Some("run-1".to_string()),
2138            artifact_refs: vec!["artifact://run-1/research-sources".to_string()],
2139            source_memory_ids: vec!["memory-1".to_string()],
2140            freshness_expires_at_ms: Some(now + 86_400_000),
2141            metadata: Some(serde_json::json!({"source_kind":"web"})),
2142            created_at_ms: now,
2143            updated_at_ms: now,
2144        };
2145        db.upsert_knowledge_item(&item).await.unwrap();
2146
2147        let loaded_item = db.get_knowledge_item("item-1").await.unwrap().unwrap();
2148        assert_eq!(loaded_item.id, "item-1");
2149        assert_eq!(loaded_item.space_id, "space-1");
2150        assert_eq!(
2151            loaded_item.coverage_key,
2152            "project-1::marketing/positioning::strategy::pricing"
2153        );
2154        assert_eq!(loaded_item.status, KnowledgeItemStatus::Promoted);
2155        assert_eq!(
2156            loaded_item.artifact_refs,
2157            vec!["artifact://run-1/research-sources".to_string()]
2158        );
2159
2160        let by_space = db.list_knowledge_items("space-1", None).await.unwrap();
2161        assert_eq!(by_space.len(), 1);
2162        let by_coverage = db
2163            .list_knowledge_items(
2164                "space-1",
2165                Some("project-1::marketing/positioning::strategy::pricing"),
2166            )
2167            .await
2168            .unwrap();
2169        assert_eq!(by_coverage.len(), 1);
2170
2171        let coverage = KnowledgeCoverageRecord {
2172            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
2173            space_id: "space-1".to_string(),
2174            latest_item_id: Some("item-1".to_string()),
2175            latest_dedupe_key: Some("item-1-dedupe".to_string()),
2176            last_seen_at_ms: now,
2177            last_promoted_at_ms: Some(now),
2178            freshness_expires_at_ms: Some(now + 86_400_000),
2179            metadata: Some(serde_json::json!({"reuse_reason":"same topic"})),
2180        };
2181        db.upsert_knowledge_coverage(&coverage).await.unwrap();
2182
2183        let loaded_coverage = db
2184            .get_knowledge_coverage(
2185                "project-1::marketing/positioning::strategy::pricing",
2186                "space-1",
2187            )
2188            .await
2189            .unwrap()
2190            .unwrap();
2191        assert_eq!(loaded_coverage.space_id, "space-1");
2192        assert_eq!(loaded_coverage.latest_item_id.as_deref(), Some("item-1"));
2193        assert_eq!(
2194            loaded_coverage.latest_dedupe_key.as_deref(),
2195            Some("item-1-dedupe")
2196        );
2197    }
2198
2199    #[tokio::test]
2200    async fn test_knowledge_promotion_working_to_promoted_updates_coverage() {
2201        let (db, _temp) = setup_test_db().await;
2202        let now = chrono::Utc::now().timestamp_millis() as u64;
2203
2204        let space = KnowledgeSpaceRecord {
2205            id: "space-promote-1".to_string(),
2206            scope: KnowledgeScope::Project,
2207            project_id: Some("project-1".to_string()),
2208            namespace: Some("engineering/debugging".to_string()),
2209            title: Some("Engineering debugging".to_string()),
2210            description: Some("Reusable debugging guidance".to_string()),
2211            trust_level: KnowledgeTrustLevel::Promoted,
2212            metadata: None,
2213            created_at_ms: now,
2214            updated_at_ms: now,
2215        };
2216        db.upsert_knowledge_space(&space).await.unwrap();
2217
2218        let item = KnowledgeItemRecord {
2219            id: "item-promote-1".to_string(),
2220            space_id: space.id.clone(),
2221            coverage_key: "project-1::engineering/debugging::startup::race".to_string(),
2222            dedupe_key: "dedupe-promote-1".to_string(),
2223            item_type: "decision".to_string(),
2224            title: "Delay startup-dependent retries".to_string(),
2225            summary: Some("Retry only after startup completed.".to_string()),
2226            payload: serde_json::json!({"action":"delay_retry"}),
2227            trust_level: KnowledgeTrustLevel::Working,
2228            status: KnowledgeItemStatus::Working,
2229            run_id: Some("run-1".to_string()),
2230            artifact_refs: vec!["artifact://run-1/debug".to_string()],
2231            source_memory_ids: vec!["memory-1".to_string()],
2232            freshness_expires_at_ms: None,
2233            metadata: None,
2234            created_at_ms: now,
2235            updated_at_ms: now,
2236        };
2237        db.upsert_knowledge_item(&item).await.unwrap();
2238
2239        let promote = KnowledgePromotionRequest {
2240            item_id: item.id.clone(),
2241            target_status: KnowledgeItemStatus::Promoted,
2242            promoted_at_ms: now + 10,
2243            freshness_expires_at_ms: Some(now + 86_400_000),
2244            reviewer_id: None,
2245            approval_id: None,
2246            reason: Some("validated in workflow".to_string()),
2247        };
2248
2249        let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
2250        assert!(result.promoted);
2251        assert_eq!(result.item.status, KnowledgeItemStatus::Promoted);
2252        assert_eq!(result.item.trust_level, KnowledgeTrustLevel::Promoted);
2253        assert_eq!(
2254            result.coverage.latest_item_id.as_deref(),
2255            Some("item-promote-1")
2256        );
2257        assert_eq!(
2258            result.coverage.latest_dedupe_key.as_deref(),
2259            Some("dedupe-promote-1")
2260        );
2261        assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 10));
2262    }
2263
2264    #[tokio::test]
2265    async fn test_knowledge_promotion_promoted_to_approved_default_requires_review() {
2266        let (db, _temp) = setup_test_db().await;
2267        let now = chrono::Utc::now().timestamp_millis() as u64;
2268
2269        let space = KnowledgeSpaceRecord {
2270            id: "space-promote-2".to_string(),
2271            scope: KnowledgeScope::Project,
2272            project_id: Some("project-1".to_string()),
2273            namespace: Some("marketing/positioning".to_string()),
2274            title: Some("Marketing positioning".to_string()),
2275            description: Some("Reusable positioning guidance".to_string()),
2276            trust_level: KnowledgeTrustLevel::Promoted,
2277            metadata: None,
2278            created_at_ms: now,
2279            updated_at_ms: now,
2280        };
2281        db.upsert_knowledge_space(&space).await.unwrap();
2282
2283        let item = KnowledgeItemRecord {
2284            id: "item-promote-2".to_string(),
2285            space_id: space.id.clone(),
2286            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
2287            dedupe_key: "dedupe-promote-2".to_string(),
2288            item_type: "evidence".to_string(),
2289            title: "Pricing observation".to_string(),
2290            summary: Some("Annual pricing changes created friction".to_string()),
2291            payload: serde_json::json!({"claim":"pricing friction"}),
2292            trust_level: KnowledgeTrustLevel::Promoted,
2293            status: KnowledgeItemStatus::Promoted,
2294            run_id: Some("run-2".to_string()),
2295            artifact_refs: vec!["artifact://run-2/research".to_string()],
2296            source_memory_ids: vec!["memory-2".to_string()],
2297            freshness_expires_at_ms: None,
2298            metadata: None,
2299            created_at_ms: now,
2300            updated_at_ms: now,
2301        };
2302        db.upsert_knowledge_item(&item).await.unwrap();
2303
2304        let promote = KnowledgePromotionRequest {
2305            item_id: item.id.clone(),
2306            target_status: KnowledgeItemStatus::ApprovedDefault,
2307            promoted_at_ms: now + 5,
2308            freshness_expires_at_ms: None,
2309            reviewer_id: None,
2310            approval_id: None,
2311            reason: Some("should require review".to_string()),
2312        };
2313
2314        let err = db.promote_knowledge_item(&promote).await.unwrap_err();
2315        match err {
2316            MemoryError::InvalidConfig(_) => {}
2317            other => panic!("unexpected error: {}", other),
2318        }
2319    }
2320
2321    #[tokio::test]
2322    async fn test_knowledge_promotion_promoted_to_approved_default_updates_coverage() {
2323        let (db, _temp) = setup_test_db().await;
2324        let now = chrono::Utc::now().timestamp_millis() as u64;
2325
2326        let space = KnowledgeSpaceRecord {
2327            id: "space-promote-3".to_string(),
2328            scope: KnowledgeScope::Project,
2329            project_id: Some("project-1".to_string()),
2330            namespace: Some("support/runbooks".to_string()),
2331            title: Some("Support runbooks".to_string()),
2332            description: Some("Reusable runbook guidance".to_string()),
2333            trust_level: KnowledgeTrustLevel::Promoted,
2334            metadata: None,
2335            created_at_ms: now,
2336            updated_at_ms: now,
2337        };
2338        db.upsert_knowledge_space(&space).await.unwrap();
2339
2340        let item = KnowledgeItemRecord {
2341            id: "item-promote-3".to_string(),
2342            space_id: space.id.clone(),
2343            coverage_key: "project-1::support/runbooks::oncall::restart".to_string(),
2344            dedupe_key: "dedupe-promote-3".to_string(),
2345            item_type: "runbook".to_string(),
2346            title: "Restart service and verify".to_string(),
2347            summary: Some("Restart then validate health endpoint.".to_string()),
2348            payload: serde_json::json!({"steps":["restart","healthcheck"]}),
2349            trust_level: KnowledgeTrustLevel::Promoted,
2350            status: KnowledgeItemStatus::Promoted,
2351            run_id: Some("run-3".to_string()),
2352            artifact_refs: vec!["artifact://run-3/runbook".to_string()],
2353            source_memory_ids: vec!["memory-3".to_string()],
2354            freshness_expires_at_ms: None,
2355            metadata: None,
2356            created_at_ms: now,
2357            updated_at_ms: now,
2358        };
2359        db.upsert_knowledge_item(&item).await.unwrap();
2360
2361        let promote = KnowledgePromotionRequest {
2362            item_id: item.id.clone(),
2363            target_status: KnowledgeItemStatus::ApprovedDefault,
2364            promoted_at_ms: now + 12,
2365            freshness_expires_at_ms: Some(now + 172_800_000),
2366            reviewer_id: Some("reviewer-1".to_string()),
2367            approval_id: Some("approval-1".to_string()),
2368            reason: Some("reviewed by ops".to_string()),
2369        };
2370
2371        let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
2372        assert!(result.promoted);
2373        assert_eq!(result.item.status, KnowledgeItemStatus::ApprovedDefault);
2374        assert_eq!(
2375            result.item.trust_level,
2376            KnowledgeTrustLevel::ApprovedDefault
2377        );
2378        assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 12));
2379        assert_eq!(
2380            result.coverage.latest_item_id.as_deref(),
2381            Some("item-promote-3")
2382        );
2383    }
2384
2385    #[tokio::test]
2386    async fn test_knowledge_promotion_rejects_deprecated() {
2387        let (db, _temp) = setup_test_db().await;
2388        let now = chrono::Utc::now().timestamp_millis() as u64;
2389
2390        let space = KnowledgeSpaceRecord {
2391            id: "space-promote-4".to_string(),
2392            scope: KnowledgeScope::Project,
2393            project_id: Some("project-1".to_string()),
2394            namespace: Some("ops".to_string()),
2395            title: Some("Ops knowledge".to_string()),
2396            description: Some("Reusable ops knowledge".to_string()),
2397            trust_level: KnowledgeTrustLevel::Promoted,
2398            metadata: None,
2399            created_at_ms: now,
2400            updated_at_ms: now,
2401        };
2402        db.upsert_knowledge_space(&space).await.unwrap();
2403
2404        let item = KnowledgeItemRecord {
2405            id: "item-promote-4".to_string(),
2406            space_id: space.id.clone(),
2407            coverage_key: "project-1::ops::incident::latency".to_string(),
2408            dedupe_key: "dedupe-promote-4".to_string(),
2409            item_type: "decision".to_string(),
2410            title: "Ignore deprecated item".to_string(),
2411            summary: None,
2412            payload: serde_json::json!({"decision":"deprecated"}),
2413            trust_level: KnowledgeTrustLevel::Promoted,
2414            status: KnowledgeItemStatus::Deprecated,
2415            run_id: Some("run-4".to_string()),
2416            artifact_refs: vec![],
2417            source_memory_ids: vec![],
2418            freshness_expires_at_ms: None,
2419            metadata: None,
2420            created_at_ms: now,
2421            updated_at_ms: now,
2422        };
2423        db.upsert_knowledge_item(&item).await.unwrap();
2424
2425        let promote = KnowledgePromotionRequest {
2426            item_id: item.id.clone(),
2427            target_status: KnowledgeItemStatus::Promoted,
2428            promoted_at_ms: now + 1,
2429            freshness_expires_at_ms: None,
2430            reviewer_id: None,
2431            approval_id: None,
2432            reason: None,
2433        };
2434
2435        let err = db.promote_knowledge_item(&promote).await.unwrap_err();
2436        match err {
2437            MemoryError::InvalidConfig(_) => {}
2438            other => panic!("unexpected error: {}", other),
2439        }
2440    }
2441
2442    #[tokio::test]
2443    async fn test_knowledge_promotion_idempotent_updates_coverage() {
2444        let (db, _temp) = setup_test_db().await;
2445        let now = chrono::Utc::now().timestamp_millis() as u64;
2446
2447        let space = KnowledgeSpaceRecord {
2448            id: "space-promote-5".to_string(),
2449            scope: KnowledgeScope::Project,
2450            project_id: Some("project-1".to_string()),
2451            namespace: Some("engineering/ops".to_string()),
2452            title: Some("Engineering ops".to_string()),
2453            description: None,
2454            trust_level: KnowledgeTrustLevel::Promoted,
2455            metadata: None,
2456            created_at_ms: now,
2457            updated_at_ms: now,
2458        };
2459        db.upsert_knowledge_space(&space).await.unwrap();
2460
2461        let item = KnowledgeItemRecord {
2462            id: "item-promote-5".to_string(),
2463            space_id: space.id.clone(),
2464            coverage_key: "project-1::engineering/ops::deploy::guardrails".to_string(),
2465            dedupe_key: "dedupe-promote-5".to_string(),
2466            item_type: "pattern".to_string(),
2467            title: "Deploy guardrails".to_string(),
2468            summary: None,
2469            payload: serde_json::json!({"pattern":"guardrails"}),
2470            trust_level: KnowledgeTrustLevel::Promoted,
2471            status: KnowledgeItemStatus::Promoted,
2472            run_id: Some("run-5".to_string()),
2473            artifact_refs: vec![],
2474            source_memory_ids: vec![],
2475            freshness_expires_at_ms: None,
2476            metadata: None,
2477            created_at_ms: now,
2478            updated_at_ms: now,
2479        };
2480        db.upsert_knowledge_item(&item).await.unwrap();
2481
2482        let promote = KnowledgePromotionRequest {
2483            item_id: item.id.clone(),
2484            target_status: KnowledgeItemStatus::Promoted,
2485            promoted_at_ms: now + 20,
2486            freshness_expires_at_ms: None,
2487            reviewer_id: None,
2488            approval_id: None,
2489            reason: None,
2490        };
2491
2492        let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
2493        assert!(!result.promoted);
2494        assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 20));
2495        assert_eq!(
2496            result.coverage.latest_item_id.as_deref(),
2497            Some("item-promote-5")
2498        );
2499    }
2500
2501    #[tokio::test]
2502    async fn test_knowledge_item_promotion_updates_coverage() {
2503        let (db, _temp) = setup_test_db().await;
2504        let now = chrono::Utc::now().timestamp_millis() as u64;
2505
2506        let space = KnowledgeSpaceRecord {
2507            id: "space-promote".to_string(),
2508            scope: KnowledgeScope::Project,
2509            project_id: Some("project-1".to_string()),
2510            namespace: Some("engineering/debugging".to_string()),
2511            title: Some("Engineering debugging".to_string()),
2512            description: Some("Reusable debugging guidance".to_string()),
2513            trust_level: KnowledgeTrustLevel::Promoted,
2514            metadata: None,
2515            created_at_ms: now,
2516            updated_at_ms: now,
2517        };
2518        db.upsert_knowledge_space(&space).await.unwrap();
2519
2520        let item = KnowledgeItemRecord {
2521            id: "item-promote".to_string(),
2522            space_id: space.id.clone(),
2523            coverage_key: "project-1::engineering/debugging::startup::race".to_string(),
2524            dedupe_key: "dedupe-promote".to_string(),
2525            item_type: "decision".to_string(),
2526            title: "Delay startup-dependent retries".to_string(),
2527            summary: Some("Retry only after startup completes.".to_string()),
2528            payload: serde_json::json!({"action": "delay_retry"}),
2529            trust_level: KnowledgeTrustLevel::Working,
2530            status: KnowledgeItemStatus::Working,
2531            run_id: Some("run-promote".to_string()),
2532            artifact_refs: vec!["artifact://run-promote/report".to_string()],
2533            source_memory_ids: vec!["memory-promote".to_string()],
2534            freshness_expires_at_ms: None,
2535            metadata: Some(serde_json::json!({"source_kind":"run"})),
2536            created_at_ms: now,
2537            updated_at_ms: now,
2538        };
2539        db.upsert_knowledge_item(&item).await.unwrap();
2540
2541        let request = KnowledgePromotionRequest {
2542            item_id: item.id.clone(),
2543            target_status: KnowledgeItemStatus::Promoted,
2544            promoted_at_ms: now + 10,
2545            freshness_expires_at_ms: Some(now + 86_400_000),
2546            reviewer_id: None,
2547            approval_id: None,
2548            reason: Some("validated".to_string()),
2549        };
2550        let promoted = db
2551            .promote_knowledge_item(&request)
2552            .await
2553            .unwrap()
2554            .expect("promotion result");
2555        assert_eq!(promoted.previous_status, KnowledgeItemStatus::Working);
2556        assert!(promoted.promoted);
2557        assert_eq!(promoted.item.status, KnowledgeItemStatus::Promoted);
2558        assert_eq!(promoted.item.trust_level, KnowledgeTrustLevel::Promoted);
2559        assert_eq!(
2560            promoted.item.freshness_expires_at_ms,
2561            Some(now + 86_400_000)
2562        );
2563        assert_eq!(
2564            promoted
2565                .item
2566                .metadata
2567                .as_ref()
2568                .and_then(|value| value.get("promotion"))
2569                .and_then(|value| value.get("to_status"))
2570                .and_then(Value::as_str),
2571            Some("promoted")
2572        );
2573        assert_eq!(
2574            promoted.coverage.latest_item_id.as_deref(),
2575            Some("item-promote")
2576        );
2577        assert_eq!(
2578            promoted.coverage.latest_dedupe_key.as_deref(),
2579            Some("dedupe-promote")
2580        );
2581        assert_eq!(promoted.coverage.last_promoted_at_ms, Some(now + 10));
2582        assert_eq!(
2583            promoted.coverage.freshness_expires_at_ms,
2584            Some(now + 86_400_000)
2585        );
2586
2587        let loaded = db
2588            .get_knowledge_item("item-promote")
2589            .await
2590            .unwrap()
2591            .unwrap();
2592        assert_eq!(loaded.status, KnowledgeItemStatus::Promoted);
2593        assert_eq!(
2594            loaded
2595                .metadata
2596                .as_ref()
2597                .and_then(|value| value.get("promotion"))
2598                .and_then(|value| value.get("from_status"))
2599                .and_then(Value::as_str),
2600            Some("working")
2601        );
2602    }
2603
2604    #[tokio::test]
2605    async fn test_knowledge_item_approved_default_requires_review() {
2606        let (db, _temp) = setup_test_db().await;
2607        let now = chrono::Utc::now().timestamp_millis() as u64;
2608
2609        let space = KnowledgeSpaceRecord {
2610            id: "space-approved".to_string(),
2611            scope: KnowledgeScope::Project,
2612            project_id: Some("project-1".to_string()),
2613            namespace: Some("marketing/positioning".to_string()),
2614            title: Some("Marketing positioning".to_string()),
2615            description: Some("Reusable positioning guidance".to_string()),
2616            trust_level: KnowledgeTrustLevel::Promoted,
2617            metadata: None,
2618            created_at_ms: now,
2619            updated_at_ms: now,
2620        };
2621        db.upsert_knowledge_space(&space).await.unwrap();
2622
2623        let item = KnowledgeItemRecord {
2624            id: "item-approved".to_string(),
2625            space_id: space.id.clone(),
2626            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
2627            dedupe_key: "dedupe-approved".to_string(),
2628            item_type: "evidence".to_string(),
2629            title: "Pricing sensitivity observation".to_string(),
2630            summary: Some("Customers reacted to annual pricing changes".to_string()),
2631            payload: serde_json::json!({"claim":"Annual pricing changes created friction"}),
2632            trust_level: KnowledgeTrustLevel::Promoted,
2633            status: KnowledgeItemStatus::Promoted,
2634            run_id: Some("run-approved".to_string()),
2635            artifact_refs: vec!["artifact://run-approved/research".to_string()],
2636            source_memory_ids: vec!["memory-approved".to_string()],
2637            freshness_expires_at_ms: Some(now + 1234),
2638            metadata: None,
2639            created_at_ms: now,
2640            updated_at_ms: now,
2641        };
2642        db.upsert_knowledge_item(&item).await.unwrap();
2643
2644        let request = KnowledgePromotionRequest {
2645            item_id: item.id.clone(),
2646            target_status: KnowledgeItemStatus::ApprovedDefault,
2647            promoted_at_ms: now + 20,
2648            freshness_expires_at_ms: Some(now + 90_000_000),
2649            reviewer_id: Some("reviewer-1".to_string()),
2650            approval_id: Some("approval-1".to_string()),
2651            reason: Some("approved as default guidance".to_string()),
2652        };
2653        let promoted = db
2654            .promote_knowledge_item(&request)
2655            .await
2656            .unwrap()
2657            .expect("promotion result");
2658        assert_eq!(promoted.previous_status, KnowledgeItemStatus::Promoted);
2659        assert_eq!(promoted.item.status, KnowledgeItemStatus::ApprovedDefault);
2660        assert_eq!(
2661            promoted.item.trust_level,
2662            KnowledgeTrustLevel::ApprovedDefault
2663        );
2664        assert_eq!(promoted.coverage.last_promoted_at_ms, Some(now + 20));
2665        assert_eq!(
2666            promoted
2667                .item
2668                .metadata
2669                .as_ref()
2670                .and_then(|value| value.get("promotion"))
2671                .and_then(|value| value.get("approval_id"))
2672                .and_then(Value::as_str),
2673            Some("approval-1")
2674        );
2675    }
2676
2677    #[tokio::test]
2678    async fn test_knowledge_item_promotion_rejects_invalid_transition() {
2679        let (db, _temp) = setup_test_db().await;
2680        let now = chrono::Utc::now().timestamp_millis() as u64;
2681
2682        let space = KnowledgeSpaceRecord {
2683            id: "space-invalid".to_string(),
2684            scope: KnowledgeScope::Project,
2685            project_id: Some("project-1".to_string()),
2686            namespace: Some("support".to_string()),
2687            title: Some("Support".to_string()),
2688            description: Some("Support guidance".to_string()),
2689            trust_level: KnowledgeTrustLevel::Promoted,
2690            metadata: None,
2691            created_at_ms: now,
2692            updated_at_ms: now,
2693        };
2694        db.upsert_knowledge_space(&space).await.unwrap();
2695
2696        let item = KnowledgeItemRecord {
2697            id: "item-invalid".to_string(),
2698            space_id: space.id.clone(),
2699            coverage_key: "project-1::support::workflow::triage".to_string(),
2700            dedupe_key: "dedupe-invalid".to_string(),
2701            item_type: "decision".to_string(),
2702            title: "Triage first".to_string(),
2703            summary: None,
2704            payload: serde_json::json!({"action":"triage"}),
2705            trust_level: KnowledgeTrustLevel::Working,
2706            status: KnowledgeItemStatus::Working,
2707            run_id: Some("run-invalid".to_string()),
2708            artifact_refs: vec![],
2709            source_memory_ids: vec![],
2710            freshness_expires_at_ms: None,
2711            metadata: None,
2712            created_at_ms: now,
2713            updated_at_ms: now,
2714        };
2715        db.upsert_knowledge_item(&item).await.unwrap();
2716
2717        let request = KnowledgePromotionRequest {
2718            item_id: item.id.clone(),
2719            target_status: KnowledgeItemStatus::ApprovedDefault,
2720            promoted_at_ms: now + 1,
2721            freshness_expires_at_ms: None,
2722            reviewer_id: Some("reviewer-1".to_string()),
2723            approval_id: Some("approval-1".to_string()),
2724            reason: Some("should fail".to_string()),
2725        };
2726        let err = db.promote_knowledge_item(&request).await.unwrap_err();
2727        assert!(matches!(err, MemoryError::InvalidConfig(_)));
2728        let loaded = db.get_knowledge_item(&item.id).await.unwrap().unwrap();
2729        assert_eq!(loaded.status, KnowledgeItemStatus::Working);
2730    }
2731}