Skip to main content

zeph_memory/graph/ingest/
ledger.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Content-hash idempotency ledger for `zeph knowledge ingest`.
5//!
6//! The ledger records every `(source_uri, content_hash)` pair that has been successfully
7//! ingested, so unchanged inputs are skipped on subsequent runs (spec-067 FR-012, INV-5).
8//!
9//! # Scope and limitations
10//!
11//! This is a **re-read / cost guard only** — it prevents redundant embedding and LLM extraction
12//! for inputs whose content has not changed since the last run. It does **not**:
13//!
14//! - Reconcile LLM extraction drift across model versions. If the model changes, re-ingest must
15//!   be forced by clearing ledger rows for the affected sources.
16//! - Delete stale Qdrant points when a file's content changes. When `content_hash` changes for
17//!   the same path, the old vectors remain in Qdrant (orphaned). Stale-point cleanup is a Phase-2
18//!   concern and is documented as a known MVP limitation in the testing playbook.
19//!
20//! # Database
21//!
22//! The ledger shares the agent's existing `SQLite` pool (`SemanticMemory::sqlite().pool()`). It is
23//! operator-triggered, one row per ingested file, and is not on the hot path — no dedicated file
24//! is needed (mirrors the `skill_trace_sessions` pattern).
25//!
26//! # Examples
27//!
28//! ```rust,no_run
29//! # async fn example() -> Result<(), zeph_memory::MemoryError> {
30//! use zeph_memory::graph::ingest::IngestLedger;
31//!
32//! // In practice, pool comes from SemanticMemory::sqlite().pool().clone().
33//! # let pool: zeph_db::DbPool = unimplemented!();
34//! let ledger = IngestLedger::new(pool);
35//! let hash = IngestLedger::content_hash(b"hello world");
36//! let uri = "specs/README.md@abc123";
37//! let batch = "01J0000000000000000000000";
38//!
39//! ledger.mark_ingested(uri, &hash, batch, 0, 0).await?;
40//! assert!(ledger.is_ingested(uri, &hash).await?);
41//! # Ok(())
42//! # }
43//! ```
44
45use zeph_db::{DbPool, query, query_as, query_scalar, sql};
46
47use crate::MemoryError;
48
49/// Outcome of resolving a (possibly abbreviated) batch id prefix against the ledger.
50///
51/// Returned by [`IngestLedger::resolve_batch_id`] to support git-style unambiguous prefix
52/// matching for `zeph knowledge rollback --batch-id`, since `zeph knowledge status` only
53/// prints an 8-character prefix of each `import_batch_id`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BatchIdResolution {
56    /// Exactly one batch id matches the supplied prefix (or matches it exactly).
57    Resolved(String),
58    /// More than one batch id shares the supplied prefix; lists every match.
59    Ambiguous(Vec<String>),
60    /// No batch id in the ledger starts with the supplied prefix.
61    NotFound,
62}
63
64/// A single row from the `knowledge_ingest_ledger` table.
65///
66/// Returned by [`IngestLedger::summary`] to back `zeph knowledge status`.
67#[derive(Debug, Clone, sqlx::FromRow)]
68pub struct LedgerEntry {
69    /// Repository-relative path qualified by revision, e.g. `specs/README.md@abc123`.
70    pub source_uri: String,
71    /// BLAKE3 hex digest of the raw file bytes at ingest time.
72    pub content_hash: String,
73    /// Opaque per-run identifier (`UUIDv4` string) grouping all files ingested in one invocation.
74    pub import_batch_id: String,
75    /// ISO-8601 timestamp stored as TEXT in both `SQLite` and `PostgreSQL` dialects.
76    pub ingested_at: String,
77    /// Number of graph entities extracted (0 for the notes sink; non-zero after Phase 2).
78    pub entities: i64,
79    /// Number of graph edges extracted (0 for the notes sink; non-zero after Phase 2).
80    pub edges: i64,
81}
82
83/// Content-hash idempotency ledger for `zeph knowledge ingest`.
84///
85/// Records `(source_uri, content_hash)` pairs of inputs that have been successfully embedded so
86/// that unchanged files are skipped on the next run (spec-067 INV-5).
87///
88/// This is a **re-read / cost guard only** — it does NOT reconcile LLM extraction drift across
89/// model versions (INV-5). An unchanged `(source_uri, content_hash)` pair skips re-embedding; a
90/// changed hash for the same URI is treated as a new input and produces a new ledger row while
91/// leaving any previous Qdrant chunks in place (stale-point cleanup is Phase 2).
92#[derive(Clone)]
93pub struct IngestLedger {
94    pool: DbPool,
95}
96
97impl IngestLedger {
98    /// Creates a new `IngestLedger` wrapping the given database pool.
99    ///
100    /// The pool should come from `SemanticMemory::sqlite().pool().clone()` so the ledger shares
101    /// the agent's existing connection pool rather than opening a new file.
102    #[must_use]
103    pub fn new(pool: DbPool) -> Self {
104        Self { pool }
105    }
106
107    /// Computes the BLAKE3 hex digest of `bytes`.
108    ///
109    /// This is the canonical content-hash format used as the `content_hash` column value.
110    /// Callers must use this function (rather than a different hash algorithm or encoding) to
111    /// ensure ledger keys are consistent across invocations.
112    ///
113    /// # Examples
114    ///
115    /// ```rust
116    /// use zeph_memory::graph::ingest::IngestLedger;
117    ///
118    /// let hash = IngestLedger::content_hash(b"hello world");
119    /// assert_eq!(hash.len(), 64, "BLAKE3 hex digest is always 64 characters");
120    /// ```
121    #[must_use]
122    pub fn content_hash(bytes: &[u8]) -> String {
123        blake3::Hasher::new()
124            .update(bytes)
125            .finalize()
126            .to_hex()
127            .to_string()
128    }
129
130    /// Returns `true` if the `(source_uri, content_hash)` pair is already recorded in the ledger.
131    ///
132    /// When this returns `true`, the caller should skip re-embedding the input — its content has
133    /// not changed since it was last ingested (spec-067 FR-012).
134    ///
135    /// # Errors
136    ///
137    /// Returns [`MemoryError::Sqlx`] on database failure.
138    pub async fn is_ingested(
139        &self,
140        source_uri: &str,
141        content_hash: &str,
142    ) -> Result<bool, MemoryError> {
143        let exists: Option<i64> = query_scalar(sql!(
144            "SELECT 1 FROM knowledge_ingest_ledger \
145             WHERE source_uri = ? AND content_hash = ?"
146        ))
147        .bind(source_uri)
148        .bind(content_hash)
149        .fetch_optional(&self.pool)
150        .await?;
151        Ok(exists.is_some())
152    }
153
154    /// Records a successful ingest of `(source_uri, content_hash)`.
155    ///
156    /// Idempotent: if the pair already exists the row is updated in place with the new
157    /// `batch_id`, `ingested_at`, `entities`, and `edges` values
158    /// (`ON CONFLICT … DO UPDATE`).
159    ///
160    /// `entities` and `edges` should be `0` for the notes sink (Phase 1). Non-zero values are
161    /// reserved for Phase 2 graph extraction.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`MemoryError::Sqlx`] on database failure.
166    pub async fn mark_ingested(
167        &self,
168        source_uri: &str,
169        content_hash: &str,
170        batch_id: &str,
171        entities: i64,
172        edges: i64,
173    ) -> Result<(), MemoryError> {
174        let now = chrono::Utc::now().to_rfc3339();
175        query(sql!(
176            "INSERT INTO knowledge_ingest_ledger \
177             (source_uri, content_hash, import_batch_id, ingested_at, entities, edges) \
178             VALUES (?, ?, ?, ?, ?, ?) \
179             ON CONFLICT(source_uri, content_hash) DO UPDATE SET \
180             import_batch_id = excluded.import_batch_id, \
181             ingested_at = excluded.ingested_at, \
182             entities = excluded.entities, \
183             edges = excluded.edges"
184        ))
185        .bind(source_uri)
186        .bind(content_hash)
187        .bind(batch_id)
188        .bind(&now)
189        .bind(entities)
190        .bind(edges)
191        .execute(&self.pool)
192        .await?;
193        Ok(())
194    }
195
196    /// Returns `true` if any row in the ledger has the given `import_batch_id`.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`MemoryError::Sqlx`] on database failure.
201    pub async fn batch_exists(&self, batch_id: &str) -> Result<bool, MemoryError> {
202        let exists: Option<i64> = query_scalar(sql!(
203            "SELECT 1 FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
204        ))
205        .bind(batch_id)
206        .fetch_optional(&self.pool)
207        .await?;
208        Ok(exists.is_some())
209    }
210
211    /// Resolves a (possibly abbreviated) `batch_id` prefix to a full `import_batch_id`.
212    ///
213    /// Mirrors git's unambiguous short-hash resolution: an exact match on a full id wins
214    /// immediately; otherwise a prefix that uniquely identifies one batch resolves to it, a
215    /// prefix shared by several batches is reported as [`BatchIdResolution::Ambiguous`], and a
216    /// prefix matching no batch is reported as [`BatchIdResolution::NotFound`]. This lets
217    /// `zeph knowledge rollback --batch-id` accept the 8-character prefix printed by
218    /// `zeph knowledge status` (#5399).
219    ///
220    /// An empty (or whitespace-only) `prefix` always resolves to [`BatchIdResolution::NotFound`],
221    /// even when the ledger is non-empty — every id trivially `starts_with("")`, so without this
222    /// guard a blank prefix (e.g. an unset `--batch-id "$VAR"` shell substitution) would silently
223    /// resolve to the sole batch in a single-batch ledger instead of being rejected.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`MemoryError::Sqlx`] on database failure.
228    pub async fn resolve_batch_id(&self, prefix: &str) -> Result<BatchIdResolution, MemoryError> {
229        if prefix.trim().is_empty() {
230            return Ok(BatchIdResolution::NotFound);
231        }
232
233        let ids: Vec<String> = query_scalar(sql!(
234            "SELECT DISTINCT import_batch_id FROM knowledge_ingest_ledger"
235        ))
236        .fetch_all(&self.pool)
237        .await?;
238
239        if ids.iter().any(|id| id == prefix) {
240            return Ok(BatchIdResolution::Resolved(prefix.to_owned()));
241        }
242
243        let mut matches: Vec<String> = ids
244            .into_iter()
245            .filter(|id| id.starts_with(prefix))
246            .collect();
247        match matches.len() {
248            0 => Ok(BatchIdResolution::NotFound),
249            1 => Ok(BatchIdResolution::Resolved(matches.remove(0))),
250            _ => Ok(BatchIdResolution::Ambiguous(matches)),
251        }
252    }
253
254    /// Deletes all ledger rows for the given `import_batch_id`.
255    ///
256    /// Returns the number of rows removed.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`MemoryError::Sqlx`] on database failure.
261    pub async fn delete_batch(&self, batch_id: &str) -> Result<u64, MemoryError> {
262        let result = query(sql!(
263            "DELETE FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
264        ))
265        .bind(batch_id)
266        .execute(&self.pool)
267        .await?;
268        Ok(result.rows_affected())
269    }
270
271    /// Deletes all ledger rows for `batch_id` within a caller-provided transaction.
272    ///
273    /// Executes the same DELETE as [`Self::delete_batch`] but uses `tx` so the caller can
274    /// combine it with other writes in a single atomic unit.
275    /// Returns the number of rows removed.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`MemoryError::Sqlx`] on database failure.
280    pub async fn delete_batch_in_tx(
281        &self,
282        batch_id: &str,
283        tx: &mut zeph_db::DbTransaction<'_>,
284    ) -> Result<u64, MemoryError> {
285        let result = query(sql!(
286            "DELETE FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
287        ))
288        .bind(batch_id)
289        .execute(&mut **tx)
290        .await?;
291        Ok(result.rows_affected())
292    }
293
294    /// Returns all ledger rows ordered by `ingested_at` descending, newest first.
295    ///
296    /// This is the data source for `zeph knowledge status`.
297    ///
298    /// # Errors
299    ///
300    /// Returns [`MemoryError::Sqlx`] on database failure.
301    pub async fn summary(&self) -> Result<Vec<LedgerEntry>, MemoryError> {
302        let rows: Vec<LedgerEntry> = query_as(sql!(
303            "SELECT source_uri, content_hash, import_batch_id, ingested_at, entities, edges \
304             FROM knowledge_ingest_ledger \
305             ORDER BY ingested_at DESC"
306        ))
307        .fetch_all(&self.pool)
308        .await?;
309        Ok(rows)
310    }
311}
312
313// Hardcodes `sqlx::SqlitePool` and is not portable to PostgreSQL; skipped entirely
314// when `postgres` is the active backend (see issue #5364).
315#[cfg(all(test, feature = "sqlite"))]
316mod tests {
317    use super::*;
318
319    async fn test_ledger() -> IngestLedger {
320        let pool = sqlx::SqlitePool::connect(":memory:")
321            .await
322            .expect("in-memory sqlite");
323        sqlx::query(
324            "CREATE TABLE knowledge_ingest_ledger (\
325             source_uri TEXT NOT NULL, \
326             content_hash TEXT NOT NULL, \
327             import_batch_id TEXT NOT NULL, \
328             ingested_at TEXT NOT NULL DEFAULT (datetime('now')), \
329             entities INTEGER NOT NULL DEFAULT 0, \
330             edges INTEGER NOT NULL DEFAULT 0, \
331             PRIMARY KEY (source_uri, content_hash)\
332             )",
333        )
334        .execute(&pool)
335        .await
336        .expect("create table");
337        IngestLedger::new(pool)
338    }
339
340    #[test]
341    fn content_hash_is_hex64() {
342        let h = IngestLedger::content_hash(b"hello");
343        assert_eq!(h.len(), 64);
344        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
345    }
346
347    #[test]
348    fn content_hash_deterministic() {
349        assert_eq!(
350            IngestLedger::content_hash(b"zeph"),
351            IngestLedger::content_hash(b"zeph"),
352        );
353    }
354
355    #[test]
356    fn content_hash_differs_for_different_inputs() {
357        assert_ne!(
358            IngestLedger::content_hash(b"foo"),
359            IngestLedger::content_hash(b"bar"),
360        );
361    }
362
363    #[tokio::test]
364    async fn is_ingested_false_when_absent() {
365        let ledger = test_ledger().await;
366        assert!(!ledger.is_ingested("uri", "hash").await.unwrap());
367    }
368
369    #[tokio::test]
370    async fn mark_then_is_ingested_round_trip() {
371        let ledger = test_ledger().await;
372        let uri = "specs/README.md@abc123";
373        let hash = IngestLedger::content_hash(b"content");
374        ledger
375            .mark_ingested(uri, &hash, "batch-1", 0, 0)
376            .await
377            .unwrap();
378        assert!(ledger.is_ingested(uri, &hash).await.unwrap());
379    }
380
381    #[tokio::test]
382    async fn different_hash_not_ingested() {
383        let ledger = test_ledger().await;
384        let uri = "specs/README.md@abc123";
385        let hash1 = IngestLedger::content_hash(b"v1");
386        let hash2 = IngestLedger::content_hash(b"v2");
387        ledger
388            .mark_ingested(uri, &hash1, "batch-1", 0, 0)
389            .await
390            .unwrap();
391        assert!(!ledger.is_ingested(uri, &hash2).await.unwrap());
392    }
393
394    #[tokio::test]
395    async fn mark_ingested_idempotent() {
396        let ledger = test_ledger().await;
397        let uri = "specs/README.md@abc123";
398        let hash = IngestLedger::content_hash(b"content");
399        ledger
400            .mark_ingested(uri, &hash, "batch-1", 0, 0)
401            .await
402            .unwrap();
403        ledger
404            .mark_ingested(uri, &hash, "batch-2", 5, 3)
405            .await
406            .unwrap();
407        let rows = ledger.summary().await.unwrap();
408        assert_eq!(rows.len(), 1);
409        assert_eq!(rows[0].import_batch_id, "batch-2");
410        assert_eq!(rows[0].entities, 5);
411        assert_eq!(rows[0].edges, 3);
412    }
413
414    #[tokio::test]
415    async fn summary_returns_all_rows() {
416        let ledger = test_ledger().await;
417        ledger.mark_ingested("a", "h1", "b1", 0, 0).await.unwrap();
418        ledger.mark_ingested("b", "h2", "b1", 0, 0).await.unwrap();
419        let rows = ledger.summary().await.unwrap();
420        assert_eq!(rows.len(), 2);
421    }
422
423    #[tokio::test]
424    async fn summary_empty_when_no_rows() {
425        let ledger = test_ledger().await;
426        assert!(ledger.summary().await.unwrap().is_empty());
427    }
428
429    #[tokio::test]
430    async fn batch_exists_false_when_no_rows() {
431        let ledger = test_ledger().await;
432        assert!(!ledger.batch_exists("nonexistent-batch").await.unwrap());
433    }
434
435    #[tokio::test]
436    async fn batch_exists_true_after_mark_ingested() {
437        let ledger = test_ledger().await;
438        ledger
439            .mark_ingested("uri", "hash", "batch-42", 0, 0)
440            .await
441            .unwrap();
442        assert!(ledger.batch_exists("batch-42").await.unwrap());
443        assert!(!ledger.batch_exists("batch-99").await.unwrap());
444    }
445
446    #[tokio::test]
447    async fn delete_batch_removes_matching_rows_only() {
448        let ledger = test_ledger().await;
449        ledger
450            .mark_ingested("a", "h1", "batch-A", 0, 0)
451            .await
452            .unwrap();
453        ledger
454            .mark_ingested("b", "h2", "batch-A", 0, 0)
455            .await
456            .unwrap();
457        ledger
458            .mark_ingested("c", "h3", "batch-B", 0, 0)
459            .await
460            .unwrap();
461
462        let removed = ledger.delete_batch("batch-A").await.unwrap();
463        assert_eq!(removed, 2);
464
465        assert!(!ledger.batch_exists("batch-A").await.unwrap());
466        assert!(ledger.batch_exists("batch-B").await.unwrap());
467    }
468
469    #[tokio::test]
470    async fn delete_batch_returns_zero_for_missing_batch() {
471        let ledger = test_ledger().await;
472        let removed = ledger.delete_batch("ghost-batch").await.unwrap();
473        assert_eq!(removed, 0);
474    }
475
476    #[tokio::test]
477    async fn resolve_batch_id_not_found_when_no_match() {
478        let ledger = test_ledger().await;
479        ledger
480            .mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
481            .await
482            .unwrap();
483        assert_eq!(
484            ledger.resolve_batch_id("ghost").await.unwrap(),
485            BatchIdResolution::NotFound
486        );
487    }
488
489    #[tokio::test]
490    async fn resolve_batch_id_empty_prefix_is_not_found_even_with_a_single_batch() {
491        let ledger = test_ledger().await;
492        ledger
493            .mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
494            .await
495            .unwrap();
496        assert_eq!(
497            ledger.resolve_batch_id("").await.unwrap(),
498            BatchIdResolution::NotFound
499        );
500        assert_eq!(
501            ledger.resolve_batch_id("   ").await.unwrap(),
502            BatchIdResolution::NotFound
503        );
504    }
505
506    #[tokio::test]
507    async fn resolve_batch_id_exact_match_wins_even_if_also_a_prefix() {
508        let ledger = test_ledger().await;
509        ledger.mark_ingested("a", "h1", "0123", 0, 0).await.unwrap();
510        ledger
511            .mark_ingested("b", "h2", "01234567", 0, 0)
512            .await
513            .unwrap();
514        assert_eq!(
515            ledger.resolve_batch_id("0123").await.unwrap(),
516            BatchIdResolution::Resolved("0123".to_owned())
517        );
518    }
519
520    #[tokio::test]
521    async fn resolve_batch_id_unique_prefix_resolves_to_full_id() {
522        let ledger = test_ledger().await;
523        ledger
524            .mark_ingested("a", "h1", "01234567-aaaa-bbbb-cccc", 0, 0)
525            .await
526            .unwrap();
527        ledger
528            .mark_ingested("b", "h2", "89abcdef-aaaa-bbbb-cccc", 0, 0)
529            .await
530            .unwrap();
531        assert_eq!(
532            ledger.resolve_batch_id("012345").await.unwrap(),
533            BatchIdResolution::Resolved("01234567-aaaa-bbbb-cccc".to_owned())
534        );
535    }
536
537    #[tokio::test]
538    async fn resolve_batch_id_ambiguous_prefix_lists_candidates() {
539        let ledger = test_ledger().await;
540        ledger
541            .mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
542            .await
543            .unwrap();
544        ledger
545            .mark_ingested("b", "h2", "01234567-bbbb", 0, 0)
546            .await
547            .unwrap();
548        let resolution = ledger.resolve_batch_id("01234567").await.unwrap();
549        match resolution {
550            BatchIdResolution::Ambiguous(mut candidates) => {
551                candidates.sort();
552                assert_eq!(candidates, ["01234567-aaaa", "01234567-bbbb"]);
553            }
554            other => panic!("expected Ambiguous, got {other:?}"),
555        }
556    }
557}