Skip to main content

mempill_postgres/
store.rs

1//! `PostgresPersistenceStore` — impl of `PersistencePort` for mempill-postgres.
2//!
3//! # Append-only
4//!
5//! Every write method is an INSERT. No UPDATE or DELETE paths exist in this file.
6//!
7//! # Atomic commit unit
8//!
9//! `begin_atomic` acquires a pooled connection and opens a `BEGIN` transaction.
10//! `commit`/`rollback` close the transaction; the connection returns to the r2d2 pool.
11//!
12//! # JSONB handling
13//!
14//! `value` and `metadata` are JSONB columns in Postgres (TEXT in SQLite).
15//! On INSERT: serialized to JSON string, cast with `$n::jsonb` in SQL.
16//! On SELECT: cast back to `::text` in `CLAIM_SELECT_COLS` → `serde_json::from_str`.
17//! This confines the JSONB divergence to the INSERT SQL; all row mapping code is identical
18//! to the SQLite path.
19//!
20//! # stream_seq (monotone per-agent sequence number)
21//!
22//! `append_ledger_entry` assigns `stream_seq` via:
23//! `SELECT COALESCE(MAX(stream_seq), 0) + 1 FROM ledger_entries WHERE agent_id = $1`
24//! within the same transaction, under the advisory lock.
25//! INVARIANT: safe only under `pg_advisory_xact_lock`; replace with a Postgres SEQUENCE
26//! if the advisory lock is ever removed.
27
28use std::sync::Arc;
29
30use mempill_core::{
31    ports::pending_adjudication::{PendingAdjudicationPort, PendingAdjudicationRow},
32    ports::persistence::PersistencePort,
33    EngineConfig, EngineHandle,
34};
35use mempill_types::{
36    claim::{Cardinality, Claim, Confidence, Criticality, Fact},
37    disposition::Disposition,
38    edge::{ClaimEdge, EdgeKind},
39    identity::{AgentId, ClaimRef},
40    ledger::{LedgerEntry, LedgerEventKind},
41    provenance::{ExternalAnchor, ExternalKind, ProvenanceLabel},
42    time::{date_granularity_to_str, str_to_date_granularity, TransactionTime, ValidTime},
43    validity::{AssertionKind, ValidityAssertion},
44};
45
46use crate::{
47    connection::{PostgresPersistenceStore, PostgresStoreError},
48    txn::PostgresTxn,
49};
50
51// ── Domain-type ↔ column mapping helpers (mirrors mempill-sqlite/src/store.rs) ──
52
53fn provenance_to_str(p: &ProvenanceLabel) -> &'static str {
54    match p {
55        ProvenanceLabel::ModelDerived => "ModelDerived",
56        ProvenanceLabel::RecallReEntry => "RecallReEntry",
57        ProvenanceLabel::External(ExternalKind::UserAsserted) => "External_UserAsserted",
58        ProvenanceLabel::External(ExternalKind::ExternalFirstHand) => "External_ExternalFirstHand",
59        _ => "Unknown",
60    }
61}
62
63fn str_to_provenance(s: &str) -> Result<ProvenanceLabel, PostgresStoreError> {
64    match s {
65        "ModelDerived" => Ok(ProvenanceLabel::ModelDerived),
66        "RecallReEntry" => Ok(ProvenanceLabel::RecallReEntry),
67        "External_UserAsserted" => Ok(ProvenanceLabel::External(ExternalKind::UserAsserted)),
68        "External_ExternalFirstHand" => Ok(ProvenanceLabel::External(ExternalKind::ExternalFirstHand)),
69        other => Err(PostgresStoreError::Mapping(format!("unknown provenance_label: {other}"))),
70    }
71}
72
73fn cardinality_to_str(c: &Cardinality) -> &'static str {
74    match c {
75        Cardinality::Functional => "Functional",
76        Cardinality::SetValued => "SetValued",
77        Cardinality::Unknown => "Unknown",
78    }
79}
80
81fn str_to_cardinality(s: &str) -> Result<Cardinality, PostgresStoreError> {
82    match s {
83        "Functional" => Ok(Cardinality::Functional),
84        "SetValued" => Ok(Cardinality::SetValued),
85        "Unknown" => Ok(Cardinality::Unknown),
86        other => Err(PostgresStoreError::Mapping(format!("unknown cardinality: {other}"))),
87    }
88}
89
90fn criticality_to_str(c: &Criticality) -> &'static str {
91    match c {
92        Criticality::Low => "Low",
93        Criticality::Medium => "Medium",
94        Criticality::High => "High",
95        Criticality::Critical => "Critical",
96    }
97}
98
99fn str_to_criticality(s: &str) -> Result<Criticality, PostgresStoreError> {
100    match s {
101        "Low" => Ok(Criticality::Low),
102        "Medium" => Ok(Criticality::Medium),
103        "High" => Ok(Criticality::High),
104        "Critical" => Ok(Criticality::Critical),
105        other => Err(PostgresStoreError::Mapping(format!("unknown criticality: {other}"))),
106    }
107}
108
109fn edge_kind_to_str(k: &EdgeKind) -> &'static str {
110    match k {
111        EdgeKind::DerivedFrom => "DerivedFrom",
112        EdgeKind::Supersedes => "Supersedes",
113        EdgeKind::DependsOn => "DependsOn",
114        EdgeKind::MutualExclusion => "MutualExclusion",
115        // EdgeKind is #[non_exhaustive] — future variants stored as "Unknown".
116        _ => "Unknown",
117    }
118}
119
120fn str_to_edge_kind(s: &str) -> Result<EdgeKind, PostgresStoreError> {
121    match s {
122        "DerivedFrom" => Ok(EdgeKind::DerivedFrom),
123        "Supersedes" => Ok(EdgeKind::Supersedes),
124        "DependsOn" => Ok(EdgeKind::DependsOn),
125        "MutualExclusion" => Ok(EdgeKind::MutualExclusion),
126        other => Err(PostgresStoreError::Mapping(format!("unknown edge_kind: {other}"))),
127    }
128}
129
130fn ledger_event_kind_to_str(k: &LedgerEventKind) -> &'static str {
131    match k {
132        LedgerEventKind::ClaimCommitted => "ClaimCommitted",
133        LedgerEventKind::ValidityAsserted => "ValidityAsserted",
134        LedgerEventKind::AdjudicationRequested => "AdjudicationRequested",
135        LedgerEventKind::AdjudicationResolved => "AdjudicationResolved",
136        LedgerEventKind::RecallReEntryDetected => "RecallReEntryDetected",
137        LedgerEventKind::Quarantined => "Quarantined",
138        LedgerEventKind::DependentFlaggedPendingReview => "DependentFlaggedPendingReview",
139        LedgerEventKind::ServedAsInjected => "ServedAsInjected",
140        LedgerEventKind::AdjudicationExpired => "AdjudicationExpired",
141        // LedgerEventKind is #[non_exhaustive] — future variants stored as "Unknown".
142        _ => "Unknown",
143    }
144}
145
146fn str_to_ledger_event_kind(s: &str) -> Result<LedgerEventKind, PostgresStoreError> {
147    match s {
148        "ClaimCommitted" => Ok(LedgerEventKind::ClaimCommitted),
149        "ValidityAsserted" => Ok(LedgerEventKind::ValidityAsserted),
150        "AdjudicationRequested" => Ok(LedgerEventKind::AdjudicationRequested),
151        "AdjudicationResolved" => Ok(LedgerEventKind::AdjudicationResolved),
152        "RecallReEntryDetected" => Ok(LedgerEventKind::RecallReEntryDetected),
153        "Quarantined" => Ok(LedgerEventKind::Quarantined),
154        "DependentFlaggedPendingReview" => Ok(LedgerEventKind::DependentFlaggedPendingReview),
155        "ServedAsInjected" => Ok(LedgerEventKind::ServedAsInjected),
156        "AdjudicationExpired" => Ok(LedgerEventKind::AdjudicationExpired),
157        other => Err(PostgresStoreError::Mapping(format!("unknown ledger event_kind: {other}"))),
158    }
159}
160
161fn disposition_to_str(d: &Disposition) -> &'static str {
162    match d {
163        Disposition::CommittedCheap => "CommittedCheap",
164        Disposition::CommittedInferred => "CommittedInferred",
165        Disposition::QueuedForAdjudication => "QueuedForAdjudication",
166        Disposition::Contested => "Contested",
167        Disposition::PendingConflict => "PendingConflict",
168        Disposition::PendingReview => "PendingReview",
169        Disposition::PendingLowConfidence => "PendingLowConfidence",
170        Disposition::Quarantined => "Quarantined",
171        Disposition::Superseded => "Superseded",
172        Disposition::Invalidated => "Invalidated",
173        Disposition::Reinstated => "Reinstated",
174        Disposition::Rejected => "Rejected",
175        // Disposition is #[non_exhaustive] — future variants stored as "Unknown".
176        _ => "Unknown",
177    }
178}
179
180fn str_to_disposition(s: &str) -> Result<Disposition, PostgresStoreError> {
181    match s {
182        "CommittedCheap" => Ok(Disposition::CommittedCheap),
183        "CommittedInferred" => Ok(Disposition::CommittedInferred),
184        "QueuedForAdjudication" => Ok(Disposition::QueuedForAdjudication),
185        "Contested" => Ok(Disposition::Contested),
186        "PendingConflict" => Ok(Disposition::PendingConflict),
187        "PendingReview" => Ok(Disposition::PendingReview),
188        "PendingLowConfidence" => Ok(Disposition::PendingLowConfidence),
189        "Quarantined" => Ok(Disposition::Quarantined),
190        "Superseded" => Ok(Disposition::Superseded),
191        "Invalidated" => Ok(Disposition::Invalidated),
192        "Reinstated" => Ok(Disposition::Reinstated),
193        "Rejected" => Ok(Disposition::Rejected),
194        other => Err(PostgresStoreError::Mapping(format!("unknown disposition: {other}"))),
195    }
196}
197
198// ── Row-to-domain-type mapping helpers ───────────────────────────────────────
199
200/// The SELECT column list for `claims` table.
201///
202/// Note: `value::text` and `metadata::text` cast JSONB → TEXT at read time so
203/// `row_to_claim` can call `serde_json::from_str` identically to the SQLite path.
204/// This confines the JSONB divergence to the INSERT path only (§2 CLAIM_SELECT_COLS note).
205///
206/// Column order must exactly match `row_to_claim` indices below.
207const CLAIM_SELECT_COLS: &str = "
208    claim_id, agent_id, subject, predicate, value::text, cardinality,
209    provenance_label, nearest_external_anchor_id, derivation_depth,
210    tx_time, valid_time_start, valid_time_end, valid_time_confidence,
211    value_confidence, criticality, derived_from,
212    metadata::text, snapshot_schema_version,
213    valid_time_start_granularity, valid_time_end_granularity
214";
215
216/// Map a postgres `Row` from the `claims` table to a `Claim` domain type.
217///
218/// Column order (must match `CLAIM_SELECT_COLS`):
219///   0  claim_id
220///   1  agent_id
221///   2  subject
222///   3  predicate
223///   4  value::text  (JSONB cast to TEXT)
224///   5  cardinality
225///   6  provenance_label
226///   7  nearest_external_anchor_id  (nullable)
227///   8  derivation_depth
228///   9  tx_time
229///  10  valid_time_start  (nullable)
230///  11  valid_time_end    (nullable)
231///  12  valid_time_confidence
232///  13  value_confidence
233///  14  criticality
234///  15  derived_from  (JSON array TEXT)
235///  16  metadata::text (nullable JSONB cast to TEXT)
236///  17  snapshot_schema_version (nullable INTEGER)
237///  18  valid_time_start_granularity (nullable TEXT, added in v3)
238///  19  valid_time_end_granularity   (nullable TEXT, added in v3)
239fn row_to_claim(row: &postgres::Row) -> Result<Claim, PostgresStoreError> {
240    let claim_id_str: String = row.get(0);
241    let agent_id_str: String = row.get(1);
242    let subject: String = row.get(2);
243    let predicate: String = row.get(3);
244    let value_json: String = row.get(4);
245    let cardinality_str: String = row.get(5);
246    let provenance_str: String = row.get(6);
247    let nearest_anchor_str: Option<String> = row.get(7);
248    let derivation_depth: i32 = row.get(8);
249    let tx_time_str: String = row.get(9);
250    let valid_time_start_str: Option<String> = row.get(10);
251    let valid_time_end_str: Option<String> = row.get(11);
252    let valid_time_confidence: f64 = row.get(12);
253    let value_confidence: f64 = row.get(13);
254    let criticality_str: String = row.get(14);
255    let derived_from_json: String = row.get(15);
256    let metadata_json: Option<String> = row.get(16);
257    let snapshot_schema_version_raw: Option<i32> = row.get(17);
258    let start_granularity_str: Option<String> = row.get(18);
259    let end_granularity_str: Option<String> = row.get(19);
260
261    let claim_id = uuid::Uuid::parse_str(&claim_id_str)
262        .map_err(|e| PostgresStoreError::Mapping(format!("claim_id UUID: {e}")))?;
263
264    let value: serde_json::Value = serde_json::from_str(&value_json)
265        .map_err(|e| PostgresStoreError::Mapping(format!("value JSON: {e}")))?;
266
267    let cardinality = str_to_cardinality(&cardinality_str)?;
268    let provenance = str_to_provenance(&provenance_str)?;
269
270    let nearest_external_anchor: Option<ClaimRef> = nearest_anchor_str
271        .map(|s| {
272            uuid::Uuid::parse_str(&s)
273                .map(ClaimRef)
274                .map_err(|e| PostgresStoreError::Mapping(format!("anchor UUID: {e}")))
275        })
276        .transpose()?;
277
278    let tx_time = chrono::DateTime::parse_from_rfc3339(&tx_time_str)
279        .map(|dt| dt.with_timezone(&chrono::Utc))
280        .map_err(|e| PostgresStoreError::Mapping(format!("tx_time parse: {e}")))?;
281
282    let valid_time_start = valid_time_start_str
283        .map(|s| {
284            chrono::DateTime::parse_from_rfc3339(&s)
285                .map(|dt| dt.with_timezone(&chrono::Utc))
286                .map_err(|e| PostgresStoreError::Mapping(format!("valid_time_start: {e}")))
287        })
288        .transpose()?;
289
290    let valid_time_end = valid_time_end_str
291        .map(|s| {
292            chrono::DateTime::parse_from_rfc3339(&s)
293                .map(|dt| dt.with_timezone(&chrono::Utc))
294                .map_err(|e| PostgresStoreError::Mapping(format!("valid_time_end: {e}")))
295        })
296        .transpose()?;
297
298    let criticality = str_to_criticality(&criticality_str)?;
299
300    let derived_from_uuids: Vec<String> = serde_json::from_str(&derived_from_json)
301        .map_err(|e| PostgresStoreError::Mapping(format!("derived_from JSON: {e}")))?;
302
303    let derived_from: Vec<ClaimRef> = derived_from_uuids
304        .iter()
305        .map(|s| {
306            uuid::Uuid::parse_str(s)
307                .map(ClaimRef)
308                .map_err(|e| PostgresStoreError::Mapping(format!("derived_from UUID: {e}")))
309        })
310        .collect::<Result<_, _>>()?;
311
312    let metadata: Option<serde_json::Value> = metadata_json
313        .map(|s| {
314            serde_json::from_str(&s)
315                .map_err(|e| PostgresStoreError::Mapping(format!("metadata JSON: {e}")))
316        })
317        .transpose()?;
318
319    let snapshot_schema_version: Option<u32> = snapshot_schema_version_raw.map(|v| v as u32);
320
321    Ok(Claim::new(
322        ClaimRef(claim_id),
323        AgentId(agent_id_str),
324        Fact { subject, predicate, value },
325        cardinality,
326        provenance,
327        ExternalAnchor {
328            nearest_external_anchor,
329            derivation_depth: derivation_depth as u32,
330        },
331        TransactionTime(tx_time),
332        ValidTime {
333            start: valid_time_start,
334            end: valid_time_end,
335            valid_time_confidence: valid_time_confidence as f32,
336            start_granularity: start_granularity_str
337                .as_deref()
338                .and_then(str_to_date_granularity),
339            end_granularity: end_granularity_str
340                .as_deref()
341                .and_then(str_to_date_granularity),
342        },
343        Confidence {
344            value_confidence: value_confidence as f32,
345            valid_time_confidence: valid_time_confidence as f32,
346        },
347        criticality,
348        derived_from,
349        metadata,
350        snapshot_schema_version,
351    ))
352}
353
354/// Map a postgres `Row` from the `claim_edges` table to a `ClaimEdge` domain type.
355fn row_to_edge(row: &postgres::Row) -> Result<ClaimEdge, PostgresStoreError> {
356    let edge_id_str: String = row.get(0);
357    let agent_id_str: String = row.get(1);
358    let from_claim_str: String = row.get(2);
359    let to_claim_str: String = row.get(3);
360    let kind_str: String = row.get(4);
361    let created_at_str: String = row.get(5);
362
363    let edge_id = uuid::Uuid::parse_str(&edge_id_str)
364        .map_err(|e| PostgresStoreError::Mapping(format!("edge_id UUID: {e}")))?;
365    let from_claim = uuid::Uuid::parse_str(&from_claim_str)
366        .map(ClaimRef)
367        .map_err(|e| PostgresStoreError::Mapping(format!("from_claim UUID: {e}")))?;
368    let to_claim = uuid::Uuid::parse_str(&to_claim_str)
369        .map(ClaimRef)
370        .map_err(|e| PostgresStoreError::Mapping(format!("to_claim UUID: {e}")))?;
371    let kind = str_to_edge_kind(&kind_str)?;
372    let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
373        .map(|dt| dt.with_timezone(&chrono::Utc))
374        .map_err(|e| PostgresStoreError::Mapping(format!("created_at parse: {e}")))?;
375
376    Ok(ClaimEdge {
377        edge_id,
378        agent_id: AgentId(agent_id_str),
379        from_claim,
380        to_claim,
381        kind,
382        created_at: TransactionTime(created_at),
383    })
384}
385
386// ── PersistencePort impl ──────────────────────────────────────────────────────
387
388impl PostgresPersistenceStore {
389    /// Return a `PostgresPendingStore` that shares the same r2d2 connection pool.
390    ///
391    /// Both `PostgresPersistenceStore` and `PostgresPendingStore` acquire connections
392    /// from the same pool. The per-agent write lock held by `EngineHandle` ensures that
393    /// the pending insert is serialized with the claim transaction commit.
394    pub fn pending_store(&self) -> PostgresPendingStore {
395        PostgresPendingStore::new(self.pool.clone())
396    }
397}
398
399impl PersistencePort for PostgresPersistenceStore {
400    type Transaction = PostgresTxn;
401    type Error = PostgresStoreError;
402
403    // ── Transaction lifecycle ─────────────────────────────────────────────────
404
405    /// Open an explicit `BEGIN` transaction scoped to `agent_id`.
406    ///
407    /// Acquires a connection from the r2d2 pool, issues `BEGIN`, then acquires the
408    /// per-agent_id advisory lock: `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`.
409    fn begin_atomic(&self, agent_id: &AgentId) -> Result<PostgresTxn, PostgresStoreError> {
410        let conn = self.pool.get()?;
411        PostgresTxn::begin(agent_id.clone(), conn)
412    }
413
414    /// Commit the transaction. The pooled connection returns to the r2d2 pool.
415    fn commit(&self, txn: PostgresTxn) -> Result<(), PostgresStoreError> {
416        txn.commit_and_drop()
417    }
418
419    /// Rollback the transaction. The pooled connection returns to the r2d2 pool.
420    fn rollback(&self, txn: PostgresTxn) -> Result<(), PostgresStoreError> {
421        txn.rollback_and_drop()
422    }
423
424    // ── Write methods (INSERT-only, I1) ───────────────────────────────────────
425
426    /// Append a claim row within the open transaction.
427    ///
428    /// `value` and `metadata` are cast to JSONB via `$n::jsonb` SQL cast (§2 JSONB note).
429    fn append_claim(
430        &self,
431        txn: &mut PostgresTxn,
432        claim: &Claim,
433    ) -> Result<ClaimRef, PostgresStoreError> {
434        let claim_id = claim.claim_ref().0.to_string();
435        let agent_id = claim.agent_id().0.clone();
436        let fact = claim.fact();
437        // Pass value and metadata as serde_json::Value so the postgres driver can
438        // encode them as JSONB binary directly (requires feature "with-serde_json-1").
439        // A String with `$n::jsonb` SQL cast does NOT work — the driver type-checks
440        // the Rust type against the declared column OID before the cast runs.
441        let value_jsonb: &serde_json::Value = &fact.value;
442        let cardinality = cardinality_to_str(claim.cardinality()).to_owned();
443        let provenance = provenance_to_str(claim.provenance()).to_owned();
444        let anchor = claim.external_anchor();
445        let nearest_anchor: Option<String> =
446            anchor.nearest_external_anchor.as_ref().map(|r| r.0.to_string());
447        let derivation_depth = anchor.derivation_depth as i32;
448        let tx_time = claim.transaction_time().0.to_rfc3339();
449        let vt = claim.valid_time();
450        let valid_time_start: Option<String> = vt.start.map(|dt| dt.to_rfc3339());
451        let valid_time_end: Option<String> = vt.end.map(|dt| dt.to_rfc3339());
452        let valid_time_confidence = vt.valid_time_confidence as f64;
453        let valid_time_start_granularity: Option<&'static str> =
454            vt.start_granularity.map(date_granularity_to_str);
455        let valid_time_end_granularity: Option<&'static str> =
456            vt.end_granularity.map(date_granularity_to_str);
457        let conf = claim.confidence();
458        let value_confidence = conf.value_confidence as f64;
459        let criticality = criticality_to_str(claim.criticality()).to_owned();
460        let derived_from_refs: Vec<String> =
461            claim.derived_from().iter().map(|r| r.0.to_string()).collect();
462        let derived_from_json = serde_json::to_string(&derived_from_refs)
463            .map_err(|e| PostgresStoreError::Mapping(format!("derived_from serialization: {e}")))?;
464        // metadata is Option<serde_json::Value>: pass as Option<&serde_json::Value>
465        let metadata_jsonb: Option<serde_json::Value> = claim.metadata().cloned();
466        let snapshot_schema_version: Option<i32> =
467            claim.snapshot_schema_version().map(|v| v as i32);
468
469        txn.client().execute(
470            "INSERT INTO claims (
471                claim_id, agent_id, subject, predicate, value, cardinality,
472                provenance_label, nearest_external_anchor_id, derivation_depth,
473                tx_time, valid_time_start, valid_time_end, valid_time_confidence,
474                value_confidence, criticality, derived_from,
475                metadata, snapshot_schema_version, embedding_model_id,
476                valid_time_start_granularity, valid_time_end_granularity
477            ) VALUES (
478                $1,  $2,  $3,  $4,  $5,  $6,
479                $7,  $8,  $9,
480                $10, $11, $12, $13,
481                $14, $15, $16,
482                $17, $18, NULL,
483                $19, $20
484            )",
485            &[
486                &claim_id,
487                &agent_id,
488                &fact.subject.as_str(),
489                &fact.predicate.as_str(),
490                &value_jsonb,
491                &cardinality,
492                &provenance,
493                &nearest_anchor,
494                &derivation_depth,
495                &tx_time,
496                &valid_time_start,
497                &valid_time_end,
498                &valid_time_confidence,
499                &value_confidence,
500                &criticality,
501                &derived_from_json,
502                &metadata_jsonb,
503                &snapshot_schema_version,
504                &valid_time_start_granularity,
505                &valid_time_end_granularity,
506            ],
507        )?;
508
509        Ok(claim.claim_ref().clone())
510    }
511
512    /// Append a validity assertion row within the open transaction.
513    fn append_validity_assertion(
514        &self,
515        txn: &mut PostgresTxn,
516        assertion: &ValidityAssertion,
517    ) -> Result<(), PostgresStoreError> {
518        let assertion_id = assertion.assertion_ref.to_string();
519        let agent_id = assertion.agent_id.0.clone();
520        let target_claim_id = assertion.target_claim.0.to_string();
521        let provenance = provenance_to_str(&assertion.provenance).to_owned();
522        let value_confidence = assertion.confidence.value_confidence as f64;
523        let valid_time_confidence = assertion.confidence.valid_time_confidence as f64;
524        let asserted_at = assertion.asserted_at.0.to_rfc3339();
525
526        let (assertion_kind, bound_at, reopen_at): (&str, Option<String>, Option<String>) =
527            match &assertion.kind {
528                AssertionKind::Bound { bound_at } => ("Bound", Some(bound_at.to_rfc3339()), None),
529                AssertionKind::Reopen { reopen_at } => ("Reopen", None, Some(reopen_at.to_rfc3339())),
530                // AssertionKind is #[non_exhaustive] — future kinds stored as "Unknown" (no-op).
531                _ => ("Unknown", None, None),
532            };
533
534        txn.client().execute(
535            "INSERT INTO validity_assertions (
536                assertion_id, agent_id, target_claim_id,
537                assertion_kind, bound_at, reopen_at,
538                provenance_label, value_confidence, valid_time_confidence, asserted_at
539            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
540            &[
541                &assertion_id,
542                &agent_id,
543                &target_claim_id,
544                &assertion_kind,
545                &bound_at,
546                &reopen_at,
547                &provenance,
548                &value_confidence,
549                &valid_time_confidence,
550                &asserted_at,
551            ],
552        )?;
553
554        Ok(())
555    }
556
557    /// Append a ledger entry row within the open transaction.
558    ///
559    /// `stream_seq` is assigned via:
560    /// `SELECT COALESCE(MAX(stream_seq), 0) + 1 FROM ledger_entries WHERE agent_id = $1`
561    /// within the same transaction, under the per-agent advisory lock.
562    ///
563    /// INVARIANT: this MAX+1 assignment is safe ONLY under `pg_advisory_xact_lock`.
564    /// If the advisory lock is ever removed, replace with a Postgres SEQUENCE object.
565    fn append_ledger_entry(
566        &self,
567        txn: &mut PostgresTxn,
568        entry: &LedgerEntry,
569    ) -> Result<(), PostgresStoreError> {
570        let entry_id = entry.entry_id.to_string();
571        let agent_id = entry.agent_id.0.clone();
572        let claim_id = entry.claim_ref.0.to_string();
573        let event_kind = ledger_event_kind_to_str(&entry.event_kind).to_owned();
574        let disposition = disposition_to_str(&entry.disposition).to_owned();
575        // Pass rationale as Option<serde_json::Value> so the driver encodes it as JSONB.
576        // A String with `$6::jsonb` cast does NOT work — see append_claim note above.
577        let rationale_jsonb: Option<serde_json::Value> = entry.rationale.clone();
578        let recorded_at = entry.recorded_at.0.to_rfc3339();
579
580        // INVARIANT: safe only under pg_advisory_xact_lock; replace with a SEQUENCE if the lock is ever removed.
581        let row = txn.client().query_one(
582            "SELECT COALESCE(MAX(stream_seq), 0) + 1 FROM ledger_entries WHERE agent_id = $1",
583            &[&agent_id],
584        )?;
585        let stream_seq: i64 = row.get(0);
586
587        txn.client().execute(
588            "INSERT INTO ledger_entries (
589                entry_id, agent_id, claim_id, event_kind, disposition, rationale, recorded_at, stream_seq
590            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
591            &[
592                &entry_id,
593                &agent_id,
594                &claim_id,
595                &event_kind,
596                &disposition,
597                &rationale_jsonb,
598                &recorded_at,
599                &stream_seq,
600            ],
601        )?;
602
603        Ok(())
604    }
605
606    /// Append a claim edge row within the open transaction.
607    fn append_claim_edge(
608        &self,
609        txn: &mut PostgresTxn,
610        edge: &ClaimEdge,
611    ) -> Result<(), PostgresStoreError> {
612        let edge_id = edge.edge_id.to_string();
613        let agent_id = edge.agent_id.0.clone();
614        let from_claim_id = edge.from_claim.0.to_string();
615        let to_claim_id = edge.to_claim.0.to_string();
616        let edge_kind = edge_kind_to_str(&edge.kind).to_owned();
617        let created_at = edge.created_at.0.to_rfc3339();
618
619        txn.client().execute(
620            "INSERT INTO claim_edges (
621                edge_id, agent_id, from_claim_id, to_claim_id, edge_kind, created_at
622            ) VALUES ($1, $2, $3, $4, $5, $6)",
623            &[
624                &edge_id,
625                &agent_id,
626                &from_claim_id,
627                &to_claim_id,
628                &edge_kind,
629                &created_at,
630            ],
631        )?;
632
633        Ok(())
634    }
635
636    // ── Read methods (pool.get() per call; non-mutating) ─────────────────────
637
638    /// Load all claims on (agent_id, subject, predicate), ordered by tx_time ASC.
639    ///
640    /// When `as_of_tx_time` is `Some(T)`, only claims with `tx_time <= T` are
641    /// returned, enforcing bi-temporal tx-time visibility. When `None`, all claims
642    /// are returned (current view).
643    fn load_subject_line(
644        &self,
645        agent_id: &AgentId,
646        subject: &str,
647        predicate: &str,
648        as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
649    ) -> Result<Vec<Claim>, PostgresStoreError> {
650        let mut conn = self.pool.get()?;
651        if let Some(cutoff) = as_of_tx_time {
652            // tx_time is a TEXT column storing RFC-3339 strings. Bind the cutoff as a String
653            // so the comparison is string-vs-string (lexicographic). RFC-3339 UTC sorts
654            // chronologically, matching the stored format written by `.to_rfc3339()` on INSERT.
655            // Binding a DateTime<Utc> directly triggers a TIMESTAMPTZ type mismatch against
656            // the TEXT column: "error serializing parameter N".
657            let cutoff_str = cutoff.to_rfc3339();
658            let sql = format!(
659                "SELECT {CLAIM_SELECT_COLS} FROM claims
660                 WHERE agent_id = $1 AND subject = $2 AND predicate = $3
661                   AND tx_time <= $4
662                 ORDER BY tx_time ASC"
663            );
664            let rows = conn.query(
665                &sql,
666                &[&agent_id.0.as_str(), &subject, &predicate, &cutoff_str.as_str()],
667            )?;
668            rows.iter().map(row_to_claim).collect()
669        } else {
670            let sql = format!(
671                "SELECT {CLAIM_SELECT_COLS} FROM claims
672                 WHERE agent_id = $1 AND subject = $2 AND predicate = $3
673                 ORDER BY tx_time ASC"
674            );
675            let rows = conn.query(
676                &sql,
677                &[&agent_id.0.as_str(), &subject, &predicate],
678            )?;
679            rows.iter().map(row_to_claim).collect()
680        }
681    }
682
683    /// Load a single claim by `ClaimRef`. Returns `None` if not found.
684    fn load_claim(
685        &self,
686        agent_id: &AgentId,
687        claim_ref: &ClaimRef,
688    ) -> Result<Option<Claim>, PostgresStoreError> {
689        let mut conn = self.pool.get()?;
690        let claim_id_str = claim_ref.0.to_string();
691        let sql = format!(
692            "SELECT {CLAIM_SELECT_COLS} FROM claims WHERE agent_id = $1 AND claim_id = $2"
693        );
694        let rows = conn.query(&sql, &[&agent_id.0.as_str(), &claim_id_str.as_str()])?;
695        match rows.first() {
696            None => Ok(None),
697            Some(row) => Ok(Some(row_to_claim(row)?)),
698        }
699    }
700
701    /// Load all validity assertions targeting a claim, ordered by asserted_at ASC.
702    fn load_validity_assertions_for(
703        &self,
704        agent_id: &AgentId,
705        claim_ref: &ClaimRef,
706    ) -> Result<Vec<ValidityAssertion>, PostgresStoreError> {
707        let mut conn = self.pool.get()?;
708        let claim_id_str = claim_ref.0.to_string();
709        let rows = conn.query(
710            "SELECT assertion_id, agent_id, target_claim_id,
711                    assertion_kind, bound_at, reopen_at,
712                    provenance_label, value_confidence, valid_time_confidence, asserted_at
713             FROM validity_assertions
714             WHERE agent_id = $1 AND target_claim_id = $2
715             ORDER BY asserted_at ASC",
716            &[&agent_id.0.as_str(), &claim_id_str.as_str()],
717        )?;
718
719        rows.iter()
720            .map(|row| {
721                let assertion_id_str: String = row.get(0);
722                let agent_id_str: String = row.get(1);
723                let target_claim_str: String = row.get(2);
724                let kind_str: String = row.get(3);
725                let bound_at_str: Option<String> = row.get(4);
726                let reopen_at_str: Option<String> = row.get(5);
727                let prov_str: String = row.get(6);
728                let value_confidence: f64 = row.get(7);
729                let valid_time_confidence: f64 = row.get(8);
730                let asserted_at_str: String = row.get(9);
731
732                let assertion_ref = uuid::Uuid::parse_str(&assertion_id_str)
733                    .map_err(|e| PostgresStoreError::Mapping(format!("assertion_id UUID: {e}")))?;
734                let target_claim = uuid::Uuid::parse_str(&target_claim_str)
735                    .map(ClaimRef)
736                    .map_err(|e| PostgresStoreError::Mapping(format!("target_claim UUID: {e}")))?;
737                let provenance = str_to_provenance(&prov_str)?;
738                let asserted_at = chrono::DateTime::parse_from_rfc3339(&asserted_at_str)
739                    .map(|dt| dt.with_timezone(&chrono::Utc))
740                    .map_err(|e| PostgresStoreError::Mapping(format!("asserted_at: {e}")))?;
741
742                let kind = match kind_str.as_str() {
743                    "Bound" => {
744                        let s = bound_at_str.ok_or_else(|| {
745                            PostgresStoreError::Mapping("bound_at is NULL for Bound assertion".into())
746                        })?;
747                        let dt = chrono::DateTime::parse_from_rfc3339(&s)
748                            .map(|dt| dt.with_timezone(&chrono::Utc))
749                            .map_err(|e| PostgresStoreError::Mapping(format!("bound_at: {e}")))?;
750                        AssertionKind::Bound { bound_at: dt }
751                    }
752                    "Reopen" => {
753                        let s = reopen_at_str.ok_or_else(|| {
754                            PostgresStoreError::Mapping("reopen_at is NULL for Reopen assertion".into())
755                        })?;
756                        let dt = chrono::DateTime::parse_from_rfc3339(&s)
757                            .map(|dt| dt.with_timezone(&chrono::Utc))
758                            .map_err(|e| PostgresStoreError::Mapping(format!("reopen_at: {e}")))?;
759                        AssertionKind::Reopen { reopen_at: dt }
760                    }
761                    other => {
762                        return Err(PostgresStoreError::Mapping(format!(
763                            "unknown assertion_kind: {other}"
764                        )))
765                    }
766                };
767
768                Ok(ValidityAssertion {
769                    assertion_ref,
770                    agent_id: AgentId(agent_id_str),
771                    target_claim,
772                    kind,
773                    provenance,
774                    confidence: Confidence {
775                        value_confidence: value_confidence as f32,
776                        valid_time_confidence: valid_time_confidence as f32,
777                    },
778                    asserted_at: TransactionTime(asserted_at),
779                })
780            })
781            .collect()
782    }
783
784    /// Load ledger entries for an agent, optionally starting from `from` (inclusive),
785    /// limited to `limit` rows, ordered by recorded_at ASC.
786    fn load_ledger(
787        &self,
788        agent_id: &AgentId,
789        from: Option<&TransactionTime>,
790        limit: usize,
791    ) -> Result<Vec<LedgerEntry>, PostgresStoreError> {
792        let mut conn = self.pool.get()?;
793        let limit_i64 = limit as i64;
794
795        let map_row = |row: &postgres::Row| -> Result<LedgerEntry, PostgresStoreError> {
796            let entry_id_str: String = row.get(0);
797            let agent_id_str: String = row.get(1);
798            let claim_id_str: String = row.get(2);
799            let event_kind_str: String = row.get(3);
800            let disposition_str: String = row.get(4);
801            let rationale_json: Option<String> = row.get(5);
802            let recorded_at_str: String = row.get(6);
803
804            let entry_id = uuid::Uuid::parse_str(&entry_id_str)
805                .map_err(|e| PostgresStoreError::Mapping(format!("entry_id UUID: {e}")))?;
806            let claim_id = uuid::Uuid::parse_str(&claim_id_str)
807                .map(ClaimRef)
808                .map_err(|e| PostgresStoreError::Mapping(format!("claim_id UUID: {e}")))?;
809            let event_kind = str_to_ledger_event_kind(&event_kind_str)?;
810            let disposition = str_to_disposition(&disposition_str)?;
811            let rationale: Option<serde_json::Value> = rationale_json
812                .map(|s| {
813                    serde_json::from_str(&s)
814                        .map_err(|e| PostgresStoreError::Mapping(format!("rationale JSON: {e}")))
815                })
816                .transpose()?;
817            let recorded_at = chrono::DateTime::parse_from_rfc3339(&recorded_at_str)
818                .map(|dt| dt.with_timezone(&chrono::Utc))
819                .map_err(|e| PostgresStoreError::Mapping(format!("recorded_at: {e}")))?;
820
821            Ok(LedgerEntry {
822                entry_id,
823                agent_id: AgentId(agent_id_str),
824                claim_ref: claim_id,
825                event_kind,
826                disposition,
827                rationale,
828                recorded_at: TransactionTime(recorded_at),
829            })
830        };
831
832        let rows = if let Some(from_time) = from {
833            let from_str = from_time.0.to_rfc3339();
834            conn.query(
835                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale::text, recorded_at
836                 FROM ledger_entries
837                 WHERE agent_id = $1 AND recorded_at >= $2
838                 ORDER BY recorded_at ASC
839                 LIMIT $3",
840                &[&agent_id.0.as_str(), &from_str.as_str(), &limit_i64],
841            )?
842        } else {
843            conn.query(
844                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale::text, recorded_at
845                 FROM ledger_entries
846                 WHERE agent_id = $1
847                 ORDER BY recorded_at ASC
848                 LIMIT $2",
849                &[&agent_id.0.as_str(), &limit_i64],
850            )?
851        };
852
853        rows.iter().map(map_row).collect()
854    }
855
856    /// Load ALL ledger entries for the given claim refs, no row cap.
857    ///
858    /// Uses `claim_id = ANY($2::text[])` to avoid per-parameter binding limits.
859    fn load_ledger_for_claims(
860        &self,
861        agent_id: &AgentId,
862        claim_refs: &[ClaimRef],
863        as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
864    ) -> Result<Vec<LedgerEntry>, PostgresStoreError> {
865        if claim_refs.is_empty() {
866            return Ok(vec![]);
867        }
868
869        let mut conn = self.pool.get()?;
870
871        let map_row = |row: &postgres::Row| -> Result<LedgerEntry, PostgresStoreError> {
872            let entry_id_str: String = row.get(0);
873            let agent_id_str: String = row.get(1);
874            let claim_id_str: String = row.get(2);
875            let event_kind_str: String = row.get(3);
876            let disposition_str: String = row.get(4);
877            let rationale_json: Option<String> = row.get(5);
878            let recorded_at_str: String = row.get(6);
879
880            let entry_id = uuid::Uuid::parse_str(&entry_id_str)
881                .map_err(|e| PostgresStoreError::Mapping(format!("entry_id UUID: {e}")))?;
882            let claim_id = uuid::Uuid::parse_str(&claim_id_str)
883                .map(ClaimRef)
884                .map_err(|e| PostgresStoreError::Mapping(format!("claim_id UUID: {e}")))?;
885            let event_kind = str_to_ledger_event_kind(&event_kind_str)?;
886            let disposition = str_to_disposition(&disposition_str)?;
887            let rationale: Option<serde_json::Value> = rationale_json
888                .map(|s| {
889                    serde_json::from_str(&s)
890                        .map_err(|e| PostgresStoreError::Mapping(format!("rationale JSON: {e}")))
891                })
892                .transpose()?;
893            let recorded_at = chrono::DateTime::parse_from_rfc3339(&recorded_at_str)
894                .map(|dt| dt.with_timezone(&chrono::Utc))
895                .map_err(|e| PostgresStoreError::Mapping(format!("recorded_at: {e}")))?;
896
897            Ok(LedgerEntry {
898                entry_id,
899                agent_id: AgentId(agent_id_str),
900                claim_ref: claim_id,
901                event_kind,
902                disposition,
903                rationale,
904                recorded_at: TransactionTime(recorded_at),
905            })
906        };
907
908        // Pass the claim refs as a Postgres text array; ANY avoids per-param binding limits.
909        let id_strings: Vec<String> = claim_refs.iter().map(|r| r.0.to_string()).collect();
910        let ids_ref: Vec<&str> = id_strings.iter().map(|s| s.as_str()).collect();
911
912        // When as_of_tx_time is Some(T), add AND recorded_at <= $3 to filter out entries
913        // recorded after T (bi-temporal tx-time travel on the disposition axis).
914        let rows = if let Some(as_of) = as_of_tx_time {
915            let as_of_str = as_of.to_rfc3339();
916            conn.query(
917                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale::text, recorded_at
918                 FROM ledger_entries
919                 WHERE agent_id = $1 AND claim_id = ANY($2) AND recorded_at <= $3
920                 ORDER BY recorded_at ASC",
921                &[&agent_id.0.as_str(), &ids_ref.as_slice(), &as_of_str.as_str()],
922            )?
923        } else {
924            conn.query(
925                "SELECT entry_id, agent_id, claim_id, event_kind, disposition, rationale::text, recorded_at
926                 FROM ledger_entries
927                 WHERE agent_id = $1 AND claim_id = ANY($2)
928                 ORDER BY recorded_at ASC",
929                &[&agent_id.0.as_str(), &ids_ref.as_slice()],
930            )?
931        };
932
933        rows.iter().map(map_row).collect()
934    }
935
936    /// Load all edges where `claim_ref` is either the from or to end, ordered by created_at ASC.
937    fn load_edges_for(
938        &self,
939        agent_id: &AgentId,
940        claim_ref: &ClaimRef,
941    ) -> Result<Vec<ClaimEdge>, PostgresStoreError> {
942        let mut conn = self.pool.get()?;
943        let claim_id_str = claim_ref.0.to_string();
944
945        let rows = conn.query(
946            "SELECT edge_id, agent_id, from_claim_id, to_claim_id, edge_kind, created_at
947             FROM claim_edges
948             WHERE agent_id = $1
949               AND (from_claim_id = $2 OR to_claim_id = $2)
950             ORDER BY created_at ASC",
951            &[&agent_id.0.as_str(), &claim_id_str.as_str()],
952        )?;
953
954        rows.iter().map(row_to_edge).collect()
955    }
956
957    /// Load the set of ClaimRefs served as injected claims for this agent (used by the Amplification Guard).
958    fn load_injected_claims(
959        &self,
960        agent_id: &AgentId,
961    ) -> Result<Vec<ClaimRef>, PostgresStoreError> {
962        let mut conn = self.pool.get()?;
963
964        let rows = conn.query(
965            "SELECT claim_id
966             FROM ledger_entries
967             WHERE agent_id = $1 AND event_kind = 'ServedAsInjected'
968             GROUP BY claim_id
969             ORDER BY MIN(recorded_at) ASC",
970            &[&agent_id.0.as_str()],
971        )?;
972
973        rows.iter()
974            .map(|row| {
975                let claim_id_str: String = row.get(0);
976                uuid::Uuid::parse_str(&claim_id_str)
977                    .map(ClaimRef)
978                    .map_err(|e| PostgresStoreError::Mapping(format!("claim_id UUID: {e}")))
979            })
980            .collect()
981    }
982
983    /// Recursive CTE lineage traversal — identical SQL to the SQLite adapter.
984    ///
985    /// Traverses `DerivedFrom` edges upward from `claim_ref`, returning all `ClaimEdge`
986    /// rows in the lineage sub-graph ordered by depth ASC, then created_at ASC within depth.
987    /// Bounded at depth 64 to prevent runaway on pathological graphs.
988    fn load_lineage(
989        &self,
990        agent_id: &AgentId,
991        claim_ref: &ClaimRef,
992    ) -> Result<Vec<ClaimEdge>, PostgresStoreError> {
993        let mut conn = self.pool.get()?;
994        let start_id = claim_ref.0.to_string();
995
996        let rows = conn.query(
997            "WITH RECURSIVE lineage(edge_id, depth) AS (
998                -- Base case: all DerivedFrom edges leaving from our starting claim
999                SELECT ce.edge_id, 1
1000                FROM claim_edges ce
1001                WHERE ce.agent_id = $1
1002                  AND ce.from_claim_id = $2
1003                  AND ce.edge_kind = 'DerivedFrom'
1004                UNION ALL
1005                -- Recursive case: follow the to_claim of the previous edge onward
1006                SELECT ce2.edge_id, l.depth + 1
1007                FROM claim_edges ce2
1008                JOIN lineage l ON ce2.from_claim_id = (
1009                    SELECT to_claim_id FROM claim_edges WHERE edge_id = l.edge_id
1010                )
1011                WHERE ce2.agent_id = $1
1012                  AND ce2.edge_kind = 'DerivedFrom'
1013                  AND l.depth < 64
1014            )
1015            SELECT ce.edge_id, ce.agent_id, ce.from_claim_id, ce.to_claim_id,
1016                   ce.edge_kind, ce.created_at,
1017                   l.depth
1018            FROM claim_edges ce
1019            JOIN lineage l ON ce.edge_id = l.edge_id
1020            ORDER BY l.depth ASC, ce.created_at ASC",
1021            &[&agent_id.0.as_str(), &start_id.as_str()],
1022        )?;
1023
1024        rows.iter()
1025            .map(|row| {
1026                let edge_id_str: String = row.get(0);
1027                let agent_id_str: String = row.get(1);
1028                let from_claim_str: String = row.get(2);
1029                let to_claim_str: String = row.get(3);
1030                let kind_str: String = row.get(4);
1031                let created_at_str: String = row.get(5);
1032                // col 6 = depth (ordering only; not part of ClaimEdge)
1033
1034                let edge_id = uuid::Uuid::parse_str(&edge_id_str)
1035                    .map_err(|e| PostgresStoreError::Mapping(format!("edge_id UUID: {e}")))?;
1036                let from_claim = uuid::Uuid::parse_str(&from_claim_str)
1037                    .map(ClaimRef)
1038                    .map_err(|e| PostgresStoreError::Mapping(format!("from_claim UUID: {e}")))?;
1039                let to_claim = uuid::Uuid::parse_str(&to_claim_str)
1040                    .map(ClaimRef)
1041                    .map_err(|e| PostgresStoreError::Mapping(format!("to_claim UUID: {e}")))?;
1042                let kind = str_to_edge_kind(&kind_str)?;
1043                let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
1044                    .map(|dt| dt.with_timezone(&chrono::Utc))
1045                    .map_err(|e| PostgresStoreError::Mapping(format!("created_at: {e}")))?;
1046
1047                Ok(ClaimEdge {
1048                    edge_id,
1049                    agent_id: AgentId(agent_id_str),
1050                    from_claim,
1051                    to_claim,
1052                    kind,
1053                    created_at: TransactionTime(created_at),
1054                })
1055            })
1056            .collect()
1057    }
1058
1059    /// Return all distinct predicates for `(agent_id, subject)`.
1060    ///
1061    /// Postgres: uses `idx_claims_subject_line (agent_id, subject, predicate, tx_time DESC)`.
1062    /// DISTINCT over (agent_id, subject, predicate) is served as an Index-Only Scan on this
1063    /// index — no Seq Scan and no HashAggregate.
1064    /// When `as_of_tx_time` is `Some(T)`, the `tx_time <= T` filter is applied before DISTINCT
1065    /// so only predicates with at least one visible claim are returned.
1066    ///
1067    /// tx_time is a TEXT column — bind as `to_rfc3339()` STRING to avoid TIMESTAMPTZ mismatch.
1068    fn list_predicates_for_subject(
1069        &self,
1070        agent_id: &AgentId,
1071        subject: &str,
1072        as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
1073    ) -> Result<Vec<String>, PostgresStoreError> {
1074        let mut conn = self.pool.get()?;
1075
1076        let predicates = if let Some(cutoff) = as_of_tx_time {
1077            let cutoff_str = cutoff.to_rfc3339();
1078            let rows = conn.query(
1079                "SELECT DISTINCT predicate
1080                 FROM claims
1081                 WHERE agent_id = $1 AND subject = $2 AND tx_time <= $3",
1082                &[&agent_id.0.as_str(), &subject, &cutoff_str.as_str()],
1083            )?;
1084            rows.iter().map(|row| Ok(row.get::<_, String>(0))).collect::<Result<Vec<_>, PostgresStoreError>>()?
1085        } else {
1086            let rows = conn.query(
1087                "SELECT DISTINCT predicate
1088                 FROM claims
1089                 WHERE agent_id = $1 AND subject = $2",
1090                &[&agent_id.0.as_str(), &subject],
1091            )?;
1092            rows.iter().map(|row| Ok(row.get::<_, String>(0))).collect::<Result<Vec<_>, PostgresStoreError>>()?
1093        };
1094
1095        Ok(predicates)
1096    }
1097
1098    /// Postgres uses a pool + per-agent advisory lock — no global write lock is needed.
1099    fn requires_global_write_serialization(&self) -> bool {
1100        false
1101    }
1102}
1103
1104// ── PostgresPendingStore ──────────────────────────────────────────────────────
1105
1106/// PostgreSQL-backed `PendingAdjudicationPort` implementation.
1107///
1108/// Uses the same r2d2 pool as `PostgresPersistenceStore`. Each method borrows a pooled
1109/// connection for the duration of a single non-transactional statement (auto-commit).
1110/// Serialization is provided by the per-agent write lock in `EngineHandle`.
1111pub struct PostgresPendingStore {
1112    pool: r2d2::Pool<r2d2_postgres::PostgresConnectionManager<postgres::NoTls>>,
1113}
1114
1115impl PostgresPendingStore {
1116    /// Create a pending store sharing the same connection pool.
1117    pub fn new(pool: r2d2::Pool<r2d2_postgres::PostgresConnectionManager<postgres::NoTls>>) -> Self {
1118        Self { pool }
1119    }
1120}
1121
1122impl PendingAdjudicationPort for PostgresPendingStore {
1123    type Error = PostgresStoreError;
1124
1125    fn insert_pending(&self, row: &PendingAdjudicationRow) -> Result<(), PostgresStoreError> {
1126        let mut conn = self.pool.get()?;
1127        let request_payload = serde_json::to_string(&row.request_payload)
1128            .map_err(|e| PostgresStoreError::Mapping(format!("request_payload serialization: {e}")))?;
1129        // queued_at and expires_at are TIMESTAMPTZ columns — pass as chrono::DateTime<Utc>
1130        // directly (requires postgres feature "with-chrono-0_4"). Passing as String caused
1131        // WrongType { postgres: Timestamptz, rust: "alloc::string::String" } errors.
1132        let queued_at: chrono::DateTime<chrono::Utc> = row.queued_at;
1133        let expires_at: Option<chrono::DateTime<chrono::Utc>> = row.expires_at;
1134        conn.execute(
1135            "INSERT INTO pending_adjudications (
1136                handle_id, agent_id, subject, predicate,
1137                challenger_claim_ref, incumbent_claim_ref,
1138                request_payload, queued_at, expires_at, status
1139            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1140            &[
1141                &row.handle_id.to_string(),
1142                &row.agent_id.0.as_str(),
1143                &row.subject.as_str(),
1144                &row.predicate.as_str(),
1145                &row.challenger_claim_ref.0.to_string(),
1146                &row.incumbent_claim_ref.0.to_string(),
1147                &request_payload.as_str(),
1148                &queued_at,
1149                &expires_at,
1150                &row.status.as_str(),
1151            ],
1152        )?;
1153        Ok(())
1154    }
1155
1156    fn get_pending(&self, handle_id: uuid::Uuid) -> Result<Option<PendingAdjudicationRow>, PostgresStoreError> {
1157        let mut conn = self.pool.get()?;
1158        let rows = conn.query(
1159            "SELECT handle_id, agent_id, subject, predicate,
1160                    challenger_claim_ref, incumbent_claim_ref,
1161                    request_payload, queued_at, expires_at, status
1162             FROM pending_adjudications
1163             WHERE handle_id = $1",
1164            &[&handle_id.to_string()],
1165        )?;
1166        match rows.into_iter().next() {
1167            None => Ok(None),
1168            Some(row) => Ok(Some(pg_row_to_pending(&row)?)),
1169        }
1170    }
1171
1172    fn list_pending(&self, agent_id: Option<&AgentId>) -> Result<Vec<PendingAdjudicationRow>, PostgresStoreError> {
1173        let mut conn = self.pool.get()?;
1174        let rows = if let Some(aid) = agent_id {
1175            conn.query(
1176                "SELECT handle_id, agent_id, subject, predicate,
1177                        challenger_claim_ref, incumbent_claim_ref,
1178                        request_payload, queued_at, expires_at, status
1179                 FROM pending_adjudications
1180                 WHERE agent_id = $1 AND status = 'pending'
1181                 ORDER BY queued_at ASC",
1182                &[&aid.0.as_str()],
1183            )?
1184        } else {
1185            conn.query(
1186                "SELECT handle_id, agent_id, subject, predicate,
1187                        challenger_claim_ref, incumbent_claim_ref,
1188                        request_payload, queued_at, expires_at, status
1189                 FROM pending_adjudications
1190                 WHERE status = 'pending'
1191                 ORDER BY queued_at ASC",
1192                &[],
1193            )?
1194        };
1195        rows.iter().map(pg_row_to_pending).collect()
1196    }
1197
1198    fn list_expired(&self, now: chrono::DateTime<chrono::Utc>) -> Result<Vec<PendingAdjudicationRow>, PostgresStoreError> {
1199        let mut conn = self.pool.get()?;
1200        // Pass now as chrono::DateTime<Utc> directly — expires_at is TIMESTAMPTZ.
1201        let rows = conn.query(
1202            "SELECT handle_id, agent_id, subject, predicate,
1203                    challenger_claim_ref, incumbent_claim_ref,
1204                    request_payload, queued_at, expires_at, status
1205             FROM pending_adjudications
1206             WHERE expires_at IS NOT NULL AND expires_at <= $1 AND status = 'pending'
1207             ORDER BY expires_at ASC",
1208            &[&now],
1209        )?;
1210        rows.iter().map(pg_row_to_pending).collect()
1211    }
1212
1213    fn mark_resolved(&self, handle_id: uuid::Uuid) -> Result<(), PostgresStoreError> {
1214        let mut conn = self.pool.get()?;
1215        conn.execute(
1216            "UPDATE pending_adjudications SET status = 'resolved' WHERE handle_id = $1",
1217            &[&handle_id.to_string()],
1218        )?;
1219        Ok(())
1220    }
1221
1222    fn mark_expired(&self, handle_id: uuid::Uuid) -> Result<(), PostgresStoreError> {
1223        let mut conn = self.pool.get()?;
1224        conn.execute(
1225            "UPDATE pending_adjudications SET status = 'expired' WHERE handle_id = $1",
1226            &[&handle_id.to_string()],
1227        )?;
1228        Ok(())
1229    }
1230
1231    /// Detect QueuedForAdjudication claims with no matching pending row (Postgres adapter).
1232    ///
1233    /// Same approach as SQLite: cross-table query joining ledger_entries + claims + pending_adjudications.
1234    fn list_queued_orphan_claims(
1235        &self,
1236    ) -> Result<Vec<mempill_core::ports::pending_adjudication::OrphanedQueuedClaim>, PostgresStoreError> {
1237        let mut conn = self.pool.get()?;
1238
1239        // Phase 1: find orphaned QueuedForAdjudication claim refs.
1240        // NOTE: the schema column is `claim_id` in both `ledger_entries` and `claims`
1241        // (not `claim_ref` — that was the original bug caught by live PG tests).
1242        let orphan_rows = conn.query(
1243            "SELECT l.agent_id, l.claim_id, c.subject, c.predicate
1244             FROM ledger_entries l
1245             JOIN claims c ON c.claim_id = l.claim_id AND c.agent_id = l.agent_id
1246             WHERE l.disposition = 'QueuedForAdjudication'
1247               AND l.recorded_at = (
1248                   SELECT MAX(l2.recorded_at) FROM ledger_entries l2
1249                   WHERE l2.claim_id = l.claim_id AND l2.agent_id = l.agent_id
1250               )
1251               AND NOT EXISTS (
1252                   SELECT 1 FROM pending_adjudications pa
1253                   WHERE pa.challenger_claim_ref = l.claim_id
1254                     AND pa.agent_id = l.agent_id
1255                     AND pa.status = 'pending'
1256               )",
1257            &[],
1258        )?;
1259
1260        let mut results = Vec::new();
1261        for row in &orphan_rows {
1262            let agent_id_str: String = row.get(0);
1263            let challenger_str: String = row.get(1);
1264            let subject: String = row.get(2);
1265            let predicate: String = row.get(3);
1266
1267            let challenger_ref = uuid::Uuid::parse_str(&challenger_str)
1268                .map(mempill_types::ClaimRef)
1269                .map_err(|e| PostgresStoreError::Mapping(format!("challenger_claim_ref UUID: {e}")))?;
1270
1271            // Phase 2: find incumbent CommittedCheap claim on the same subject line.
1272            // NOTE: schema column is `claim_id` (not `claim_ref`) in both tables.
1273            let incumbent_rows = conn.query(
1274                "SELECT l.claim_id
1275                 FROM ledger_entries l
1276                 JOIN claims c ON c.claim_id = l.claim_id AND c.agent_id = l.agent_id
1277                 WHERE l.agent_id = $1
1278                   AND c.subject = $2
1279                   AND c.predicate = $3
1280                   AND l.disposition = 'CommittedCheap'
1281                   AND l.recorded_at = (
1282                       SELECT MAX(l2.recorded_at) FROM ledger_entries l2
1283                       WHERE l2.claim_id = l.claim_id AND l2.agent_id = l.agent_id
1284                   )
1285                 ORDER BY l.recorded_at DESC
1286                 LIMIT 1",
1287                &[&agent_id_str.as_str(), &subject.as_str(), &predicate.as_str()],
1288            )?;
1289
1290            let incumbent_ref = incumbent_rows.first()
1291                .map(|ir| {
1292                    let ref_str: String = ir.get(0);
1293                    uuid::Uuid::parse_str(&ref_str)
1294                        .map(mempill_types::ClaimRef)
1295                        .map_err(|e| PostgresStoreError::Mapping(format!("incumbent UUID: {e}")))
1296                })
1297                .transpose()?;
1298
1299            results.push(mempill_core::ports::pending_adjudication::OrphanedQueuedClaim {
1300                agent_id: mempill_types::AgentId(agent_id_str),
1301                challenger_claim_ref: challenger_ref,
1302                incumbent_claim_ref: incumbent_ref,
1303                subject,
1304                predicate,
1305            });
1306        }
1307
1308        Ok(results)
1309    }
1310}
1311
1312/// Map a Postgres `Row` from `pending_adjudications` to a `PendingAdjudicationRow`.
1313///
1314/// `queued_at` and `expires_at` are `TIMESTAMPTZ` columns; we read them as
1315/// `chrono::DateTime<chrono::Utc>` directly via the `with-chrono-0_4` postgres feature.
1316/// All other UUID-like columns are stored as TEXT and parsed manually.
1317fn pg_row_to_pending(row: &postgres::Row) -> Result<PendingAdjudicationRow, PostgresStoreError> {
1318    let handle_id_str: String = row.get(0);
1319    let agent_id_str: String = row.get(1);
1320    let subject: String = row.get(2);
1321    let predicate: String = row.get(3);
1322    let challenger_str: String = row.get(4);
1323    let incumbent_str: String = row.get(5);
1324    let payload_json: String = row.get(6);
1325    // TIMESTAMPTZ columns — read as native chrono type (with-chrono-0_4 feature).
1326    let queued_at: chrono::DateTime<chrono::Utc> = row.get(7);
1327    let expires_at: Option<chrono::DateTime<chrono::Utc>> = row.get(8);
1328    let status: String = row.get(9);
1329
1330    let handle_id = uuid::Uuid::parse_str(&handle_id_str)
1331        .map_err(|e| PostgresStoreError::Mapping(format!("handle_id UUID: {e}")))?;
1332    let challenger_claim_ref = uuid::Uuid::parse_str(&challenger_str)
1333        .map(ClaimRef)
1334        .map_err(|e| PostgresStoreError::Mapping(format!("challenger_claim_ref UUID: {e}")))?;
1335    let incumbent_claim_ref = uuid::Uuid::parse_str(&incumbent_str)
1336        .map(ClaimRef)
1337        .map_err(|e| PostgresStoreError::Mapping(format!("incumbent_claim_ref UUID: {e}")))?;
1338    let request_payload: mempill_types::AdjudicationRequest =
1339        serde_json::from_str(&payload_json)
1340            .map_err(|e| PostgresStoreError::Mapping(format!("request_payload JSON: {e}")))?;
1341
1342    Ok(PendingAdjudicationRow {
1343        handle_id,
1344        agent_id: AgentId(agent_id_str),
1345        subject,
1346        predicate,
1347        challenger_claim_ref,
1348        incumbent_claim_ref,
1349        request_payload,
1350        queued_at,
1351        expires_at,
1352        status,
1353    })
1354}
1355
1356// ── Constructor ───────────────────────────────────────────────────────────────
1357
1358/// Convenience constructor: build a `PostgresEngine<O, V>` (an `EngineHandle` backed
1359/// by `PostgresPersistenceStore`) from a connection string.
1360///
1361/// This is the recommended entry point for callers that want the full async EngineHandle.
1362pub fn open_postgres<O, V>(
1363    connection_string: &str,
1364    oracle: Option<Arc<O>>,
1365    vector: Option<Arc<V>>,
1366    config: EngineConfig,
1367) -> Result<EngineHandle<PostgresPersistenceStore, O, V>, PostgresStoreError>
1368where
1369    O: mempill_core::ports::OraclePort + Send + Sync + 'static,
1370    V: mempill_core::ports::VectorPort + Send + Sync + 'static,
1371{
1372    let store = PostgresPersistenceStore::new(connection_string)?;
1373    Ok(EngineHandle::new(Arc::new(store), oracle, vector, config))
1374}
1375
1376/// Convenience constructor: build a `PostgresEngine<O, V>` wired with a real oracle
1377/// and the Postgres-backed pending-adjudication store.
1378///
1379/// Mirrors `open_postgres` but calls `EngineHandle::new_with_pending_store` so that
1380/// `QueuedForAdjudication` rows are persisted and verdicts can be delivered via
1381/// `EngineHandle::submit_adjudication`.
1382///
1383/// `open_postgres` (no-oracle variant) is left UNCHANGED.
1384pub fn open_postgres_with_oracle<O, V>(
1385    connection_string: &str,
1386    oracle: Arc<O>,
1387    vector: Option<Arc<V>>,
1388    config: EngineConfig,
1389) -> Result<EngineHandle<PostgresPersistenceStore, O, V>, PostgresStoreError>
1390where
1391    O: mempill_core::ports::OraclePort + Send + Sync + 'static,
1392    V: mempill_core::ports::VectorPort + Send + Sync + 'static,
1393{
1394    let store = PostgresPersistenceStore::new(connection_string)?;
1395    let store_arc = Arc::new(store);
1396    let pending_store: Arc<dyn mempill_core::ErasedPendingStore> = Arc::new(
1397        mempill_core::ErasedPendingStoreAdapter::new(store_arc.pending_store()),
1398    );
1399    Ok(EngineHandle::new_with_pending_store::<()>(
1400        store_arc,
1401        Some(oracle),
1402        vector,
1403        pending_store,
1404        config,
1405    ))
1406}