1#![allow(clippy::all)]
2
3use 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
31pub 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
42fn 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 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}