Skip to main content

semantic_memory/
knowledge.rs

1//! Fact CRUD with FTS5 synchronization.
2//!
3//! Every fact operation that touches `facts_fts` is transactional.
4
5use crate::db;
6use crate::db::{bytes_to_embedding, parse_optional_json, with_transaction};
7#[cfg(feature = "hnsw")]
8use crate::db::{enqueue_pending_index_op, PendingIndexOpKind};
9#[cfg(feature = "hnsw")]
10use crate::episodes;
11use crate::error::MemoryError;
12use crate::quantize::{self, Quantizer};
13use crate::types::{Fact, NamespaceDeleteReport};
14use crate::{merge_trace_ctx, MemoryStore};
15use rusqlite::{params, Connection};
16use stack_ids::TraceCtx;
17
18/// Insert a fact and its FTS entry in a transaction.
19#[allow(dead_code)]
20pub fn insert_fact_with_fts(
21    conn: &Connection,
22    fact_id: &str,
23    namespace: &str,
24    content: &str,
25    embedding_bytes: &[u8],
26    source: Option<&str>,
27    metadata: Option<&serde_json::Value>,
28) -> Result<(), MemoryError> {
29    insert_fact_with_fts_q8(
30        conn,
31        fact_id,
32        namespace,
33        content,
34        embedding_bytes,
35        None,
36        source,
37        metadata,
38        None,
39    )
40}
41
42/// Insert a fact with both f32 and quantized embeddings.
43#[allow(clippy::too_many_arguments)]
44pub fn insert_fact_with_fts_q8(
45    conn: &Connection,
46    fact_id: &str,
47    namespace: &str,
48    content: &str,
49    embedding_bytes: &[u8],
50    q8_bytes: Option<&[u8]>,
51    source: Option<&str>,
52    metadata: Option<&serde_json::Value>,
53    sparse: Option<(&crate::SparseWeights, &str)>,
54) -> Result<(), MemoryError> {
55    let metadata_str = metadata.map(|m| m.to_string());
56    with_transaction(conn, |tx| {
57        tx.execute(
58            "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
59             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
60            params![
61                fact_id,
62                namespace,
63                content,
64                source,
65                embedding_bytes,
66                q8_bytes,
67                metadata_str
68            ],
69        )?;
70
71        tx.execute(
72            "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
73            params![fact_id],
74        )?;
75        let fts_rowid = tx.last_insert_rowid();
76
77        tx.execute(
78            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
79            params![fts_rowid, content],
80        )?;
81
82        #[cfg(feature = "hnsw")]
83        enqueue_pending_index_op(
84            tx,
85            &format!("fact:{}", fact_id),
86            "fact",
87            PendingIndexOpKind::Upsert,
88        )?;
89        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
90        if let Some((weights, representation)) = sparse {
91            db::store_sparse_vector(tx, &format!("fact:{fact_id}"), weights, representation)?;
92        }
93
94        Ok(())
95    })
96}
97
98/// Insert a fact within an existing transaction (no nested transaction).
99///
100/// Used by the import boundary where the outer transaction is already active.
101#[allow(clippy::too_many_arguments)]
102pub fn insert_fact_in_tx(
103    tx: &rusqlite::Transaction<'_>,
104    fact_id: &str,
105    namespace: &str,
106    content: &str,
107    embedding_bytes: &[u8],
108    q8_bytes: Option<&[u8]>,
109    source: Option<&str>,
110    metadata: Option<&serde_json::Value>,
111) -> Result<(), MemoryError> {
112    let metadata_str = metadata.map(|m| m.to_string());
113    tx.execute(
114        "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
115         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
116        params![
117            fact_id,
118            namespace,
119            content,
120            source,
121            embedding_bytes,
122            q8_bytes,
123            metadata_str
124        ],
125    )?;
126
127    tx.execute(
128        "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
129        params![fact_id],
130    )?;
131    let fts_rowid = tx.last_insert_rowid();
132
133    tx.execute(
134        "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
135        params![fts_rowid, content],
136    )?;
137
138    #[cfg(feature = "hnsw")]
139    enqueue_pending_index_op(
140        tx,
141        &format!("fact:{}", fact_id),
142        "fact",
143        PendingIndexOpKind::Upsert,
144    )?;
145    db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
146
147    Ok(())
148}
149
150/// Delete a fact and its FTS entry in a transaction.
151#[allow(dead_code)] // public API — used by external consumers, not internally
152pub fn delete_fact_with_fts(conn: &Connection, fact_id: &str) -> Result<(), MemoryError> {
153    with_transaction(conn, |tx| {
154        let fts_rowid: i64 = tx
155            .query_row(
156                "SELECT rowid FROM facts_rowid_map WHERE fact_id = ?1",
157                params![fact_id],
158                |row| row.get(0),
159            )
160            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
161
162        let content: String = tx
163            .query_row(
164                "SELECT content FROM facts WHERE id = ?1",
165                params![fact_id],
166                |row| row.get(0),
167            )
168            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
169
170        tx.execute(
171            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
172            params![fts_rowid, content],
173        )?;
174        tx.execute(
175            "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
176            params![fact_id],
177        )?;
178        tx.execute(
179            "DELETE FROM episode_causes WHERE cause_node_id IN (?1, ?2)",
180            params![fact_id, format!("fact:{fact_id}")],
181        )?;
182        tx.execute(
183            "DELETE FROM derivation_edges
184             WHERE (source_kind = 'fact' AND source_id = ?1)
185                OR (target_kind = 'fact' AND target_id = ?1)",
186            params![fact_id],
187        )?;
188        tx.execute("DELETE FROM facts WHERE id = ?1", params![fact_id])?;
189
190        #[cfg(feature = "hnsw")]
191        enqueue_pending_index_op(
192            tx,
193            &format!("fact:{}", fact_id),
194            "fact",
195            PendingIndexOpKind::Delete,
196        )?;
197        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
198
199        Ok(())
200    })
201}
202
203/// Update a fact's content and embeddings, with FTS synchronization.
204#[allow(dead_code)] // public API — used by external consumers, not internally
205pub fn update_fact_with_fts(
206    conn: &Connection,
207    fact_id: &str,
208    new_content: &str,
209    new_embedding_bytes: &[u8],
210    new_q8_bytes: Option<&[u8]>,
211) -> Result<(), MemoryError> {
212    with_transaction(conn, |tx| {
213        let (fts_rowid, old_content): (i64, String) = tx
214            .query_row(
215                "SELECT fm.rowid, f.content
216                 FROM facts f
217                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
218                 WHERE f.id = ?1",
219                params![fact_id],
220                |row| Ok((row.get(0)?, row.get(1)?)),
221            )
222            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
223
224        tx.execute(
225            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
226            params![fts_rowid, old_content],
227        )?;
228
229        tx.execute(
230            "UPDATE facts
231             SET content = ?1,
232                 embedding = ?2,
233                 embedding_q8 = ?3,
234                 updated_at = datetime('now')
235             WHERE id = ?4",
236            params![new_content, new_embedding_bytes, new_q8_bytes, fact_id],
237        )?;
238
239        tx.execute(
240            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
241            params![fts_rowid, new_content],
242        )?;
243        tx.execute(
244            "DELETE FROM derivation_edges
245             WHERE (source_kind = 'fact' AND source_id = ?1)
246                OR (target_kind = 'fact' AND target_id = ?1)",
247            params![fact_id],
248        )?;
249
250        #[cfg(feature = "hnsw")]
251        enqueue_pending_index_op(
252            tx,
253            &format!("fact:{}", fact_id),
254            "fact",
255            PendingIndexOpKind::Upsert,
256        )?;
257        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
258
259        Ok(())
260    })
261}
262
263/// Delete all namespace-scoped memory atomically and report every affected surface.
264#[cfg(feature = "admin-ops")]
265pub fn delete_namespace(
266    conn: &Connection,
267    namespace: &str,
268) -> Result<NamespaceDeleteReport, MemoryError> {
269    with_transaction(conn, |tx| {
270        let mut report = NamespaceDeleteReport::default();
271        let delete_session = |session_id: &str| -> Result<(usize, usize), MemoryError> {
272            let message_data: Vec<(i64, String, i64, bool)> = {
273                let mut stmt = tx.prepare(
274                    "SELECT m.id, m.content, mm.rowid, m.embedding IS NOT NULL
275                     FROM messages m
276                     JOIN messages_rowid_map mm ON mm.message_id = m.id
277                     WHERE m.session_id = ?1",
278                )?;
279                let rows = stmt.query_map(params![session_id], |row| {
280                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
281                })?;
282                rows.collect::<Result<Vec<_>, _>>()?
283            };
284
285            for (message_id, content, fts_rowid, has_embedding) in &message_data {
286                #[cfg(not(feature = "hnsw"))]
287                let _ = (message_id, has_embedding);
288                tx.execute(
289                    "INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', ?1, ?2)",
290                    params![fts_rowid, content],
291                )?;
292                #[cfg(feature = "hnsw")]
293                if *has_embedding {
294                    enqueue_pending_index_op(
295                        tx,
296                        &format!("msg:{}", message_id),
297                        "message",
298                        PendingIndexOpKind::Delete,
299                    )?;
300                }
301            }
302
303            let affected = tx.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
304            if affected == 0 {
305                return Err(MemoryError::SessionNotFound(session_id.to_string()));
306            }
307            let hnsw_ops = message_data
308                .iter()
309                .filter(|(_, _, _, has_embedding)| *has_embedding)
310                .count();
311            Ok((message_data.len(), hnsw_ops))
312        };
313
314        let document_ids: Vec<String> = {
315            let mut stmt = tx.prepare("SELECT id FROM documents WHERE namespace = ?1")?;
316            let ids = stmt
317                .query_map(params![namespace], |row| row.get(0))?
318                .collect::<Result<Vec<_>, _>>()?;
319            ids
320        };
321
322        let session_ids: Vec<String> = {
323            let mut stmt = tx.prepare("SELECT id, metadata FROM sessions")?;
324            let rows = stmt.query_map([], |row| {
325                Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
326            })?;
327            let mut ids = Vec::new();
328            for row in rows {
329                let (session_id, metadata_raw) = row?;
330                let metadata = parse_optional_json(
331                    "sessions",
332                    &session_id,
333                    "metadata",
334                    metadata_raw.as_deref(),
335                )?;
336                let namespace_matches = metadata
337                    .as_ref()
338                    .and_then(|value| {
339                        value
340                            .get("namespace")
341                            .or_else(|| value.get("scope_namespace"))
342                    })
343                    .and_then(|value| value.as_str())
344                    == Some(namespace);
345                if namespace_matches {
346                    ids.push(session_id);
347                }
348            }
349            ids
350        };
351
352        for session_id in &session_ids {
353            let (messages, hnsw_ops) = delete_session(session_id)?;
354            report.messages += messages;
355            report.hnsw_ops += hnsw_ops;
356        }
357        report.sessions = session_ids.len();
358
359        let delete_derivation_edges_for_id = |kind: &str, id: &str| -> Result<(), MemoryError> {
360            tx.execute(
361                "DELETE FROM derivation_edges
362                 WHERE (source_kind = ?1 AND source_id = ?2)
363                    OR (target_kind = ?1 AND target_id = ?2)",
364                params![kind, id],
365            )?;
366            Ok(())
367        };
368
369        let delete_derivation_edges_for_ids =
370            |kind: &str, ids: &[String]| -> Result<(), MemoryError> {
371                for id in ids {
372                    delete_derivation_edges_for_id(kind, id)?;
373                }
374                Ok(())
375            };
376
377        let facts: Vec<(String, i64, String)> = {
378            let mut stmt = tx.prepare(
379                "SELECT f.id, fm.rowid, f.content
380                 FROM facts f
381                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
382                 WHERE f.namespace = ?1",
383            )?;
384            let facts = stmt
385                .query_map(params![namespace], |row| {
386                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
387                })?
388                .collect::<Result<Vec<_>, _>>()?;
389            facts
390        };
391
392        for (fact_id, fts_rowid, content) in &facts {
393            tx.execute(
394                "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
395                params![fts_rowid, content],
396            )?;
397            tx.execute(
398                "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
399                params![fact_id],
400            )?;
401
402            #[cfg(feature = "hnsw")]
403            enqueue_pending_index_op(
404                tx,
405                &format!("fact:{}", fact_id),
406                "fact",
407                PendingIndexOpKind::Delete,
408            )?;
409            #[cfg(feature = "hnsw")]
410            {
411                report.hnsw_ops += 1;
412            }
413        }
414        tx.execute("DELETE FROM facts WHERE namespace = ?1", params![namespace])?;
415        report.facts = facts.len();
416
417        for doc_id in &document_ids {
418            let mut stmt = tx.prepare(
419                "SELECT c.id, c.content, cm.rowid
420                 FROM chunks c
421                 JOIN chunks_rowid_map cm ON cm.chunk_id = c.id
422                 WHERE c.document_id = ?1",
423            )?;
424            let chunk_rows: Vec<(String, String, i64)> = stmt
425                .query_map(params![doc_id], |row| {
426                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
427                })?
428                .collect::<Result<Vec<_>, _>>()?;
429            report.chunks += chunk_rows.len();
430
431            for (chunk_id, content, fts_rowid) in &chunk_rows {
432                tx.execute(
433                    "INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', ?1, ?2)",
434                    params![fts_rowid, content],
435                )?;
436                tx.execute(
437                    "DELETE FROM chunks_rowid_map WHERE chunk_id = ?1",
438                    params![chunk_id],
439                )?;
440                #[cfg(feature = "hnsw")]
441                enqueue_pending_index_op(
442                    tx,
443                    &format!("chunk:{}", chunk_id),
444                    "chunk",
445                    PendingIndexOpKind::Delete,
446                )?;
447                #[cfg(feature = "hnsw")]
448                {
449                    report.hnsw_ops += 1;
450                }
451            }
452
453            tx.execute("DELETE FROM chunks WHERE document_id = ?1", params![doc_id])?;
454        }
455
456        for doc_id in &document_ids {
457            let mut stmt = tx.prepare(
458                "SELECT e.episode_id, e.search_text, erm.rowid
459                 FROM episodes e
460                 JOIN episodes_rowid_map erm ON erm.episode_id = e.episode_id
461                 WHERE e.document_id = ?1",
462            )?;
463            let episode_rows: Vec<(String, String, i64)> = stmt
464                .query_map(params![doc_id], |row| {
465                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
466                })?
467                .collect::<Result<Vec<_>, _>>()?;
468            report.episodes += episode_rows.len();
469
470            for (episode_id, search_text, fts_rowid) in &episode_rows {
471                tx.execute(
472                    "INSERT INTO episodes_fts(episodes_fts, rowid, content) VALUES ('delete', ?1, ?2)",
473                    params![fts_rowid, search_text],
474                )?;
475                tx.execute(
476                    "DELETE FROM episodes_rowid_map WHERE episode_id = ?1",
477                    params![episode_id],
478                )?;
479                tx.execute(
480                    "DELETE FROM episode_causes WHERE episode_id = ?1",
481                    params![episode_id],
482                )?;
483                #[cfg(feature = "hnsw")]
484                enqueue_pending_index_op(
485                    tx,
486                    &episodes::episode_item_key(episode_id),
487                    "episode",
488                    PendingIndexOpKind::Delete,
489                )?;
490                #[cfg(feature = "hnsw")]
491                {
492                    report.hnsw_ops += 1;
493                }
494            }
495
496            tx.execute(
497                "DELETE FROM episodes WHERE document_id = ?1",
498                params![doc_id],
499            )?;
500            tx.execute("DELETE FROM documents WHERE id = ?1", params![doc_id])?;
501        }
502        report.documents = document_ids.len();
503
504        let claim_ids: Vec<String> = {
505            let mut stmt =
506                tx.prepare("SELECT claim_id FROM claim_versions WHERE scope_namespace = ?1")?;
507            let ids = stmt
508                .query_map(params![namespace], |row| row.get(0))?
509                .collect::<Result<Vec<_>, _>>()?;
510            ids
511        };
512
513        let claim_version_ids: Vec<String> = {
514            let mut stmt = tx.prepare(
515                "SELECT claim_version_id FROM claim_versions WHERE scope_namespace = ?1",
516            )?;
517            let ids = stmt
518                .query_map(params![namespace], |row| row.get(0))?
519                .collect::<Result<Vec<_>, _>>()?;
520            ids
521        };
522
523        let relation_version_ids: Vec<String> = {
524            let mut stmt = tx.prepare(
525                "SELECT relation_version_id FROM relation_versions WHERE scope_namespace = ?1",
526            )?;
527            let ids = stmt
528                .query_map(params![namespace], |row| row.get(0))?
529                .collect::<Result<Vec<_>, _>>()?;
530            ids
531        };
532
533        let alias_entity_ids: Vec<String> = {
534            let mut stmt = tx.prepare(
535                "SELECT canonical_entity_id FROM entity_aliases WHERE scope_namespace = ?1",
536            )?;
537            let ids = stmt
538                .query_map(params![namespace], |row| row.get(0))?
539                .collect::<Result<Vec<_>, _>>()?;
540            ids
541        };
542
543        let evidence_handles: Vec<String> = {
544            let mut stmt = tx.prepare(
545                "SELECT er.fetch_handle FROM evidence_refs er
546                 JOIN projection_import_log pil ON er.source_envelope_id = pil.source_envelope_id
547                 WHERE pil.scope_namespace = ?1",
548            )?;
549            let handles = stmt
550                .query_map(params![namespace], |row| row.get(0))?
551                .collect::<Result<Vec<_>, _>>()?;
552            handles
553        };
554
555        let episode_ids: Vec<String> = {
556            let mut stmt = tx.prepare(
557                "SELECT episode_id FROM episode_links
558                 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
559            )?;
560            let ids = stmt
561                .query_map(params![namespace], |row| row.get(0))?
562                .collect::<Result<Vec<_>, _>>()?;
563            ids
564        };
565
566        delete_derivation_edges_for_ids("claim", &claim_ids)?;
567        delete_derivation_edges_for_ids("claim_version", &claim_version_ids)?;
568        delete_derivation_edges_for_ids("relation_version", &relation_version_ids)?;
569        delete_derivation_edges_for_ids("entity", &alias_entity_ids)?;
570        delete_derivation_edges_for_ids("evidence_ref", &evidence_handles)?;
571        delete_derivation_edges_for_ids("episode", &episode_ids)?;
572
573        report.projection_rows += tx.execute(
574            "DELETE FROM claim_versions WHERE scope_namespace = ?1",
575            params![namespace],
576        )?;
577        report.projection_rows += tx.execute(
578            "DELETE FROM relation_versions WHERE scope_namespace = ?1",
579            params![namespace],
580        )?;
581        report.projection_rows += tx.execute(
582            "DELETE FROM entity_aliases WHERE scope_namespace = ?1",
583            params![namespace],
584        )?;
585        report.projection_rows += tx.execute(
586            "DELETE FROM evidence_refs
587             WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
588            params![namespace],
589        )?;
590        report.projection_rows += tx.execute(
591            "DELETE FROM episode_links
592             WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
593            params![namespace],
594        )?;
595        report.projection_rows += tx.execute(
596            "DELETE FROM projection_import_failures WHERE scope_namespace = ?1",
597            params![namespace],
598        )?;
599        report.projection_rows += tx.execute(
600            "DELETE FROM projection_import_log WHERE scope_namespace = ?1",
601            params![namespace],
602        )?;
603
604        Ok(report)
605    })
606}
607
608/// Get a fact by ID.
609pub fn get_fact(conn: &Connection, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
610    let result = conn.query_row(
611        "SELECT id, namespace, content, source, created_at, updated_at, metadata
612         FROM facts WHERE id = ?1",
613        params![fact_id],
614        |row| {
615            Ok((
616                row.get::<_, String>(0)?,
617                row.get::<_, String>(1)?,
618                row.get::<_, String>(2)?,
619                row.get::<_, Option<String>>(3)?,
620                row.get::<_, String>(4)?,
621                row.get::<_, String>(5)?,
622                row.get::<_, Option<String>>(6)?,
623            ))
624        },
625    );
626
627    match result {
628        Ok((id, namespace, content, source, created_at, updated_at, metadata_raw)) => {
629            Ok(Some(Fact {
630                metadata: parse_optional_json("facts", &id, "metadata", metadata_raw.as_deref())?,
631                id,
632                namespace,
633                content,
634                source,
635                created_at,
636                updated_at,
637            }))
638        }
639        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
640        Err(err) => Err(MemoryError::Database(err)),
641    }
642}
643
644/// Get a fact embedding vector.
645pub fn get_fact_embedding(
646    conn: &Connection,
647    fact_id: &str,
648) -> Result<Option<Vec<f32>>, MemoryError> {
649    let result: Result<Option<Vec<u8>>, _> = conn.query_row(
650        "SELECT embedding FROM facts WHERE id = ?1",
651        params![fact_id],
652        |row| row.get(0),
653    );
654
655    match result {
656        Ok(Some(bytes)) => Ok(Some(bytes_to_embedding(&bytes)?)),
657        Ok(None) => Ok(None),
658        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
659        Err(err) => Err(MemoryError::Database(err)),
660    }
661}
662
663/// List the distinct namespaces that currently contain facts.
664pub fn list_fact_namespaces(conn: &Connection) -> Result<Vec<String>, MemoryError> {
665    let mut stmt = conn.prepare("SELECT DISTINCT namespace FROM facts ORDER BY namespace")?;
666    let rows = stmt
667        .query_map([], |row| row.get::<_, String>(0))?
668        .collect::<Result<Vec<_>, _>>()?;
669    Ok(rows)
670}
671
672/// List facts within a namespace.
673#[allow(dead_code)] // retained as an internal compatibility seam for older callers
674pub fn list_facts(
675    conn: &Connection,
676    namespace: &str,
677    limit: usize,
678    offset: usize,
679) -> Result<Vec<Fact>, MemoryError> {
680    list_facts_with_view(conn, namespace, limit, offset, &StateView::Current)
681}
682
683/// Authority state selected by a fact retrieval.
684#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
685pub enum StateView {
686    Current,
687    HistoricalAt(String),
688    RecordedAsOf(String),
689    IncludeSuperseded,
690}
691
692pub(crate) fn fact_is_visible_with_view(
693    conn: &Connection,
694    fact_id: &str,
695    view: &StateView,
696) -> Result<bool, MemoryError> {
697    let forgotten: bool = conn.query_row(
698        "SELECT EXISTS(SELECT 1 FROM forgotten_facts WHERE fact_id = ?1)",
699        params![fact_id],
700        |row| row.get(0),
701    )?;
702    if forgotten {
703        return Ok(false);
704    }
705    let cutoff = match view {
706        StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
707            let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
708                MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
709            })?;
710            Some(
711                parsed
712                    .with_timezone(&chrono::Utc)
713                    .format("%Y-%m-%d %H:%M:%S%.6f")
714                    .to_string(),
715            )
716        }
717        _ => None,
718    };
719    let include_superseded = matches!(view, StateView::IncludeSuperseded);
720    let visible: i64 = conn.query_row(
721        "SELECT EXISTS(
722             SELECT 1 FROM facts f
723             WHERE f.id = ?1
724               AND (?2 IS NULL OR f.created_at <= ?2)
725               AND (?3 = 1 OR NOT EXISTS (
726                   SELECT 1 FROM graph_edges ge
727                   WHERE ge.target = 'fact:' || f.id
728                     AND ge.is_invalidated = 0
729                     AND COALESCE(
730                         json_extract(ge.edge_type, '$.relation'),
731                         json_extract(ge.edge_type, '$.entity.relation')
732                     ) IN ('supersedes', 'redacts')
733                     AND (?2 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?2)
734               ))
735         )",
736        params![fact_id, cutoff, include_superseded],
737        |row| row.get(0),
738    )?;
739    Ok(visible != 0)
740}
741
742/// List facts under an explicit authority-state view. Inconsistent lineage is rejected.
743pub fn list_facts_with_view(
744    conn: &Connection,
745    namespace: &str,
746    limit: usize,
747    offset: usize,
748    view: &StateView,
749) -> Result<Vec<Fact>, MemoryError> {
750    let cutoff = match view {
751        StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
752            let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
753                MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
754            })?;
755            Some(
756                parsed
757                    .with_timezone(&chrono::Utc)
758                    .format("%Y-%m-%d %H:%M:%S%.6f")
759                    .to_string(),
760            )
761        }
762        _ => None,
763    };
764    let inconsistent: i64 = conn.query_row(
765        "SELECT COUNT(*) FROM (
766             SELECT target FROM graph_edges
767             WHERE is_invalidated = 0
768               AND COALESCE(
769                   json_extract(edge_type, '$.relation'),
770                   json_extract(edge_type, '$.entity.relation')
771               ) IN ('supersedes', 'redacts')
772               AND (?1 IS NULL OR COALESCE(recorded_time, recorded_at) <= ?1)
773             GROUP BY target HAVING COUNT(DISTINCT source) > 1
774         )",
775        params![cutoff.as_deref()],
776        |row| row.get(0),
777    )?;
778    if inconsistent != 0 {
779        return Err(MemoryError::Other(
780            "inconsistent fact lineage: multiple active heads".into(),
781        ));
782    }
783    let include_superseded = matches!(view, StateView::IncludeSuperseded);
784    let mut stmt = conn.prepare(
785        "SELECT id, namespace, content, source, created_at, updated_at, metadata
786         FROM facts
787         WHERE namespace = ?1
788           AND NOT EXISTS (
789               SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = facts.id
790           )
791           AND (?4 IS NULL OR created_at <= ?4)
792           AND (?5 = 1 OR NOT EXISTS (
793               SELECT 1 FROM graph_edges ge
794               WHERE ge.target = 'fact:' || facts.id
795                 AND ge.is_invalidated = 0
796                 AND COALESCE(
797                     json_extract(ge.edge_type, '$.relation'),
798                     json_extract(ge.edge_type, '$.entity.relation')
799                 ) IN ('supersedes', 'redacts')
800                 AND (?4 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?4)
801           ))
802         ORDER BY updated_at DESC
803         LIMIT ?2 OFFSET ?3",
804    )?;
805
806    let facts = stmt
807        .query_map(
808            params![
809                namespace,
810                limit as i64,
811                offset as i64,
812                cutoff,
813                include_superseded
814            ],
815            |row| {
816                Ok((
817                    row.get::<_, String>(0)?,
818                    row.get::<_, String>(1)?,
819                    row.get::<_, String>(2)?,
820                    row.get::<_, Option<String>>(3)?,
821                    row.get::<_, String>(4)?,
822                    row.get::<_, String>(5)?,
823                    row.get::<_, Option<String>>(6)?,
824                ))
825            },
826        )?
827        .collect::<Result<Vec<_>, _>>()?
828        .into_iter()
829        .map(
830            |(id, namespace, content, source, created_at, updated_at, metadata_raw)| {
831                Ok(Fact {
832                    metadata: parse_optional_json(
833                        "facts",
834                        &id,
835                        "metadata",
836                        metadata_raw.as_deref(),
837                    )?,
838                    id,
839                    namespace,
840                    content,
841                    source,
842                    created_at,
843                    updated_at,
844                })
845            },
846        )
847        .collect::<Result<Vec<_>, MemoryError>>()?;
848
849    Ok(facts)
850}
851
852impl MemoryStore {
853    /// Explicitly ungoverned compatibility write.
854    ///
855    /// This preserves the pre-authority raw storage API for migrations and local tooling. It does
856    /// not create an origin label and its output is therefore denied by every governed path.
857    pub async fn add_fact_raw_compat(
858        &self,
859        namespace: &str,
860        content: &str,
861        source: Option<&str>,
862        metadata: Option<serde_json::Value>,
863        trace_ctx: Option<&TraceCtx>,
864    ) -> Result<Fact, MemoryError> {
865        let id = self
866            .add_fact_with_trace(namespace, content, source, metadata, trace_ctx)
867            .await?;
868        self.get_fact(&id)
869            .await?
870            .ok_or(MemoryError::FactNotFound(id))
871    }
872
873    /// Store a fact with automatic embedding. Returns the fact ID (UUID v4).
874    ///
875    /// This is a non-authoritative storage primitive. Governed mutations must
876    /// use [`MemoryStore::authority`] so admission and lineage are enforced.
877    pub async fn add_fact(
878        &self,
879        namespace: &str,
880        content: &str,
881        source: Option<&str>,
882        metadata: Option<serde_json::Value>,
883    ) -> Result<String, MemoryError> {
884        self.add_fact_with_trace(namespace, content, source, metadata, None)
885            .await
886    }
887
888    /// Store a fact with automatic embedding and optional trace metadata.
889    pub async fn add_fact_with_trace(
890        &self,
891        namespace: &str,
892        content: &str,
893        source: Option<&str>,
894        metadata: Option<serde_json::Value>,
895        trace_ctx: Option<&TraceCtx>,
896    ) -> Result<String, MemoryError> {
897        self.validate_content("fact.content", content)?;
898
899        // Dedup: check if a fact with the same content already exists.
900        // This prevents the 4-5% DB bloat from duplicate ingestion.
901        let ns_check = namespace.to_string();
902        let ct_check = content.to_string();
903        let existing_id = self
904            .with_read_conn(move |conn| {
905                let result: Option<String> = conn
906                    .query_row(
907                        "SELECT id FROM facts WHERE content = ?1 AND namespace = ?2 LIMIT 1",
908                        rusqlite::params![&ct_check, &ns_check],
909                        |row| row.get::<_, String>(0),
910                    )
911                    .ok();
912                Ok(result)
913            })
914            .await?;
915
916        if let Some(id) = existing_id {
917            return Ok(id);
918        }
919
920        let (embedding, sparse, sparse_representation) = self
921            .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
922            .await?;
923        self.validate_embedding_dimensions(&embedding)?;
924        let embedding_bytes = db::embedding_to_bytes(&embedding);
925        let fact_id = uuid::Uuid::new_v4().to_string();
926        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
927
928        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
929        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
930        let q8_bytes = quantizer
931            .quantize(&embedding)
932            .map(|qv| quantize::pack_quantized(&qv))
933            .ok();
934
935        let ns = namespace.to_string();
936        let ct = content.to_string();
937        let fid = fact_id.clone();
938        let src = source.map(|s| s.to_string());
939        let meta = merge_trace_ctx(metadata, trace_ctx);
940        self.with_write_conn(move |conn| {
941            let current_count: usize = conn.query_row(
942                "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
943                rusqlite::params![&ns],
944                |row| row.get(0),
945            )?;
946            if current_count >= max_facts_per_namespace {
947                return Err(MemoryError::NamespaceFull {
948                    namespace: ns.clone(),
949                    count: current_count,
950                    limit: max_facts_per_namespace,
951                });
952            }
953            insert_fact_with_fts_q8(
954                conn,
955                &fid,
956                &ns,
957                &ct,
958                &embedding_bytes,
959                q8_bytes.as_deref(),
960                src.as_deref(),
961                meta.as_ref(),
962                sparse.as_ref().zip(sparse_representation.as_deref()),
963            )
964        })
965        .await?;
966
967        self.clear_search_cache();
968
969        #[cfg(feature = "hnsw")]
970        self.sync_pending_hnsw_ops_best_effort("add_fact").await;
971
972        Ok(fact_id)
973    }
974
975    /// Store a fact with a pre-computed embedding.
976    pub async fn add_fact_with_embedding(
977        &self,
978        namespace: &str,
979        content: &str,
980        embedding: &[f32],
981        source: Option<&str>,
982        metadata: Option<serde_json::Value>,
983    ) -> Result<String, MemoryError> {
984        self.add_fact_with_embedding_and_trace(
985            namespace, content, embedding, source, metadata, None,
986        )
987        .await
988    }
989
990    /// Store a fact with a pre-computed embedding and optional trace metadata.
991    pub async fn add_fact_with_embedding_and_trace(
992        &self,
993        namespace: &str,
994        content: &str,
995        embedding: &[f32],
996        source: Option<&str>,
997        metadata: Option<serde_json::Value>,
998        trace_ctx: Option<&TraceCtx>,
999    ) -> Result<String, MemoryError> {
1000        self.validate_content("fact.content", content)?;
1001        self.validate_embedding_dimensions(embedding)?;
1002        let embedding_bytes = db::embedding_to_bytes(embedding);
1003        let sparse = self.inner.config.search.derive_sparse_from_dense.then(|| {
1004            crate::SparseWeights::from_dense(
1005                embedding,
1006                self.inner.config.search.sparse_derive_top_k,
1007                self.inner.config.search.sparse_derive_min_weight,
1008            )
1009        });
1010        let fact_id = uuid::Uuid::new_v4().to_string();
1011        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1012
1013        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
1014        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1015        let q8_bytes = quantizer
1016            .quantize(embedding)
1017            .map(|qv| quantize::pack_quantized(&qv))
1018            .ok();
1019
1020        let ns = namespace.to_string();
1021        let ct = content.to_string();
1022        let fid = fact_id.clone();
1023        let src = source.map(|s| s.to_string());
1024        let meta = merge_trace_ctx(metadata, trace_ctx);
1025        self.with_write_conn(move |conn| {
1026            let current_count: usize = conn.query_row(
1027                "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1028                rusqlite::params![&ns],
1029                |row| row.get(0),
1030            )?;
1031            if current_count >= max_facts_per_namespace {
1032                return Err(MemoryError::NamespaceFull {
1033                    namespace: ns.clone(),
1034                    count: current_count,
1035                    limit: max_facts_per_namespace,
1036                });
1037            }
1038            insert_fact_with_fts_q8(
1039                conn,
1040                &fid,
1041                &ns,
1042                &ct,
1043                &embedding_bytes,
1044                q8_bytes.as_deref(),
1045                src.as_deref(),
1046                meta.as_ref(),
1047                sparse
1048                    .as_ref()
1049                    .map(|weights| (weights, "generic_dense_derived_sparse")),
1050            )
1051        })
1052        .await?;
1053
1054        self.clear_search_cache();
1055
1056        #[cfg(feature = "hnsw")]
1057        self.sync_pending_hnsw_ops_best_effort("add_fact_with_embedding")
1058            .await;
1059
1060        Ok(fact_id)
1061    }
1062
1063    /// **DANGER**: This physically mutates/deletes a truth-bearing row.
1064    /// This is admin-only and gated behind the `admin-ops` feature.
1065    /// Default agent-facing APIs should use supersession (add a new fact
1066    /// with a supersession link) instead of hard delete/update.
1067    #[cfg(feature = "admin-ops")]
1068    pub async fn update_fact(&self, fact_id: &str, content: &str) -> Result<(), MemoryError> {
1069        self.validate_content("fact.content", content)?;
1070        let (embedding, sparse, sparse_representation) = self
1071            .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
1072            .await?;
1073        self.validate_embedding_dimensions(&embedding)?;
1074        let embedding_bytes = db::embedding_to_bytes(&embedding);
1075        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1076        let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1077            .quantize(&embedding)
1078            .map(|qv| quantize::pack_quantized(&qv))
1079            .ok();
1080
1081        let fid = fact_id.to_string();
1082        let ct = content.to_string();
1083        self.with_write_conn(move |conn| {
1084            update_fact_with_fts(conn, &fid, &ct, &embedding_bytes, q8_bytes.as_deref())?;
1085            let item_key = format!("fact:{fid}");
1086            if let Some((weights, representation)) =
1087                sparse.as_ref().zip(sparse_representation.as_deref())
1088            {
1089                db::store_sparse_vector(conn, &item_key, weights, representation)?;
1090            } else {
1091                db::delete_sparse_vector(conn, &item_key)?;
1092            }
1093            Ok(())
1094        })
1095        .await?;
1096
1097        #[cfg(feature = "hnsw")]
1098        self.sync_pending_hnsw_ops_best_effort("update_fact").await;
1099
1100        self.clear_search_cache();
1101
1102        Ok(())
1103    }
1104
1105    /// **DANGER**: This physically mutates/deletes a truth-bearing row.
1106    /// This is admin-only and gated behind the `admin-ops` feature.
1107    /// Default agent-facing APIs should use supersession (add a new fact
1108    /// with a supersession link) instead of hard delete/update.
1109    #[cfg(feature = "admin-ops")]
1110    pub async fn delete_fact(&self, fact_id: &str) -> Result<(), MemoryError> {
1111        let fid = fact_id.to_string();
1112        self.with_write_conn(move |conn| delete_fact_with_fts(conn, &fid))
1113            .await?;
1114
1115        #[cfg(feature = "hnsw")]
1116        self.sync_pending_hnsw_ops_best_effort("delete_fact").await;
1117
1118        self.clear_search_cache();
1119
1120        Ok(())
1121    }
1122
1123    /// **DANGER**: physically deletes every truth-bearing row in a namespace.
1124    /// This is admin-only and gated behind the `admin-ops` feature. Ordinary
1125    /// callers must use governed supersession/forgetting flows instead.
1126    #[cfg(feature = "admin-ops")]
1127    pub async fn delete_namespace(
1128        &self,
1129        namespace: &str,
1130    ) -> Result<NamespaceDeleteReport, MemoryError> {
1131        let ns = namespace.to_string();
1132        let count = self
1133            .with_write_conn(move |conn| delete_namespace(conn, &ns))
1134            .await?;
1135
1136        #[cfg(feature = "hnsw")]
1137        self.sync_pending_hnsw_ops_best_effort("delete_namespace")
1138            .await;
1139
1140        self.clear_search_cache();
1141
1142        Ok(count)
1143    }
1144
1145    /// Get a fact by ID.
1146    pub async fn get_fact(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1147        let fid = fact_id.to_string();
1148        self.with_read_conn(move |conn| get_fact(conn, &fid)).await
1149    }
1150
1151    /// Explicitly ungoverned compatibility read. Prefer `authority().get_fact_governed`.
1152    pub async fn get_fact_raw_compat(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1153        self.get_fact(fact_id).await
1154    }
1155
1156    /// Get a fact's embedding vector.
1157    pub async fn get_fact_embedding(&self, fact_id: &str) -> Result<Option<Vec<f32>>, MemoryError> {
1158        let fid = fact_id.to_string();
1159        self.with_read_conn(move |conn| get_fact_embedding(conn, &fid))
1160            .await
1161    }
1162
1163    /// List all facts in a namespace using the default `Current` view.
1164    pub async fn list_facts(
1165        &self,
1166        namespace: &str,
1167        limit: usize,
1168        offset: usize,
1169    ) -> Result<Vec<Fact>, MemoryError> {
1170        self.list_facts_with_view(namespace, limit, offset, StateView::Current)
1171            .await
1172    }
1173
1174    /// List facts under an explicit bitemporal authority-state view.
1175    pub async fn list_facts_with_view(
1176        &self,
1177        namespace: &str,
1178        limit: usize,
1179        offset: usize,
1180        view: StateView,
1181    ) -> Result<Vec<Fact>, MemoryError> {
1182        let ns = namespace.to_string();
1183        self.with_read_conn(move |conn| list_facts_with_view(conn, &ns, limit, offset, &view))
1184            .await
1185    }
1186
1187    /// List the distinct namespaces that currently contain facts.
1188    pub async fn list_fact_namespaces(&self) -> Result<Vec<String>, MemoryError> {
1189        self.with_read_conn(move |conn| list_fact_namespaces(conn))
1190            .await
1191    }
1192}
1193
1194#[cfg(test)]
1195mod state_view_regression_tests {
1196    use super::*;
1197    use crate::db::run_migrations;
1198    use rusqlite::Connection;
1199
1200    fn seeded() -> Connection {
1201        let conn = Connection::open_in_memory().unwrap();
1202        run_migrations(&conn).unwrap();
1203        for (id, content, created) in [
1204            ("old", "same topic old", "2026-07-10 21:00:00"),
1205            ("new", "same topic new", "2026-07-10 21:12:01"),
1206        ] {
1207            conn.execute(
1208                "INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES (?1, 'n', ?2, ?3, ?3)",
1209                params![id, content, created],
1210            ).unwrap();
1211        }
1212        conn
1213    }
1214
1215    fn supersedes(conn: &Connection, source: &str, target: &str, recorded: &str) {
1216        conn.execute(
1217            "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1218             VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"type\":\"entity\",\"relation\":\"supersedes\"}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1219            params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1220        ).unwrap();
1221    }
1222
1223    fn supersedes_canonical(conn: &Connection, source: &str, target: &str, recorded: &str) {
1224        conn.execute(
1225            "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1226             VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"entity\":{\"relation\":\"supersedes\"}}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1227            params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1228        ).unwrap();
1229    }
1230
1231    #[test]
1232    fn historical_view_excludes_future_fact_and_reconstructs_pre_supersession_head() {
1233        let conn = seeded();
1234        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1235        let rows = list_facts_with_view(
1236            &conn,
1237            "n",
1238            10,
1239            0,
1240            &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1241        )
1242        .unwrap();
1243        assert_eq!(
1244            rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1245            ["old"]
1246        );
1247    }
1248
1249    #[test]
1250    fn historical_view_preserves_pre_adjudication_conflict() {
1251        let conn = seeded();
1252        conn.execute(
1253            "UPDATE facts SET created_at = '2026-07-10 21:10:00', updated_at = '2026-07-10 21:10:00' WHERE id = 'new'",
1254            [],
1255        )
1256        .unwrap();
1257        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1258
1259        let rows = list_facts_with_view(
1260            &conn,
1261            "n",
1262            10,
1263            0,
1264            &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1265        )
1266        .unwrap();
1267        let ids = rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>();
1268        assert!(
1269            ids.contains(&"old"),
1270            "prior observation must remain visible"
1271        );
1272        assert!(ids.contains(&"new"), "conflicting observation created before the cutoff must remain visible until adjudication");
1273    }
1274
1275    #[test]
1276    fn current_view_excludes_superseded_fact() {
1277        let conn = seeded();
1278        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1279        let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1280        assert_eq!(
1281            rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1282            ["new"]
1283        );
1284    }
1285
1286    #[test]
1287    fn current_view_accepts_canonical_entity_edge_serialization() {
1288        let conn = seeded();
1289        supersedes_canonical(&conn, "new", "old", "2026-07-10 21:12:01");
1290        let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1291        assert_eq!(
1292            rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>(),
1293            vec!["new"]
1294        );
1295    }
1296
1297    #[test]
1298    fn multiple_active_heads_fail_closed() {
1299        let conn = seeded();
1300        conn.execute("INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES ('other', 'n', 'same topic conflicting', '2026-07-10 21:13:00', '2026-07-10 21:13:00')", []).unwrap();
1301        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1302        supersedes(&conn, "other", "old", "2026-07-10 21:13:00");
1303        assert!(list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).is_err());
1304    }
1305}