Skip to main content

semantic_memory/
projection_import.rs

1//! V10 legacy projection import boundary.
2//!
3//! ## Phase status: compatibility / migration-only
4//!
5//! This module provides the **legacy** (V10) import path. It remains functional
6//! for backward compatibility during the migration cycle but is **not the
7//! canonical import path** for new integrations.
8//!
9//! **Canonical path**: Use `MemoryStore::import_projection_batch()` (V11+),
10//! which accepts `ProjectionImportBatchV3` output from `forge-memory-bridge`.
11//! `ProjectionImportBatchV2` remains a compatibility-normalized import shape.
12//!
13//! **Removal condition**: This module and its types (`ImportEnvelope`,
14//! `ImportRecord`, `import_envelope()`) will be removed once all callers have
15//! migrated to the bridge pipeline. The V10 `import_log` table is retained
16//! read-only for audit after removal.
17//!
18//! ## Authority boundary
19//!
20//! `semantic-memory` owns queryable knowledge persistence. This module accepts
21//! **already-interpreted** projection inputs and writes them atomically. It does
22//! NOT interpret raw verification data, decide promotion policy, or contain
23//! Forge-specific transformation logic.
24//!
25//! ## Import semantics
26//!
27//! - **Atomic per envelope**: all records in an envelope are committed together
28//!   or not at all.
29//! - **Idempotent**: re-importing the same envelope (identified by
30//!   `envelope_id` + `schema_version` + `content_digest`) is a safe no-op.
31//! - **No partial visibility**: if any record fails, the entire envelope is
32//!   rolled back.
33//! - **Provenance preserved**: every imported record carries envelope metadata
34//!   in its JSON metadata for traceability.
35
36#![allow(deprecated)]
37
38use crate::error::MemoryError;
39use crate::types::TraceId;
40use serde::{Deserialize, Serialize};
41pub use stack_ids::EnvelopeId;
42
43// ─── Envelope Types ────────────────────────────────────────────
44
45/// An import envelope containing projection records to be atomically ingested.
46///
47/// ## Phase status: compatibility / migration-only
48///
49/// This type is the V10 legacy import format. New integrations should use
50/// `MemoryStore::import_projection_batch()` with `ProjectionImportBatchV3`
51/// from `forge-memory-bridge`.
52///
53/// **Removal condition**: removed when all callers migrate to the bridge pipeline.
54///
55/// The envelope is the unit of atomicity: all records are committed together
56/// or the entire import is rolled back.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ImportEnvelope {
59    /// Unique envelope identity (assigned by source authority).
60    pub envelope_id: EnvelopeId,
61    /// Schema version of the export format.
62    pub schema_version: String,
63    /// Content digest (e.g. blake3 hash) for deduplication.
64    pub content_digest: String,
65    /// Which system produced this envelope (e.g. "forge", "external").
66    pub source_authority: String,
67    /// Cross-crate trace identifier for auditability.
68    pub trace_id: Option<TraceId>,
69    /// Target namespace for all records in this envelope.
70    pub namespace: String,
71    /// The projection records to import.
72    pub records: Vec<ImportRecord>,
73}
74
75impl ImportEnvelope {
76    /// Validate envelope structure. Returns an error if the envelope is malformed.
77    pub fn validate(&self) -> Result<(), MemoryError> {
78        if self.envelope_id.is_empty() {
79            return Err(MemoryError::ImportInvalid {
80                reason: "envelope_id must not be empty".into(),
81            });
82        }
83        if self.schema_version.is_empty() {
84            return Err(MemoryError::ImportInvalid {
85                reason: "schema_version must not be empty".into(),
86            });
87        }
88        if self.content_digest.is_empty() {
89            return Err(MemoryError::ImportInvalid {
90                reason: "content_digest must not be empty".into(),
91            });
92        }
93        if self.source_authority.is_empty() {
94            return Err(MemoryError::ImportInvalid {
95                reason: "source_authority must not be empty".into(),
96            });
97        }
98        if self.namespace.is_empty() {
99            return Err(MemoryError::ImportInvalid {
100                reason: "namespace must not be empty".into(),
101            });
102        }
103        if self.records.is_empty() {
104            return Err(MemoryError::ImportInvalid {
105                reason: "envelope must contain at least one record".into(),
106            });
107        }
108        for (i, record) in self.records.iter().enumerate() {
109            record.validate().map_err(|e| MemoryError::ImportInvalid {
110                reason: format!("record[{i}]: {e}"),
111            })?;
112        }
113        Ok(())
114    }
115
116    /// Dedupe key: (envelope_id, schema_version, content_digest).
117    pub(crate) fn dedupe_key(&self) -> (&str, &str, &str) {
118        (
119            self.envelope_id.as_str(),
120            &self.schema_version,
121            &self.content_digest,
122        )
123    }
124}
125
126/// A single record within an import envelope.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(tag = "kind", rename_all = "snake_case")]
129pub enum ImportRecord {
130    /// A fact (claim/knowledge projection).
131    Fact {
132        /// The fact content text.
133        content: String,
134        /// Source attribution.
135        source: Option<String>,
136        /// Additional metadata.
137        metadata: Option<serde_json::Value>,
138    },
139    /// An episode (causal record) attached to an existing document.
140    Episode {
141        /// The document this episode is attached to.
142        document_id: String,
143        /// Episode metadata.
144        meta: crate::types::EpisodeMeta,
145    },
146}
147
148impl ImportRecord {
149    fn validate(&self) -> Result<(), MemoryError> {
150        match self {
151            ImportRecord::Fact { content, .. } => {
152                if content.is_empty() {
153                    return Err(MemoryError::ImportInvalid {
154                        reason: "fact content must not be empty".into(),
155                    });
156                }
157            }
158            ImportRecord::Episode { document_id, .. } => {
159                if document_id.is_empty() {
160                    return Err(MemoryError::ImportInvalid {
161                        reason: "episode document_id must not be empty".into(),
162                    });
163                }
164            }
165        }
166        Ok(())
167    }
168
169    /// Text content for embedding generation.
170    pub fn content_text(&self) -> &str {
171        match self {
172            ImportRecord::Fact { content, .. } => content,
173            ImportRecord::Episode { meta, .. } => &meta.effect_type,
174        }
175    }
176}
177
178// ─── Import Status / Receipt ───────────────────────────────────
179
180/// Lifecycle status of an import.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ImportStatus {
184    /// Successfully imported all records.
185    Complete,
186    /// Envelope was already imported (idempotent no-op).
187    AlreadyImported,
188    /// Import was attempted but aborted.
189    Aborted { reason: String },
190}
191
192impl ImportStatus {
193    pub fn as_str(&self) -> &str {
194        match self {
195            Self::Complete => "complete",
196            Self::AlreadyImported => "already_imported",
197            Self::Aborted { .. } => "aborted",
198        }
199    }
200
201    pub fn from_str_value(s: &str) -> Self {
202        match s {
203            "complete" => Self::Complete,
204            "already_imported" => Self::AlreadyImported,
205            s if s.starts_with("aborted:") => Self::Aborted {
206                reason: s.strip_prefix("aborted:").unwrap_or("").to_string(),
207            },
208            _ => Self::Aborted {
209                reason: format!("unknown status: {s}"),
210            },
211        }
212    }
213
214    /// Encode for storage.
215    pub(crate) fn to_db_string(&self) -> String {
216        match self {
217            Self::Complete => "complete".into(),
218            Self::AlreadyImported => "already_imported".into(),
219            Self::Aborted { reason } => format!("aborted:{reason}"),
220        }
221    }
222}
223
224/// Receipt confirming the outcome of an import attempt.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ImportReceipt {
227    /// Envelope that was imported.
228    pub envelope_id: EnvelopeId,
229    /// Schema version.
230    pub schema_version: String,
231    /// Content digest.
232    pub content_digest: String,
233    /// Import outcome.
234    pub status: ImportStatus,
235    /// Number of records in the envelope.
236    pub record_count: usize,
237    /// ISO 8601 timestamp of the import.
238    pub imported_at: String,
239    /// Whether this was a duplicate (idempotent no-op).
240    pub was_duplicate: bool,
241    /// Trace ID if provided.
242    pub trace_id: Option<TraceId>,
243}
244
245// ─── Projection Freshness ──────────────────────────────────────
246
247/// Freshness/lifecycle status for imported projections.
248///
249/// Used by the runtime to explain projection state to callers.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum ImportProjectionFreshness {
253    /// Projection was recently imported and is current.
254    Current,
255    /// Projection exists but the last import is older than the staleness threshold.
256    Stale { last_import_at: String },
257    /// A newer envelope has superseded this projection's data.
258    Superseded { superseded_by: EnvelopeId },
259    /// The last import attempt for this scope/source failed.
260    ImportFailed { error: String, attempted_at: String },
261    /// No import has ever been recorded for this scope.
262    NeverImported,
263}
264
265// ─── DB Operations ─────────────────────────────────────────────
266
267/// V10 migration SQL: import log table.
268pub(crate) const MIGRATION_V10: &str = r#"
269CREATE TABLE IF NOT EXISTS import_log (
270    envelope_id     TEXT NOT NULL,
271    schema_version  TEXT NOT NULL,
272    content_digest  TEXT NOT NULL,
273    source_authority TEXT NOT NULL,
274    namespace       TEXT NOT NULL,
275    trace_id        TEXT,
276    record_count    INTEGER NOT NULL,
277    status          TEXT NOT NULL DEFAULT 'complete',
278    imported_at     TEXT NOT NULL DEFAULT (datetime('now')),
279    PRIMARY KEY (envelope_id, schema_version, content_digest)
280);
281
282CREATE INDEX IF NOT EXISTS idx_import_log_namespace ON import_log(namespace);
283CREATE INDEX IF NOT EXISTS idx_import_log_imported_at ON import_log(imported_at DESC);
284CREATE INDEX IF NOT EXISTS idx_import_log_source ON import_log(source_authority);
285"#;
286
287/// Check if an envelope has already been imported.
288pub(crate) fn check_import_exists(
289    conn: &rusqlite::Connection,
290    envelope_id: &str,
291    schema_version: &str,
292    content_digest: &str,
293) -> Result<bool, MemoryError> {
294    let count: i64 = conn
295        .query_row(
296            "SELECT COUNT(*) FROM import_log
297             WHERE envelope_id = ?1 AND schema_version = ?2 AND content_digest = ?3",
298            rusqlite::params![envelope_id, schema_version, content_digest],
299            |row| row.get(0),
300        )
301        .unwrap_or(0);
302    Ok(count > 0)
303}
304
305/// Insert an import log entry within a transaction.
306pub(crate) fn insert_import_log(
307    tx: &rusqlite::Transaction<'_>,
308    envelope: &ImportEnvelope,
309    status: &ImportStatus,
310    record_count: usize,
311) -> Result<(), MemoryError> {
312    tx.execute(
313        "INSERT OR REPLACE INTO import_log
314         (envelope_id, schema_version, content_digest, source_authority,
315          namespace, trace_id, record_count, status)
316         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
317        rusqlite::params![
318            envelope.envelope_id.as_str(),
319            envelope.schema_version,
320            envelope.content_digest,
321            envelope.source_authority,
322            envelope.namespace,
323            envelope.trace_id.as_ref().map(|t| t.as_str()),
324            record_count as i64,
325            status.to_db_string(),
326        ],
327    )?;
328    Ok(())
329}
330
331/// Query import receipts, optionally filtered by namespace.
332pub(crate) fn query_import_log(
333    conn: &rusqlite::Connection,
334    namespace: Option<&str>,
335    limit: usize,
336) -> Result<Vec<ImportReceipt>, MemoryError> {
337    let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(ns) = namespace {
338        (
339            "SELECT envelope_id, schema_version, content_digest, status,
340                    record_count, imported_at, trace_id
341             FROM import_log
342             WHERE namespace = ?1
343             ORDER BY imported_at DESC
344             LIMIT ?2",
345            vec![
346                Box::new(ns.to_string()) as Box<dyn rusqlite::types::ToSql>,
347                Box::new(limit as i64),
348            ],
349        )
350    } else {
351        (
352            "SELECT envelope_id, schema_version, content_digest, status,
353                    record_count, imported_at, trace_id
354             FROM import_log
355             ORDER BY imported_at DESC
356             LIMIT ?1",
357            vec![Box::new(limit as i64) as Box<dyn rusqlite::types::ToSql>],
358        )
359    };
360
361    let mut stmt = conn.prepare(sql)?;
362    let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
363    let rows = stmt
364        .query_map(params_refs.as_slice(), |row| {
365            Ok((
366                row.get::<_, String>(0)?,
367                row.get::<_, String>(1)?,
368                row.get::<_, String>(2)?,
369                row.get::<_, String>(3)?,
370                row.get::<_, i64>(4)?,
371                row.get::<_, String>(5)?,
372                row.get::<_, Option<String>>(6)?,
373            ))
374        })?
375        .collect::<Result<Vec<_>, _>>()?;
376
377    Ok(rows
378        .into_iter()
379        .map(
380            |(
381                envelope_id,
382                schema_version,
383                content_digest,
384                status,
385                record_count,
386                imported_at,
387                trace_id,
388            )| {
389                let status_parsed = ImportStatus::from_str_value(&status);
390                let was_duplicate = matches!(status_parsed, ImportStatus::AlreadyImported);
391                ImportReceipt {
392                    envelope_id: EnvelopeId(envelope_id),
393                    schema_version,
394                    content_digest,
395                    status: status_parsed,
396                    record_count: record_count as usize,
397                    imported_at,
398                    was_duplicate,
399                    trace_id: trace_id.map(TraceId::new),
400                }
401            },
402        )
403        .collect())
404}
405
406/// Get the most recent import timestamp for a namespace.
407pub(crate) fn last_import_at(
408    conn: &rusqlite::Connection,
409    namespace: &str,
410) -> Result<Option<String>, MemoryError> {
411    // Check both V10 (import_log) and V11 (projection_import_log) tables,
412    // returning the most recent timestamp from either.
413    // INTENTIONAL: V10 import_log table may not exist on newer schemas; None is expected
414    let v10: Option<String> = conn
415        .query_row(
416            "SELECT imported_at FROM import_log
417             WHERE namespace = ?1 AND status = 'complete'
418             ORDER BY imported_at DESC LIMIT 1",
419            rusqlite::params![namespace],
420            |row| row.get(0),
421        )
422        .ok();
423
424    // INTENTIONAL: V11 projection_import_log table may not exist on older schemas; None is expected
425    let v11: Option<String> = conn
426        .query_row(
427            "SELECT imported_at FROM projection_import_log
428             WHERE scope_namespace = ?1 AND status = 'complete'
429             ORDER BY imported_at DESC LIMIT 1",
430            rusqlite::params![namespace],
431            |row| row.get(0),
432        )
433        .ok();
434
435    // Return the more recent of the two
436    let result = match (v10, v11) {
437        (Some(a), Some(b)) => Some(if a >= b { a } else { b }),
438        (Some(a), None) => Some(a),
439        (None, Some(b)) => Some(b),
440        (None, None) => None,
441    };
442    Ok(result)
443}