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, MemoryTier, ProjectMemoryStats, DEFAULT_EMBEDDING_DIMENSION,
11};
12use chrono::{DateTime, Utc};
13use rusqlite::{ffi::sqlite3_auto_extension, params, Connection, OptionalExtension, Row};
14use sqlite_vec::sqlite3_vec_init;
15use std::collections::HashSet;
16use std::path::Path;
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::sync::Mutex;
20
21type ProjectIndexStatusRow = (
22    Option<String>,
23    Option<i64>,
24    Option<i64>,
25    Option<i64>,
26    Option<i64>,
27    Option<i64>,
28);
29
30/// Database connection manager
31pub struct MemoryDatabase {
32    conn: Arc<Mutex<Connection>>,
33    db_path: std::path::PathBuf,
34}
35
36include!("memory_database_impl_parts/part01.rs");
37include!("memory_database_impl_parts/part02.rs");
38
39/// Convert a database row to a MemoryChunk
40fn row_to_chunk(row: &Row, tier: MemoryTier) -> Result<MemoryChunk, rusqlite::Error> {
41    let id: String = row.get(0)?;
42    let content: String = row.get(1)?;
43    let (session_id, project_id, source_idx, created_at_idx, token_count_idx, metadata_idx) =
44        match tier {
45            MemoryTier::Session => (
46                Some(row.get(2)?),
47                row.get(3)?,
48                4usize,
49                5usize,
50                6usize,
51                7usize,
52            ),
53            MemoryTier::Project => (
54                row.get(2)?,
55                Some(row.get(3)?),
56                4usize,
57                5usize,
58                6usize,
59                7usize,
60            ),
61            MemoryTier::Global => (None, None, 2usize, 3usize, 4usize, 5usize),
62        };
63
64    let source: String = row.get(source_idx)?;
65    let created_at_str: String = row.get(created_at_idx)?;
66    let token_count: i64 = row.get(token_count_idx)?;
67    let metadata_str: Option<String> = row.get(metadata_idx)?;
68
69    let created_at = DateTime::parse_from_rfc3339(&created_at_str)
70        .map_err(|e| {
71            rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e))
72        })?
73        .with_timezone(&Utc);
74
75    let metadata = metadata_str
76        .filter(|s| !s.is_empty())
77        .and_then(|s| serde_json::from_str(&s).ok());
78
79    let source_path = row.get::<_, Option<String>>("source_path").ok().flatten();
80    let source_mtime = row.get::<_, Option<i64>>("source_mtime").ok().flatten();
81    let source_size = row.get::<_, Option<i64>>("source_size").ok().flatten();
82    let source_hash = row.get::<_, Option<String>>("source_hash").ok().flatten();
83
84    Ok(MemoryChunk {
85        id,
86        content,
87        tier,
88        session_id,
89        project_id,
90        source,
91        source_path,
92        source_mtime,
93        source_size,
94        source_hash,
95        created_at,
96        token_count,
97        metadata,
98    })
99}
100
101fn require_scope_id<'a>(tier: MemoryTier, scope: Option<&'a str>) -> MemoryResult<&'a str> {
102    scope
103        .filter(|value| !value.trim().is_empty())
104        .ok_or_else(|| {
105            crate::types::MemoryError::InvalidConfig(match tier {
106                MemoryTier::Session => "tier=session requires session_id".to_string(),
107                MemoryTier::Project => "tier=project requires project_id".to_string(),
108                MemoryTier::Global => "tier=global does not require a scope id".to_string(),
109            })
110        })
111}
112
113fn row_to_global_record(row: &Row) -> Result<GlobalMemoryRecord, rusqlite::Error> {
114    let metadata_str: Option<String> = row.get(12)?;
115    let provenance_str: Option<String> = row.get(13)?;
116    Ok(GlobalMemoryRecord {
117        id: row.get(0)?,
118        user_id: row.get(1)?,
119        source_type: row.get(2)?,
120        content: row.get(3)?,
121        content_hash: row.get(4)?,
122        run_id: row.get(5)?,
123        session_id: row.get(6)?,
124        message_id: row.get(7)?,
125        tool_name: row.get(8)?,
126        project_tag: row.get(9)?,
127        channel_tag: row.get(10)?,
128        host_tag: row.get(11)?,
129        metadata: metadata_str
130            .filter(|s| !s.is_empty())
131            .and_then(|s| serde_json::from_str(&s).ok()),
132        provenance: provenance_str
133            .filter(|s| !s.is_empty())
134            .and_then(|s| serde_json::from_str(&s).ok()),
135        redaction_status: row.get(14)?,
136        redaction_count: row.get::<_, i64>(15)? as u32,
137        visibility: row.get(16)?,
138        demoted: row.get::<_, i64>(17)? != 0,
139        score_boost: row.get(18)?,
140        created_at_ms: row.get::<_, i64>(19)? as u64,
141        updated_at_ms: row.get::<_, i64>(20)? as u64,
142        expires_at_ms: row.get::<_, Option<i64>>(21)?.map(|v| v as u64),
143    })
144}
145
146impl MemoryDatabase {
147    pub async fn get_node_by_uri(
148        &self,
149        uri: &str,
150    ) -> MemoryResult<Option<crate::types::MemoryNode>> {
151        let conn = self.conn.lock().await;
152        let mut stmt = conn.prepare(
153            "SELECT id, uri, parent_uri, node_type, created_at, updated_at, metadata
154             FROM memory_nodes WHERE uri = ?1",
155        )?;
156
157        let result = stmt.query_row(params![uri], |row| {
158            let node_type_str: String = row.get(3)?;
159            let node_type = node_type_str
160                .parse()
161                .unwrap_or(crate::types::NodeType::File);
162            let metadata_str: Option<String> = row.get(6)?;
163            Ok(crate::types::MemoryNode {
164                id: row.get(0)?,
165                uri: row.get(1)?,
166                parent_uri: row.get(2)?,
167                node_type,
168                created_at: row.get::<_, String>(4)?.parse().unwrap_or_default(),
169                updated_at: row.get::<_, String>(5)?.parse().unwrap_or_default(),
170                metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
171            })
172        });
173
174        match result {
175            Ok(node) => Ok(Some(node)),
176            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
177            Err(e) => Err(MemoryError::Database(e)),
178        }
179    }
180
181    pub async fn create_node(
182        &self,
183        uri: &str,
184        parent_uri: Option<&str>,
185        node_type: crate::types::NodeType,
186        metadata: Option<&serde_json::Value>,
187    ) -> MemoryResult<String> {
188        let id = uuid::Uuid::new_v4().to_string();
189        let now = Utc::now().to_rfc3339();
190        let metadata_str = metadata.map(|m| serde_json::to_string(m)).transpose()?;
191
192        let conn = self.conn.lock().await;
193        conn.execute(
194            "INSERT INTO memory_nodes (id, uri, parent_uri, node_type, created_at, updated_at, metadata)
195             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
196            params![id, uri, parent_uri, node_type.to_string(), now, now, metadata_str],
197        )?;
198
199        Ok(id)
200    }
201
202    pub async fn list_directory(&self, uri: &str) -> MemoryResult<Vec<crate::types::MemoryNode>> {
203        let conn = self.conn.lock().await;
204        let mut stmt = conn.prepare(
205            "SELECT id, uri, parent_uri, node_type, created_at, updated_at, metadata
206             FROM memory_nodes WHERE parent_uri = ?1 ORDER BY node_type DESC, uri ASC",
207        )?;
208
209        let rows = stmt.query_map(params![uri], |row| {
210            let node_type_str: String = row.get(3)?;
211            let node_type = node_type_str
212                .parse()
213                .unwrap_or(crate::types::NodeType::File);
214            let metadata_str: Option<String> = row.get(6)?;
215            Ok(crate::types::MemoryNode {
216                id: row.get(0)?,
217                uri: row.get(1)?,
218                parent_uri: row.get(2)?,
219                node_type,
220                created_at: row.get::<_, String>(4)?.parse().unwrap_or_default(),
221                updated_at: row.get::<_, String>(5)?.parse().unwrap_or_default(),
222                metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
223            })
224        })?;
225
226        rows.collect::<Result<Vec<_>, _>>()
227            .map_err(MemoryError::Database)
228    }
229
230    pub async fn get_layer(
231        &self,
232        node_id: &str,
233        layer_type: crate::types::LayerType,
234    ) -> MemoryResult<Option<crate::types::MemoryLayer>> {
235        let conn = self.conn.lock().await;
236        let mut stmt = conn.prepare(
237            "SELECT id, node_id, layer_type, content, token_count, embedding_id, created_at, source_chunk_id
238             FROM memory_layers WHERE node_id = ?1 AND layer_type = ?2"
239        )?;
240
241        let result = stmt.query_row(params![node_id, layer_type.to_string()], |row| {
242            let layer_type_str: String = row.get(2)?;
243            let layer_type = layer_type_str
244                .parse()
245                .unwrap_or(crate::types::LayerType::L2);
246            Ok(crate::types::MemoryLayer {
247                id: row.get(0)?,
248                node_id: row.get(1)?,
249                layer_type,
250                content: row.get(3)?,
251                token_count: row.get(4)?,
252                embedding_id: row.get(5)?,
253                created_at: row.get::<_, String>(6)?.parse().unwrap_or_default(),
254                source_chunk_id: row.get(7)?,
255            })
256        });
257
258        match result {
259            Ok(layer) => Ok(Some(layer)),
260            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
261            Err(e) => Err(MemoryError::Database(e)),
262        }
263    }
264
265    pub async fn create_layer(
266        &self,
267        node_id: &str,
268        layer_type: crate::types::LayerType,
269        content: &str,
270        token_count: i64,
271        source_chunk_id: Option<&str>,
272    ) -> MemoryResult<String> {
273        let id = uuid::Uuid::new_v4().to_string();
274        let now = Utc::now().to_rfc3339();
275
276        let conn = self.conn.lock().await;
277        conn.execute(
278            "INSERT INTO memory_layers (id, node_id, layer_type, content, token_count, created_at, source_chunk_id)
279             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
280            params![id, node_id, layer_type.to_string(), content, token_count, now, source_chunk_id],
281        )?;
282
283        Ok(id)
284    }
285
286    pub async fn get_children_tree(
287        &self,
288        parent_uri: &str,
289        max_depth: usize,
290    ) -> MemoryResult<Vec<crate::types::TreeNode>> {
291        if max_depth == 0 {
292            return Ok(Vec::new());
293        }
294
295        let children = self.list_directory(parent_uri).await?;
296        let mut tree_nodes = Vec::new();
297
298        for child in children {
299            let layer_summary = self.get_layer_summary(&child.id).await?;
300
301            let grandchildren = if child.node_type == crate::types::NodeType::Directory {
302                Box::pin(self.get_children_tree(&child.uri, max_depth.saturating_sub(1))).await?
303            } else {
304                Vec::new()
305            };
306
307            tree_nodes.push(crate::types::TreeNode {
308                node: child,
309                children: grandchildren,
310                layer_summary,
311            });
312        }
313
314        Ok(tree_nodes)
315    }
316
317    async fn get_layer_summary(
318        &self,
319        node_id: &str,
320    ) -> MemoryResult<Option<crate::types::LayerSummary>> {
321        let l0 = self.get_layer(node_id, crate::types::LayerType::L0).await?;
322        let l1 = self.get_layer(node_id, crate::types::LayerType::L1).await?;
323        let has_l2 = self
324            .get_layer(node_id, crate::types::LayerType::L2)
325            .await?
326            .is_some();
327
328        if l0.is_none() && l1.is_none() && !has_l2 {
329            return Ok(None);
330        }
331
332        Ok(Some(crate::types::LayerSummary {
333            l0_preview: l0.map(|l| truncate_string(&l.content, 100)),
334            l1_preview: l1.map(|l| truncate_string(&l.content, 200)),
335            has_l2,
336        }))
337    }
338
339    pub async fn node_exists(&self, uri: &str) -> MemoryResult<bool> {
340        let conn = self.conn.lock().await;
341        let count: i64 = conn.query_row(
342            "SELECT COUNT(*) FROM memory_nodes WHERE uri = ?1",
343            params![uri],
344            |row| row.get(0),
345        )?;
346        Ok(count > 0)
347    }
348}
349
350fn row_to_knowledge_space(row: &Row) -> Result<KnowledgeSpaceRecord, rusqlite::Error> {
351    let scope = row
352        .get::<_, String>(1)?
353        .parse()
354        .unwrap_or(tandem_orchestrator::KnowledgeScope::Project);
355    let trust_level = row
356        .get::<_, String>(6)?
357        .parse()
358        .unwrap_or(tandem_orchestrator::KnowledgeTrustLevel::Promoted);
359    let metadata = row
360        .get::<_, Option<String>>(7)?
361        .and_then(|raw| serde_json::from_str(&raw).ok());
362    Ok(KnowledgeSpaceRecord {
363        id: row.get(0)?,
364        scope,
365        project_id: row.get(2)?,
366        namespace: row.get(3)?,
367        title: row.get(4)?,
368        description: row.get(5)?,
369        trust_level,
370        metadata,
371        created_at_ms: row.get::<_, i64>(8)? as u64,
372        updated_at_ms: row.get::<_, i64>(9)? as u64,
373    })
374}
375
376fn row_to_knowledge_item(row: &Row) -> Result<KnowledgeItemRecord, rusqlite::Error> {
377    let trust_level = row
378        .get::<_, String>(8)?
379        .parse()
380        .unwrap_or(tandem_orchestrator::KnowledgeTrustLevel::Promoted);
381    let status = row
382        .get::<_, String>(9)?
383        .parse()
384        .unwrap_or(KnowledgeItemStatus::Working);
385    let payload = row
386        .get::<_, String>(7)
387        .ok()
388        .and_then(|raw| serde_json::from_str(&raw).ok())
389        .unwrap_or(serde_json::Value::Null);
390    let artifact_refs = row
391        .get::<_, String>(11)
392        .ok()
393        .and_then(|raw| serde_json::from_str(&raw).ok())
394        .unwrap_or_default();
395    let source_memory_ids = row
396        .get::<_, String>(12)
397        .ok()
398        .and_then(|raw| serde_json::from_str(&raw).ok())
399        .unwrap_or_default();
400    let metadata = row
401        .get::<_, Option<String>>(14)?
402        .and_then(|raw| serde_json::from_str(&raw).ok());
403    Ok(KnowledgeItemRecord {
404        id: row.get(0)?,
405        space_id: row.get(1)?,
406        coverage_key: row.get(2)?,
407        dedupe_key: row.get(3)?,
408        item_type: row.get(4)?,
409        title: row.get(5)?,
410        summary: row.get(6)?,
411        payload,
412        trust_level,
413        status,
414        run_id: row.get(10)?,
415        artifact_refs,
416        source_memory_ids,
417        freshness_expires_at_ms: row.get::<_, Option<i64>>(13)?.map(|value| value as u64),
418        metadata,
419        created_at_ms: row.get::<_, i64>(15)? as u64,
420        updated_at_ms: row.get::<_, i64>(16)? as u64,
421    })
422}
423
424fn row_to_knowledge_coverage(row: &Row) -> Result<KnowledgeCoverageRecord, rusqlite::Error> {
425    let metadata = row
426        .get::<_, Option<String>>(7)?
427        .and_then(|raw| serde_json::from_str(&raw).ok());
428    Ok(KnowledgeCoverageRecord {
429        coverage_key: row.get(0)?,
430        space_id: row.get(1)?,
431        latest_item_id: row.get(2)?,
432        latest_dedupe_key: row.get(3)?,
433        last_seen_at_ms: row.get::<_, i64>(4)? as u64,
434        last_promoted_at_ms: row.get::<_, Option<i64>>(5)?.map(|value| value as u64),
435        freshness_expires_at_ms: row.get::<_, Option<i64>>(6)?.map(|value| value as u64),
436        metadata,
437    })
438}
439
440fn truncate_string(s: &str, max_len: usize) -> String {
441    if s.len() <= max_len {
442        s.to_string()
443    } else {
444        format!("{}...", &s[..max_len.saturating_sub(3)])
445    }
446}
447
448fn build_fts_query(query: &str) -> String {
449    let tokens = query
450        .split_whitespace()
451        .filter_map(|tok| {
452            let cleaned =
453                tok.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
454            if cleaned.is_empty() {
455                None
456            } else {
457                Some(format!("\"{}\"", cleaned))
458            }
459        })
460        .collect::<Vec<_>>();
461    if tokens.is_empty() {
462        "\"\"".to_string()
463    } else {
464        tokens.join(" OR ")
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use serde_json::Value;
472    use tandem_orchestrator::{KnowledgeScope, KnowledgeTrustLevel};
473    use tempfile::TempDir;
474
475    async fn setup_test_db() -> (MemoryDatabase, TempDir) {
476        let temp_dir = TempDir::new().unwrap();
477        let db_path = temp_dir.path().join("test_memory.db");
478        let db = MemoryDatabase::new(&db_path).await.unwrap();
479        (db, temp_dir)
480    }
481
482    #[tokio::test]
483    async fn test_init_schema() {
484        let (db, _temp) = setup_test_db().await;
485        // If we get here, schema was initialized successfully
486        let stats = db.get_stats().await.unwrap();
487        assert_eq!(stats.total_chunks, 0);
488    }
489
490    #[tokio::test]
491    async fn test_knowledge_registry_roundtrip() {
492        let (db, _temp) = setup_test_db().await;
493
494        let space = KnowledgeSpaceRecord {
495            id: "space-1".to_string(),
496            scope: tandem_orchestrator::KnowledgeScope::Project,
497            project_id: Some("project-1".to_string()),
498            namespace: Some("support".to_string()),
499            title: Some("Support Knowledge".to_string()),
500            description: Some("Reusable support guidance".to_string()),
501            trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
502            metadata: Some(serde_json::json!({"owner": "ops"})),
503            created_at_ms: 1,
504            updated_at_ms: 2,
505        };
506        db.upsert_knowledge_space(&space).await.unwrap();
507
508        let item = KnowledgeItemRecord {
509            id: "item-1".to_string(),
510            space_id: space.id.clone(),
511            coverage_key: "project-1/support/debugging/slow-start".to_string(),
512            dedupe_key: "dedupe-1".to_string(),
513            item_type: "decision".to_string(),
514            title: "Restart service before retry".to_string(),
515            summary: Some("When the service is stale, restart before retrying.".to_string()),
516            payload: serde_json::json!({"action": "restart"}),
517            trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
518            status: KnowledgeItemStatus::Promoted,
519            run_id: Some("run-1".to_string()),
520            artifact_refs: vec!["artifact://run-1/report".to_string()],
521            source_memory_ids: vec!["memory-1".to_string()],
522            freshness_expires_at_ms: Some(10),
523            metadata: Some(serde_json::json!({"source": "run"})),
524            created_at_ms: 3,
525            updated_at_ms: 4,
526        };
527        db.upsert_knowledge_item(&item).await.unwrap();
528
529        let coverage = KnowledgeCoverageRecord {
530            coverage_key: item.coverage_key.clone(),
531            space_id: space.id.clone(),
532            latest_item_id: Some(item.id.clone()),
533            latest_dedupe_key: Some(item.dedupe_key.clone()),
534            last_seen_at_ms: 5,
535            last_promoted_at_ms: Some(6),
536            freshness_expires_at_ms: Some(10),
537            metadata: Some(serde_json::json!({"coverage": true})),
538        };
539        db.upsert_knowledge_coverage(&coverage).await.unwrap();
540
541        let loaded_space = db.get_knowledge_space(&space.id).await.unwrap().unwrap();
542        assert_eq!(loaded_space.namespace.as_deref(), Some("support"));
543
544        let loaded_items = db
545            .list_knowledge_items(&space.id, Some(&item.coverage_key))
546            .await
547            .unwrap();
548        assert_eq!(loaded_items.len(), 1);
549        assert_eq!(loaded_items[0].title, item.title);
550
551        let loaded_coverage = db
552            .get_knowledge_coverage(&item.coverage_key, &space.id)
553            .await
554            .unwrap()
555            .unwrap();
556        assert_eq!(loaded_coverage.latest_item_id.as_deref(), Some("item-1"));
557    }
558
559    #[tokio::test]
560    async fn test_store_and_retrieve_chunk() {
561        let (db, _temp) = setup_test_db().await;
562
563        let chunk = MemoryChunk {
564            id: "test-1".to_string(),
565            content: "Test content".to_string(),
566            tier: MemoryTier::Session,
567            session_id: Some("session-1".to_string()),
568            project_id: Some("project-1".to_string()),
569            source: "user_message".to_string(),
570            source_path: None,
571            source_mtime: None,
572            source_size: None,
573            source_hash: None,
574            created_at: Utc::now(),
575            token_count: 10,
576            metadata: None,
577        };
578
579        let embedding = vec![0.1f32; DEFAULT_EMBEDDING_DIMENSION];
580        db.store_chunk(&chunk, &embedding).await.unwrap();
581
582        let chunks = db.get_session_chunks("session-1").await.unwrap();
583        assert_eq!(chunks.len(), 1);
584        assert_eq!(chunks[0].content, "Test content");
585    }
586
587    #[tokio::test]
588    async fn test_store_and_retrieve_global_chunk() {
589        let (db, _temp) = setup_test_db().await;
590
591        let chunk = MemoryChunk {
592            id: "global-1".to_string(),
593            content: "Global note".to_string(),
594            tier: MemoryTier::Global,
595            session_id: None,
596            project_id: None,
597            source: "agent_note".to_string(),
598            source_path: None,
599            source_mtime: None,
600            source_size: None,
601            source_hash: None,
602            created_at: Utc::now(),
603            token_count: 7,
604            metadata: Some(serde_json::json!({"kind":"test"})),
605        };
606
607        let embedding = vec![0.2f32; DEFAULT_EMBEDDING_DIMENSION];
608        db.store_chunk(&chunk, &embedding).await.unwrap();
609
610        let chunks = db.get_global_chunks(10).await.unwrap();
611        assert_eq!(chunks.len(), 1);
612        assert_eq!(chunks[0].content, "Global note");
613        assert_eq!(chunks[0].source, "agent_note");
614        assert_eq!(chunks[0].token_count, 7);
615        assert_eq!(chunks[0].tier, MemoryTier::Global);
616    }
617
618    #[tokio::test]
619    async fn test_global_chunk_exists_by_source_hash() {
620        let (db, _temp) = setup_test_db().await;
621
622        let chunk = MemoryChunk {
623            id: "global-hash".to_string(),
624            content: "Global hash note".to_string(),
625            tier: MemoryTier::Global,
626            session_id: None,
627            project_id: None,
628            source: "chat_exchange".to_string(),
629            source_path: None,
630            source_mtime: None,
631            source_size: None,
632            source_hash: Some("hash-123".to_string()),
633            created_at: Utc::now(),
634            token_count: 5,
635            metadata: None,
636        };
637
638        let embedding = vec![0.3f32; DEFAULT_EMBEDDING_DIMENSION];
639        db.store_chunk(&chunk, &embedding).await.unwrap();
640
641        assert!(db
642            .global_chunk_exists_by_source_hash("hash-123")
643            .await
644            .unwrap());
645        assert!(!db
646            .global_chunk_exists_by_source_hash("missing-hash")
647            .await
648            .unwrap());
649    }
650
651    #[tokio::test]
652    async fn test_config_crud() {
653        let (db, _temp) = setup_test_db().await;
654
655        let config = db.get_or_create_config("project-1").await.unwrap();
656        assert_eq!(config.max_chunks, 10000);
657
658        let new_config = MemoryConfig {
659            max_chunks: 5000,
660            ..Default::default()
661        };
662        db.update_config("project-1", &new_config).await.unwrap();
663
664        let updated = db.get_or_create_config("project-1").await.unwrap();
665        assert_eq!(updated.max_chunks, 5000);
666    }
667
668    #[tokio::test]
669    async fn test_global_memory_put_search_and_dedup() {
670        let (db, _temp) = setup_test_db().await;
671        let now = chrono::Utc::now().timestamp_millis() as u64;
672        let record = GlobalMemoryRecord {
673            id: "gm-1".to_string(),
674            user_id: "user-a".to_string(),
675            source_type: "user_message".to_string(),
676            content: "remember rust workspace layout".to_string(),
677            content_hash: "h1".to_string(),
678            run_id: "run-1".to_string(),
679            session_id: Some("s1".to_string()),
680            message_id: Some("m1".to_string()),
681            tool_name: None,
682            project_tag: Some("proj-x".to_string()),
683            channel_tag: None,
684            host_tag: None,
685            metadata: None,
686            provenance: None,
687            redaction_status: "passed".to_string(),
688            redaction_count: 0,
689            visibility: "private".to_string(),
690            demoted: false,
691            score_boost: 0.0,
692            created_at_ms: now,
693            updated_at_ms: now,
694            expires_at_ms: None,
695        };
696        let first = db.put_global_memory_record(&record).await.unwrap();
697        assert!(first.stored);
698        let second = db.put_global_memory_record(&record).await.unwrap();
699        assert!(second.deduped);
700
701        let hits = db
702            .search_global_memory("user-a", "rust workspace", 5, Some("proj-x"), None, None)
703            .await
704            .unwrap();
705        assert!(!hits.is_empty());
706        assert_eq!(hits[0].record.id, "gm-1");
707    }
708
709    #[tokio::test]
710    async fn test_knowledge_registry_round_trip() {
711        let (db, _temp) = setup_test_db().await;
712        let now = chrono::Utc::now().timestamp_millis() as u64;
713
714        let space = KnowledgeSpaceRecord {
715            id: "space-1".to_string(),
716            scope: KnowledgeScope::Project,
717            project_id: Some("project-1".to_string()),
718            namespace: Some("marketing/positioning".to_string()),
719            title: Some("Marketing positioning".to_string()),
720            description: Some("Reusable positioning guidance".to_string()),
721            trust_level: KnowledgeTrustLevel::ApprovedDefault,
722            metadata: Some(serde_json::json!({"owner":"marketing"})),
723            created_at_ms: now,
724            updated_at_ms: now,
725        };
726        db.upsert_knowledge_space(&space).await.unwrap();
727
728        let loaded_space = db.get_knowledge_space("space-1").await.unwrap().unwrap();
729        assert_eq!(loaded_space.id, "space-1");
730        assert_eq!(loaded_space.scope, KnowledgeScope::Project);
731        assert_eq!(loaded_space.project_id.as_deref(), Some("project-1"));
732        assert_eq!(
733            loaded_space.namespace.as_deref(),
734            Some("marketing/positioning")
735        );
736
737        let item = KnowledgeItemRecord {
738            id: "item-1".to_string(),
739            space_id: "space-1".to_string(),
740            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
741            dedupe_key: "item-1-dedupe".to_string(),
742            item_type: "evidence".to_string(),
743            title: "Pricing sensitivity observation".to_string(),
744            summary: Some("Customers reacted to annual pricing changes".to_string()),
745            payload: serde_json::json!({"claim":"Annual pricing changes created friction"}),
746            trust_level: KnowledgeTrustLevel::Promoted,
747            status: KnowledgeItemStatus::Promoted,
748            run_id: Some("run-1".to_string()),
749            artifact_refs: vec!["artifact://run-1/research-sources".to_string()],
750            source_memory_ids: vec!["memory-1".to_string()],
751            freshness_expires_at_ms: Some(now + 86_400_000),
752            metadata: Some(serde_json::json!({"source_kind":"web"})),
753            created_at_ms: now,
754            updated_at_ms: now,
755        };
756        db.upsert_knowledge_item(&item).await.unwrap();
757
758        let loaded_item = db.get_knowledge_item("item-1").await.unwrap().unwrap();
759        assert_eq!(loaded_item.id, "item-1");
760        assert_eq!(loaded_item.space_id, "space-1");
761        assert_eq!(
762            loaded_item.coverage_key,
763            "project-1::marketing/positioning::strategy::pricing"
764        );
765        assert_eq!(loaded_item.status, KnowledgeItemStatus::Promoted);
766        assert_eq!(
767            loaded_item.artifact_refs,
768            vec!["artifact://run-1/research-sources".to_string()]
769        );
770
771        let by_space = db.list_knowledge_items("space-1", None).await.unwrap();
772        assert_eq!(by_space.len(), 1);
773        let by_coverage = db
774            .list_knowledge_items(
775                "space-1",
776                Some("project-1::marketing/positioning::strategy::pricing"),
777            )
778            .await
779            .unwrap();
780        assert_eq!(by_coverage.len(), 1);
781
782        let coverage = KnowledgeCoverageRecord {
783            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
784            space_id: "space-1".to_string(),
785            latest_item_id: Some("item-1".to_string()),
786            latest_dedupe_key: Some("item-1-dedupe".to_string()),
787            last_seen_at_ms: now,
788            last_promoted_at_ms: Some(now),
789            freshness_expires_at_ms: Some(now + 86_400_000),
790            metadata: Some(serde_json::json!({"reuse_reason":"same topic"})),
791        };
792        db.upsert_knowledge_coverage(&coverage).await.unwrap();
793
794        let loaded_coverage = db
795            .get_knowledge_coverage(
796                "project-1::marketing/positioning::strategy::pricing",
797                "space-1",
798            )
799            .await
800            .unwrap()
801            .unwrap();
802        assert_eq!(loaded_coverage.space_id, "space-1");
803        assert_eq!(loaded_coverage.latest_item_id.as_deref(), Some("item-1"));
804        assert_eq!(
805            loaded_coverage.latest_dedupe_key.as_deref(),
806            Some("item-1-dedupe")
807        );
808    }
809
810    #[tokio::test]
811    async fn test_knowledge_promotion_working_to_promoted_updates_coverage() {
812        let (db, _temp) = setup_test_db().await;
813        let now = chrono::Utc::now().timestamp_millis() as u64;
814
815        let space = KnowledgeSpaceRecord {
816            id: "space-promote-1".to_string(),
817            scope: KnowledgeScope::Project,
818            project_id: Some("project-1".to_string()),
819            namespace: Some("engineering/debugging".to_string()),
820            title: Some("Engineering debugging".to_string()),
821            description: Some("Reusable debugging guidance".to_string()),
822            trust_level: KnowledgeTrustLevel::Promoted,
823            metadata: None,
824            created_at_ms: now,
825            updated_at_ms: now,
826        };
827        db.upsert_knowledge_space(&space).await.unwrap();
828
829        let item = KnowledgeItemRecord {
830            id: "item-promote-1".to_string(),
831            space_id: space.id.clone(),
832            coverage_key: "project-1::engineering/debugging::startup::race".to_string(),
833            dedupe_key: "dedupe-promote-1".to_string(),
834            item_type: "decision".to_string(),
835            title: "Delay startup-dependent retries".to_string(),
836            summary: Some("Retry only after startup completed.".to_string()),
837            payload: serde_json::json!({"action":"delay_retry"}),
838            trust_level: KnowledgeTrustLevel::Working,
839            status: KnowledgeItemStatus::Working,
840            run_id: Some("run-1".to_string()),
841            artifact_refs: vec!["artifact://run-1/debug".to_string()],
842            source_memory_ids: vec!["memory-1".to_string()],
843            freshness_expires_at_ms: None,
844            metadata: None,
845            created_at_ms: now,
846            updated_at_ms: now,
847        };
848        db.upsert_knowledge_item(&item).await.unwrap();
849
850        let promote = KnowledgePromotionRequest {
851            item_id: item.id.clone(),
852            target_status: KnowledgeItemStatus::Promoted,
853            promoted_at_ms: now + 10,
854            freshness_expires_at_ms: Some(now + 86_400_000),
855            reviewer_id: None,
856            approval_id: None,
857            reason: Some("validated in workflow".to_string()),
858        };
859
860        let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
861        assert!(result.promoted);
862        assert_eq!(result.item.status, KnowledgeItemStatus::Promoted);
863        assert_eq!(result.item.trust_level, KnowledgeTrustLevel::Promoted);
864        assert_eq!(
865            result.coverage.latest_item_id.as_deref(),
866            Some("item-promote-1")
867        );
868        assert_eq!(
869            result.coverage.latest_dedupe_key.as_deref(),
870            Some("dedupe-promote-1")
871        );
872        assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 10));
873    }
874
875    #[tokio::test]
876    async fn test_knowledge_promotion_promoted_to_approved_default_requires_review() {
877        let (db, _temp) = setup_test_db().await;
878        let now = chrono::Utc::now().timestamp_millis() as u64;
879
880        let space = KnowledgeSpaceRecord {
881            id: "space-promote-2".to_string(),
882            scope: KnowledgeScope::Project,
883            project_id: Some("project-1".to_string()),
884            namespace: Some("marketing/positioning".to_string()),
885            title: Some("Marketing positioning".to_string()),
886            description: Some("Reusable positioning guidance".to_string()),
887            trust_level: KnowledgeTrustLevel::Promoted,
888            metadata: None,
889            created_at_ms: now,
890            updated_at_ms: now,
891        };
892        db.upsert_knowledge_space(&space).await.unwrap();
893
894        let item = KnowledgeItemRecord {
895            id: "item-promote-2".to_string(),
896            space_id: space.id.clone(),
897            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
898            dedupe_key: "dedupe-promote-2".to_string(),
899            item_type: "evidence".to_string(),
900            title: "Pricing observation".to_string(),
901            summary: Some("Annual pricing changes created friction".to_string()),
902            payload: serde_json::json!({"claim":"pricing friction"}),
903            trust_level: KnowledgeTrustLevel::Promoted,
904            status: KnowledgeItemStatus::Promoted,
905            run_id: Some("run-2".to_string()),
906            artifact_refs: vec!["artifact://run-2/research".to_string()],
907            source_memory_ids: vec!["memory-2".to_string()],
908            freshness_expires_at_ms: None,
909            metadata: None,
910            created_at_ms: now,
911            updated_at_ms: now,
912        };
913        db.upsert_knowledge_item(&item).await.unwrap();
914
915        let promote = KnowledgePromotionRequest {
916            item_id: item.id.clone(),
917            target_status: KnowledgeItemStatus::ApprovedDefault,
918            promoted_at_ms: now + 5,
919            freshness_expires_at_ms: None,
920            reviewer_id: None,
921            approval_id: None,
922            reason: Some("should require review".to_string()),
923        };
924
925        let err = db.promote_knowledge_item(&promote).await.unwrap_err();
926        match err {
927            MemoryError::InvalidConfig(_) => {}
928            other => panic!("unexpected error: {}", other),
929        }
930    }
931
932    #[tokio::test]
933    async fn test_knowledge_promotion_promoted_to_approved_default_updates_coverage() {
934        let (db, _temp) = setup_test_db().await;
935        let now = chrono::Utc::now().timestamp_millis() as u64;
936
937        let space = KnowledgeSpaceRecord {
938            id: "space-promote-3".to_string(),
939            scope: KnowledgeScope::Project,
940            project_id: Some("project-1".to_string()),
941            namespace: Some("support/runbooks".to_string()),
942            title: Some("Support runbooks".to_string()),
943            description: Some("Reusable runbook guidance".to_string()),
944            trust_level: KnowledgeTrustLevel::Promoted,
945            metadata: None,
946            created_at_ms: now,
947            updated_at_ms: now,
948        };
949        db.upsert_knowledge_space(&space).await.unwrap();
950
951        let item = KnowledgeItemRecord {
952            id: "item-promote-3".to_string(),
953            space_id: space.id.clone(),
954            coverage_key: "project-1::support/runbooks::oncall::restart".to_string(),
955            dedupe_key: "dedupe-promote-3".to_string(),
956            item_type: "runbook".to_string(),
957            title: "Restart service and verify".to_string(),
958            summary: Some("Restart then validate health endpoint.".to_string()),
959            payload: serde_json::json!({"steps":["restart","healthcheck"]}),
960            trust_level: KnowledgeTrustLevel::Promoted,
961            status: KnowledgeItemStatus::Promoted,
962            run_id: Some("run-3".to_string()),
963            artifact_refs: vec!["artifact://run-3/runbook".to_string()],
964            source_memory_ids: vec!["memory-3".to_string()],
965            freshness_expires_at_ms: None,
966            metadata: None,
967            created_at_ms: now,
968            updated_at_ms: now,
969        };
970        db.upsert_knowledge_item(&item).await.unwrap();
971
972        let promote = KnowledgePromotionRequest {
973            item_id: item.id.clone(),
974            target_status: KnowledgeItemStatus::ApprovedDefault,
975            promoted_at_ms: now + 12,
976            freshness_expires_at_ms: Some(now + 172_800_000),
977            reviewer_id: Some("reviewer-1".to_string()),
978            approval_id: Some("approval-1".to_string()),
979            reason: Some("reviewed by ops".to_string()),
980        };
981
982        let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
983        assert!(result.promoted);
984        assert_eq!(result.item.status, KnowledgeItemStatus::ApprovedDefault);
985        assert_eq!(
986            result.item.trust_level,
987            KnowledgeTrustLevel::ApprovedDefault
988        );
989        assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 12));
990        assert_eq!(
991            result.coverage.latest_item_id.as_deref(),
992            Some("item-promote-3")
993        );
994    }
995
996    #[tokio::test]
997    async fn test_knowledge_promotion_rejects_deprecated() {
998        let (db, _temp) = setup_test_db().await;
999        let now = chrono::Utc::now().timestamp_millis() as u64;
1000
1001        let space = KnowledgeSpaceRecord {
1002            id: "space-promote-4".to_string(),
1003            scope: KnowledgeScope::Project,
1004            project_id: Some("project-1".to_string()),
1005            namespace: Some("ops".to_string()),
1006            title: Some("Ops knowledge".to_string()),
1007            description: Some("Reusable ops knowledge".to_string()),
1008            trust_level: KnowledgeTrustLevel::Promoted,
1009            metadata: None,
1010            created_at_ms: now,
1011            updated_at_ms: now,
1012        };
1013        db.upsert_knowledge_space(&space).await.unwrap();
1014
1015        let item = KnowledgeItemRecord {
1016            id: "item-promote-4".to_string(),
1017            space_id: space.id.clone(),
1018            coverage_key: "project-1::ops::incident::latency".to_string(),
1019            dedupe_key: "dedupe-promote-4".to_string(),
1020            item_type: "decision".to_string(),
1021            title: "Ignore deprecated item".to_string(),
1022            summary: None,
1023            payload: serde_json::json!({"decision":"deprecated"}),
1024            trust_level: KnowledgeTrustLevel::Promoted,
1025            status: KnowledgeItemStatus::Deprecated,
1026            run_id: Some("run-4".to_string()),
1027            artifact_refs: vec![],
1028            source_memory_ids: vec![],
1029            freshness_expires_at_ms: None,
1030            metadata: None,
1031            created_at_ms: now,
1032            updated_at_ms: now,
1033        };
1034        db.upsert_knowledge_item(&item).await.unwrap();
1035
1036        let promote = KnowledgePromotionRequest {
1037            item_id: item.id.clone(),
1038            target_status: KnowledgeItemStatus::Promoted,
1039            promoted_at_ms: now + 1,
1040            freshness_expires_at_ms: None,
1041            reviewer_id: None,
1042            approval_id: None,
1043            reason: None,
1044        };
1045
1046        let err = db.promote_knowledge_item(&promote).await.unwrap_err();
1047        match err {
1048            MemoryError::InvalidConfig(_) => {}
1049            other => panic!("unexpected error: {}", other),
1050        }
1051    }
1052
1053    #[tokio::test]
1054    async fn test_knowledge_promotion_idempotent_updates_coverage() {
1055        let (db, _temp) = setup_test_db().await;
1056        let now = chrono::Utc::now().timestamp_millis() as u64;
1057
1058        let space = KnowledgeSpaceRecord {
1059            id: "space-promote-5".to_string(),
1060            scope: KnowledgeScope::Project,
1061            project_id: Some("project-1".to_string()),
1062            namespace: Some("engineering/ops".to_string()),
1063            title: Some("Engineering ops".to_string()),
1064            description: None,
1065            trust_level: KnowledgeTrustLevel::Promoted,
1066            metadata: None,
1067            created_at_ms: now,
1068            updated_at_ms: now,
1069        };
1070        db.upsert_knowledge_space(&space).await.unwrap();
1071
1072        let item = KnowledgeItemRecord {
1073            id: "item-promote-5".to_string(),
1074            space_id: space.id.clone(),
1075            coverage_key: "project-1::engineering/ops::deploy::guardrails".to_string(),
1076            dedupe_key: "dedupe-promote-5".to_string(),
1077            item_type: "pattern".to_string(),
1078            title: "Deploy guardrails".to_string(),
1079            summary: None,
1080            payload: serde_json::json!({"pattern":"guardrails"}),
1081            trust_level: KnowledgeTrustLevel::Promoted,
1082            status: KnowledgeItemStatus::Promoted,
1083            run_id: Some("run-5".to_string()),
1084            artifact_refs: vec![],
1085            source_memory_ids: vec![],
1086            freshness_expires_at_ms: None,
1087            metadata: None,
1088            created_at_ms: now,
1089            updated_at_ms: now,
1090        };
1091        db.upsert_knowledge_item(&item).await.unwrap();
1092
1093        let promote = KnowledgePromotionRequest {
1094            item_id: item.id.clone(),
1095            target_status: KnowledgeItemStatus::Promoted,
1096            promoted_at_ms: now + 20,
1097            freshness_expires_at_ms: None,
1098            reviewer_id: None,
1099            approval_id: None,
1100            reason: None,
1101        };
1102
1103        let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
1104        assert!(!result.promoted);
1105        assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 20));
1106        assert_eq!(
1107            result.coverage.latest_item_id.as_deref(),
1108            Some("item-promote-5")
1109        );
1110    }
1111
1112    #[tokio::test]
1113    async fn test_knowledge_item_promotion_updates_coverage() {
1114        let (db, _temp) = setup_test_db().await;
1115        let now = chrono::Utc::now().timestamp_millis() as u64;
1116
1117        let space = KnowledgeSpaceRecord {
1118            id: "space-promote".to_string(),
1119            scope: KnowledgeScope::Project,
1120            project_id: Some("project-1".to_string()),
1121            namespace: Some("engineering/debugging".to_string()),
1122            title: Some("Engineering debugging".to_string()),
1123            description: Some("Reusable debugging guidance".to_string()),
1124            trust_level: KnowledgeTrustLevel::Promoted,
1125            metadata: None,
1126            created_at_ms: now,
1127            updated_at_ms: now,
1128        };
1129        db.upsert_knowledge_space(&space).await.unwrap();
1130
1131        let item = KnowledgeItemRecord {
1132            id: "item-promote".to_string(),
1133            space_id: space.id.clone(),
1134            coverage_key: "project-1::engineering/debugging::startup::race".to_string(),
1135            dedupe_key: "dedupe-promote".to_string(),
1136            item_type: "decision".to_string(),
1137            title: "Delay startup-dependent retries".to_string(),
1138            summary: Some("Retry only after startup completes.".to_string()),
1139            payload: serde_json::json!({"action": "delay_retry"}),
1140            trust_level: KnowledgeTrustLevel::Working,
1141            status: KnowledgeItemStatus::Working,
1142            run_id: Some("run-promote".to_string()),
1143            artifact_refs: vec!["artifact://run-promote/report".to_string()],
1144            source_memory_ids: vec!["memory-promote".to_string()],
1145            freshness_expires_at_ms: None,
1146            metadata: Some(serde_json::json!({"source_kind":"run"})),
1147            created_at_ms: now,
1148            updated_at_ms: now,
1149        };
1150        db.upsert_knowledge_item(&item).await.unwrap();
1151
1152        let request = KnowledgePromotionRequest {
1153            item_id: item.id.clone(),
1154            target_status: KnowledgeItemStatus::Promoted,
1155            promoted_at_ms: now + 10,
1156            freshness_expires_at_ms: Some(now + 86_400_000),
1157            reviewer_id: None,
1158            approval_id: None,
1159            reason: Some("validated".to_string()),
1160        };
1161        let promoted = db
1162            .promote_knowledge_item(&request)
1163            .await
1164            .unwrap()
1165            .expect("promotion result");
1166        assert_eq!(promoted.previous_status, KnowledgeItemStatus::Working);
1167        assert!(promoted.promoted);
1168        assert_eq!(promoted.item.status, KnowledgeItemStatus::Promoted);
1169        assert_eq!(promoted.item.trust_level, KnowledgeTrustLevel::Promoted);
1170        assert_eq!(
1171            promoted.item.freshness_expires_at_ms,
1172            Some(now + 86_400_000)
1173        );
1174        assert_eq!(
1175            promoted
1176                .item
1177                .metadata
1178                .as_ref()
1179                .and_then(|value| value.get("promotion"))
1180                .and_then(|value| value.get("to_status"))
1181                .and_then(Value::as_str),
1182            Some("promoted")
1183        );
1184        assert_eq!(
1185            promoted.coverage.latest_item_id.as_deref(),
1186            Some("item-promote")
1187        );
1188        assert_eq!(
1189            promoted.coverage.latest_dedupe_key.as_deref(),
1190            Some("dedupe-promote")
1191        );
1192        assert_eq!(promoted.coverage.last_promoted_at_ms, Some(now + 10));
1193        assert_eq!(
1194            promoted.coverage.freshness_expires_at_ms,
1195            Some(now + 86_400_000)
1196        );
1197
1198        let loaded = db
1199            .get_knowledge_item("item-promote")
1200            .await
1201            .unwrap()
1202            .unwrap();
1203        assert_eq!(loaded.status, KnowledgeItemStatus::Promoted);
1204        assert_eq!(
1205            loaded
1206                .metadata
1207                .as_ref()
1208                .and_then(|value| value.get("promotion"))
1209                .and_then(|value| value.get("from_status"))
1210                .and_then(Value::as_str),
1211            Some("working")
1212        );
1213    }
1214
1215    #[tokio::test]
1216    async fn test_knowledge_item_approved_default_requires_review() {
1217        let (db, _temp) = setup_test_db().await;
1218        let now = chrono::Utc::now().timestamp_millis() as u64;
1219
1220        let space = KnowledgeSpaceRecord {
1221            id: "space-approved".to_string(),
1222            scope: KnowledgeScope::Project,
1223            project_id: Some("project-1".to_string()),
1224            namespace: Some("marketing/positioning".to_string()),
1225            title: Some("Marketing positioning".to_string()),
1226            description: Some("Reusable positioning guidance".to_string()),
1227            trust_level: KnowledgeTrustLevel::Promoted,
1228            metadata: None,
1229            created_at_ms: now,
1230            updated_at_ms: now,
1231        };
1232        db.upsert_knowledge_space(&space).await.unwrap();
1233
1234        let item = KnowledgeItemRecord {
1235            id: "item-approved".to_string(),
1236            space_id: space.id.clone(),
1237            coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
1238            dedupe_key: "dedupe-approved".to_string(),
1239            item_type: "evidence".to_string(),
1240            title: "Pricing sensitivity observation".to_string(),
1241            summary: Some("Customers reacted to annual pricing changes".to_string()),
1242            payload: serde_json::json!({"claim":"Annual pricing changes created friction"}),
1243            trust_level: KnowledgeTrustLevel::Promoted,
1244            status: KnowledgeItemStatus::Promoted,
1245            run_id: Some("run-approved".to_string()),
1246            artifact_refs: vec!["artifact://run-approved/research".to_string()],
1247            source_memory_ids: vec!["memory-approved".to_string()],
1248            freshness_expires_at_ms: Some(now + 1234),
1249            metadata: None,
1250            created_at_ms: now,
1251            updated_at_ms: now,
1252        };
1253        db.upsert_knowledge_item(&item).await.unwrap();
1254
1255        let request = KnowledgePromotionRequest {
1256            item_id: item.id.clone(),
1257            target_status: KnowledgeItemStatus::ApprovedDefault,
1258            promoted_at_ms: now + 20,
1259            freshness_expires_at_ms: Some(now + 90_000_000),
1260            reviewer_id: Some("reviewer-1".to_string()),
1261            approval_id: Some("approval-1".to_string()),
1262            reason: Some("approved as default guidance".to_string()),
1263        };
1264        let promoted = db
1265            .promote_knowledge_item(&request)
1266            .await
1267            .unwrap()
1268            .expect("promotion result");
1269        assert_eq!(promoted.previous_status, KnowledgeItemStatus::Promoted);
1270        assert_eq!(promoted.item.status, KnowledgeItemStatus::ApprovedDefault);
1271        assert_eq!(
1272            promoted.item.trust_level,
1273            KnowledgeTrustLevel::ApprovedDefault
1274        );
1275        assert_eq!(promoted.coverage.last_promoted_at_ms, Some(now + 20));
1276        assert_eq!(
1277            promoted
1278                .item
1279                .metadata
1280                .as_ref()
1281                .and_then(|value| value.get("promotion"))
1282                .and_then(|value| value.get("approval_id"))
1283                .and_then(Value::as_str),
1284            Some("approval-1")
1285        );
1286    }
1287
1288    #[tokio::test]
1289    async fn test_knowledge_item_promotion_rejects_invalid_transition() {
1290        let (db, _temp) = setup_test_db().await;
1291        let now = chrono::Utc::now().timestamp_millis() as u64;
1292
1293        let space = KnowledgeSpaceRecord {
1294            id: "space-invalid".to_string(),
1295            scope: KnowledgeScope::Project,
1296            project_id: Some("project-1".to_string()),
1297            namespace: Some("support".to_string()),
1298            title: Some("Support".to_string()),
1299            description: Some("Support guidance".to_string()),
1300            trust_level: KnowledgeTrustLevel::Promoted,
1301            metadata: None,
1302            created_at_ms: now,
1303            updated_at_ms: now,
1304        };
1305        db.upsert_knowledge_space(&space).await.unwrap();
1306
1307        let item = KnowledgeItemRecord {
1308            id: "item-invalid".to_string(),
1309            space_id: space.id.clone(),
1310            coverage_key: "project-1::support::workflow::triage".to_string(),
1311            dedupe_key: "dedupe-invalid".to_string(),
1312            item_type: "decision".to_string(),
1313            title: "Triage first".to_string(),
1314            summary: None,
1315            payload: serde_json::json!({"action":"triage"}),
1316            trust_level: KnowledgeTrustLevel::Working,
1317            status: KnowledgeItemStatus::Working,
1318            run_id: Some("run-invalid".to_string()),
1319            artifact_refs: vec![],
1320            source_memory_ids: vec![],
1321            freshness_expires_at_ms: None,
1322            metadata: None,
1323            created_at_ms: now,
1324            updated_at_ms: now,
1325        };
1326        db.upsert_knowledge_item(&item).await.unwrap();
1327
1328        let request = KnowledgePromotionRequest {
1329            item_id: item.id.clone(),
1330            target_status: KnowledgeItemStatus::ApprovedDefault,
1331            promoted_at_ms: now + 1,
1332            freshness_expires_at_ms: None,
1333            reviewer_id: Some("reviewer-1".to_string()),
1334            approval_id: Some("approval-1".to_string()),
1335            reason: Some("should fail".to_string()),
1336        };
1337        let err = db.promote_knowledge_item(&request).await.unwrap_err();
1338        assert!(matches!(err, MemoryError::InvalidConfig(_)));
1339        let loaded = db.get_knowledge_item(&item.id).await.unwrap().unwrap();
1340        assert_eq!(loaded.status, KnowledgeItemStatus::Working);
1341    }
1342}