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, OptionalExtension};
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, u64)>,
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, stream_epoch)) = journal {
96            let payload =
97                crate::journal::encode_fact_create_payload(&crate::journal::FactCreatePayloadV1 {
98                    fact_id: fact_id.to_string(),
99                    namespace: namespace.to_string(),
100                    content: content.to_string(),
101                    source: source.map(str::to_string),
102                    metadata: metadata.cloned(),
103                })?;
104            crate::journal::append_verified_in_tx(
105                tx,
106                device_id,
107                store_id,
108                stream_epoch,
109                crate::journal::FACT_CREATE_OPERATION,
110                crate::journal::FACT_CREATE_PAYLOAD_SCHEMA,
111                &payload,
112            )?;
113        }
114
115        Ok(())
116    })
117}
118
119/// Insert a fact within an existing transaction (no nested transaction).
120///
121/// Used by the import boundary where the outer transaction is already active.
122#[allow(clippy::too_many_arguments)]
123pub fn insert_fact_in_tx(
124    tx: &rusqlite::Transaction<'_>,
125    fact_id: &str,
126    namespace: &str,
127    content: &str,
128    embedding_bytes: &[u8],
129    q8_bytes: Option<&[u8]>,
130    source: Option<&str>,
131    metadata: Option<&serde_json::Value>,
132) -> Result<(), MemoryError> {
133    let metadata_str = metadata.map(|m| m.to_string());
134    tx.execute(
135        "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
136         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
137        params![
138            fact_id,
139            namespace,
140            content,
141            source,
142            embedding_bytes,
143            q8_bytes,
144            metadata_str
145        ],
146    )?;
147
148    tx.execute(
149        "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
150        params![fact_id],
151    )?;
152    let fts_rowid = tx.last_insert_rowid();
153
154    tx.execute(
155        "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
156        params![fts_rowid, content],
157    )?;
158
159    #[cfg(feature = "hnsw")]
160    enqueue_pending_index_op(
161        tx,
162        &format!("fact:{}", fact_id),
163        "fact",
164        PendingIndexOpKind::Upsert,
165    )?;
166    db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
167
168    Ok(())
169}
170
171/// Delete a fact and its FTS entry in a transaction.
172#[allow(dead_code)] // public API — used by external consumers, not internally
173pub fn delete_fact_with_fts(conn: &Connection, fact_id: &str) -> Result<(), MemoryError> {
174    with_transaction(conn, |tx| {
175        let fts_rowid: i64 = tx
176            .query_row(
177                "SELECT rowid FROM facts_rowid_map WHERE fact_id = ?1",
178                params![fact_id],
179                |row| row.get(0),
180            )
181            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
182
183        let content: String = tx
184            .query_row(
185                "SELECT content FROM facts WHERE id = ?1",
186                params![fact_id],
187                |row| row.get(0),
188            )
189            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
190
191        tx.execute(
192            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
193            params![fts_rowid, content],
194        )?;
195        tx.execute(
196            "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
197            params![fact_id],
198        )?;
199        tx.execute(
200            "DELETE FROM episode_causes WHERE cause_node_id IN (?1, ?2)",
201            params![fact_id, format!("fact:{fact_id}")],
202        )?;
203        tx.execute(
204            "DELETE FROM derivation_edges
205             WHERE (source_kind = 'fact' AND source_id = ?1)
206                OR (target_kind = 'fact' AND target_id = ?1)",
207            params![fact_id],
208        )?;
209        tx.execute("DELETE FROM facts WHERE id = ?1", params![fact_id])?;
210
211        #[cfg(feature = "hnsw")]
212        enqueue_pending_index_op(
213            tx,
214            &format!("fact:{}", fact_id),
215            "fact",
216            PendingIndexOpKind::Delete,
217        )?;
218        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
219
220        Ok(())
221    })
222}
223
224/// Update a fact's content and embeddings, with FTS synchronization.
225#[allow(dead_code)] // public API — used by external consumers, not internally
226pub fn update_fact_with_fts(
227    conn: &Connection,
228    fact_id: &str,
229    new_content: &str,
230    new_embedding_bytes: &[u8],
231    new_q8_bytes: Option<&[u8]>,
232) -> Result<(), MemoryError> {
233    with_transaction(conn, |tx| {
234        let (fts_rowid, old_content): (i64, String) = tx
235            .query_row(
236                "SELECT fm.rowid, f.content
237                 FROM facts f
238                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
239                 WHERE f.id = ?1",
240                params![fact_id],
241                |row| Ok((row.get(0)?, row.get(1)?)),
242            )
243            .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
244
245        tx.execute(
246            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
247            params![fts_rowid, old_content],
248        )?;
249
250        tx.execute(
251            "UPDATE facts
252             SET content = ?1,
253                 embedding = ?2,
254                 embedding_q8 = ?3,
255                 updated_at = datetime('now')
256             WHERE id = ?4",
257            params![new_content, new_embedding_bytes, new_q8_bytes, fact_id],
258        )?;
259
260        tx.execute(
261            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
262            params![fts_rowid, new_content],
263        )?;
264        tx.execute(
265            "DELETE FROM derivation_edges
266             WHERE (source_kind = 'fact' AND source_id = ?1)
267                OR (target_kind = 'fact' AND target_id = ?1)",
268            params![fact_id],
269        )?;
270
271        #[cfg(feature = "hnsw")]
272        enqueue_pending_index_op(
273            tx,
274            &format!("fact:{}", fact_id),
275            "fact",
276            PendingIndexOpKind::Upsert,
277        )?;
278        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
279
280        Ok(())
281    })
282}
283
284/// Delete all namespace-scoped memory atomically and report every affected surface.
285#[cfg(feature = "admin-ops")]
286pub fn delete_namespace(
287    conn: &Connection,
288    namespace: &str,
289) -> Result<NamespaceDeleteReport, MemoryError> {
290    with_transaction(conn, |tx| {
291        let mut report = NamespaceDeleteReport::default();
292        let delete_session = |session_id: &str| -> Result<(usize, usize), MemoryError> {
293            let message_data: Vec<(i64, String, i64, bool)> = {
294                let mut stmt = tx.prepare(
295                    "SELECT m.id, m.content, mm.rowid, m.embedding IS NOT NULL
296                     FROM messages m
297                     JOIN messages_rowid_map mm ON mm.message_id = m.id
298                     WHERE m.session_id = ?1",
299                )?;
300                let rows = stmt.query_map(params![session_id], |row| {
301                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
302                })?;
303                rows.collect::<Result<Vec<_>, _>>()?
304            };
305
306            for (message_id, content, fts_rowid, has_embedding) in &message_data {
307                #[cfg(not(feature = "hnsw"))]
308                let _ = (message_id, has_embedding);
309                tx.execute(
310                    "INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', ?1, ?2)",
311                    params![fts_rowid, content],
312                )?;
313                #[cfg(feature = "hnsw")]
314                if *has_embedding {
315                    enqueue_pending_index_op(
316                        tx,
317                        &format!("msg:{}", message_id),
318                        "message",
319                        PendingIndexOpKind::Delete,
320                    )?;
321                }
322            }
323
324            let affected = tx.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
325            if affected == 0 {
326                return Err(MemoryError::SessionNotFound(session_id.to_string()));
327            }
328            let hnsw_ops = message_data
329                .iter()
330                .filter(|(_, _, _, has_embedding)| *has_embedding)
331                .count();
332            Ok((message_data.len(), hnsw_ops))
333        };
334
335        let document_ids: Vec<String> = {
336            let mut stmt = tx.prepare("SELECT id FROM documents WHERE namespace = ?1")?;
337            let ids = stmt
338                .query_map(params![namespace], |row| row.get(0))?
339                .collect::<Result<Vec<_>, _>>()?;
340            ids
341        };
342
343        let session_ids: Vec<String> = {
344            let mut stmt = tx.prepare("SELECT id, metadata FROM sessions")?;
345            let rows = stmt.query_map([], |row| {
346                Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
347            })?;
348            let mut ids = Vec::new();
349            for row in rows {
350                let (session_id, metadata_raw) = row?;
351                let metadata = parse_optional_json(
352                    "sessions",
353                    &session_id,
354                    "metadata",
355                    metadata_raw.as_deref(),
356                )?;
357                let namespace_matches = metadata
358                    .as_ref()
359                    .and_then(|value| {
360                        value
361                            .get("namespace")
362                            .or_else(|| value.get("scope_namespace"))
363                    })
364                    .and_then(|value| value.as_str())
365                    == Some(namespace);
366                if namespace_matches {
367                    ids.push(session_id);
368                }
369            }
370            ids
371        };
372
373        for session_id in &session_ids {
374            let (messages, hnsw_ops) = delete_session(session_id)?;
375            report.messages += messages;
376            report.hnsw_ops += hnsw_ops;
377        }
378        report.sessions = session_ids.len();
379
380        let delete_derivation_edges_for_id = |kind: &str, id: &str| -> Result<(), MemoryError> {
381            tx.execute(
382                "DELETE FROM derivation_edges
383                 WHERE (source_kind = ?1 AND source_id = ?2)
384                    OR (target_kind = ?1 AND target_id = ?2)",
385                params![kind, id],
386            )?;
387            Ok(())
388        };
389
390        let delete_derivation_edges_for_ids =
391            |kind: &str, ids: &[String]| -> Result<(), MemoryError> {
392                for id in ids {
393                    delete_derivation_edges_for_id(kind, id)?;
394                }
395                Ok(())
396            };
397
398        let facts: Vec<(String, i64, String)> = {
399            let mut stmt = tx.prepare(
400                "SELECT f.id, fm.rowid, f.content
401                 FROM facts f
402                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
403                 WHERE f.namespace = ?1",
404            )?;
405            let facts = stmt
406                .query_map(params![namespace], |row| {
407                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
408                })?
409                .collect::<Result<Vec<_>, _>>()?;
410            facts
411        };
412
413        for (fact_id, fts_rowid, content) in &facts {
414            tx.execute(
415                "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
416                params![fts_rowid, content],
417            )?;
418            tx.execute(
419                "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
420                params![fact_id],
421            )?;
422
423            #[cfg(feature = "hnsw")]
424            enqueue_pending_index_op(
425                tx,
426                &format!("fact:{}", fact_id),
427                "fact",
428                PendingIndexOpKind::Delete,
429            )?;
430            #[cfg(feature = "hnsw")]
431            {
432                report.hnsw_ops += 1;
433            }
434        }
435        tx.execute("DELETE FROM facts WHERE namespace = ?1", params![namespace])?;
436        report.facts = facts.len();
437
438        for doc_id in &document_ids {
439            let mut stmt = tx.prepare(
440                "SELECT c.id, c.content, cm.rowid
441                 FROM chunks c
442                 JOIN chunks_rowid_map cm ON cm.chunk_id = c.id
443                 WHERE c.document_id = ?1",
444            )?;
445            let chunk_rows: Vec<(String, String, i64)> = stmt
446                .query_map(params![doc_id], |row| {
447                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
448                })?
449                .collect::<Result<Vec<_>, _>>()?;
450            report.chunks += chunk_rows.len();
451
452            for (chunk_id, content, fts_rowid) in &chunk_rows {
453                tx.execute(
454                    "INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', ?1, ?2)",
455                    params![fts_rowid, content],
456                )?;
457                tx.execute(
458                    "DELETE FROM chunks_rowid_map WHERE chunk_id = ?1",
459                    params![chunk_id],
460                )?;
461                #[cfg(feature = "hnsw")]
462                enqueue_pending_index_op(
463                    tx,
464                    &format!("chunk:{}", chunk_id),
465                    "chunk",
466                    PendingIndexOpKind::Delete,
467                )?;
468                #[cfg(feature = "hnsw")]
469                {
470                    report.hnsw_ops += 1;
471                }
472            }
473
474            tx.execute("DELETE FROM chunks WHERE document_id = ?1", params![doc_id])?;
475        }
476
477        for doc_id in &document_ids {
478            let mut stmt = tx.prepare(
479                "SELECT e.episode_id, e.search_text, erm.rowid
480                 FROM episodes e
481                 JOIN episodes_rowid_map erm ON erm.episode_id = e.episode_id
482                 WHERE e.document_id = ?1",
483            )?;
484            let episode_rows: Vec<(String, String, i64)> = stmt
485                .query_map(params![doc_id], |row| {
486                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
487                })?
488                .collect::<Result<Vec<_>, _>>()?;
489            report.episodes += episode_rows.len();
490
491            for (episode_id, search_text, fts_rowid) in &episode_rows {
492                tx.execute(
493                    "INSERT INTO episodes_fts(episodes_fts, rowid, content) VALUES ('delete', ?1, ?2)",
494                    params![fts_rowid, search_text],
495                )?;
496                tx.execute(
497                    "DELETE FROM episodes_rowid_map WHERE episode_id = ?1",
498                    params![episode_id],
499                )?;
500                tx.execute(
501                    "DELETE FROM episode_causes WHERE episode_id = ?1",
502                    params![episode_id],
503                )?;
504                #[cfg(feature = "hnsw")]
505                enqueue_pending_index_op(
506                    tx,
507                    &episodes::episode_item_key(episode_id),
508                    "episode",
509                    PendingIndexOpKind::Delete,
510                )?;
511                #[cfg(feature = "hnsw")]
512                {
513                    report.hnsw_ops += 1;
514                }
515            }
516
517            tx.execute(
518                "DELETE FROM episodes WHERE document_id = ?1",
519                params![doc_id],
520            )?;
521            tx.execute("DELETE FROM documents WHERE id = ?1", params![doc_id])?;
522        }
523        report.documents = document_ids.len();
524
525        let claim_ids: Vec<String> = {
526            let mut stmt =
527                tx.prepare("SELECT claim_id FROM claim_versions WHERE scope_namespace = ?1")?;
528            let ids = stmt
529                .query_map(params![namespace], |row| row.get(0))?
530                .collect::<Result<Vec<_>, _>>()?;
531            ids
532        };
533
534        let claim_version_ids: Vec<String> = {
535            let mut stmt = tx.prepare(
536                "SELECT claim_version_id FROM claim_versions WHERE scope_namespace = ?1",
537            )?;
538            let ids = stmt
539                .query_map(params![namespace], |row| row.get(0))?
540                .collect::<Result<Vec<_>, _>>()?;
541            ids
542        };
543
544        let relation_version_ids: Vec<String> = {
545            let mut stmt = tx.prepare(
546                "SELECT relation_version_id FROM relation_versions WHERE scope_namespace = ?1",
547            )?;
548            let ids = stmt
549                .query_map(params![namespace], |row| row.get(0))?
550                .collect::<Result<Vec<_>, _>>()?;
551            ids
552        };
553
554        let alias_entity_ids: Vec<String> = {
555            let mut stmt = tx.prepare(
556                "SELECT canonical_entity_id FROM entity_aliases WHERE scope_namespace = ?1",
557            )?;
558            let ids = stmt
559                .query_map(params![namespace], |row| row.get(0))?
560                .collect::<Result<Vec<_>, _>>()?;
561            ids
562        };
563
564        let evidence_handles: Vec<String> = {
565            let mut stmt = tx.prepare(
566                "SELECT er.fetch_handle FROM evidence_refs er
567                 JOIN projection_import_log pil ON er.source_envelope_id = pil.source_envelope_id
568                 WHERE pil.scope_namespace = ?1",
569            )?;
570            let handles = stmt
571                .query_map(params![namespace], |row| row.get(0))?
572                .collect::<Result<Vec<_>, _>>()?;
573            handles
574        };
575
576        let episode_ids: Vec<String> = {
577            let mut stmt = tx.prepare(
578                "SELECT episode_id FROM episode_links
579                 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
580            )?;
581            let ids = stmt
582                .query_map(params![namespace], |row| row.get(0))?
583                .collect::<Result<Vec<_>, _>>()?;
584            ids
585        };
586
587        delete_derivation_edges_for_ids("claim", &claim_ids)?;
588        delete_derivation_edges_for_ids("claim_version", &claim_version_ids)?;
589        delete_derivation_edges_for_ids("relation_version", &relation_version_ids)?;
590        delete_derivation_edges_for_ids("entity", &alias_entity_ids)?;
591        delete_derivation_edges_for_ids("evidence_ref", &evidence_handles)?;
592        delete_derivation_edges_for_ids("episode", &episode_ids)?;
593
594        report.projection_rows += tx.execute(
595            "DELETE FROM claim_versions WHERE scope_namespace = ?1",
596            params![namespace],
597        )?;
598        report.projection_rows += tx.execute(
599            "DELETE FROM relation_versions WHERE scope_namespace = ?1",
600            params![namespace],
601        )?;
602        report.projection_rows += tx.execute(
603            "DELETE FROM entity_aliases WHERE scope_namespace = ?1",
604            params![namespace],
605        )?;
606        report.projection_rows += tx.execute(
607            "DELETE FROM evidence_refs
608             WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
609            params![namespace],
610        )?;
611        report.projection_rows += tx.execute(
612            "DELETE FROM episode_links
613             WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
614            params![namespace],
615        )?;
616        report.projection_rows += tx.execute(
617            "DELETE FROM projection_import_failures WHERE scope_namespace = ?1",
618            params![namespace],
619        )?;
620        report.projection_rows += tx.execute(
621            "DELETE FROM projection_import_log WHERE scope_namespace = ?1",
622            params![namespace],
623        )?;
624
625        Ok(report)
626    })
627}
628
629/// Get a fact by ID.
630pub fn get_fact(conn: &Connection, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
631    let result = conn.query_row(
632        "SELECT id, namespace, content, source, created_at, updated_at, metadata
633         FROM facts WHERE id = ?1",
634        params![fact_id],
635        |row| {
636            Ok((
637                row.get::<_, String>(0)?,
638                row.get::<_, String>(1)?,
639                row.get::<_, String>(2)?,
640                row.get::<_, Option<String>>(3)?,
641                row.get::<_, String>(4)?,
642                row.get::<_, String>(5)?,
643                row.get::<_, Option<String>>(6)?,
644            ))
645        },
646    );
647
648    match result {
649        Ok((id, namespace, content, source, created_at, updated_at, metadata_raw)) => {
650            Ok(Some(Fact {
651                metadata: parse_optional_json("facts", &id, "metadata", metadata_raw.as_deref())?,
652                id,
653                namespace,
654                content,
655                source,
656                created_at,
657                updated_at,
658            }))
659        }
660        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
661        Err(err) => Err(MemoryError::Database(err)),
662    }
663}
664
665/// Get a fact embedding vector.
666pub fn get_fact_embedding(
667    conn: &Connection,
668    fact_id: &str,
669) -> Result<Option<Vec<f32>>, MemoryError> {
670    let result: Result<Option<Vec<u8>>, _> = conn.query_row(
671        "SELECT embedding FROM facts WHERE id = ?1",
672        params![fact_id],
673        |row| row.get(0),
674    );
675
676    match result {
677        Ok(Some(bytes)) => Ok(Some(bytes_to_embedding(&bytes)?)),
678        Ok(None) => Ok(None),
679        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
680        Err(err) => Err(MemoryError::Database(err)),
681    }
682}
683
684/// List the distinct namespaces that currently contain facts.
685pub fn list_fact_namespaces(conn: &Connection) -> Result<Vec<String>, MemoryError> {
686    let mut stmt = conn.prepare("SELECT DISTINCT namespace FROM facts ORDER BY namespace")?;
687    let rows = stmt
688        .query_map([], |row| row.get::<_, String>(0))?
689        .collect::<Result<Vec<_>, _>>()?;
690    Ok(rows)
691}
692
693/// List facts within a namespace.
694#[allow(dead_code)] // retained as an internal compatibility seam for older callers
695pub fn list_facts(
696    conn: &Connection,
697    namespace: &str,
698    limit: usize,
699    offset: usize,
700) -> Result<Vec<Fact>, MemoryError> {
701    list_facts_with_view(conn, namespace, limit, offset, &StateView::Current)
702}
703
704/// Authority state selected by a fact retrieval.
705#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
706pub enum StateView {
707    Current,
708    HistoricalAt(String),
709    RecordedAsOf(String),
710    IncludeSuperseded,
711}
712
713pub(crate) fn fact_is_visible_with_view(
714    conn: &Connection,
715    fact_id: &str,
716    view: &StateView,
717) -> Result<bool, MemoryError> {
718    let forgotten: bool = conn.query_row(
719        "SELECT EXISTS(SELECT 1 FROM forgotten_facts WHERE fact_id = ?1)",
720        params![fact_id],
721        |row| row.get(0),
722    )?;
723    if forgotten {
724        return Ok(false);
725    }
726    let cutoff = match view {
727        StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
728            let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
729                MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
730            })?;
731            Some(
732                parsed
733                    .with_timezone(&chrono::Utc)
734                    .format("%Y-%m-%d %H:%M:%S%.6f")
735                    .to_string(),
736            )
737        }
738        _ => None,
739    };
740    let include_superseded = matches!(view, StateView::IncludeSuperseded);
741    let visible: i64 = conn.query_row(
742        "SELECT EXISTS(
743             SELECT 1 FROM facts f
744             WHERE f.id = ?1
745               AND (?2 IS NULL OR f.created_at <= ?2)
746               AND (?3 = 1 OR NOT EXISTS (
747                   SELECT 1 FROM graph_edges ge
748                   WHERE ge.target = 'fact:' || f.id
749                     AND ge.is_invalidated = 0
750                     AND COALESCE(
751                         json_extract(ge.edge_type, '$.relation'),
752                         json_extract(ge.edge_type, '$.entity.relation')
753                     ) IN ('supersedes', 'redacts')
754                     AND (?2 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?2)
755               ))
756         )",
757        params![fact_id, cutoff, include_superseded],
758        |row| row.get(0),
759    )?;
760    Ok(visible != 0)
761}
762
763/// List facts under an explicit authority-state view. Inconsistent lineage is rejected.
764pub fn list_facts_with_view(
765    conn: &Connection,
766    namespace: &str,
767    limit: usize,
768    offset: usize,
769    view: &StateView,
770) -> Result<Vec<Fact>, MemoryError> {
771    let cutoff = match view {
772        StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
773            let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
774                MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
775            })?;
776            Some(
777                parsed
778                    .with_timezone(&chrono::Utc)
779                    .format("%Y-%m-%d %H:%M:%S%.6f")
780                    .to_string(),
781            )
782        }
783        _ => None,
784    };
785    let inconsistent: i64 = conn.query_row(
786        "SELECT COUNT(*) FROM (
787             SELECT target FROM graph_edges
788             WHERE is_invalidated = 0
789               AND COALESCE(
790                   json_extract(edge_type, '$.relation'),
791                   json_extract(edge_type, '$.entity.relation')
792               ) IN ('supersedes', 'redacts')
793               AND (?1 IS NULL OR COALESCE(recorded_time, recorded_at) <= ?1)
794             GROUP BY target HAVING COUNT(DISTINCT source) > 1
795         )",
796        params![cutoff.as_deref()],
797        |row| row.get(0),
798    )?;
799    if inconsistent != 0 {
800        return Err(MemoryError::Other(
801            "inconsistent fact lineage: multiple active heads".into(),
802        ));
803    }
804    let include_superseded = matches!(view, StateView::IncludeSuperseded);
805    let mut stmt = conn.prepare(
806        "SELECT id, namespace, content, source, created_at, updated_at, metadata
807         FROM facts
808         WHERE namespace = ?1
809           AND NOT EXISTS (
810               SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = facts.id
811           )
812           AND (?4 IS NULL OR created_at <= ?4)
813           AND (?5 = 1 OR NOT EXISTS (
814               SELECT 1 FROM graph_edges ge
815               WHERE ge.target = 'fact:' || facts.id
816                 AND ge.is_invalidated = 0
817                 AND COALESCE(
818                     json_extract(ge.edge_type, '$.relation'),
819                     json_extract(ge.edge_type, '$.entity.relation')
820                 ) IN ('supersedes', 'redacts')
821                 AND (?4 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?4)
822           ))
823         ORDER BY updated_at DESC
824         LIMIT ?2 OFFSET ?3",
825    )?;
826
827    let facts = stmt
828        .query_map(
829            params![
830                namespace,
831                limit as i64,
832                offset as i64,
833                cutoff,
834                include_superseded
835            ],
836            |row| {
837                Ok((
838                    row.get::<_, String>(0)?,
839                    row.get::<_, String>(1)?,
840                    row.get::<_, String>(2)?,
841                    row.get::<_, Option<String>>(3)?,
842                    row.get::<_, String>(4)?,
843                    row.get::<_, String>(5)?,
844                    row.get::<_, Option<String>>(6)?,
845                ))
846            },
847        )?
848        .collect::<Result<Vec<_>, _>>()?
849        .into_iter()
850        .map(
851            |(id, namespace, content, source, created_at, updated_at, metadata_raw)| {
852                Ok(Fact {
853                    metadata: parse_optional_json(
854                        "facts",
855                        &id,
856                        "metadata",
857                        metadata_raw.as_deref(),
858                    )?,
859                    id,
860                    namespace,
861                    content,
862                    source,
863                    created_at,
864                    updated_at,
865                })
866            },
867        )
868        .collect::<Result<Vec<_>, MemoryError>>()?;
869
870    Ok(facts)
871}
872
873impl MemoryStore {
874    /// Explicitly ungoverned compatibility write.
875    ///
876    /// This preserves the pre-authority raw storage API for migrations and local tooling. It does
877    /// not create an origin label and its output is therefore denied by every governed path.
878    pub async fn add_fact_raw_compat(
879        &self,
880        namespace: &str,
881        content: &str,
882        source: Option<&str>,
883        metadata: Option<serde_json::Value>,
884        trace_ctx: Option<&TraceCtx>,
885    ) -> Result<Fact, MemoryError> {
886        let id = self
887            .add_fact_with_trace(namespace, content, source, metadata, trace_ctx)
888            .await?;
889        self.get_fact(&id)
890            .await?
891            .ok_or(MemoryError::FactNotFound(id))
892    }
893
894    /// Store a fact with automatic embedding. Returns the fact ID (UUID v4).
895    ///
896    /// This is a non-authoritative storage primitive. Governed mutations must
897    /// use [`MemoryStore::authority`] so admission and lineage are enforced.
898    pub async fn add_fact(
899        &self,
900        namespace: &str,
901        content: &str,
902        source: Option<&str>,
903        metadata: Option<serde_json::Value>,
904    ) -> Result<String, MemoryError> {
905        self.add_fact_with_trace(namespace, content, source, metadata, None)
906            .await
907    }
908
909    /// Store a fact with automatic embedding and optional trace metadata.
910    pub async fn add_fact_with_trace(
911        &self,
912        namespace: &str,
913        content: &str,
914        source: Option<&str>,
915        metadata: Option<serde_json::Value>,
916        trace_ctx: Option<&TraceCtx>,
917    ) -> Result<String, MemoryError> {
918        self.validate_content("fact.content", content)?;
919
920        // Dedup: check if a fact with the same content already exists.
921        // This prevents the 4-5% DB bloat from duplicate ingestion.
922        let ns_check = namespace.to_string();
923        let ct_check = content.to_string();
924        let existing_id = self
925            .with_read_conn(move |conn| {
926                let result: Option<String> = conn
927                    .query_row(
928                        "SELECT id FROM facts WHERE content = ?1 AND namespace = ?2 LIMIT 1",
929                        rusqlite::params![&ct_check, &ns_check],
930                        |row| row.get::<_, String>(0),
931                    )
932                    .ok();
933                Ok(result)
934            })
935            .await?;
936
937        if let Some(id) = existing_id {
938            return Ok(id);
939        }
940
941        let (embedding, sparse, sparse_representation) = self
942            .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
943            .await?;
944        self.validate_embedding_dimensions(&embedding)?;
945        let embedding_bytes = db::embedding_to_bytes(&embedding);
946        let fact_id = uuid::Uuid::new_v4().to_string();
947        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
948
949        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
950        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
951        let q8_bytes = quantizer
952            .quantize(&embedding)
953            .map(|qv| quantize::pack_quantized(&qv))
954            .ok();
955
956        let ns = namespace.to_string();
957        let ct = content.to_string();
958        let fid = fact_id.clone();
959        let src = source.map(|s| s.to_string());
960        let meta = merge_trace_ctx(metadata, trace_ctx);
961        let journal = self.replication_journal_identity();
962        self.with_write_conn(move |conn| {
963            let current_count: usize = conn.query_row(
964                "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
965                rusqlite::params![&ns],
966                |row| row.get(0),
967            )?;
968            if current_count >= max_facts_per_namespace {
969                return Err(MemoryError::NamespaceFull {
970                    namespace: ns.clone(),
971                    count: current_count,
972                    limit: max_facts_per_namespace,
973                });
974            }
975            insert_fact_with_fts_q8(
976                conn,
977                &fid,
978                &ns,
979                &ct,
980                &embedding_bytes,
981                q8_bytes.as_deref(),
982                src.as_deref(),
983                meta.as_ref(),
984                sparse.as_ref().zip(sparse_representation.as_deref()),
985                journal.as_ref().map(|(device_id, store_id, stream_epoch)| {
986                    (device_id.as_str(), store_id.as_str(), *stream_epoch)
987                }),
988            )
989        })
990        .await?;
991
992        self.clear_search_cache();
993
994        #[cfg(feature = "hnsw")]
995        self.sync_pending_hnsw_ops_best_effort("add_fact").await;
996
997        Ok(fact_id)
998    }
999
1000    /// Store a fact with a pre-computed embedding.
1001    pub async fn add_fact_with_embedding(
1002        &self,
1003        namespace: &str,
1004        content: &str,
1005        embedding: &[f32],
1006        source: Option<&str>,
1007        metadata: Option<serde_json::Value>,
1008    ) -> Result<String, MemoryError> {
1009        self.add_fact_with_embedding_and_trace(
1010            namespace, content, embedding, source, metadata, None,
1011        )
1012        .await
1013    }
1014
1015    /// Store a fact with a pre-computed embedding and optional trace metadata.
1016    pub async fn add_fact_with_embedding_and_trace(
1017        &self,
1018        namespace: &str,
1019        content: &str,
1020        embedding: &[f32],
1021        source: Option<&str>,
1022        metadata: Option<serde_json::Value>,
1023        trace_ctx: Option<&TraceCtx>,
1024    ) -> Result<String, MemoryError> {
1025        self.validate_content("fact.content", content)?;
1026        self.validate_embedding_dimensions(embedding)?;
1027        let embedding_bytes = db::embedding_to_bytes(embedding);
1028        let sparse = self.inner.config.search.derive_sparse_from_dense.then(|| {
1029            crate::SparseWeights::from_dense(
1030                embedding,
1031                self.inner.config.search.sparse_derive_top_k,
1032                self.inner.config.search.sparse_derive_min_weight,
1033            )
1034        });
1035        let fact_id = uuid::Uuid::new_v4().to_string();
1036        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1037
1038        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
1039        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1040        let q8_bytes = quantizer
1041            .quantize(embedding)
1042            .map(|qv| quantize::pack_quantized(&qv))
1043            .ok();
1044
1045        let ns = namespace.to_string();
1046        let ct = content.to_string();
1047        let fid = fact_id.clone();
1048        let src = source.map(|s| s.to_string());
1049        let meta = merge_trace_ctx(metadata, trace_ctx);
1050        let journal = self.replication_journal_identity();
1051        self.with_write_conn(move |conn| {
1052            let current_count: usize = conn.query_row(
1053                "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1054                rusqlite::params![&ns],
1055                |row| row.get(0),
1056            )?;
1057            if current_count >= max_facts_per_namespace {
1058                return Err(MemoryError::NamespaceFull {
1059                    namespace: ns.clone(),
1060                    count: current_count,
1061                    limit: max_facts_per_namespace,
1062                });
1063            }
1064            insert_fact_with_fts_q8(
1065                conn,
1066                &fid,
1067                &ns,
1068                &ct,
1069                &embedding_bytes,
1070                q8_bytes.as_deref(),
1071                src.as_deref(),
1072                meta.as_ref(),
1073                sparse
1074                    .as_ref()
1075                    .map(|weights| (weights, "generic_dense_derived_sparse")),
1076                journal.as_ref().map(|(device_id, store_id, stream_epoch)| {
1077                    (device_id.as_str(), store_id.as_str(), *stream_epoch)
1078                }),
1079            )
1080        })
1081        .await?;
1082
1083        self.clear_search_cache();
1084
1085        #[cfg(feature = "hnsw")]
1086        self.sync_pending_hnsw_ops_best_effort("add_fact_with_embedding")
1087            .await;
1088
1089        Ok(fact_id)
1090    }
1091
1092    /// Apply one closed, verified fact-create envelope to a replica shard.
1093    ///
1094    /// The exact canonical fact ID, semantic row, FTS/index bookkeeping,
1095    /// receiver inbox, stream head, and durable duplicate evidence commit in a
1096    /// single SQLite transaction. The caller cannot provide SQL or a replay
1097    /// callback. Transport authentication must be completed before this owner
1098    /// API is called; this method independently validates semantic-memory's
1099    /// canonical payload and digest-chain contract.
1100    pub async fn apply_verified_fact_create(
1101        &self,
1102        envelope: crate::journal::FactCreateReplicaEnvelopeV1,
1103    ) -> Result<crate::journal::ReplicaApplyOutcome, MemoryError> {
1104        use crate::journal::{
1105            validate_fact_create_replica_envelope, ReplicaApplyOutcome, GENESIS_PREDECESSOR,
1106        };
1107
1108        let payload = validate_fact_create_replica_envelope(&envelope)?;
1109
1110        // Fast-path terminal stream decisions before embedding. The write
1111        // transaction below repeats every check authoritatively, so this is an
1112        // optimization rather than a trust boundary. It ensures a retry can
1113        // recover a durable Duplicate ACK even while the embedding provider is
1114        // unavailable.
1115        let preflight_device_id = envelope.home_device_id.clone();
1116        let preflight_store_id = envelope.store_id.clone();
1117        let preflight_epoch =
1118            i64::try_from(envelope.stream_epoch).map_err(|_| MemoryError::InvalidConfig {
1119                field: "replication.stream_epoch",
1120                reason: "does not fit SQLite INTEGER".to_string(),
1121            })?;
1122        let preflight_stream_epoch = envelope.stream_epoch;
1123        let preflight_sequence = envelope.sequence;
1124        let preflight_envelope_digest = envelope.envelope_digest;
1125        let preflight_predecessor_digest = envelope.predecessor_digest;
1126        let preflight = self
1127            .with_read_conn(move |conn| {
1128                let existing_digest: Option<Vec<u8>> = conn
1129                    .query_row(
1130                        "SELECT envelope_digest FROM replication_inbox
1131                         WHERE home_device_id = ?1 AND store_id = ?2
1132                           AND stream_epoch = ?3 AND sequence = ?4",
1133                        rusqlite::params![
1134                            &preflight_device_id,
1135                            &preflight_store_id,
1136                            preflight_epoch,
1137                            preflight_sequence,
1138                        ],
1139                        |row| row.get(0),
1140                    )
1141                    .optional()?;
1142                if let Some(existing_digest) = existing_digest {
1143                    return if existing_digest.as_slice() == preflight_envelope_digest.as_slice() {
1144                        Ok(Some(ReplicaApplyOutcome::Duplicate {
1145                            sequence: preflight_sequence,
1146                        }))
1147                    } else {
1148                        Ok(Some(ReplicaApplyOutcome::Fork {
1149                            sequence: preflight_sequence,
1150                        }))
1151                    };
1152                }
1153
1154                let stream: Option<(i64, i64, Vec<u8>)> = conn
1155                    .query_row(
1156                        "SELECT stream_epoch, next_sequence, head_digest
1157                         FROM replication_inbox_streams
1158                         WHERE home_device_id = ?1 AND store_id = ?2",
1159                        rusqlite::params![&preflight_device_id, &preflight_store_id],
1160                        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1161                    )
1162                    .optional()?;
1163                let Some((active_epoch, expected, head_bytes)) = stream else {
1164                    return if preflight_sequence > 1 {
1165                        Ok(Some(ReplicaApplyOutcome::Gap {
1166                            expected: 1,
1167                            received: preflight_sequence,
1168                        }))
1169                    } else if preflight_predecessor_digest != GENESIS_PREDECESSOR {
1170                        Ok(Some(ReplicaApplyOutcome::Fork {
1171                            sequence: preflight_sequence,
1172                        }))
1173                    } else {
1174                        Ok(None)
1175                    };
1176                };
1177                let active = u64::try_from(active_epoch).map_err(|_| MemoryError::CorruptData {
1178                    table: "replication_inbox_streams",
1179                    row_id: format!("{preflight_device_id}/{preflight_store_id}"),
1180                    detail: "active stream epoch is negative".to_string(),
1181                })?;
1182                if active != preflight_stream_epoch {
1183                    return Ok(Some(ReplicaApplyOutcome::EpochConflict {
1184                        active,
1185                        received: preflight_stream_epoch,
1186                    }));
1187                }
1188                if preflight_sequence > expected {
1189                    return Ok(Some(ReplicaApplyOutcome::Gap {
1190                        expected,
1191                        received: preflight_sequence,
1192                    }));
1193                }
1194                if preflight_sequence < expected {
1195                    return Ok(Some(ReplicaApplyOutcome::Fork {
1196                        sequence: preflight_sequence,
1197                    }));
1198                }
1199                let head: [u8; 32] =
1200                    head_bytes
1201                        .try_into()
1202                        .map_err(|bytes: Vec<u8>| MemoryError::CorruptData {
1203                            table: "replication_inbox_streams",
1204                            row_id: format!("{preflight_device_id}/{preflight_store_id}"),
1205                            detail: format!("head digest must be 32 bytes, got {}", bytes.len()),
1206                        })?;
1207                if preflight_predecessor_digest != head {
1208                    return Ok(Some(ReplicaApplyOutcome::Fork {
1209                        sequence: preflight_sequence,
1210                    }));
1211                }
1212                Ok(None)
1213            })
1214            .await?;
1215        if let Some(outcome) = preflight {
1216            return Ok(outcome);
1217        }
1218
1219        self.validate_content("fact.content", &payload.content)?;
1220        let (embedding, sparse, sparse_representation) = self
1221            .embed_text_with_sparse_internal(&payload.content, crate::EmbeddingPurpose::Document)
1222            .await?;
1223        self.validate_embedding_dimensions(&embedding)?;
1224        let embedding_bytes = db::embedding_to_bytes(&embedding);
1225        let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1226            .quantize(&embedding)
1227            .map(|value| quantize::pack_quantized(&value))
1228            .ok();
1229        let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1230        let applied_fact_id = payload.fact_id.clone();
1231
1232        let outcome = self
1233            .with_write_conn(move |conn| {
1234                let epoch = i64::try_from(envelope.stream_epoch).map_err(|_| {
1235                    MemoryError::InvalidConfig {
1236                        field: "replication.stream_epoch",
1237                        reason: "does not fit SQLite INTEGER".to_string(),
1238                    }
1239                })?;
1240                let next_sequence =
1241                    envelope
1242                        .sequence
1243                        .checked_add(1)
1244                        .ok_or_else(|| MemoryError::CorruptData {
1245                            table: "replication_inbox",
1246                            row_id: envelope.sequence.to_string(),
1247                            detail: "sequence overflow".to_string(),
1248                        })?;
1249
1250                // SAFETY: semantic-memory owns the single writer connection;
1251                // all receiver state below must share this outer transaction.
1252                let tx = conn.unchecked_transaction()?;
1253
1254                let existing_digest: Option<Vec<u8>> = tx
1255                    .query_row(
1256                        "SELECT envelope_digest FROM replication_inbox
1257                         WHERE home_device_id = ?1 AND store_id = ?2
1258                           AND stream_epoch = ?3 AND sequence = ?4",
1259                        rusqlite::params![
1260                            &envelope.home_device_id,
1261                            &envelope.store_id,
1262                            epoch,
1263                            envelope.sequence,
1264                        ],
1265                        |row| row.get(0),
1266                    )
1267                    .optional()?;
1268                if let Some(existing_digest) = existing_digest {
1269                    return if existing_digest.as_slice() == envelope.envelope_digest.as_slice() {
1270                        Ok(ReplicaApplyOutcome::Duplicate {
1271                            sequence: envelope.sequence,
1272                        })
1273                    } else {
1274                        Ok(ReplicaApplyOutcome::Fork {
1275                            sequence: envelope.sequence,
1276                        })
1277                    };
1278                }
1279
1280                let stream: Option<(i64, i64, Vec<u8>)> = tx
1281                    .query_row(
1282                        "SELECT stream_epoch, next_sequence, head_digest
1283                         FROM replication_inbox_streams
1284                         WHERE home_device_id = ?1 AND store_id = ?2",
1285                        rusqlite::params![&envelope.home_device_id, &envelope.store_id],
1286                        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1287                    )
1288                    .optional()?;
1289
1290                let (expected, expected_predecessor, create_stream) =
1291                    if let Some((active_epoch, expected, head_bytes)) = stream {
1292                        let active =
1293                            u64::try_from(active_epoch).map_err(|_| MemoryError::CorruptData {
1294                                table: "replication_inbox_streams",
1295                                row_id: format!(
1296                                    "{}/{}",
1297                                    envelope.home_device_id, envelope.store_id
1298                                ),
1299                                detail: "active stream epoch is negative".to_string(),
1300                            })?;
1301                        if active != envelope.stream_epoch {
1302                            return Ok(ReplicaApplyOutcome::EpochConflict {
1303                                active,
1304                                received: envelope.stream_epoch,
1305                            });
1306                        }
1307                        let head: [u8; 32] = head_bytes.try_into().map_err(|bytes: Vec<u8>| {
1308                            MemoryError::CorruptData {
1309                                table: "replication_inbox_streams",
1310                                row_id: format!(
1311                                    "{}/{}",
1312                                    envelope.home_device_id, envelope.store_id
1313                                ),
1314                                detail: format!(
1315                                    "head digest must be 32 bytes, got {}",
1316                                    bytes.len()
1317                                ),
1318                            }
1319                        })?;
1320                        (expected, head, false)
1321                    } else {
1322                        (1, GENESIS_PREDECESSOR, true)
1323                    };
1324
1325                if envelope.sequence > expected {
1326                    return Ok(ReplicaApplyOutcome::Gap {
1327                        expected,
1328                        received: envelope.sequence,
1329                    });
1330                }
1331                if envelope.sequence < expected
1332                    || envelope.predecessor_digest != expected_predecessor
1333                {
1334                    return Ok(ReplicaApplyOutcome::Fork {
1335                        sequence: envelope.sequence,
1336                    });
1337                }
1338
1339                let existing_fact: Option<(String, String, Option<String>, Option<String>)> = tx
1340                    .query_row(
1341                        "SELECT namespace, content, source, metadata FROM facts WHERE id = ?1",
1342                        [&payload.fact_id],
1343                        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1344                    )
1345                    .optional()?;
1346                if let Some((namespace, content, source, metadata_raw)) = existing_fact {
1347                    let metadata = metadata_raw
1348                        .map(|raw| {
1349                            serde_json::from_str::<serde_json::Value>(&raw).map_err(|error| {
1350                                MemoryError::CorruptData {
1351                                    table: "facts",
1352                                    row_id: payload.fact_id.clone(),
1353                                    detail: format!("invalid stored metadata JSON: {error}"),
1354                                }
1355                            })
1356                        })
1357                        .transpose()?;
1358                    if namespace != payload.namespace
1359                        || content != payload.content
1360                        || source != payload.source
1361                        || metadata != payload.metadata
1362                    {
1363                        return Ok(ReplicaApplyOutcome::Fork {
1364                            sequence: envelope.sequence,
1365                        });
1366                    }
1367                } else {
1368                    let current_count: usize = tx.query_row(
1369                        "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1370                        [&payload.namespace],
1371                        |row| row.get(0),
1372                    )?;
1373                    if current_count >= max_facts_per_namespace {
1374                        return Err(MemoryError::NamespaceFull {
1375                            namespace: payload.namespace.clone(),
1376                            count: current_count,
1377                            limit: max_facts_per_namespace,
1378                        });
1379                    }
1380                    insert_fact_in_tx(
1381                        &tx,
1382                        &payload.fact_id,
1383                        &payload.namespace,
1384                        &payload.content,
1385                        &embedding_bytes,
1386                        q8_bytes.as_deref(),
1387                        payload.source.as_deref(),
1388                        payload.metadata.as_ref(),
1389                    )?;
1390                    if let Some((weights, representation)) =
1391                        sparse.as_ref().zip(sparse_representation.as_deref())
1392                    {
1393                        db::store_sparse_vector(
1394                            &tx,
1395                            &format!("fact:{}", payload.fact_id),
1396                            weights,
1397                            representation,
1398                        )?;
1399                    }
1400                }
1401
1402                if create_stream {
1403                    tx.execute(
1404                        "INSERT INTO replication_inbox_streams
1405                         (home_device_id, store_id, stream_epoch, next_sequence, head_digest)
1406                         VALUES (?1, ?2, ?3, 1, ?4)",
1407                        rusqlite::params![
1408                            &envelope.home_device_id,
1409                            &envelope.store_id,
1410                            epoch,
1411                            GENESIS_PREDECESSOR.as_slice(),
1412                        ],
1413                    )?;
1414                }
1415
1416                tx.execute(
1417                    "INSERT INTO replication_inbox
1418                     (home_device_id, store_id, stream_epoch, sequence,
1419                      operation_kind, payload_schema, payload, payload_digest,
1420                      predecessor_digest, envelope_digest, fact_id)
1421                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1422                    rusqlite::params![
1423                        &envelope.home_device_id,
1424                        &envelope.store_id,
1425                        epoch,
1426                        envelope.sequence,
1427                        &envelope.operation_kind,
1428                        &envelope.payload_schema,
1429                        &envelope.payload,
1430                        envelope.payload_digest.as_slice(),
1431                        envelope.predecessor_digest.as_slice(),
1432                        envelope.envelope_digest.as_slice(),
1433                        &payload.fact_id,
1434                    ],
1435                )?;
1436
1437                let advanced = tx.execute(
1438                    "UPDATE replication_inbox_streams
1439                     SET next_sequence = ?4, head_digest = ?5, updated_at = datetime('now')
1440                     WHERE home_device_id = ?1 AND store_id = ?2 AND stream_epoch = ?3
1441                       AND next_sequence = ?6 AND head_digest = ?7",
1442                    rusqlite::params![
1443                        &envelope.home_device_id,
1444                        &envelope.store_id,
1445                        epoch,
1446                        next_sequence,
1447                        envelope.envelope_digest.as_slice(),
1448                        envelope.sequence,
1449                        expected_predecessor.as_slice(),
1450                    ],
1451                )?;
1452                if advanced != 1 {
1453                    return Err(MemoryError::Other(
1454                        "replication inbox stream allocator lost ownership".to_string(),
1455                    ));
1456                }
1457                tx.commit()?;
1458                Ok(ReplicaApplyOutcome::Applied {
1459                    sequence: envelope.sequence,
1460                    fact_id: payload.fact_id,
1461                })
1462            })
1463            .await?;
1464
1465        if matches!(
1466            &outcome,
1467            crate::journal::ReplicaApplyOutcome::Applied { .. }
1468        ) {
1469            self.clear_search_cache();
1470            #[cfg(feature = "hnsw")]
1471            self.sync_pending_hnsw_ops_best_effort("apply_verified_fact_create")
1472                .await;
1473        }
1474        debug_assert!(
1475            !matches!(&outcome, crate::journal::ReplicaApplyOutcome::Applied { fact_id, .. } if fact_id != &applied_fact_id),
1476            "receiver returned a fact ID different from the validated payload"
1477        );
1478        Ok(outcome)
1479    }
1480
1481    /// **DANGER**: This physically mutates/deletes a truth-bearing row.
1482    /// This is admin-only and gated behind the `admin-ops` feature.
1483    /// Default agent-facing APIs should use supersession (add a new fact
1484    /// with a supersession link) instead of hard delete/update.
1485    #[cfg(feature = "admin-ops")]
1486    pub async fn update_fact(&self, fact_id: &str, content: &str) -> Result<(), MemoryError> {
1487        self.validate_content("fact.content", content)?;
1488        let (embedding, sparse, sparse_representation) = self
1489            .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
1490            .await?;
1491        self.validate_embedding_dimensions(&embedding)?;
1492        let embedding_bytes = db::embedding_to_bytes(&embedding);
1493        // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1494        let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1495            .quantize(&embedding)
1496            .map(|qv| quantize::pack_quantized(&qv))
1497            .ok();
1498
1499        let fid = fact_id.to_string();
1500        let ct = content.to_string();
1501        self.with_write_conn(move |conn| {
1502            update_fact_with_fts(conn, &fid, &ct, &embedding_bytes, q8_bytes.as_deref())?;
1503            let item_key = format!("fact:{fid}");
1504            if let Some((weights, representation)) =
1505                sparse.as_ref().zip(sparse_representation.as_deref())
1506            {
1507                db::store_sparse_vector(conn, &item_key, weights, representation)?;
1508            } else {
1509                db::delete_sparse_vector(conn, &item_key)?;
1510            }
1511            Ok(())
1512        })
1513        .await?;
1514
1515        #[cfg(feature = "hnsw")]
1516        self.sync_pending_hnsw_ops_best_effort("update_fact").await;
1517
1518        self.clear_search_cache();
1519
1520        Ok(())
1521    }
1522
1523    /// **DANGER**: This physically mutates/deletes a truth-bearing row.
1524    /// This is admin-only and gated behind the `admin-ops` feature.
1525    /// Default agent-facing APIs should use supersession (add a new fact
1526    /// with a supersession link) instead of hard delete/update.
1527    #[cfg(feature = "admin-ops")]
1528    pub async fn delete_fact(&self, fact_id: &str) -> Result<(), MemoryError> {
1529        let fid = fact_id.to_string();
1530        self.with_write_conn(move |conn| delete_fact_with_fts(conn, &fid))
1531            .await?;
1532
1533        #[cfg(feature = "hnsw")]
1534        self.sync_pending_hnsw_ops_best_effort("delete_fact").await;
1535
1536        self.clear_search_cache();
1537
1538        Ok(())
1539    }
1540
1541    /// **DANGER**: physically deletes every truth-bearing row in a namespace.
1542    /// This is admin-only and gated behind the `admin-ops` feature. Ordinary
1543    /// callers must use governed supersession/forgetting flows instead.
1544    #[cfg(feature = "admin-ops")]
1545    pub async fn delete_namespace(
1546        &self,
1547        namespace: &str,
1548    ) -> Result<NamespaceDeleteReport, MemoryError> {
1549        let ns = namespace.to_string();
1550        let count = self
1551            .with_write_conn(move |conn| delete_namespace(conn, &ns))
1552            .await?;
1553
1554        #[cfg(feature = "hnsw")]
1555        self.sync_pending_hnsw_ops_best_effort("delete_namespace")
1556            .await;
1557
1558        self.clear_search_cache();
1559
1560        Ok(count)
1561    }
1562
1563    /// Get a fact by ID.
1564    pub async fn get_fact(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1565        let fid = fact_id.to_string();
1566        self.with_read_conn(move |conn| get_fact(conn, &fid)).await
1567    }
1568
1569    /// Explicitly ungoverned compatibility read. Prefer `authority().get_fact_governed`.
1570    pub async fn get_fact_raw_compat(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1571        self.get_fact(fact_id).await
1572    }
1573
1574    /// Get a fact's embedding vector.
1575    pub async fn get_fact_embedding(&self, fact_id: &str) -> Result<Option<Vec<f32>>, MemoryError> {
1576        let fid = fact_id.to_string();
1577        self.with_read_conn(move |conn| get_fact_embedding(conn, &fid))
1578            .await
1579    }
1580
1581    /// List all facts in a namespace using the default `Current` view.
1582    pub async fn list_facts(
1583        &self,
1584        namespace: &str,
1585        limit: usize,
1586        offset: usize,
1587    ) -> Result<Vec<Fact>, MemoryError> {
1588        self.list_facts_with_view(namespace, limit, offset, StateView::Current)
1589            .await
1590    }
1591
1592    /// List facts under an explicit bitemporal authority-state view.
1593    pub async fn list_facts_with_view(
1594        &self,
1595        namespace: &str,
1596        limit: usize,
1597        offset: usize,
1598        view: StateView,
1599    ) -> Result<Vec<Fact>, MemoryError> {
1600        let ns = namespace.to_string();
1601        self.with_read_conn(move |conn| list_facts_with_view(conn, &ns, limit, offset, &view))
1602            .await
1603    }
1604
1605    /// List the distinct namespaces that currently contain facts.
1606    pub async fn list_fact_namespaces(&self) -> Result<Vec<String>, MemoryError> {
1607        self.with_read_conn(move |conn| list_fact_namespaces(conn))
1608            .await
1609    }
1610}
1611
1612#[cfg(test)]
1613mod state_view_regression_tests {
1614    use super::*;
1615    use crate::db::run_migrations;
1616    use rusqlite::Connection;
1617
1618    fn seeded() -> Connection {
1619        let conn = Connection::open_in_memory().unwrap();
1620        run_migrations(&conn).unwrap();
1621        for (id, content, created) in [
1622            ("old", "same topic old", "2026-07-10 21:00:00"),
1623            ("new", "same topic new", "2026-07-10 21:12:01"),
1624        ] {
1625            conn.execute(
1626                "INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES (?1, 'n', ?2, ?3, ?3)",
1627                params![id, content, created],
1628            ).unwrap();
1629        }
1630        conn
1631    }
1632
1633    fn supersedes(conn: &Connection, source: &str, target: &str, recorded: &str) {
1634        conn.execute(
1635            "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1636             VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"type\":\"entity\",\"relation\":\"supersedes\"}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1637            params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1638        ).unwrap();
1639    }
1640
1641    fn supersedes_canonical(conn: &Connection, source: &str, target: &str, recorded: &str) {
1642        conn.execute(
1643            "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1644             VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"entity\":{\"relation\":\"supersedes\"}}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1645            params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1646        ).unwrap();
1647    }
1648
1649    #[test]
1650    fn historical_view_excludes_future_fact_and_reconstructs_pre_supersession_head() {
1651        let conn = seeded();
1652        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1653        let rows = list_facts_with_view(
1654            &conn,
1655            "n",
1656            10,
1657            0,
1658            &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1659        )
1660        .unwrap();
1661        assert_eq!(
1662            rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1663            ["old"]
1664        );
1665    }
1666
1667    #[test]
1668    fn historical_view_preserves_pre_adjudication_conflict() {
1669        let conn = seeded();
1670        conn.execute(
1671            "UPDATE facts SET created_at = '2026-07-10 21:10:00', updated_at = '2026-07-10 21:10:00' WHERE id = 'new'",
1672            [],
1673        )
1674        .unwrap();
1675        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1676
1677        let rows = list_facts_with_view(
1678            &conn,
1679            "n",
1680            10,
1681            0,
1682            &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1683        )
1684        .unwrap();
1685        let ids = rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>();
1686        assert!(
1687            ids.contains(&"old"),
1688            "prior observation must remain visible"
1689        );
1690        assert!(ids.contains(&"new"), "conflicting observation created before the cutoff must remain visible until adjudication");
1691    }
1692
1693    #[test]
1694    fn current_view_excludes_superseded_fact() {
1695        let conn = seeded();
1696        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1697        let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1698        assert_eq!(
1699            rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1700            ["new"]
1701        );
1702    }
1703
1704    #[test]
1705    fn current_view_accepts_canonical_entity_edge_serialization() {
1706        let conn = seeded();
1707        supersedes_canonical(&conn, "new", "old", "2026-07-10 21:12:01");
1708        let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1709        assert_eq!(
1710            rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>(),
1711            vec!["new"]
1712        );
1713    }
1714
1715    #[test]
1716    fn multiple_active_heads_fail_closed() {
1717        let conn = seeded();
1718        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();
1719        supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1720        supersedes(&conn, "other", "old", "2026-07-10 21:13:00");
1721        assert!(list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).is_err());
1722    }
1723}