Skip to main content

mempill_sqlite/
store.rs

1//! `SqlitePersistenceStore` — impl of `PersistencePort` for mempill-sqlite.
2//!
3//! # Append-only
4//!
5//! Every write method is an INSERT.  No UPDATE or DELETE paths exist in this file.
6//! Attempts to update or delete data must be rejected at the application layer.
7//!
8//! # Atomic commit unit
9//!
10//! The store does NOT manage transaction lifecycle — the application use-case does.
11//! `begin_atomic` moves the connection into a `SqliteTxn`; `commit` and `rollback` return
12//! it.  This guarantees that {claim + validity assertion + ledger entry + edge} land in one
13//! SQLite transaction or not at all.
14//!
15//! # Single-writer per agent_id
16//!
17//! v0.1 is single-process embedded.  The `AgentWriteLockMap` in mempill-core coordinates
18//! per-agent_id writes at the async boundary.  The store is structurally read-safe because
19//! reads do not acquire any lock, and writes are serialised by the application layer.
20//!
21//! # Connection ownership model
22//!
23//! The store owns `Box<Connection>` inside a `std::cell::Cell`-like hand-off: `begin_atomic`
24//! takes it out; `commit`/`rollback` put it back.  We use `Arc<Mutex<Option<Box<Connection>>>>`
25//! so the store is `Send + Sync` and can be shared across `spawn_blocking` calls.
26//! The `Option` is always `Some` except during the window between `begin_atomic` and
27//! `commit`/`rollback`.  Calling `begin_atomic` while a txn is already open returns an error.
28
29use std::sync::{Arc, Mutex};
30
31use mempill_core::ports::pending_adjudication::{PendingAdjudicationPort, PendingAdjudicationRow};
32use mempill_core::ports::persistence::PersistencePort;
33use mempill_types::{
34    claim::{Cardinality, Claim, Confidence, Criticality, Fact},
35    edge::{ClaimEdge, EdgeKind},
36    identity::{AgentId, ClaimRef},
37    ledger::{LedgerEntry, LedgerEventKind},
38    provenance::{ExternalAnchor, ExternalKind, ProvenanceLabel},
39    time::{date_granularity_to_str, str_to_date_granularity, TransactionTime, ValidTime},
40    validity::{AssertionKind, ValidityAssertion},
41};
42use rusqlite::Connection;
43
44use crate::{txn::SqliteTxn, SqliteStoreError};
45
46// ── SqlitePersistenceStore ────────────────────────────────────────────────────
47
48/// The SQLite-backed implementation of `PersistencePort`.
49///
50/// Construct via `SqlitePersistenceStore::new(conn)` where `conn` is a fully-initialised
51/// rusqlite `Connection` (PRAGMAs applied, migrations run — use `connection::open` or
52/// `connection::open_in_memory`).
53pub struct SqlitePersistenceStore {
54    /// Connection slot.  `None` only while a `SqliteTxn` is active.
55    conn: Arc<Mutex<Option<Box<Connection>>>>,
56}
57
58impl SqlitePersistenceStore {
59    /// Create a store wrapping an already-initialised `Connection`.
60    pub fn new(conn: Connection) -> Self {
61        Self {
62            conn: Arc::new(Mutex::new(Some(Box::new(conn)))),
63        }
64    }
65
66    /// Return a `SqlitePendingStore` that shares the same SQLite connection.
67    ///
68    /// This is the standard way to construct the pending-adjudication adapter:
69    /// ```rust,ignore
70    /// let store = SqlitePersistenceStore::new(conn);
71    /// let pending = store.pending_store();
72    /// ```
73    /// Both `SqlitePersistenceStore` and `SqlitePendingStore` share the connection Arc,
74    /// so the pending insert is serialized with the claim transaction by the EngineHandle
75    /// write lock — not by a shared rusqlite transaction.
76    pub fn pending_store(&self) -> SqlitePendingStore {
77        SqlitePendingStore::new(Arc::clone(&self.conn))
78    }
79}
80
81// SAFETY: Connection is Send (rusqlite guarantees this); Mutex makes it Sync.
82unsafe impl Send for SqlitePersistenceStore {}
83unsafe impl Sync for SqlitePersistenceStore {}
84
85// ── Domain-type ↔ column mapping helpers ─────────────────────────────────────
86
87/// Serialize `ProvenanceLabel` to the TEXT column value used in the schema (§5).
88/// Format: `'ModelDerived'`, `'RecallReEntry'`, `'External_UserAsserted'`,
89/// `'External_ExternalFirstHand'`.
90fn provenance_to_str(p: &ProvenanceLabel) -> &'static str {
91    match p {
92        ProvenanceLabel::ModelDerived => "ModelDerived",
93        ProvenanceLabel::RecallReEntry => "RecallReEntry",
94        ProvenanceLabel::External(ExternalKind::UserAsserted) => "External_UserAsserted",
95        ProvenanceLabel::External(ExternalKind::ExternalFirstHand) => "External_ExternalFirstHand",
96        // ProvenanceLabel is #[non_exhaustive]; future variants will be caught here at compile time.
97        _ => "Unknown",
98    }
99}
100
101/// Deserialize the TEXT column value back to `ProvenanceLabel`.
102/// Used by the read path.
103fn str_to_provenance(s: &str) -> Result<ProvenanceLabel, SqliteStoreError> {
104    match s {
105        "ModelDerived" => Ok(ProvenanceLabel::ModelDerived),
106        "RecallReEntry" => Ok(ProvenanceLabel::RecallReEntry),
107        "External_UserAsserted" => {
108            Ok(ProvenanceLabel::External(ExternalKind::UserAsserted))
109        }
110        "External_ExternalFirstHand" => {
111            Ok(ProvenanceLabel::External(ExternalKind::ExternalFirstHand))
112        }
113        other => Err(SqliteStoreError::Mapping(format!(
114            "unknown provenance_label value: {other}"
115        ))),
116    }
117}
118
119fn cardinality_to_str(c: &Cardinality) -> &'static str {
120    match c {
121        Cardinality::Functional => "Functional",
122        Cardinality::SetValued => "SetValued",
123        Cardinality::Unknown => "Unknown",
124    }
125}
126
127fn str_to_cardinality(s: &str) -> Result<Cardinality, SqliteStoreError> {
128    match s {
129        "Functional" => Ok(Cardinality::Functional),
130        "SetValued" => Ok(Cardinality::SetValued),
131        "Unknown" => Ok(Cardinality::Unknown),
132        other => Err(SqliteStoreError::Mapping(format!(
133            "unknown cardinality value: {other}"
134        ))),
135    }
136}
137
138fn criticality_to_str(c: &Criticality) -> &'static str {
139    match c {
140        Criticality::Low => "Low",
141        Criticality::Medium => "Medium",
142        Criticality::High => "High",
143        Criticality::Critical => "Critical",
144    }
145}
146
147fn str_to_criticality(s: &str) -> Result<Criticality, SqliteStoreError> {
148    match s {
149        "Low" => Ok(Criticality::Low),
150        "Medium" => Ok(Criticality::Medium),
151        "High" => Ok(Criticality::High),
152        "Critical" => Ok(Criticality::Critical),
153        other => Err(SqliteStoreError::Mapping(format!(
154            "unknown criticality value: {other}"
155        ))),
156    }
157}
158
159fn edge_kind_to_str(k: &EdgeKind) -> &'static str {
160    match k {
161        EdgeKind::DerivedFrom => "DerivedFrom",
162        EdgeKind::Supersedes => "Supersedes",
163        EdgeKind::DependsOn => "DependsOn",
164        EdgeKind::MutualExclusion => "MutualExclusion",
165        // EdgeKind is #[non_exhaustive] — future variants stored as "Unknown".
166        _ => "Unknown",
167    }
168}
169
170fn str_to_edge_kind(s: &str) -> Result<EdgeKind, SqliteStoreError> {
171    match s {
172        "DerivedFrom" => Ok(EdgeKind::DerivedFrom),
173        "Supersedes" => Ok(EdgeKind::Supersedes),
174        "DependsOn" => Ok(EdgeKind::DependsOn),
175        "MutualExclusion" => Ok(EdgeKind::MutualExclusion),
176        other => Err(SqliteStoreError::Mapping(format!(
177            "unknown edge_kind value: {other}"
178        ))),
179    }
180}
181
182fn ledger_event_kind_to_str(k: &LedgerEventKind) -> &'static str {
183    // AdjudicationExpired maps to "AdjudicationExpired" for the TTL sweep and lazy expiry path.
184    match k {
185        LedgerEventKind::ClaimCommitted => "ClaimCommitted",
186        LedgerEventKind::ValidityAsserted => "ValidityAsserted",
187        LedgerEventKind::AdjudicationRequested => "AdjudicationRequested",
188        LedgerEventKind::AdjudicationResolved => "AdjudicationResolved",
189        LedgerEventKind::RecallReEntryDetected => "RecallReEntryDetected",
190        LedgerEventKind::Quarantined => "Quarantined",
191        LedgerEventKind::DependentFlaggedPendingReview => "DependentFlaggedPendingReview",
192        LedgerEventKind::ServedAsInjected => "ServedAsInjected",
193        LedgerEventKind::AdjudicationExpired => "AdjudicationExpired",
194        // LedgerEventKind is #[non_exhaustive] — future variants stored as "Unknown".
195        _ => "Unknown",
196    }
197}
198
199fn str_to_ledger_event_kind(s: &str) -> Result<LedgerEventKind, SqliteStoreError> {
200    match s {
201        "ClaimCommitted" => Ok(LedgerEventKind::ClaimCommitted),
202        "ValidityAsserted" => Ok(LedgerEventKind::ValidityAsserted),
203        "AdjudicationRequested" => Ok(LedgerEventKind::AdjudicationRequested),
204        "AdjudicationResolved" => Ok(LedgerEventKind::AdjudicationResolved),
205        "RecallReEntryDetected" => Ok(LedgerEventKind::RecallReEntryDetected),
206        "Quarantined" => Ok(LedgerEventKind::Quarantined),
207        "DependentFlaggedPendingReview" => Ok(LedgerEventKind::DependentFlaggedPendingReview),
208        "ServedAsInjected" => Ok(LedgerEventKind::ServedAsInjected),
209        "AdjudicationExpired" => Ok(LedgerEventKind::AdjudicationExpired),
210        other => Err(SqliteStoreError::Mapping(format!(
211            "unknown ledger event_kind value: {other}"
212        ))),
213    }
214}
215
216fn disposition_to_str(d: &mempill_types::disposition::Disposition) -> &'static str {
217    use mempill_types::disposition::Disposition;
218    match d {
219        Disposition::CommittedCheap => "CommittedCheap",
220        Disposition::CommittedInferred => "CommittedInferred",
221        Disposition::QueuedForAdjudication => "QueuedForAdjudication",
222        Disposition::Contested => "Contested",
223        Disposition::PendingConflict => "PendingConflict",
224        Disposition::PendingReview => "PendingReview",
225        Disposition::PendingLowConfidence => "PendingLowConfidence",
226        Disposition::Quarantined => "Quarantined",
227        Disposition::Superseded => "Superseded",
228        Disposition::Invalidated => "Invalidated",
229        Disposition::Reinstated => "Reinstated",
230        Disposition::Rejected => "Rejected",
231        // Disposition is #[non_exhaustive] — future variants stored as "Unknown".
232        _ => "Unknown",
233    }
234}
235
236fn str_to_disposition(s: &str) -> Result<mempill_types::disposition::Disposition, SqliteStoreError> {
237    use mempill_types::disposition::Disposition;
238    match s {
239        "CommittedCheap" => Ok(Disposition::CommittedCheap),
240        "CommittedInferred" => Ok(Disposition::CommittedInferred),
241        "QueuedForAdjudication" => Ok(Disposition::QueuedForAdjudication),
242        "Contested" => Ok(Disposition::Contested),
243        "PendingConflict" => Ok(Disposition::PendingConflict),
244        "PendingReview" => Ok(Disposition::PendingReview),
245        "PendingLowConfidence" => Ok(Disposition::PendingLowConfidence),
246        "Quarantined" => Ok(Disposition::Quarantined),
247        "Superseded" => Ok(Disposition::Superseded),
248        "Invalidated" => Ok(Disposition::Invalidated),
249        "Reinstated" => Ok(Disposition::Reinstated),
250        "Rejected" => Ok(Disposition::Rejected),
251        other => Err(SqliteStoreError::Mapping(format!(
252            "unknown disposition value: {other}"
253        ))),
254    }
255}
256
257// ── Row-to-domain-type mapping helpers ───────────────────────────────────────
258
259/// Map a rusqlite `Row` from the `claims` table to a `Claim` domain type.
260///
261/// Column order (must match every SELECT that feeds this function):
262///   0  claim_id
263///   1  agent_id
264///   2  subject
265///   3  predicate
266///   4  value  (JSON text)
267///   5  cardinality
268///   6  provenance_label
269///   7  nearest_external_anchor_id  (nullable TEXT)
270///   8  derivation_depth
271///   9  tx_time
272///  10  valid_time_start  (nullable)
273///  11  valid_time_end    (nullable)
274///  12  valid_time_confidence
275///  13  value_confidence
276///  14  criticality
277///  15  derived_from  (JSON array of UUID strings)
278///  16  metadata      (nullable JSON text)
279///  17  snapshot_schema_version  (nullable INTEGER)
280///  18  valid_time_start_granularity  (nullable TEXT, added in v3)
281///  19  valid_time_end_granularity    (nullable TEXT, added in v3)
282fn row_to_claim(row: &rusqlite::Row<'_>) -> Result<Claim, rusqlite::Error> {
283    // We map rusqlite errors to SqliteStoreError in the caller; use rusqlite::Error here
284    // so this fn can be used directly as a row-mapper closure.
285    let claim_id_str: String = row.get(0)?;
286    let agent_id_str: String = row.get(1)?;
287    let subject: String = row.get(2)?;
288    let predicate: String = row.get(3)?;
289    let value_json: String = row.get(4)?;
290    let cardinality_str: String = row.get(5)?;
291    let provenance_str: String = row.get(6)?;
292    let nearest_anchor_str: Option<String> = row.get(7)?;
293    let derivation_depth: i64 = row.get(8)?;
294    let tx_time_str: String = row.get(9)?;
295    let valid_time_start_str: Option<String> = row.get(10)?;
296    let valid_time_end_str: Option<String> = row.get(11)?;
297    let valid_time_confidence: f64 = row.get(12)?;
298    let value_confidence: f64 = row.get(13)?;
299    let criticality_str: String = row.get(14)?;
300    let derived_from_json: String = row.get(15)?;
301    let metadata_json: Option<String> = row.get(16)?;
302    let snapshot_schema_version_raw: Option<i64> = row.get(17)?;
303    let start_granularity_str: Option<String> = row.get(18)?;
304    let end_granularity_str: Option<String> = row.get(19)?;
305
306    // These mapping errors cannot be expressed as rusqlite::Error cleanly; use
307    // rusqlite::Error::InvalidColumnType as a carrier — callers convert to SqliteStoreError.
308    let to_rusqlite_err = |msg: String| rusqlite::Error::InvalidColumnType(
309        0,
310        msg,
311        rusqlite::types::Type::Text,
312    );
313
314    let claim_id = uuid::Uuid::parse_str(&claim_id_str)
315        .map_err(|e| to_rusqlite_err(format!("claim_id UUID parse: {e}")))?;
316
317    let value: serde_json::Value = serde_json::from_str(&value_json)
318        .map_err(|e| to_rusqlite_err(format!("value JSON parse: {e}")))?;
319
320    let cardinality = str_to_cardinality(&cardinality_str)
321        .map_err(|e| to_rusqlite_err(e.to_string()))?;
322
323    let provenance = str_to_provenance(&provenance_str)
324        .map_err(|e| to_rusqlite_err(e.to_string()))?;
325
326    let nearest_external_anchor: Option<ClaimRef> = nearest_anchor_str
327        .map(|s| {
328            uuid::Uuid::parse_str(&s)
329                .map(ClaimRef)
330                .map_err(|e| to_rusqlite_err(format!("anchor UUID parse: {e}")))
331        })
332        .transpose()?;
333
334    let tx_time = chrono::DateTime::parse_from_rfc3339(&tx_time_str)
335        .map(|dt| dt.with_timezone(&chrono::Utc))
336        .map_err(|e| to_rusqlite_err(format!("tx_time parse: {e}")))?;
337
338    let valid_time_start = valid_time_start_str
339        .map(|s| {
340            chrono::DateTime::parse_from_rfc3339(&s)
341                .map(|dt| dt.with_timezone(&chrono::Utc))
342                .map_err(|e| to_rusqlite_err(format!("valid_time_start parse: {e}")))
343        })
344        .transpose()?;
345
346    let valid_time_end = valid_time_end_str
347        .map(|s| {
348            chrono::DateTime::parse_from_rfc3339(&s)
349                .map(|dt| dt.with_timezone(&chrono::Utc))
350                .map_err(|e| to_rusqlite_err(format!("valid_time_end parse: {e}")))
351        })
352        .transpose()?;
353
354    let criticality = str_to_criticality(&criticality_str)
355        .map_err(|e| to_rusqlite_err(e.to_string()))?;
356
357    let derived_from_uuids: Vec<String> = serde_json::from_str(&derived_from_json)
358        .map_err(|e| to_rusqlite_err(format!("derived_from JSON parse: {e}")))?;
359
360    let derived_from: Vec<ClaimRef> = derived_from_uuids
361        .iter()
362        .map(|s| {
363            uuid::Uuid::parse_str(s)
364                .map(ClaimRef)
365                .map_err(|e| to_rusqlite_err(format!("derived_from UUID parse: {e}")))
366        })
367        .collect::<Result<_, _>>()?;
368
369    let metadata: Option<serde_json::Value> = metadata_json
370        .map(|s| {
371            serde_json::from_str(&s)
372                .map_err(|e| to_rusqlite_err(format!("metadata JSON parse: {e}")))
373        })
374        .transpose()?;
375
376    let snapshot_schema_version: Option<u32> =
377        snapshot_schema_version_raw.map(|v| v as u32);
378
379    Ok(Claim::new(
380        ClaimRef(claim_id),
381        AgentId(agent_id_str),
382        Fact { subject, predicate, value },
383        cardinality,
384        provenance,
385        ExternalAnchor {
386            nearest_external_anchor,
387            derivation_depth: derivation_depth as u32,
388        },
389        TransactionTime(tx_time),
390        ValidTime {
391            start: valid_time_start,
392            end: valid_time_end,
393            valid_time_confidence: valid_time_confidence as f32,
394            start_granularity: start_granularity_str
395                .as_deref()
396                .and_then(str_to_date_granularity),
397            end_granularity: end_granularity_str
398                .as_deref()
399                .and_then(str_to_date_granularity),
400        },
401        Confidence {
402            value_confidence: value_confidence as f32,
403            valid_time_confidence: valid_time_confidence as f32,
404        },
405        criticality,
406        derived_from,
407        metadata,
408        snapshot_schema_version,
409    ))
410}
411
412/// The SELECT column list that must be used with `row_to_claim`.
413/// Columns must be in the exact order defined in `row_to_claim`.
414const CLAIM_SELECT_COLS: &str = "
415    claim_id, agent_id, subject, predicate, value, cardinality,
416    provenance_label, nearest_external_anchor_id, derivation_depth,
417    tx_time, valid_time_start, valid_time_end, valid_time_confidence,
418    value_confidence, criticality, derived_from,
419    metadata, snapshot_schema_version,
420    valid_time_start_granularity, valid_time_end_granularity
421";
422
423/// Map a rusqlite `Row` from the `claim_edges` table to a `ClaimEdge` domain type.
424fn row_to_edge(row: &rusqlite::Row<'_>) -> Result<ClaimEdge, rusqlite::Error> {
425    let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
426        0, msg, rusqlite::types::Type::Text,
427    );
428
429    let edge_id_str: String = row.get(0)?;
430    let agent_id_str: String = row.get(1)?;
431    let from_claim_str: String = row.get(2)?;
432    let to_claim_str: String = row.get(3)?;
433    let kind_str: String = row.get(4)?;
434    let created_at_str: String = row.get(5)?;
435
436    let edge_id = uuid::Uuid::parse_str(&edge_id_str)
437        .map_err(|e| to_err(format!("edge_id UUID: {e}")))?;
438    let from_claim = uuid::Uuid::parse_str(&from_claim_str)
439        .map(ClaimRef)
440        .map_err(|e| to_err(format!("from_claim UUID: {e}")))?;
441    let to_claim = uuid::Uuid::parse_str(&to_claim_str)
442        .map(ClaimRef)
443        .map_err(|e| to_err(format!("to_claim UUID: {e}")))?;
444    let kind = str_to_edge_kind(&kind_str)
445        .map_err(|e| to_err(e.to_string()))?;
446    let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
447        .map(|dt| dt.with_timezone(&chrono::Utc))
448        .map_err(|e| to_err(format!("created_at parse: {e}")))?;
449
450    Ok(ClaimEdge {
451        edge_id,
452        agent_id: AgentId(agent_id_str),
453        from_claim,
454        to_claim,
455        kind,
456        created_at: TransactionTime(created_at),
457    })
458}
459
460// ── PersistencePort impl ──────────────────────────────────────────────────────
461
462impl PersistencePort for SqlitePersistenceStore {
463    type Transaction = SqliteTxn;
464    type Error = SqliteStoreError;
465
466    // ── Transaction lifecycle ─────────────────────────────────────────────────
467
468    /// Open an explicit `BEGIN DEFERRED` transaction scoped to `agent_id`.
469    ///
470    /// The connection is moved into the returned `SqliteTxn`.  Calling `begin_atomic`
471    /// again before `commit`/`rollback` returns `SqliteStoreError::TxnAlreadyOpen`.
472    fn begin_atomic(&self, agent_id: &AgentId) -> Result<SqliteTxn, SqliteStoreError> {
473        let mut slot = self.conn.lock().expect("SqlitePersistenceStore: mutex poisoned");
474        let conn = slot.take().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
475        SqliteTxn::begin(agent_id.clone(), conn)
476    }
477
478    /// Commit the transaction and return the connection to the store.
479    fn commit(&self, txn: SqliteTxn) -> Result<(), SqliteStoreError> {
480        let conn = txn.commit_and_return()?;
481        let mut slot = self.conn.lock().expect("SqlitePersistenceStore: mutex poisoned");
482        *slot = Some(conn);
483        Ok(())
484    }
485
486    /// Rollback the transaction and return the connection to the store.
487    /// On rollback all rows appended within the txn are discarded (all-or-nothing atomicity).
488    fn rollback(&self, txn: SqliteTxn) -> Result<(), SqliteStoreError> {
489        let conn = txn.rollback_and_return()?;
490        let mut slot = self.conn.lock().expect("SqlitePersistenceStore: mutex poisoned");
491        *slot = Some(conn);
492        Ok(())
493    }
494
495    // ── Write methods (INSERT-only, I1) ───────────────────────────────────────
496
497    /// Append a claim row within the open transaction.
498    ///
499    /// Column mapping (§5):
500    /// - `claim_id` ← `claim.claim_ref().0` (UUID → TEXT)
501    /// - `agent_id` ← `claim.agent_id().0`
502    /// - `provenance_label` ← `provenance_to_str(claim.provenance())` (NOT NULL; bi-temporal provenance column)
503    /// - `nearest_external_anchor_id` ← `ExternalAnchor.nearest_external_anchor` (nullable)
504    /// - `derived_from` ← JSON array of ClaimRef UUIDs
505    fn append_claim(
506        &self,
507        txn: &mut SqliteTxn,
508        claim: &Claim,
509    ) -> Result<ClaimRef, SqliteStoreError> {
510        let conn = txn.conn();
511
512        let claim_id = claim.claim_ref().0.to_string();
513        let agent_id = claim.agent_id().0.as_str();
514        let fact = claim.fact();
515        let value_json = serde_json::to_string(&fact.value)
516            .map_err(|e| SqliteStoreError::Mapping(format!("value serialization: {e}")))?;
517        let cardinality = cardinality_to_str(claim.cardinality());
518        let provenance = provenance_to_str(claim.provenance());
519        let anchor = claim.external_anchor();
520        let nearest_anchor: Option<String> =
521            anchor.nearest_external_anchor.as_ref().map(|r| r.0.to_string());
522        let derivation_depth = anchor.derivation_depth as i64;
523        let tx_time = claim.transaction_time().0.to_rfc3339();
524        let vt = claim.valid_time();
525        let valid_time_start: Option<String> = vt.start.map(|dt| dt.to_rfc3339());
526        let valid_time_end: Option<String> = vt.end.map(|dt| dt.to_rfc3339());
527        let valid_time_confidence = vt.valid_time_confidence as f64;
528        let valid_time_start_granularity: Option<&'static str> =
529            vt.start_granularity.map(date_granularity_to_str);
530        let valid_time_end_granularity: Option<&'static str> =
531            vt.end_granularity.map(date_granularity_to_str);
532        let conf = claim.confidence();
533        let value_confidence = conf.value_confidence as f64;
534        let criticality = criticality_to_str(claim.criticality());
535        let derived_from_refs: Vec<String> =
536            claim.derived_from().iter().map(|r| r.0.to_string()).collect();
537        let derived_from_json = serde_json::to_string(&derived_from_refs)
538            .map_err(|e| SqliteStoreError::Mapping(format!("derived_from serialization: {e}")))?;
539        let metadata: Option<String> = claim
540            .metadata()
541            .map(|v| {
542                serde_json::to_string(v)
543                    .map_err(|e| SqliteStoreError::Mapping(format!("metadata serialization: {e}")))
544            })
545            .transpose()?;
546        let snapshot_schema_version: Option<i64> =
547            claim.snapshot_schema_version().map(|v| v as i64);
548
549        conn.execute(
550            "INSERT INTO claims (
551                claim_id, agent_id, subject, predicate, value, cardinality,
552                provenance_label, nearest_external_anchor_id, derivation_depth,
553                tx_time, valid_time_start, valid_time_end, valid_time_confidence,
554                value_confidence, criticality, derived_from,
555                metadata, snapshot_schema_version, embedding_model_id,
556                valid_time_start_granularity, valid_time_end_granularity
557            ) VALUES (
558                ?1,  ?2,  ?3,  ?4,  ?5,  ?6,
559                ?7,  ?8,  ?9,
560                ?10, ?11, ?12, ?13,
561                ?14, ?15, ?16,
562                ?17, ?18, NULL,
563                ?19, ?20
564            )",
565            rusqlite::params![
566                claim_id,
567                agent_id,
568                fact.subject.as_str(),
569                fact.predicate.as_str(),
570                value_json.as_str(),
571                cardinality,
572                provenance,
573                nearest_anchor,
574                derivation_depth,
575                tx_time.as_str(),
576                valid_time_start,
577                valid_time_end,
578                valid_time_confidence,
579                value_confidence,
580                criticality,
581                derived_from_json.as_str(),
582                metadata,
583                snapshot_schema_version,
584                valid_time_start_granularity,
585                valid_time_end_granularity,
586            ],
587        )?;
588
589        Ok(claim.claim_ref().clone())
590    }
591
592    /// Append a validity assertion row (Bound or Reopen) within the open transaction.
593    fn append_validity_assertion(
594        &self,
595        txn: &mut SqliteTxn,
596        assertion: &ValidityAssertion,
597    ) -> Result<(), SqliteStoreError> {
598        let conn = txn.conn();
599
600        let assertion_id = assertion.assertion_ref.to_string();
601        let agent_id = assertion.agent_id.0.as_str();
602        let target_claim_id = assertion.target_claim.0.to_string();
603        let provenance = provenance_to_str(&assertion.provenance);
604        let value_confidence = assertion.confidence.value_confidence as f64;
605        let valid_time_confidence = assertion.confidence.valid_time_confidence as f64;
606        let asserted_at = assertion.asserted_at.0.to_rfc3339();
607
608        let (assertion_kind, bound_at, reopen_at): (&str, Option<String>, Option<String>) =
609            match &assertion.kind {
610                AssertionKind::Bound { bound_at } => {
611                    ("Bound", Some(bound_at.to_rfc3339()), None)
612                }
613                AssertionKind::Reopen { reopen_at } => {
614                    ("Reopen", None, Some(reopen_at.to_rfc3339()))
615                }
616                // AssertionKind is #[non_exhaustive] — future kinds stored as "Unknown" (no-op).
617                _ => ("Unknown", None, None),
618            };
619
620        conn.execute(
621            "INSERT INTO validity_assertions (
622                assertion_id, agent_id, target_claim_id,
623                assertion_kind, bound_at, reopen_at,
624                provenance_label, value_confidence, valid_time_confidence, asserted_at
625            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
626            rusqlite::params![
627                assertion_id.as_str(),
628                agent_id,
629                target_claim_id.as_str(),
630                assertion_kind,
631                bound_at,
632                reopen_at,
633                provenance,
634                value_confidence,
635                valid_time_confidence,
636                asserted_at.as_str(),
637            ],
638        )?;
639
640        Ok(())
641    }
642
643    /// Append a ledger entry row within the open transaction.
644    fn append_ledger_entry(
645        &self,
646        txn: &mut SqliteTxn,
647        entry: &LedgerEntry,
648    ) -> Result<(), SqliteStoreError> {
649        let conn = txn.conn();
650
651        let entry_id = entry.entry_id.to_string();
652        let agent_id = entry.agent_id.0.as_str();
653        let claim_id = entry.claim_ref.0.to_string();
654        let event_kind = ledger_event_kind_to_str(&entry.event_kind);
655        let disposition = disposition_to_str(&entry.disposition);
656        let rationale: Option<String> = entry
657            .rationale
658            .as_ref()
659            .map(|v| {
660                serde_json::to_string(v)
661                    .map_err(|e| SqliteStoreError::Mapping(format!("rationale serialization: {e}")))
662            })
663            .transpose()?;
664        let recorded_at = entry.recorded_at.0.to_rfc3339();
665
666        conn.execute(
667            "INSERT INTO ledger_entries (
668                entry_id, agent_id, claim_id, event_kind, disposition, rationale, recorded_at
669            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
670            rusqlite::params![
671                entry_id.as_str(),
672                agent_id,
673                claim_id.as_str(),
674                event_kind,
675                disposition,
676                rationale,
677                recorded_at.as_str(),
678            ],
679        )?;
680
681        Ok(())
682    }
683
684    /// Append a claim edge row within the open transaction.
685    fn append_claim_edge(
686        &self,
687        txn: &mut SqliteTxn,
688        edge: &ClaimEdge,
689    ) -> Result<(), SqliteStoreError> {
690        let conn = txn.conn();
691
692        let edge_id = edge.edge_id.to_string();
693        let agent_id = edge.agent_id.0.as_str();
694        let from_claim_id = edge.from_claim.0.to_string();
695        let to_claim_id = edge.to_claim.0.to_string();
696        let edge_kind = edge_kind_to_str(&edge.kind);
697        let created_at = edge.created_at.0.to_rfc3339();
698
699        conn.execute(
700            "INSERT INTO claim_edges (
701                edge_id, agent_id, from_claim_id, to_claim_id, edge_kind, created_at
702            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
703            rusqlite::params![
704                edge_id.as_str(),
705                agent_id,
706                from_claim_id.as_str(),
707                to_claim_id.as_str(),
708                edge_kind,
709                created_at.as_str(),
710            ],
711        )?;
712
713        Ok(())
714    }
715
716    // ── Read methods (non-mutating; lock connection slot directly) ───────────
717
718    /// Load all claims on the given (agent_id, subject, predicate) subject-line,
719    /// ordered by tx_time ASC (oldest first — callers fold in tx_time order).
720    ///
721    /// When `as_of_tx_time` is `Some(T)`, only claims with `tx_time <= T` are
722    /// returned, enforcing bi-temporal tx-time visibility. When `None`, all claims
723    /// are returned (current view). Uses `idx_claims_subject_line` covering index (§5).
724    fn load_subject_line(
725        &self,
726        agent_id: &AgentId,
727        subject: &str,
728        predicate: &str,
729        as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
730    ) -> Result<Vec<Claim>, SqliteStoreError> {
731        let slot = self.conn.lock().expect("mutex poisoned");
732        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
733
734        if let Some(cutoff) = as_of_tx_time {
735            let sql = format!(
736                "SELECT {CLAIM_SELECT_COLS} FROM claims
737                 WHERE agent_id = ?1 AND subject = ?2 AND predicate = ?3
738                   AND tx_time <= ?4
739                 ORDER BY tx_time ASC"
740            );
741            let cutoff_str = cutoff.to_rfc3339();
742            let mut stmt = conn.prepare(&sql)?;
743            let rows = stmt.query_map(
744                rusqlite::params![agent_id.0.as_str(), subject, predicate, cutoff_str],
745                row_to_claim,
746            )?;
747            let mut claims = Vec::new();
748            for row in rows {
749                claims.push(row?);
750            }
751            Ok(claims)
752        } else {
753            let sql = format!(
754                "SELECT {CLAIM_SELECT_COLS} FROM claims
755                 WHERE agent_id = ?1 AND subject = ?2 AND predicate = ?3
756                 ORDER BY tx_time ASC"
757            );
758            let mut stmt = conn.prepare(&sql)?;
759            let rows = stmt.query_map(
760                rusqlite::params![agent_id.0.as_str(), subject, predicate],
761                row_to_claim,
762            )?;
763            let mut claims = Vec::new();
764            for row in rows {
765                claims.push(row?);
766            }
767            Ok(claims)
768        }
769    }
770
771    /// Load a single claim by its `ClaimRef`. Returns `None` if not found.
772    fn load_claim(
773        &self,
774        agent_id: &AgentId,
775        claim_ref: &ClaimRef,
776    ) -> Result<Option<Claim>, SqliteStoreError> {
777        let slot = self.conn.lock().expect("mutex poisoned");
778        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
779
780        let sql = format!(
781            "SELECT {CLAIM_SELECT_COLS} FROM claims
782             WHERE agent_id = ?1 AND claim_id = ?2"
783        );
784        let mut stmt = conn.prepare(&sql)?;
785        let mut rows = stmt.query_map(
786            rusqlite::params![agent_id.0.as_str(), claim_ref.0.to_string()],
787            row_to_claim,
788        )?;
789
790        match rows.next() {
791            None => Ok(None),
792            Some(row) => Ok(Some(row?)),
793        }
794    }
795
796    /// Load all validity assertions targeting a claim, ordered by asserted_at ASC.
797    ///
798    /// Uses `idx_validity_assertions_target` index (§5).
799    fn load_validity_assertions_for(
800        &self,
801        agent_id: &AgentId,
802        claim_ref: &ClaimRef,
803    ) -> Result<Vec<ValidityAssertion>, SqliteStoreError> {
804        let slot = self.conn.lock().expect("mutex poisoned");
805        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
806
807        let mut stmt = conn.prepare(
808            "SELECT assertion_id, agent_id, target_claim_id,
809                    assertion_kind, bound_at, reopen_at,
810                    provenance_label, value_confidence, valid_time_confidence, asserted_at
811             FROM validity_assertions
812             WHERE agent_id = ?1 AND target_claim_id = ?2
813             ORDER BY asserted_at ASC",
814        )?;
815
816        let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
817            0, msg, rusqlite::types::Type::Text,
818        );
819
820        let rows = stmt.query_map(
821            rusqlite::params![agent_id.0.as_str(), claim_ref.0.to_string()],
822            |row| {
823                let assertion_id_str: String = row.get(0)?;
824                let agent_id_str: String = row.get(1)?;
825                let target_claim_str: String = row.get(2)?;
826                let kind_str: String = row.get(3)?;
827                let bound_at_str: Option<String> = row.get(4)?;
828                let reopen_at_str: Option<String> = row.get(5)?;
829                let prov_str: String = row.get(6)?;
830                let value_confidence: f64 = row.get(7)?;
831                let valid_time_confidence: f64 = row.get(8)?;
832                let asserted_at_str: String = row.get(9)?;
833
834                let assertion_ref = uuid::Uuid::parse_str(&assertion_id_str)
835                    .map_err(|e| to_err(format!("assertion_id UUID: {e}")))?;
836                let target_claim = uuid::Uuid::parse_str(&target_claim_str)
837                    .map(ClaimRef)
838                    .map_err(|e| to_err(format!("target_claim UUID: {e}")))?;
839                let provenance = str_to_provenance(&prov_str)
840                    .map_err(|e| to_err(e.to_string()))?;
841                let asserted_at = chrono::DateTime::parse_from_rfc3339(&asserted_at_str)
842                    .map(|dt| dt.with_timezone(&chrono::Utc))
843                    .map_err(|e| to_err(format!("asserted_at parse: {e}")))?;
844
845                let kind = match kind_str.as_str() {
846                    "Bound" => {
847                        let s = bound_at_str.ok_or_else(|| to_err("bound_at is NULL for Bound assertion".into()))?;
848                        let dt = chrono::DateTime::parse_from_rfc3339(&s)
849                            .map(|dt| dt.with_timezone(&chrono::Utc))
850                            .map_err(|e| to_err(format!("bound_at parse: {e}")))?;
851                        AssertionKind::Bound { bound_at: dt }
852                    }
853                    "Reopen" => {
854                        let s = reopen_at_str.ok_or_else(|| to_err("reopen_at is NULL for Reopen assertion".into()))?;
855                        let dt = chrono::DateTime::parse_from_rfc3339(&s)
856                            .map(|dt| dt.with_timezone(&chrono::Utc))
857                            .map_err(|e| to_err(format!("reopen_at parse: {e}")))?;
858                        AssertionKind::Reopen { reopen_at: dt }
859                    }
860                    other => return Err(to_err(format!("unknown assertion_kind: {other}"))),
861                };
862
863                Ok(ValidityAssertion {
864                    assertion_ref,
865                    agent_id: AgentId(agent_id_str),
866                    target_claim,
867                    kind,
868                    provenance,
869                    confidence: Confidence {
870                        value_confidence: value_confidence as f32,
871                        valid_time_confidence: valid_time_confidence as f32,
872                    },
873                    asserted_at: TransactionTime(asserted_at),
874                })
875            },
876        )?;
877
878        let mut assertions = Vec::new();
879        for row in rows {
880            assertions.push(row?);
881        }
882        Ok(assertions)
883    }
884
885    /// Load ledger entries for an agent, optionally starting from `from` (inclusive),
886    /// limited to `limit` rows, ordered by recorded_at ASC.
887    ///
888    /// Uses `idx_ledger_agent_time` index (§5). `from = None` returns from the beginning.
889    fn load_ledger(
890        &self,
891        agent_id: &AgentId,
892        from: Option<&TransactionTime>,
893        limit: usize,
894    ) -> Result<Vec<LedgerEntry>, SqliteStoreError> {
895        let slot = self.conn.lock().expect("mutex poisoned");
896        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
897
898        let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
899            0, msg, rusqlite::types::Type::Text,
900        );
901
902        let from_str: Option<String> = from.map(|t| t.0.to_rfc3339());
903        let limit_i64 = limit as i64;
904
905        let map_row = |row: &rusqlite::Row<'_>| {
906            let entry_id_str: String = row.get(0)?;
907            let agent_id_str: String = row.get(1)?;
908            let claim_id_str: String = row.get(2)?;
909            let event_kind_str: String = row.get(3)?;
910            let disposition_str: String = row.get(4)?;
911            let rationale_json: Option<String> = row.get(5)?;
912            let recorded_at_str: String = row.get(6)?;
913
914            let entry_id = uuid::Uuid::parse_str(&entry_id_str)
915                .map_err(|e| to_err(format!("entry_id UUID: {e}")))?;
916            let claim_id = uuid::Uuid::parse_str(&claim_id_str)
917                .map(ClaimRef)
918                .map_err(|e| to_err(format!("claim_id UUID: {e}")))?;
919            let event_kind = str_to_ledger_event_kind(&event_kind_str)
920                .map_err(|e| to_err(e.to_string()))?;
921            let disposition = str_to_disposition(&disposition_str)
922                .map_err(|e| to_err(e.to_string()))?;
923            let rationale: Option<serde_json::Value> = rationale_json
924                .map(|s| serde_json::from_str(&s).map_err(|e| to_err(format!("rationale JSON: {e}"))))
925                .transpose()?;
926            let recorded_at = chrono::DateTime::parse_from_rfc3339(&recorded_at_str)
927                .map(|dt| dt.with_timezone(&chrono::Utc))
928                .map_err(|e| to_err(format!("recorded_at parse: {e}")))?;
929
930            Ok(LedgerEntry {
931                entry_id,
932                agent_id: AgentId(agent_id_str),
933                claim_ref: claim_id,
934                event_kind,
935                disposition,
936                rationale,
937                recorded_at: TransactionTime(recorded_at),
938            })
939        };
940
941        let mut entries = Vec::new();
942
943        if let Some(ref from_val) = from_str {
944            let mut stmt = conn.prepare(
945                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale, recorded_at
946                 FROM ledger_entries
947                 WHERE agent_id = ?1 AND recorded_at >= ?2
948                 ORDER BY recorded_at ASC
949                 LIMIT ?3",
950            )?;
951            let rows = stmt.query_map(
952                rusqlite::params![agent_id.0.as_str(), from_val.as_str(), limit_i64],
953                map_row,
954            )?;
955            for row in rows {
956                entries.push(row?);
957            }
958        } else {
959            let mut stmt = conn.prepare(
960                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale, recorded_at
961                 FROM ledger_entries
962                 WHERE agent_id = ?1
963                 ORDER BY recorded_at ASC
964                 LIMIT ?2",
965            )?;
966            let rows = stmt.query_map(
967                rusqlite::params![agent_id.0.as_str(), limit_i64],
968                map_row,
969            )?;
970            for row in rows {
971                entries.push(row?);
972            }
973        }
974
975        Ok(entries)
976    }
977
978    /// Load ALL ledger entries for the given claim refs, no row cap.
979    ///
980    /// SQLite limits bound parameters to ~999 per statement (SQLITE_LIMIT_VARIABLE_NUMBER).
981    /// Chunks the IN list into batches of 900 and concatenates results so this method is
982    /// safe for any slice size.
983    fn load_ledger_for_claims(
984        &self,
985        agent_id: &AgentId,
986        claim_refs: &[ClaimRef],
987        as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
988    ) -> Result<Vec<LedgerEntry>, SqliteStoreError> {
989        if claim_refs.is_empty() {
990            return Ok(vec![]);
991        }
992
993        let slot = self.conn.lock().expect("mutex poisoned");
994        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
995
996        let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
997            0, msg, rusqlite::types::Type::Text,
998        );
999
1000        let map_row = |row: &rusqlite::Row<'_>| {
1001            let entry_id_str: String = row.get(0)?;
1002            let agent_id_str: String = row.get(1)?;
1003            let claim_id_str: String = row.get(2)?;
1004            let event_kind_str: String = row.get(3)?;
1005            let disposition_str: String = row.get(4)?;
1006            let rationale_json: Option<String> = row.get(5)?;
1007            let recorded_at_str: String = row.get(6)?;
1008
1009            let entry_id = uuid::Uuid::parse_str(&entry_id_str)
1010                .map_err(|e| to_err(format!("entry_id UUID: {e}")))?;
1011            let claim_id = uuid::Uuid::parse_str(&claim_id_str)
1012                .map(ClaimRef)
1013                .map_err(|e| to_err(format!("claim_id UUID: {e}")))?;
1014            let event_kind = str_to_ledger_event_kind(&event_kind_str)
1015                .map_err(|e| to_err(e.to_string()))?;
1016            let disposition = str_to_disposition(&disposition_str)
1017                .map_err(|e| to_err(e.to_string()))?;
1018            let rationale: Option<serde_json::Value> = rationale_json
1019                .map(|s| serde_json::from_str(&s).map_err(|e| to_err(format!("rationale JSON: {e}"))))
1020                .transpose()?;
1021            let recorded_at = chrono::DateTime::parse_from_rfc3339(&recorded_at_str)
1022                .map(|dt| dt.with_timezone(&chrono::Utc))
1023                .map_err(|e| to_err(format!("recorded_at parse: {e}")))?;
1024
1025            Ok(LedgerEntry {
1026                entry_id,
1027                agent_id: AgentId(agent_id_str),
1028                claim_ref: claim_id,
1029                event_kind,
1030                disposition,
1031                rationale,
1032                recorded_at: TransactionTime(recorded_at),
1033            })
1034        };
1035
1036        let mut all_entries = Vec::new();
1037        // SQLite's default SQLITE_LIMIT_VARIABLE_NUMBER is 999; use 900 to leave headroom
1038        // for the agent_id parameter (and the optional as_of_tx_time param).
1039        const CHUNK: usize = 900;
1040
1041        // Serialize as_of_tx_time once outside the chunk loop.
1042        let as_of_str: Option<String> = as_of_tx_time.map(|t| t.to_rfc3339());
1043
1044        for chunk in claim_refs.chunks(CHUNK) {
1045            // Placeholders are positional: ?1 = agent_id, ?2..?N+1 = claim_ids,
1046            // ?N+2 = as_of_tx_time (only present when Some).
1047            let id_start = 2usize;
1048            let placeholders: Vec<String> = (id_start..=chunk.len() + id_start - 1)
1049                .map(|i| format!("?{i}"))
1050                .collect();
1051            let as_of_clause = if as_of_str.is_some() {
1052                format!(" AND recorded_at <= ?{}", chunk.len() + id_start)
1053            } else {
1054                String::new()
1055            };
1056            let sql = format!(
1057                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale, recorded_at
1058                 FROM ledger_entries
1059                 WHERE agent_id = ?1 AND claim_id IN ({}){}
1060                 ORDER BY recorded_at ASC",
1061                placeholders.join(", "),
1062                as_of_clause
1063            );
1064
1065            let mut stmt = conn.prepare(&sql)?;
1066            // Build params: ?1=agent_id, ?2..?N+1=claim_ids, ?N+2=as_of (when Some).
1067            let agent_str = agent_id.0.as_str();
1068            let id_strings: Vec<String> = chunk.iter().map(|r| r.0.to_string()).collect();
1069
1070            // rusqlite requires a Vec<&dyn ToSql> when params are heterogeneous.
1071            let mut params: Vec<&dyn rusqlite::types::ToSql> =
1072                Vec::with_capacity(1 + id_strings.len() + usize::from(as_of_str.is_some()));
1073            params.push(&agent_str);
1074            for s in &id_strings {
1075                params.push(s);
1076            }
1077            if let Some(ref s) = as_of_str {
1078                params.push(s);
1079            }
1080
1081            let rows = stmt.query_map(params.as_slice(), map_row)?;
1082            for row in rows {
1083                all_entries.push(row?);
1084            }
1085        }
1086
1087        Ok(all_entries)
1088    }
1089
1090    /// Load all edges where `claim_ref` is either the from or to end, for this agent.
1091    /// Ordered by `created_at ASC` (deterministic cascade — required by convention).
1092    ///
1093    /// Uses `idx_edges_from` and `idx_edges_to` indexes (§5).
1094    fn load_edges_for(
1095        &self,
1096        agent_id: &AgentId,
1097        claim_ref: &ClaimRef,
1098    ) -> Result<Vec<ClaimEdge>, SqliteStoreError> {
1099        let slot = self.conn.lock().expect("mutex poisoned");
1100        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1101
1102        let claim_id_str = claim_ref.0.to_string();
1103
1104        let mut stmt = conn.prepare(
1105            "SELECT edge_id, agent_id, from_claim_id, to_claim_id, edge_kind, created_at
1106             FROM claim_edges
1107             WHERE agent_id = ?1
1108               AND (from_claim_id = ?2 OR to_claim_id = ?2)
1109             ORDER BY created_at ASC",
1110        )?;
1111
1112        let rows = stmt.query_map(
1113            rusqlite::params![agent_id.0.as_str(), claim_id_str.as_str()],
1114            row_to_edge,
1115        )?;
1116
1117        let mut edges = Vec::new();
1118        for row in rows {
1119            edges.push(row?);
1120        }
1121        Ok(edges)
1122    }
1123
1124    /// Load the set of ClaimRefs served as injected claims for this agent (used by the Amplification Guard).
1125    ///
1126    /// Scans `ledger_entries` for `event_kind = 'ServedAsInjected'` and returns
1127    /// the distinct set of claim IDs, ordered by recorded_at ASC.
1128    fn load_injected_claims(
1129        &self,
1130        agent_id: &AgentId,
1131    ) -> Result<Vec<ClaimRef>, SqliteStoreError> {
1132        let slot = self.conn.lock().expect("mutex poisoned");
1133        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1134
1135        let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
1136            0, msg, rusqlite::types::Type::Text,
1137        );
1138
1139        let mut stmt = conn.prepare(
1140            "SELECT claim_id
1141             FROM ledger_entries
1142             WHERE agent_id = ?1 AND event_kind = 'ServedAsInjected'
1143             GROUP BY claim_id
1144             ORDER BY MIN(recorded_at) ASC",
1145        )?;
1146
1147        let rows = stmt.query_map(
1148            rusqlite::params![agent_id.0.as_str()],
1149            |row| {
1150                let claim_id_str: String = row.get(0)?;
1151                uuid::Uuid::parse_str(&claim_id_str)
1152                    .map(ClaimRef)
1153                    .map_err(|e| to_err(format!("claim_id UUID: {e}")))
1154            },
1155        )?;
1156
1157        let mut refs = Vec::new();
1158        for row in rows {
1159            refs.push(row?);
1160        }
1161        Ok(refs)
1162    }
1163
1164    /// Recursive CTE lineage traversal.
1165    ///
1166    /// Traverses `DerivedFrom` edges upward (from `claim_ref` to its ancestors),
1167    /// returning all `ClaimEdge` rows in the lineage sub-graph, ordered by depth
1168    /// (shallowest first, then by `created_at ASC` within the same depth level).
1169    ///
1170    /// The CTE is bounded by `max_depth = 64` to prevent runaway on pathological graphs.
1171    fn load_lineage(
1172        &self,
1173        agent_id: &AgentId,
1174        claim_ref: &ClaimRef,
1175    ) -> Result<Vec<ClaimEdge>, SqliteStoreError> {
1176        let slot = self.conn.lock().expect("mutex poisoned");
1177        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1178
1179        let start_id = claim_ref.0.to_string();
1180
1181        // Recursive CTE: start from claim_ref and follow DerivedFrom edges upward.
1182        // Each step follows edges where the current node is the `from_claim_id`
1183        // (meaning: this claim was DerivedFrom to_claim_id, so ancestor is to_claim_id).
1184        let mut stmt = conn.prepare(
1185            "WITH RECURSIVE lineage(edge_id, depth) AS (
1186                -- Base case: all DerivedFrom edges leaving from our starting claim
1187                SELECT ce.edge_id, 1
1188                FROM claim_edges ce
1189                WHERE ce.agent_id = ?1
1190                  AND ce.from_claim_id = ?2
1191                  AND ce.edge_kind = 'DerivedFrom'
1192                UNION ALL
1193                -- Recursive case: follow the to_claim of the previous edge onward
1194                SELECT ce2.edge_id, l.depth + 1
1195                FROM claim_edges ce2
1196                JOIN lineage l ON ce2.from_claim_id = (
1197                    SELECT to_claim_id FROM claim_edges WHERE edge_id = l.edge_id
1198                )
1199                WHERE ce2.agent_id = ?1
1200                  AND ce2.edge_kind = 'DerivedFrom'
1201                  AND l.depth < 64
1202            )
1203            SELECT ce.edge_id, ce.agent_id, ce.from_claim_id, ce.to_claim_id,
1204                   ce.edge_kind, ce.created_at,
1205                   l.depth
1206            FROM claim_edges ce
1207            JOIN lineage l ON ce.edge_id = l.edge_id
1208            ORDER BY l.depth ASC, ce.created_at ASC",
1209        )?;
1210
1211        let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
1212            0, msg, rusqlite::types::Type::Text,
1213        );
1214
1215        let rows = stmt.query_map(
1216            rusqlite::params![agent_id.0.as_str(), start_id.as_str()],
1217            |row| {
1218                let edge_id_str: String = row.get(0)?;
1219                let agent_id_str: String = row.get(1)?;
1220                let from_claim_str: String = row.get(2)?;
1221                let to_claim_str: String = row.get(3)?;
1222                let kind_str: String = row.get(4)?;
1223                let created_at_str: String = row.get(5)?;
1224                // col 6 = depth (used only for ordering; not part of ClaimEdge)
1225
1226                let edge_id = uuid::Uuid::parse_str(&edge_id_str)
1227                    .map_err(|e| to_err(format!("edge_id UUID: {e}")))?;
1228                let from_claim = uuid::Uuid::parse_str(&from_claim_str)
1229                    .map(ClaimRef)
1230                    .map_err(|e| to_err(format!("from_claim UUID: {e}")))?;
1231                let to_claim = uuid::Uuid::parse_str(&to_claim_str)
1232                    .map(ClaimRef)
1233                    .map_err(|e| to_err(format!("to_claim UUID: {e}")))?;
1234                let kind = str_to_edge_kind(&kind_str)
1235                    .map_err(|e| to_err(e.to_string()))?;
1236                let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
1237                    .map(|dt| dt.with_timezone(&chrono::Utc))
1238                    .map_err(|e| to_err(format!("created_at parse: {e}")))?;
1239
1240                Ok(ClaimEdge {
1241                    edge_id,
1242                    agent_id: AgentId(agent_id_str),
1243                    from_claim,
1244                    to_claim,
1245                    kind,
1246                    created_at: TransactionTime(created_at),
1247                })
1248            },
1249        )?;
1250
1251        let mut edges = Vec::new();
1252        for row in rows {
1253            edges.push(row?);
1254        }
1255        Ok(edges)
1256    }
1257
1258    /// Return all distinct predicates for `(agent_id, subject)`.
1259    ///
1260    /// Uses `idx_claims_subject_line (agent_id, subject, predicate, tx_time DESC)`.
1261    ///
1262    /// SQLite DISTINCT over the leading three columns of the covering index is served
1263    /// as an index scan — no temp B-tree because the DISTINCT columns are a prefix of the
1264    /// index.  When `as_of_tx_time` is `Some(T)`, the `tx_time <= T` filter is applied
1265    /// before the DISTINCT so only predicates with at least one visible claim are returned.
1266    fn list_predicates_for_subject(
1267        &self,
1268        agent_id: &AgentId,
1269        subject: &str,
1270        as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
1271    ) -> Result<Vec<String>, SqliteStoreError> {
1272        let slot = self.conn.lock().expect("mutex poisoned");
1273        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1274
1275        let predicates = if let Some(cutoff) = as_of_tx_time {
1276            let cutoff_str = cutoff.to_rfc3339();
1277            let mut stmt = conn.prepare(
1278                "SELECT DISTINCT predicate
1279                 FROM claims
1280                 WHERE agent_id = ?1 AND subject = ?2 AND tx_time <= ?3",
1281            )?;
1282            let rows = stmt.query_map(
1283                rusqlite::params![agent_id.0.as_str(), subject, cutoff_str.as_str()],
1284                |row| row.get::<_, String>(0),
1285            )?;
1286            let mut preds = Vec::new();
1287            for row in rows {
1288                preds.push(row?);
1289            }
1290            preds
1291        } else {
1292            let mut stmt = conn.prepare(
1293                "SELECT DISTINCT predicate
1294                 FROM claims
1295                 WHERE agent_id = ?1 AND subject = ?2",
1296            )?;
1297            let rows = stmt.query_map(
1298                rusqlite::params![agent_id.0.as_str(), subject],
1299                |row| row.get::<_, String>(0),
1300            )?;
1301            let mut preds = Vec::new();
1302            for row in rows {
1303                preds.push(row?);
1304            }
1305            preds
1306        };
1307
1308        Ok(predicates)
1309    }
1310}
1311
1312// ── SqlitePendingStore ────────────────────────────────────────────────────────
1313
1314/// SQLite-backed `PendingAdjudicationPort` implementation.
1315///
1316/// Shares the same connection mutex as `SqlitePersistenceStore` but operates OUTSIDE
1317/// the claim transaction — reads and writes go directly on the connection (no BEGIN/COMMIT
1318/// wrapping). The per-agent write lock held by `EngineHandle` ensures these writes are
1319/// serialized with the claim txn commit.
1320///
1321/// Construct via `SqlitePendingStore::new(conn_arc)` sharing the same connection Arc
1322/// as the `SqlitePersistenceStore`.
1323pub struct SqlitePendingStore {
1324    conn: Arc<Mutex<Option<Box<Connection>>>>,
1325}
1326
1327impl SqlitePendingStore {
1328    /// Create a pending store sharing the connection with a `SqlitePersistenceStore`.
1329    pub fn new(conn: Arc<Mutex<Option<Box<Connection>>>>) -> Self {
1330        Self { conn }
1331    }
1332}
1333
1334// SAFETY: Connection is Send; Mutex makes it Sync.
1335unsafe impl Send for SqlitePendingStore {}
1336unsafe impl Sync for SqlitePendingStore {}
1337
1338impl PendingAdjudicationPort for SqlitePendingStore {
1339    type Error = SqliteStoreError;
1340
1341    fn insert_pending(&self, row: &PendingAdjudicationRow) -> Result<(), SqliteStoreError> {
1342        let slot = self.conn.lock().expect("mutex poisoned");
1343        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1344
1345        let request_payload = serde_json::to_string(&row.request_payload)
1346            .map_err(|e| SqliteStoreError::Mapping(format!("request_payload serialization: {e}")))?;
1347        let queued_at = row.queued_at.to_rfc3339();
1348        let expires_at: Option<String> = row.expires_at.map(|dt| dt.to_rfc3339());
1349
1350        conn.execute(
1351            "INSERT INTO pending_adjudications (
1352                handle_id, agent_id, subject, predicate,
1353                challenger_claim_ref, incumbent_claim_ref,
1354                request_payload, queued_at, expires_at, status
1355            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1356            rusqlite::params![
1357                row.handle_id.to_string(),
1358                row.agent_id.0.as_str(),
1359                row.subject.as_str(),
1360                row.predicate.as_str(),
1361                row.challenger_claim_ref.0.to_string(),
1362                row.incumbent_claim_ref.0.to_string(),
1363                request_payload.as_str(),
1364                queued_at.as_str(),
1365                expires_at,
1366                row.status.as_str(),
1367            ],
1368        )?;
1369        Ok(())
1370    }
1371
1372    fn get_pending(&self, handle_id: uuid::Uuid) -> Result<Option<PendingAdjudicationRow>, SqliteStoreError> {
1373        let slot = self.conn.lock().expect("mutex poisoned");
1374        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1375
1376        let mut stmt = conn.prepare(
1377            "SELECT handle_id, agent_id, subject, predicate,
1378                    challenger_claim_ref, incumbent_claim_ref,
1379                    request_payload, queued_at, expires_at, status
1380             FROM pending_adjudications
1381             WHERE handle_id = ?1",
1382        )?;
1383
1384        let mut rows = stmt.query_map(
1385            rusqlite::params![handle_id.to_string()],
1386            row_to_pending,
1387        )?;
1388
1389        match rows.next() {
1390            None => Ok(None),
1391            Some(row) => Ok(Some(row.map_err(|e| SqliteStoreError::Mapping(e.to_string()))?)),
1392        }
1393    }
1394
1395    fn list_pending(&self, agent_id: Option<&mempill_types::AgentId>) -> Result<Vec<PendingAdjudicationRow>, SqliteStoreError> {
1396        let slot = self.conn.lock().expect("mutex poisoned");
1397        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1398
1399        let rows = if let Some(aid) = agent_id {
1400            let mut stmt = conn.prepare(
1401                "SELECT handle_id, agent_id, subject, predicate,
1402                        challenger_claim_ref, incumbent_claim_ref,
1403                        request_payload, queued_at, expires_at, status
1404                 FROM pending_adjudications
1405                 WHERE agent_id = ?1 AND status = 'pending'
1406                 ORDER BY queued_at ASC",
1407            )?;
1408            let mapped = stmt.query_map(rusqlite::params![aid.0.as_str()], row_to_pending)?;
1409            let mut result = Vec::new();
1410            for r in mapped {
1411                result.push(r.map_err(|e| SqliteStoreError::Mapping(e.to_string()))?);
1412            }
1413            result
1414        } else {
1415            let mut stmt = conn.prepare(
1416                "SELECT handle_id, agent_id, subject, predicate,
1417                        challenger_claim_ref, incumbent_claim_ref,
1418                        request_payload, queued_at, expires_at, status
1419                 FROM pending_adjudications
1420                 WHERE status = 'pending'
1421                 ORDER BY queued_at ASC",
1422            )?;
1423            let mapped = stmt.query_map([], row_to_pending)?;
1424            let mut result = Vec::new();
1425            for r in mapped {
1426                result.push(r.map_err(|e| SqliteStoreError::Mapping(e.to_string()))?);
1427            }
1428            result
1429        };
1430        Ok(rows)
1431    }
1432
1433    fn list_expired(&self, now: chrono::DateTime<chrono::Utc>) -> Result<Vec<PendingAdjudicationRow>, SqliteStoreError> {
1434        let slot = self.conn.lock().expect("mutex poisoned");
1435        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1436
1437        let now_str = now.to_rfc3339();
1438        let mut stmt = conn.prepare(
1439            "SELECT handle_id, agent_id, subject, predicate,
1440                    challenger_claim_ref, incumbent_claim_ref,
1441                    request_payload, queued_at, expires_at, status
1442             FROM pending_adjudications
1443             WHERE expires_at IS NOT NULL AND expires_at <= ?1 AND status = 'pending'
1444             ORDER BY expires_at ASC",
1445        )?;
1446        let mapped = stmt.query_map(rusqlite::params![now_str.as_str()], row_to_pending)?;
1447        let mut result = Vec::new();
1448        for r in mapped {
1449            result.push(r.map_err(|e| SqliteStoreError::Mapping(e.to_string()))?);
1450        }
1451        Ok(result)
1452    }
1453
1454    fn mark_resolved(&self, handle_id: uuid::Uuid) -> Result<(), SqliteStoreError> {
1455        let slot = self.conn.lock().expect("mutex poisoned");
1456        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1457
1458        conn.execute(
1459            "UPDATE pending_adjudications SET status = 'resolved' WHERE handle_id = ?1",
1460            rusqlite::params![handle_id.to_string()],
1461        )?;
1462        Ok(())
1463    }
1464
1465    fn mark_expired(&self, handle_id: uuid::Uuid) -> Result<(), SqliteStoreError> {
1466        let slot = self.conn.lock().expect("mutex poisoned");
1467        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1468
1469        conn.execute(
1470            "UPDATE pending_adjudications SET status = 'expired' WHERE handle_id = ?1",
1471            rusqlite::params![handle_id.to_string()],
1472        )?;
1473        Ok(())
1474    }
1475
1476    /// Detect QueuedForAdjudication claims (by latest ledger disposition) with no matching
1477    /// pending row (status = 'pending').
1478    ///
1479    /// Approach: find claim_ids whose most-recent ledger entry has disposition =
1480    /// 'QueuedForAdjudication' via a subquery on max(recorded_at), then check for absence
1481    /// of a matching pending_adjudications row. Returns orphaned claim refs with
1482    /// agent_id, subject, predicate, and best-guess incumbent.
1483    ///
1484    /// NOTE: The schema uses `claim_id` (not `claim_ref`) in `ledger_entries` and `claims`.
1485    fn list_queued_orphan_claims(
1486        &self,
1487    ) -> Result<Vec<mempill_core::ports::pending_adjudication::OrphanedQueuedClaim>, SqliteStoreError> {
1488        let slot = self.conn.lock().expect("mutex poisoned");
1489        let conn = slot.as_ref().ok_or(SqliteStoreError::TxnAlreadyOpen)?;
1490
1491        // Step 1: Find all (agent_id, claim_id) pairs whose latest ledger disposition is
1492        // 'QueuedForAdjudication' with no matching pending_adjudications row (status='pending').
1493        let mut stmt = conn.prepare(
1494            "SELECT l.agent_id, l.claim_id, c.subject, c.predicate
1495             FROM ledger_entries l
1496             JOIN claims c ON c.claim_id = l.claim_id AND c.agent_id = l.agent_id
1497             WHERE l.disposition = 'QueuedForAdjudication'
1498               AND l.recorded_at = (
1499                   SELECT MAX(l2.recorded_at) FROM ledger_entries l2
1500                   WHERE l2.claim_id = l.claim_id AND l2.agent_id = l.agent_id
1501               )
1502               AND NOT EXISTS (
1503                   SELECT 1 FROM pending_adjudications pa
1504                   WHERE pa.challenger_claim_ref = l.claim_id
1505                     AND pa.agent_id = l.agent_id
1506                     AND pa.status = 'pending'
1507               )",
1508        )?;
1509
1510        let orphan_rows: Vec<(String, String, String, String)> = stmt
1511            .query_map([], |row| {
1512                Ok((
1513                    row.get::<_, String>(0)?,
1514                    row.get::<_, String>(1)?,
1515                    row.get::<_, String>(2)?,
1516                    row.get::<_, String>(3)?,
1517                ))
1518            })?
1519            .filter_map(|r| r.ok())
1520            .collect();
1521
1522        let mut results = Vec::new();
1523        for (agent_id_str, challenger_str, subject, predicate) in orphan_rows {
1524            use mempill_types::ClaimRef;
1525
1526            let challenger_ref = uuid::Uuid::parse_str(&challenger_str)
1527                .map(ClaimRef)
1528                .map_err(|e| SqliteStoreError::Mapping(format!("challenger_claim_ref UUID: {e}")))?;
1529
1530            // Step 2: Find the incumbent (CommittedCheap) on the same subject line.
1531            let incumbent_ref = find_committed_cheap_claim(conn, &agent_id_str, &subject, &predicate)?;
1532
1533            results.push(mempill_core::ports::pending_adjudication::OrphanedQueuedClaim {
1534                agent_id: mempill_types::AgentId(agent_id_str),
1535                challenger_claim_ref: challenger_ref,
1536                incumbent_claim_ref: incumbent_ref,
1537                subject,
1538                predicate,
1539            });
1540        }
1541
1542        Ok(results)
1543    }
1544}
1545
1546/// Map a rusqlite `Row` from `pending_adjudications` to a `PendingAdjudicationRow`.
1547///
1548/// Column order (must match every SELECT):
1549///   0  handle_id
1550///   1  agent_id
1551///   2  subject
1552///   3  predicate
1553///   4  challenger_claim_ref
1554///   5  incumbent_claim_ref
1555///   6  request_payload  (JSON text)
1556///   7  queued_at        (ISO-8601)
1557///   8  expires_at       (ISO-8601, nullable)
1558///   9  status
1559fn row_to_pending(row: &rusqlite::Row<'_>) -> Result<PendingAdjudicationRow, rusqlite::Error> {
1560    let to_err = |msg: String| rusqlite::Error::InvalidColumnType(
1561        0, msg, rusqlite::types::Type::Text,
1562    );
1563
1564    let handle_id_str: String = row.get(0)?;
1565    let agent_id_str: String = row.get(1)?;
1566    let subject: String = row.get(2)?;
1567    let predicate: String = row.get(3)?;
1568    let challenger_str: String = row.get(4)?;
1569    let incumbent_str: String = row.get(5)?;
1570    let payload_json: String = row.get(6)?;
1571    let queued_at_str: String = row.get(7)?;
1572    let expires_at_str: Option<String> = row.get(8)?;
1573    let status: String = row.get(9)?;
1574
1575    let handle_id = uuid::Uuid::parse_str(&handle_id_str)
1576        .map_err(|e| to_err(format!("handle_id UUID: {e}")))?;
1577    let challenger_claim_ref = uuid::Uuid::parse_str(&challenger_str)
1578        .map(ClaimRef)
1579        .map_err(|e| to_err(format!("challenger_claim_ref UUID: {e}")))?;
1580    let incumbent_claim_ref = uuid::Uuid::parse_str(&incumbent_str)
1581        .map(ClaimRef)
1582        .map_err(|e| to_err(format!("incumbent_claim_ref UUID: {e}")))?;
1583    let request_payload: mempill_types::AdjudicationRequest =
1584        serde_json::from_str(&payload_json)
1585            .map_err(|e| to_err(format!("request_payload JSON: {e}")))?;
1586    let queued_at = chrono::DateTime::parse_from_rfc3339(&queued_at_str)
1587        .map(|dt| dt.with_timezone(&chrono::Utc))
1588        .map_err(|e| to_err(format!("queued_at parse: {e}")))?;
1589    let expires_at = expires_at_str
1590        .map(|s| {
1591            chrono::DateTime::parse_from_rfc3339(&s)
1592                .map(|dt| dt.with_timezone(&chrono::Utc))
1593                .map_err(|e| to_err(format!("expires_at parse: {e}")))
1594        })
1595        .transpose()?;
1596
1597    Ok(PendingAdjudicationRow {
1598        handle_id,
1599        agent_id: AgentId(agent_id_str),
1600        subject,
1601        predicate,
1602        challenger_claim_ref,
1603        incumbent_claim_ref,
1604        request_payload,
1605        queued_at,
1606        expires_at,
1607        status,
1608    })
1609}
1610
1611/// Find the most recent CommittedCheap claim on the same (agent_id, subject, predicate)
1612/// subject line, used to identify the incumbent during orphan recovery.
1613///
1614/// Returns `None` if no CommittedCheap claim exists (sweep will skip reverting such orphans
1615/// — they cannot be surfaced as Contested without a known incumbent).
1616///
1617/// NOTE: The schema uses `claim_id` (not `claim_ref`) in both `claims` and `ledger_entries`.
1618fn find_committed_cheap_claim(
1619    conn: &Connection,
1620    agent_id: &str,
1621    subject: &str,
1622    predicate: &str,
1623) -> Result<Option<mempill_types::ClaimRef>, SqliteStoreError> {
1624    // Find the claim_id from the same subject line whose latest ledger entry is CommittedCheap.
1625    let mut stmt = conn.prepare(
1626        "SELECT l.claim_id
1627         FROM ledger_entries l
1628         JOIN claims c ON c.claim_id = l.claim_id AND c.agent_id = l.agent_id
1629         WHERE l.agent_id = ?1
1630           AND c.subject = ?2
1631           AND c.predicate = ?3
1632           AND l.disposition = 'CommittedCheap'
1633           AND l.recorded_at = (
1634               SELECT MAX(l2.recorded_at) FROM ledger_entries l2
1635               WHERE l2.claim_id = l.claim_id AND l2.agent_id = l.agent_id
1636           )
1637         ORDER BY l.recorded_at DESC
1638         LIMIT 1",
1639    )?;
1640
1641    let mut rows = stmt.query_map(rusqlite::params![agent_id, subject, predicate], |row| {
1642        row.get::<_, String>(0)
1643    })?;
1644
1645    if let Some(Ok(ref_str)) = rows.next() {
1646        let claim_ref = uuid::Uuid::parse_str(&ref_str)
1647            .map(mempill_types::ClaimRef)
1648            .map_err(|e| SqliteStoreError::Mapping(format!("incumbent_claim_ref UUID: {e}")))?;
1649        Ok(Some(claim_ref))
1650    } else {
1651        Ok(None)
1652    }
1653}
1654
1655// ── Tests ─────────────────────────────────────────────────────────────────────
1656
1657#[cfg(test)]
1658mod tests {
1659    use super::*;
1660    use crate::connection::open_in_memory;
1661    use chrono::Utc;
1662    use mempill_types::{
1663        claim::{Cardinality, Claim, Confidence, Criticality, Fact},
1664        disposition::Disposition,
1665        identity::AgentId,
1666        ledger::LedgerEventKind,
1667        provenance::{ExternalAnchor, ExternalKind, ProvenanceLabel},
1668        time::{TransactionTime, ValidTime},
1669        validity::AssertionKind,
1670    };
1671    use uuid::Uuid;
1672
1673    fn make_store() -> SqlitePersistenceStore {
1674        let conn = open_in_memory().expect("in-memory connection should open");
1675        SqlitePersistenceStore::new(conn)
1676    }
1677
1678    fn make_agent() -> AgentId {
1679        AgentId("test-agent-1".into())
1680    }
1681
1682    fn make_claim(agent_id: &AgentId) -> Claim {
1683        Claim::new(
1684            ClaimRef(Uuid::new_v4()),
1685            agent_id.clone(),
1686            Fact {
1687                subject: "user".into(),
1688                predicate: "favourite_colour".into(),
1689                value: serde_json::json!("blue"),
1690            },
1691            Cardinality::Functional,
1692            ProvenanceLabel::External(ExternalKind::UserAsserted),
1693            ExternalAnchor { nearest_external_anchor: None, derivation_depth: 0 },
1694            TransactionTime(Utc::now()),
1695            ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None},
1696            Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
1697            Criticality::Low,
1698            vec![],
1699            None,
1700            None,
1701        )
1702    }
1703
1704    fn make_ledger_entry(
1705        agent_id: &AgentId,
1706        claim_ref: &ClaimRef,
1707    ) -> LedgerEntry {
1708        LedgerEntry {
1709            entry_id: Uuid::new_v4(),
1710            agent_id: agent_id.clone(),
1711            claim_ref: claim_ref.clone(),
1712            event_kind: LedgerEventKind::ClaimCommitted,
1713            disposition: Disposition::CommittedCheap,
1714            rationale: None,
1715            recorded_at: TransactionTime(Utc::now()),
1716        }
1717    }
1718
1719    // ── WRITE ROUND-TRIP ──────────────────────────────────────────────────────
1720
1721    /// Append a claim within a Txn, commit, then verify the row exists via raw SELECT.
1722    /// (We use raw SQL here for direct verification without the typed read path.)
1723    #[test]
1724    fn write_round_trip_claim_persists_after_commit() {
1725        let store = make_store();
1726        let agent = make_agent();
1727        let claim = make_claim(&agent);
1728        let claim_id = claim.claim_ref().0.to_string();
1729
1730        let mut txn = store.begin_atomic(&agent).expect("begin_atomic should succeed");
1731        store.append_claim(&mut txn, &claim).expect("append_claim should succeed");
1732        store.commit(txn).expect("commit should succeed");
1733
1734        // Re-acquire the connection to verify via raw SQL.
1735        let slot = store.conn.lock().unwrap();
1736        let conn = slot.as_ref().expect("connection must be back after commit");
1737        let count: i64 = conn
1738            .query_row(
1739                "SELECT COUNT(*) FROM claims WHERE claim_id = ?1",
1740                [claim_id.as_str()],
1741                |r| r.get(0),
1742            )
1743            .expect("SELECT should succeed");
1744        assert_eq!(count, 1, "claim row must exist after commit");
1745    }
1746
1747    /// Append a claim and verify all provenance columns are stored correctly.
1748    #[test]
1749    fn write_round_trip_provenance_not_null() {
1750        let store = make_store();
1751        let agent = make_agent();
1752        let claim = make_claim(&agent);
1753        let claim_id = claim.claim_ref().0.to_string();
1754
1755        let mut txn = store.begin_atomic(&agent).expect("begin_atomic should succeed");
1756        store.append_claim(&mut txn, &claim).expect("append_claim should succeed");
1757        store.commit(txn).expect("commit should succeed");
1758
1759        let slot = store.conn.lock().unwrap();
1760        let conn = slot.as_ref().unwrap();
1761
1762        // provenance_label must be non-NULL (I2 — NOT NULL constraint in schema).
1763        let prov: String = conn
1764            .query_row(
1765                "SELECT provenance_label FROM claims WHERE claim_id = ?1",
1766                [claim_id.as_str()],
1767                |r| r.get(0),
1768            )
1769            .expect("provenance_label must be selectable");
1770        assert_eq!(
1771            prov, "External_UserAsserted",
1772            "provenance_label column must be non-NULL and correct"
1773        );
1774
1775        // tx_time must be non-NULL (I2).
1776        let tx_time: String = conn
1777            .query_row(
1778                "SELECT tx_time FROM claims WHERE claim_id = ?1",
1779                [claim_id.as_str()],
1780                |r| r.get(0),
1781            )
1782            .expect("tx_time must be selectable");
1783        assert!(!tx_time.is_empty(), "tx_time must be non-NULL");
1784    }
1785
1786    // ── ATOMICITY ─────────────────────────────────────────────────────────────
1787
1788    /// Begin a Txn, append {claim + validity assertion + ledger entry}, force rollback.
1789    /// All three rows must be absent after rollback — all-or-nothing atomicity.
1790    #[test]
1791    fn atomicity_rollback_leaves_zero_rows() {
1792        let store = make_store();
1793        let agent = make_agent();
1794        let claim = make_claim(&agent);
1795        let claim_ref = claim.claim_ref().clone();
1796        let claim_id = claim_ref.0.to_string();
1797
1798        let assertion = mempill_types::validity::ValidityAssertion {
1799            assertion_ref: Uuid::new_v4(),
1800            agent_id: agent.clone(),
1801            target_claim: claim_ref.clone(),
1802            kind: AssertionKind::Bound { bound_at: Utc::now() },
1803            provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
1804            confidence: mempill_types::claim::Confidence {
1805                value_confidence: 0.9,
1806                valid_time_confidence: 0.9,
1807            },
1808            asserted_at: TransactionTime(Utc::now()),
1809        };
1810        let assertion_id = assertion.assertion_ref.to_string();
1811
1812        let ledger_entry = make_ledger_entry(&agent, &claim_ref);
1813        let entry_id = ledger_entry.entry_id.to_string();
1814
1815        let mut txn = store.begin_atomic(&agent).expect("begin_atomic should succeed");
1816        store.append_claim(&mut txn, &claim).expect("append_claim in txn should succeed");
1817        store
1818            .append_validity_assertion(&mut txn, &assertion)
1819            .expect("append_validity_assertion in txn should succeed");
1820        store
1821            .append_ledger_entry(&mut txn, &ledger_entry)
1822            .expect("append_ledger_entry in txn should succeed");
1823
1824        // Force rollback — must leave zero rows.
1825        store.rollback(txn).expect("rollback should succeed");
1826
1827        let slot = store.conn.lock().unwrap();
1828        let conn = slot.as_ref().expect("connection must be back after rollback");
1829
1830        let claim_count: i64 = conn
1831            .query_row(
1832                "SELECT COUNT(*) FROM claims WHERE claim_id = ?1",
1833                [claim_id.as_str()],
1834                |r| r.get(0),
1835            )
1836            .unwrap();
1837        let assertion_count: i64 = conn
1838            .query_row(
1839                "SELECT COUNT(*) FROM validity_assertions WHERE assertion_id = ?1",
1840                [assertion_id.as_str()],
1841                |r| r.get(0),
1842            )
1843            .unwrap();
1844        let ledger_count: i64 = conn
1845            .query_row(
1846                "SELECT COUNT(*) FROM ledger_entries WHERE entry_id = ?1",
1847                [entry_id.as_str()],
1848                |r| r.get(0),
1849            )
1850            .unwrap();
1851
1852        assert_eq!(claim_count, 0, "claim row must not exist after rollback");
1853        assert_eq!(assertion_count, 0, "validity_assertion row must not exist after rollback");
1854        assert_eq!(ledger_count, 0, "ledger_entry row must not exist after rollback");
1855    }
1856
1857    // ── VALIDITY ASSERTION ROUND-TRIP ─────────────────────────────────────────
1858
1859    #[test]
1860    fn write_round_trip_validity_assertion() {
1861        let store = make_store();
1862        let agent = make_agent();
1863        let claim = make_claim(&agent);
1864        let claim_ref = claim.claim_ref().clone();
1865
1866        let assertion = mempill_types::validity::ValidityAssertion {
1867            assertion_ref: Uuid::new_v4(),
1868            agent_id: agent.clone(),
1869            target_claim: claim_ref.clone(),
1870            kind: AssertionKind::Bound { bound_at: Utc::now() },
1871            provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
1872            confidence: mempill_types::claim::Confidence {
1873                value_confidence: 0.95,
1874                valid_time_confidence: 0.8,
1875            },
1876            asserted_at: TransactionTime(Utc::now()),
1877        };
1878        let assertion_id = assertion.assertion_ref.to_string();
1879
1880        let mut txn = store.begin_atomic(&agent).unwrap();
1881        store.append_claim(&mut txn, &claim).unwrap();
1882        store.append_validity_assertion(&mut txn, &assertion).unwrap();
1883        store.commit(txn).unwrap();
1884
1885        let slot = store.conn.lock().unwrap();
1886        let conn = slot.as_ref().unwrap();
1887        let count: i64 = conn
1888            .query_row(
1889                "SELECT COUNT(*) FROM validity_assertions WHERE assertion_id = ?1",
1890                [assertion_id.as_str()],
1891                |r| r.get(0),
1892            )
1893            .unwrap();
1894        assert_eq!(count, 1, "validity_assertion row must exist after commit");
1895    }
1896
1897    // ── LEDGER ENTRY ROUND-TRIP ───────────────────────────────────────────────
1898
1899    #[test]
1900    fn write_round_trip_ledger_entry() {
1901        let store = make_store();
1902        let agent = make_agent();
1903        let claim = make_claim(&agent);
1904        let claim_ref = claim.claim_ref().clone();
1905        let entry = make_ledger_entry(&agent, &claim_ref);
1906        let entry_id = entry.entry_id.to_string();
1907
1908        let mut txn = store.begin_atomic(&agent).unwrap();
1909        store.append_claim(&mut txn, &claim).unwrap();
1910        store.append_ledger_entry(&mut txn, &entry).unwrap();
1911        store.commit(txn).unwrap();
1912
1913        let slot = store.conn.lock().unwrap();
1914        let conn = slot.as_ref().unwrap();
1915        let count: i64 = conn
1916            .query_row(
1917                "SELECT COUNT(*) FROM ledger_entries WHERE entry_id = ?1",
1918                [entry_id.as_str()],
1919                |r| r.get(0),
1920            )
1921            .unwrap();
1922        assert_eq!(count, 1, "ledger_entry row must exist after commit");
1923    }
1924
1925    // ── CLAIM EDGE ROUND-TRIP ─────────────────────────────────────────────────
1926
1927    #[test]
1928    fn write_round_trip_claim_edge() {
1929        let store = make_store();
1930        let agent = make_agent();
1931        let from_claim = make_claim(&agent);
1932        let to_claim = make_claim(&agent);
1933        let from_ref = from_claim.claim_ref().clone();
1934        let to_ref = to_claim.claim_ref().clone();
1935
1936        let edge = ClaimEdge {
1937            edge_id: Uuid::new_v4(),
1938            agent_id: agent.clone(),
1939            from_claim: from_ref.clone(),
1940            to_claim: to_ref.clone(),
1941            kind: EdgeKind::DerivedFrom,
1942            created_at: TransactionTime(Utc::now()),
1943        };
1944        let edge_id = edge.edge_id.to_string();
1945
1946        let mut txn = store.begin_atomic(&agent).unwrap();
1947        store.append_claim(&mut txn, &from_claim).unwrap();
1948        store.append_claim(&mut txn, &to_claim).unwrap();
1949        store.append_claim_edge(&mut txn, &edge).unwrap();
1950        store.commit(txn).unwrap();
1951
1952        let slot = store.conn.lock().unwrap();
1953        let conn = slot.as_ref().unwrap();
1954        let count: i64 = conn
1955            .query_row(
1956                "SELECT COUNT(*) FROM claim_edges WHERE edge_id = ?1",
1957                [edge_id.as_str()],
1958                |r| r.get(0),
1959            )
1960            .unwrap();
1961        assert_eq!(count, 1, "claim_edge row must exist after commit");
1962    }
1963
1964    // ── READ PATH TESTS ───────────────────────────────────────────────────────
1965
1966    /// Write a claim then load_claim returns it with all fields intact (round-trip).
1967    #[test]
1968    fn read_load_claim_round_trip() {
1969        let store = make_store();
1970        let agent = make_agent();
1971        let claim = make_claim(&agent);
1972        let claim_ref = claim.claim_ref().clone();
1973
1974        let mut txn = store.begin_atomic(&agent).unwrap();
1975        store.append_claim(&mut txn, &claim).unwrap();
1976        store.commit(txn).unwrap();
1977
1978        let loaded = store.load_claim(&agent, &claim_ref).unwrap();
1979        assert!(loaded.is_some(), "load_claim must return Some for existing claim");
1980        let loaded = loaded.unwrap();
1981        assert_eq!(loaded.claim_ref(), &claim_ref);
1982        assert_eq!(loaded.agent_id(), &agent);
1983        assert_eq!(loaded.fact().subject, "user");
1984        assert_eq!(loaded.fact().predicate, "favourite_colour");
1985        assert_eq!(loaded.fact().value, serde_json::json!("blue"));
1986        assert_eq!(loaded.provenance(), claim.provenance());
1987        assert_eq!(loaded.cardinality(), claim.cardinality());
1988        assert_eq!(loaded.criticality(), claim.criticality());
1989    }
1990
1991    /// load_claim returns None for a non-existent ClaimRef.
1992    #[test]
1993    fn read_load_claim_missing_returns_none() {
1994        let store = make_store();
1995        let agent = make_agent();
1996        let missing_ref = ClaimRef(Uuid::new_v4());
1997        let result = store.load_claim(&agent, &missing_ref).unwrap();
1998        assert!(result.is_none(), "load_claim must return None for unknown claim_ref");
1999    }
2000
2001    /// Write a claim then load_subject_line returns it.
2002    #[test]
2003    fn read_load_subject_line_round_trip() {
2004        let store = make_store();
2005        let agent = make_agent();
2006        let claim = make_claim(&agent);
2007        let claim_ref = claim.claim_ref().clone();
2008
2009        let mut txn = store.begin_atomic(&agent).unwrap();
2010        store.append_claim(&mut txn, &claim).unwrap();
2011        store.commit(txn).unwrap();
2012
2013        let claims = store.load_subject_line(&agent, "user", "favourite_colour", None).unwrap();
2014        assert_eq!(claims.len(), 1, "load_subject_line must return the single written claim");
2015        assert_eq!(claims[0].claim_ref(), &claim_ref);
2016    }
2017
2018    /// load_subject_line returns empty vec when nothing matches.
2019    #[test]
2020    fn read_load_subject_line_empty_when_no_match() {
2021        let store = make_store();
2022        let agent = make_agent();
2023        let claims = store.load_subject_line(&agent, "nonexistent", "pred", None).unwrap();
2024        assert!(claims.is_empty(), "load_subject_line must return empty vec for unknown subject-line");
2025    }
2026
2027    /// Write a validity assertion then load_validity_assertions_for returns it.
2028    #[test]
2029    fn read_load_validity_assertions_round_trip() {
2030        let store = make_store();
2031        let agent = make_agent();
2032        let claim = make_claim(&agent);
2033        let claim_ref = claim.claim_ref().clone();
2034
2035        let assertion = mempill_types::validity::ValidityAssertion {
2036            assertion_ref: Uuid::new_v4(),
2037            agent_id: agent.clone(),
2038            target_claim: claim_ref.clone(),
2039            kind: AssertionKind::Bound { bound_at: Utc::now() },
2040            provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
2041            confidence: mempill_types::claim::Confidence {
2042                value_confidence: 0.9,
2043                valid_time_confidence: 0.8,
2044            },
2045            asserted_at: TransactionTime(Utc::now()),
2046        };
2047
2048        let mut txn = store.begin_atomic(&agent).unwrap();
2049        store.append_claim(&mut txn, &claim).unwrap();
2050        store.append_validity_assertion(&mut txn, &assertion).unwrap();
2051        store.commit(txn).unwrap();
2052
2053        let loaded = store.load_validity_assertions_for(&agent, &claim_ref).unwrap();
2054        assert_eq!(loaded.len(), 1, "must return one validity assertion");
2055        assert_eq!(loaded[0].assertion_ref, assertion.assertion_ref);
2056        assert_eq!(loaded[0].target_claim, claim_ref);
2057        assert!(matches!(loaded[0].kind, AssertionKind::Bound { .. }));
2058    }
2059
2060    /// load_validity_assertions_for returns empty when no assertions exist.
2061    #[test]
2062    fn read_load_validity_assertions_empty_when_none() {
2063        let store = make_store();
2064        let agent = make_agent();
2065        let claim = make_claim(&agent);
2066        let claim_ref = claim.claim_ref().clone();
2067
2068        let mut txn = store.begin_atomic(&agent).unwrap();
2069        store.append_claim(&mut txn, &claim).unwrap();
2070        store.commit(txn).unwrap();
2071
2072        let loaded = store.load_validity_assertions_for(&agent, &claim_ref).unwrap();
2073        assert!(loaded.is_empty(), "must return empty vec when no assertions");
2074    }
2075
2076    /// Write a ledger entry and load_ledger returns it.
2077    #[test]
2078    fn read_load_ledger_round_trip() {
2079        let store = make_store();
2080        let agent = make_agent();
2081        let claim = make_claim(&agent);
2082        let claim_ref = claim.claim_ref().clone();
2083        let entry = make_ledger_entry(&agent, &claim_ref);
2084
2085        let mut txn = store.begin_atomic(&agent).unwrap();
2086        store.append_claim(&mut txn, &claim).unwrap();
2087        store.append_ledger_entry(&mut txn, &entry).unwrap();
2088        store.commit(txn).unwrap();
2089
2090        let loaded = store.load_ledger(&agent, None, 100).unwrap();
2091        assert_eq!(loaded.len(), 1, "must return one ledger entry");
2092        assert_eq!(loaded[0].entry_id, entry.entry_id);
2093        assert_eq!(loaded[0].claim_ref, claim_ref);
2094        assert_eq!(loaded[0].event_kind, LedgerEventKind::ClaimCommitted);
2095    }
2096
2097    /// load_ledger respects the `from` bound — entries before `from` are excluded.
2098    #[test]
2099    fn read_load_ledger_from_bound_filters_earlier_entries() {
2100        let store = make_store();
2101        let agent = make_agent();
2102
2103        // Two claims: early and late
2104        let claim_early = make_claim(&agent);
2105        let claim_late = make_claim(&agent);
2106        let ref_early = claim_early.claim_ref().clone();
2107        let ref_late = claim_late.claim_ref().clone();
2108
2109        let t_early = TransactionTime(Utc::now() - chrono::Duration::seconds(10));
2110        let t_late = TransactionTime(Utc::now());
2111
2112        let entry_early = mempill_types::ledger::LedgerEntry {
2113            entry_id: Uuid::new_v4(),
2114            agent_id: agent.clone(),
2115            claim_ref: ref_early.clone(),
2116            event_kind: LedgerEventKind::ClaimCommitted,
2117            disposition: mempill_types::disposition::Disposition::CommittedCheap,
2118            rationale: None,
2119            recorded_at: t_early.clone(),
2120        };
2121        let entry_late = mempill_types::ledger::LedgerEntry {
2122            entry_id: Uuid::new_v4(),
2123            agent_id: agent.clone(),
2124            claim_ref: ref_late.clone(),
2125            event_kind: LedgerEventKind::ClaimCommitted,
2126            disposition: mempill_types::disposition::Disposition::CommittedCheap,
2127            rationale: None,
2128            recorded_at: t_late.clone(),
2129        };
2130
2131        let mut txn = store.begin_atomic(&agent).unwrap();
2132        store.append_claim(&mut txn, &claim_early).unwrap();
2133        store.append_claim(&mut txn, &claim_late).unwrap();
2134        store.append_ledger_entry(&mut txn, &entry_early).unwrap();
2135        store.append_ledger_entry(&mut txn, &entry_late).unwrap();
2136        store.commit(txn).unwrap();
2137
2138        // Load from t_late — should only see the late entry
2139        let loaded = store.load_ledger(&agent, Some(&t_late), 100).unwrap();
2140        assert_eq!(loaded.len(), 1, "only the late entry must be returned when from=t_late");
2141        assert_eq!(loaded[0].entry_id, entry_late.entry_id);
2142    }
2143
2144    /// load_ledger returns empty when agent has no entries.
2145    #[test]
2146    fn read_load_ledger_empty_when_none() {
2147        let store = make_store();
2148        let agent = make_agent();
2149        let loaded = store.load_ledger(&agent, None, 100).unwrap();
2150        assert!(loaded.is_empty(), "must return empty vec when no ledger entries");
2151    }
2152
2153    /// load_edges_for returns edges and they are ordered by created_at ASC (deterministic).
2154    #[test]
2155    fn read_load_edges_for_ordering_created_at_asc() {
2156        let store = make_store();
2157        let agent = make_agent();
2158
2159        let claim_a = make_claim(&agent);
2160        let claim_b = make_claim(&agent);
2161        let claim_c = make_claim(&agent);
2162        let ref_a = claim_a.claim_ref().clone();
2163        let ref_b = claim_b.claim_ref().clone();
2164        let ref_c = claim_c.claim_ref().clone();
2165
2166        // Edge A→B created first, A→C created second (microsecond gap guaranteed by sleep or offset)
2167        let t1 = TransactionTime(Utc::now() - chrono::Duration::seconds(5));
2168        let t2 = TransactionTime(Utc::now());
2169
2170        let edge_ab = ClaimEdge {
2171            edge_id: Uuid::new_v4(),
2172            agent_id: agent.clone(),
2173            from_claim: ref_a.clone(),
2174            to_claim: ref_b.clone(),
2175            kind: EdgeKind::DependsOn,
2176            created_at: t1,
2177        };
2178        let edge_ac = ClaimEdge {
2179            edge_id: Uuid::new_v4(),
2180            agent_id: agent.clone(),
2181            from_claim: ref_a.clone(),
2182            to_claim: ref_c.clone(),
2183            kind: EdgeKind::DependsOn,
2184            created_at: t2,
2185        };
2186
2187        let mut txn = store.begin_atomic(&agent).unwrap();
2188        // Insert in reverse order to prove ORDER BY drives the result
2189        store.append_claim(&mut txn, &claim_a).unwrap();
2190        store.append_claim(&mut txn, &claim_b).unwrap();
2191        store.append_claim(&mut txn, &claim_c).unwrap();
2192        store.append_claim_edge(&mut txn, &edge_ac).unwrap(); // insert late edge first
2193        store.append_claim_edge(&mut txn, &edge_ab).unwrap(); // insert early edge second
2194        store.commit(txn).unwrap();
2195
2196        let loaded = store.load_edges_for(&agent, &ref_a).unwrap();
2197        assert_eq!(loaded.len(), 2, "must return both edges");
2198        // Verify ASC ordering: AB (earlier created_at) must come before AC
2199        assert_eq!(loaded[0].to_claim, ref_b, "earlier edge (A→B) must be first");
2200        assert_eq!(loaded[1].to_claim, ref_c, "later edge (A→C) must be second");
2201    }
2202
2203    /// load_edges_for returns empty when no edges exist for the claim.
2204    #[test]
2205    fn read_load_edges_for_empty_when_none() {
2206        let store = make_store();
2207        let agent = make_agent();
2208        let claim = make_claim(&agent);
2209        let claim_ref = claim.claim_ref().clone();
2210
2211        let mut txn = store.begin_atomic(&agent).unwrap();
2212        store.append_claim(&mut txn, &claim).unwrap();
2213        store.commit(txn).unwrap();
2214
2215        let loaded = store.load_edges_for(&agent, &claim_ref).unwrap();
2216        assert!(loaded.is_empty(), "must return empty vec when no edges");
2217    }
2218
2219    /// load_injected_claims returns ClaimRefs from ServedAsInjected ledger entries.
2220    #[test]
2221    fn read_load_injected_claims_round_trip() {
2222        use mempill_types::disposition::Disposition;
2223
2224        let store = make_store();
2225        let agent = make_agent();
2226        let claim = make_claim(&agent);
2227        let claim_ref = claim.claim_ref().clone();
2228
2229        let injected_entry = mempill_types::ledger::LedgerEntry {
2230            entry_id: Uuid::new_v4(),
2231            agent_id: agent.clone(),
2232            claim_ref: claim_ref.clone(),
2233            event_kind: LedgerEventKind::ServedAsInjected,
2234            disposition: Disposition::CommittedCheap,
2235            rationale: None,
2236            recorded_at: TransactionTime(Utc::now()),
2237        };
2238
2239        let mut txn = store.begin_atomic(&agent).unwrap();
2240        store.append_claim(&mut txn, &claim).unwrap();
2241        store.append_ledger_entry(&mut txn, &injected_entry).unwrap();
2242        store.commit(txn).unwrap();
2243
2244        let loaded = store.load_injected_claims(&agent).unwrap();
2245        assert_eq!(loaded.len(), 1, "must return one injected claim ref");
2246        assert_eq!(loaded[0], claim_ref);
2247    }
2248
2249    /// load_injected_claims returns empty when no ServedAsInjected entries exist.
2250    #[test]
2251    fn read_load_injected_claims_empty_when_none() {
2252        let store = make_store();
2253        let agent = make_agent();
2254        let loaded = store.load_injected_claims(&agent).unwrap();
2255        assert!(loaded.is_empty(), "must return empty vec when no injected claims");
2256    }
2257
2258    /// LINEAGE CTE: multi-hop A→B→C chain is fully traversed.
2259    #[test]
2260    fn read_load_lineage_multi_hop_derived_from() {
2261        let store = make_store();
2262        let agent = make_agent();
2263
2264        // A is derived from B; B is derived from C.
2265        // load_lineage(A) must return edges: A→B and B→C (full chain).
2266        let claim_a = make_claim(&agent);
2267        let claim_b = make_claim(&agent);
2268        let claim_c = make_claim(&agent);
2269        let ref_a = claim_a.claim_ref().clone();
2270        let ref_b = claim_b.claim_ref().clone();
2271        let ref_c = claim_c.claim_ref().clone();
2272
2273        let edge_ab = ClaimEdge {
2274            edge_id: Uuid::new_v4(),
2275            agent_id: agent.clone(),
2276            from_claim: ref_a.clone(),
2277            to_claim: ref_b.clone(),
2278            kind: EdgeKind::DerivedFrom,
2279            created_at: TransactionTime(Utc::now() - chrono::Duration::seconds(2)),
2280        };
2281        let edge_bc = ClaimEdge {
2282            edge_id: Uuid::new_v4(),
2283            agent_id: agent.clone(),
2284            from_claim: ref_b.clone(),
2285            to_claim: ref_c.clone(),
2286            kind: EdgeKind::DerivedFrom,
2287            created_at: TransactionTime(Utc::now() - chrono::Duration::seconds(1)),
2288        };
2289
2290        let mut txn = store.begin_atomic(&agent).unwrap();
2291        store.append_claim(&mut txn, &claim_a).unwrap();
2292        store.append_claim(&mut txn, &claim_b).unwrap();
2293        store.append_claim(&mut txn, &claim_c).unwrap();
2294        store.append_claim_edge(&mut txn, &edge_ab).unwrap();
2295        store.append_claim_edge(&mut txn, &edge_bc).unwrap();
2296        store.commit(txn).unwrap();
2297
2298        let lineage = store.load_lineage(&agent, &ref_a).unwrap();
2299        assert_eq!(lineage.len(), 2, "lineage must contain both DerivedFrom hops A→B and B→C");
2300
2301        // Shallowest (depth 1) first: A→B edge
2302        assert_eq!(lineage[0].from_claim, ref_a, "first edge must start from A");
2303        assert_eq!(lineage[0].to_claim, ref_b, "first edge must point to B");
2304        // Deeper (depth 2): B→C edge
2305        assert_eq!(lineage[1].from_claim, ref_b, "second edge must start from B");
2306        assert_eq!(lineage[1].to_claim, ref_c, "second edge must point to C");
2307    }
2308
2309    /// load_lineage returns empty when the claim has no DerivedFrom edges.
2310    #[test]
2311    fn read_load_lineage_empty_when_no_derived_from_edges() {
2312        let store = make_store();
2313        let agent = make_agent();
2314        let claim = make_claim(&agent);
2315        let claim_ref = claim.claim_ref().clone();
2316
2317        let mut txn = store.begin_atomic(&agent).unwrap();
2318        store.append_claim(&mut txn, &claim).unwrap();
2319        store.commit(txn).unwrap();
2320
2321        let lineage = store.load_lineage(&agent, &claim_ref).unwrap();
2322        assert!(lineage.is_empty(), "load_lineage must return empty vec when no DerivedFrom edges");
2323    }
2324
2325    // ── TXN ALREADY OPEN guard ────────────────────────────────────────────────
2326
2327    #[test]
2328    fn begin_atomic_while_txn_open_returns_error() {
2329        let store = make_store();
2330        let agent = make_agent();
2331
2332        let _txn = store.begin_atomic(&agent).expect("first begin_atomic should succeed");
2333        let result = store.begin_atomic(&agent);
2334        assert!(
2335            matches!(result, Err(SqliteStoreError::TxnAlreadyOpen)),
2336            "second begin_atomic must return TxnAlreadyOpen"
2337        );
2338    }
2339
2340    // ── FULL ATOMIC UNIT (I9 positive path) ───────────────────────────────────
2341
2342    /// Append {claim + validity assertion + ledger entry + edge} and commit.
2343    /// All four rows must land atomically.
2344    #[test]
2345    fn atomic_unit_all_four_rows_on_commit() {
2346        let store = make_store();
2347        let agent = make_agent();
2348        let claim_a = make_claim(&agent);
2349        let claim_b = make_claim(&agent);
2350        let claim_ref_a = claim_a.claim_ref().clone();
2351        let claim_ref_b = claim_b.claim_ref().clone();
2352
2353        let assertion = mempill_types::validity::ValidityAssertion {
2354            assertion_ref: Uuid::new_v4(),
2355            agent_id: agent.clone(),
2356            target_claim: claim_ref_a.clone(),
2357            kind: AssertionKind::Bound { bound_at: Utc::now() },
2358            provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
2359            confidence: mempill_types::claim::Confidence {
2360                value_confidence: 0.9,
2361                valid_time_confidence: 0.9,
2362            },
2363            asserted_at: TransactionTime(Utc::now()),
2364        };
2365        let ledger = make_ledger_entry(&agent, &claim_ref_a);
2366        let edge = ClaimEdge {
2367            edge_id: Uuid::new_v4(),
2368            agent_id: agent.clone(),
2369            from_claim: claim_ref_a.clone(),
2370            to_claim: claim_ref_b.clone(),
2371            kind: EdgeKind::Supersedes,
2372            created_at: TransactionTime(Utc::now()),
2373        };
2374
2375        let mut txn = store.begin_atomic(&agent).unwrap();
2376        store.append_claim(&mut txn, &claim_a).unwrap();
2377        store.append_claim(&mut txn, &claim_b).unwrap();
2378        store.append_validity_assertion(&mut txn, &assertion).unwrap();
2379        store.append_ledger_entry(&mut txn, &ledger).unwrap();
2380        store.append_claim_edge(&mut txn, &edge).unwrap();
2381        store.commit(txn).unwrap();
2382
2383        let slot = store.conn.lock().unwrap();
2384        let conn = slot.as_ref().unwrap();
2385
2386        let claims: i64 = conn
2387            .query_row("SELECT COUNT(*) FROM claims", [], |r| r.get(0))
2388            .unwrap();
2389        let assertions: i64 = conn
2390            .query_row("SELECT COUNT(*) FROM validity_assertions", [], |r| r.get(0))
2391            .unwrap();
2392        let ledger_count: i64 = conn
2393            .query_row("SELECT COUNT(*) FROM ledger_entries", [], |r| r.get(0))
2394            .unwrap();
2395        let edges: i64 = conn
2396            .query_row("SELECT COUNT(*) FROM claim_edges", [], |r| r.get(0))
2397            .unwrap();
2398
2399        assert_eq!(claims, 2, "two claim rows must exist");
2400        assert_eq!(assertions, 1, "one validity_assertion row must exist");
2401        assert_eq!(ledger_count, 1, "one ledger_entry row must exist");
2402        assert_eq!(edges, 1, "one claim_edge row must exist");
2403    }
2404
2405    // ── SqlitePendingStore tests ──────────────────────────────────────────────
2406
2407    use mempill_core::ports::pending_adjudication::{PendingAdjudicationPort, PendingAdjudicationRow};
2408    use mempill_types::{
2409        AdjudicationRequest, Belief, CurrencySignal, CurrencyState, OverturnReason, SubjectLineRef,
2410    };
2411
2412    fn make_adj_request(agent: &AgentId) -> AdjudicationRequest {
2413        let claim_ref = ClaimRef(Uuid::new_v4());
2414        let now = TransactionTime(Utc::now());
2415        AdjudicationRequest {
2416            subject_line: SubjectLineRef {
2417                agent_id: agent.clone(),
2418                subject: "user".into(),
2419                predicate: "city".into(),
2420            },
2421            incumbent: Belief {
2422                claim_ref: claim_ref.clone(),
2423                fact: mempill_types::Fact {
2424                    subject: "user".into(),
2425                    predicate: "city".into(),
2426                    value: serde_json::json!("Berlin"),
2427                },
2428                provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
2429                valid_time: ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None},
2430                transaction_time: now.clone(),
2431                confidence: Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
2432                currency_signal: CurrencySignal {
2433                    last_refreshed_at: now.clone(),
2434                    state: CurrencyState::Fresh,
2435                    corroboration_count: 0,
2436                },
2437                criticality: Criticality::Low,
2438            },
2439            challenger: make_claim(agent),
2440            criticality: Criticality::Low,
2441            reason: OverturnReason::ExternalContradiction,
2442        }
2443    }
2444
2445    fn make_pending_row(agent: &AgentId) -> PendingAdjudicationRow {
2446        PendingAdjudicationRow {
2447            handle_id: Uuid::new_v4(),
2448            agent_id: agent.clone(),
2449            subject: "user".into(),
2450            predicate: "city".into(),
2451            challenger_claim_ref: ClaimRef(Uuid::new_v4()),
2452            incumbent_claim_ref: ClaimRef(Uuid::new_v4()),
2453            request_payload: make_adj_request(agent),
2454            queued_at: Utc::now(),
2455            expires_at: None,
2456            status: "pending".to_string(),
2457        }
2458    }
2459
2460    /// insert_pending + get_pending round-trip.
2461    #[test]
2462    fn w3_sqlite_pending_insert_and_get_round_trip() {
2463        let store = make_store();
2464        let pending = store.pending_store();
2465        let agent = make_agent();
2466        let row = make_pending_row(&agent);
2467        let handle_id = row.handle_id;
2468
2469        pending.insert_pending(&row).expect("insert_pending must succeed");
2470
2471        let fetched = pending.get_pending(handle_id).expect("get_pending must succeed");
2472        let fetched = fetched.expect("row must be present");
2473        assert_eq!(fetched.handle_id, handle_id);
2474        assert_eq!(fetched.agent_id, agent);
2475        assert_eq!(fetched.subject, "user");
2476        assert_eq!(fetched.predicate, "city");
2477        assert_eq!(fetched.challenger_claim_ref, row.challenger_claim_ref);
2478        assert_eq!(fetched.incumbent_claim_ref, row.incumbent_claim_ref);
2479        assert_eq!(fetched.status, "pending");
2480        assert!(fetched.expires_at.is_none());
2481    }
2482
2483    /// get_pending returns None for unknown handle_id.
2484    #[test]
2485    fn w3_sqlite_pending_get_nonexistent_returns_none() {
2486        let store = make_store();
2487        let pending = store.pending_store();
2488        let result = pending.get_pending(Uuid::new_v4()).expect("get_pending must not error");
2489        assert!(result.is_none(), "unknown handle_id must return None");
2490    }
2491
2492    /// list_pending returns only pending rows for the given agent.
2493    #[test]
2494    fn w3_sqlite_pending_list_pending_by_agent() {
2495        let store = make_store();
2496        let pending = store.pending_store();
2497        let agent = make_agent();
2498        let agent2 = AgentId("other-agent".into());
2499
2500        let row1 = make_pending_row(&agent);
2501        let row2 = make_pending_row(&agent);
2502        let row3 = make_pending_row(&agent2);
2503
2504        pending.insert_pending(&row1).unwrap();
2505        pending.insert_pending(&row2).unwrap();
2506        pending.insert_pending(&row3).unwrap();
2507
2508        let agent_rows = pending.list_pending(Some(&agent)).unwrap();
2509        assert_eq!(agent_rows.len(), 2, "must return exactly 2 rows for agent");
2510
2511        let all_rows = pending.list_pending(None).unwrap();
2512        assert_eq!(all_rows.len(), 3, "list_pending(None) must return all 3 rows");
2513    }
2514
2515    /// mark_resolved changes status to 'resolved'; resolved row no longer in list_pending.
2516    #[test]
2517    fn w3_sqlite_pending_mark_resolved() {
2518        let store = make_store();
2519        let pending = store.pending_store();
2520        let agent = make_agent();
2521        let row = make_pending_row(&agent);
2522        let handle_id = row.handle_id;
2523
2524        pending.insert_pending(&row).unwrap();
2525        pending.mark_resolved(handle_id).unwrap();
2526
2527        // get_pending should still find it (status = 'resolved').
2528        let fetched = pending.get_pending(handle_id).unwrap().unwrap();
2529        assert_eq!(fetched.status, "resolved", "status must be 'resolved' after mark_resolved");
2530
2531        // list_pending should not include it.
2532        let pending_rows = pending.list_pending(Some(&agent)).unwrap();
2533        assert!(pending_rows.is_empty(), "resolved row must not appear in list_pending");
2534    }
2535
2536    /// Durability: persist a pending row, drop the store, reopen on the same in-memory
2537    /// connection via the shared Arc, and confirm get_pending still finds the row.
2538    ///
2539    /// NOTE: true file-backed durability (drop + reopen file) is tested in lib.rs integration.
2540    /// Here we verify the row survives dropping and re-acquiring the store handle.
2541    #[test]
2542    fn w3_sqlite_pending_durability_shared_arc() {
2543        let conn = open_in_memory().expect("in-memory connection must open");
2544        let persistence = SqlitePersistenceStore::new(conn);
2545        let pending = persistence.pending_store();
2546        let agent = make_agent();
2547        let row = make_pending_row(&agent);
2548        let handle_id = row.handle_id;
2549
2550        pending.insert_pending(&row).unwrap();
2551        drop(pending); // drop the pending store handle — Arc keeps connection alive
2552
2553        // Re-acquire a new pending store from the same persistence store.
2554        let pending2 = persistence.pending_store();
2555        let fetched = pending2.get_pending(handle_id).unwrap();
2556        assert!(fetched.is_some(), "pending row must survive store handle drop (durability via shared Arc)");
2557        assert_eq!(fetched.unwrap().handle_id, handle_id);
2558    }
2559}