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