Skip to main content

mnemo_postgres/
storage.rs

1use mnemo_core::error::{Error, Result};
2use mnemo_core::model::acl::{Acl, Permission};
3use mnemo_core::model::agent_profile::AgentProfile;
4use mnemo_core::model::checkpoint::Checkpoint;
5use mnemo_core::model::delegation::{Delegation, DelegationScope};
6use mnemo_core::model::embedding_baseline::EmbeddingBaseline;
7use mnemo_core::model::event::AgentEvent;
8use mnemo_core::model::memory::MemoryRecord;
9use mnemo_core::model::relation::Relation;
10use mnemo_core::model::write_provenance::{
11    WriteOp, WriteProvenance, flags_from_storage, flags_to_storage,
12};
13use mnemo_core::storage::{MemoryFilter, StorageBackend};
14use pgvector::Vector;
15use sqlx::Row;
16use uuid::Uuid;
17
18/// PostgreSQL-backed storage for Mnemo.
19///
20/// Wraps a `sqlx::PgPool` and runs schema migrations on construction.
21/// Embeddings are stored using the pgvector `vector` column type, while
22/// event embeddings are stored as `BYTEA` (serialised `Vec<f32>` in
23/// little-endian byte order), matching the DuckDB backend convention.
24pub struct PgStorage {
25    pool: sqlx::PgPool,
26    dimensions: usize,
27}
28
29impl PgStorage {
30    /// Connect to a PostgreSQL database and run migrations.
31    ///
32    /// `url` is a standard `postgres://` connection string.
33    /// `dimensions` controls the width of the pgvector `vector` column.
34    pub async fn connect(url: &str, dimensions: usize) -> Result<Self> {
35        let pool = sqlx::PgPool::connect(url)
36            .await
37            .map_err(|e| Error::Storage(e.to_string()))?;
38        let storage = Self { pool, dimensions };
39        crate::migrations::run_migrations(&storage.pool, dimensions).await?;
40        Ok(storage)
41    }
42
43    /// Build a `PgStorage` from an existing pool (useful for tests).
44    pub async fn from_pool(pool: sqlx::PgPool, dimensions: usize) -> Result<Self> {
45        crate::migrations::run_migrations(&pool, dimensions).await?;
46        Ok(Self { pool, dimensions })
47    }
48
49    /// A clone of the connection pool, so a [`crate::PgVectorIndex`] can share
50    /// the same connections for ANN search. `sqlx::PgPool` is `Arc`-backed, so
51    /// the clone is cheap and points at the same pool.
52    pub fn pool(&self) -> sqlx::PgPool {
53        self.pool.clone()
54    }
55
56    /// The pgvector `vector(dim)` column width this storage was migrated with.
57    pub fn dimensions(&self) -> usize {
58        self.dimensions
59    }
60}
61
62// ---------------------------------------------------------------------------
63// Helpers
64// ---------------------------------------------------------------------------
65
66fn map_sqlx(e: sqlx::Error) -> Error {
67    Error::Storage(e.to_string())
68}
69
70fn serialize_embedding(embedding: &Option<Vec<f32>>) -> Option<Vec<u8>> {
71    embedding
72        .as_ref()
73        .map(|v| v.iter().flat_map(|f| f.to_le_bytes()).collect())
74}
75
76fn deserialize_embedding(blob: Option<Vec<u8>>) -> Option<Vec<f32>> {
77    blob.map(|bytes| {
78        // `as_chunks::<4>()` rather than `chunks_exact(4)`: it yields `&[u8; 4]`
79        // directly, so `from_le_bytes` takes the array instead of four indexed
80        // reads. Required by clippy 1.98's `chunks_exact_to_as_chunks`, and the
81        // result is the better code anyway - the indexing form carried four
82        // bounds checks the type system can prove unnecessary.
83        bytes
84            .as_chunks::<4>()
85            .0
86            .iter()
87            .map(|chunk| f32::from_le_bytes(*chunk))
88            .collect()
89    })
90}
91
92fn row_to_memory(row: &sqlx::postgres::PgRow) -> std::result::Result<MemoryRecord, sqlx::Error> {
93    let tags: Vec<String> = row.try_get::<Vec<String>, _>("tags").unwrap_or_default();
94    let metadata: serde_json::Value = row
95        .try_get("metadata")
96        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
97
98    // pgvector stores the embedding as its own type; we retrieve the raw text
99    // representation and parse back to Vec<f32>. If the column is NULL we get None.
100    let embedding: Option<Vec<f32>> = {
101        let raw: Option<String> = row.try_get("embedding_text").ok().flatten();
102        raw.and_then(|s| {
103            // pgvector text output looks like "[0.1,0.2,0.3]"
104            let trimmed = s.trim_start_matches('[').trim_end_matches(']');
105            if trimmed.is_empty() {
106                None
107            } else {
108                Some(
109                    trimmed
110                        .split(',')
111                        .filter_map(|v| v.trim().parse::<f32>().ok())
112                        .collect(),
113                )
114            }
115        })
116    };
117
118    Ok(MemoryRecord {
119        id: row.get("id"),
120        agent_id: row.get("agent_id"),
121        content: row.get("content"),
122        memory_type: row
123            .get::<String, _>("memory_type")
124            .parse()
125            .unwrap_or(mnemo_core::model::memory::MemoryType::Semantic),
126        scope: row
127            .get::<String, _>("scope")
128            .parse()
129            .unwrap_or(mnemo_core::model::memory::Scope::Private),
130        importance: row.get("importance"),
131        tags,
132        metadata,
133        embedding,
134        content_hash: row.get("content_hash"),
135        prev_hash: row.get("prev_hash"),
136        source_type: row
137            .get::<String, _>("source_type")
138            .parse()
139            .unwrap_or(mnemo_core::model::memory::SourceType::Agent),
140        source_id: row.get("source_id"),
141        consolidation_state: row
142            .get::<String, _>("consolidation_state")
143            .parse()
144            .unwrap_or(mnemo_core::model::memory::ConsolidationState::Raw),
145        access_count: row.get::<i64, _>("access_count") as u64,
146        org_id: row.get("org_id"),
147        thread_id: row.get("thread_id"),
148        created_at: row.get("created_at"),
149        updated_at: row.get("updated_at"),
150        last_accessed_at: row.get("last_accessed_at"),
151        expires_at: row.get("expires_at"),
152        deleted_at: row.get("deleted_at"),
153        decay_rate: row.get("decay_rate"),
154        created_by: row.get("created_by"),
155        version: row.get::<i32, _>("version") as u32,
156        prev_version_id: row.get("prev_version_id"),
157        quarantined: row.get("quarantined"),
158        quarantine_reason: row.get("quarantine_reason"),
159        decay_function: row.get("decay_function"),
160    })
161}
162
163/// The standard SELECT column list for the memories table.
164/// We cast the pgvector `embedding` column to text so we can parse it
165/// back into `Vec<f32>` without depending on a pgvector Rust decode path.
166///
167/// NOTE (sqlx 0.9 `SqlSafeStr`): queries that interpolate this const into
168/// a `format!`ed SQL string are wrapped in `sqlx::AssertSqlSafe`. This is
169/// audited-safe: the only interpolated values are this column-list const,
170/// code-built `$N` placeholder fragments, and numeric `usize` limit/offset
171/// — all caller data is bound via `$N`, never string-interpolated.
172const MEMORY_COLUMNS: &str = r#"
173    id, agent_id, content, memory_type, scope, importance,
174    tags, metadata, embedding::text AS embedding_text,
175    content_hash, prev_hash, source_type, source_id,
176    consolidation_state, access_count, org_id, thread_id,
177    created_at, updated_at, last_accessed_at, expires_at,
178    deleted_at, decay_rate, created_by, version, prev_version_id,
179    quarantined, quarantine_reason, decay_function
180"#;
181
182fn row_to_event(row: &sqlx::postgres::PgRow) -> std::result::Result<AgentEvent, sqlx::Error> {
183    let payload: serde_json::Value = row.try_get("payload").unwrap_or(serde_json::Value::Null);
184    let embedding_blob: Option<Vec<u8>> = row.try_get("embedding").unwrap_or(None);
185
186    Ok(AgentEvent {
187        id: row.get("id"),
188        agent_id: row.get("agent_id"),
189        thread_id: row.get("thread_id"),
190        run_id: row.get("run_id"),
191        parent_event_id: row.get("parent_event_id"),
192        event_type: row
193            .get::<String, _>("event_type")
194            .parse()
195            .unwrap_or(mnemo_core::model::event::EventType::Error),
196        payload,
197        trace_id: row.get("trace_id"),
198        span_id: row.get("span_id"),
199        model: row.get("model"),
200        tokens_input: row.get("tokens_input"),
201        tokens_output: row.get("tokens_output"),
202        latency_ms: row.get("latency_ms"),
203        cost_usd: row.get("cost_usd"),
204        timestamp: row.get("timestamp"),
205        logical_clock: row.get("logical_clock"),
206        content_hash: row.get("content_hash"),
207        prev_hash: row.get("prev_hash"),
208        embedding: deserialize_embedding(embedding_blob),
209    })
210}
211
212fn row_to_relation(row: &sqlx::postgres::PgRow) -> std::result::Result<Relation, sqlx::Error> {
213    let metadata: serde_json::Value = row
214        .try_get("metadata")
215        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
216
217    Ok(Relation {
218        id: row.get("id"),
219        source_id: row.get("source_id"),
220        target_id: row.get("target_id"),
221        relation_type: row.get("relation_type"),
222        weight: row.get("weight"),
223        metadata,
224        created_at: row.get("created_at"),
225    })
226}
227
228fn row_to_checkpoint(row: &sqlx::postgres::PgRow) -> std::result::Result<Checkpoint, sqlx::Error> {
229    let state_snapshot: serde_json::Value = row
230        .try_get("state_snapshot")
231        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
232    let state_diff: Option<serde_json::Value> = row.try_get("state_diff").unwrap_or(None);
233
234    // memory_refs is stored as TEXT[] of UUID strings
235    let memory_refs_raw: Vec<String> = row.try_get("memory_refs").unwrap_or_default();
236    let memory_refs: Vec<Uuid> = memory_refs_raw
237        .iter()
238        .filter_map(|s| Uuid::parse_str(s).ok())
239        .collect();
240
241    let metadata: serde_json::Value = row
242        .try_get("metadata")
243        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
244
245    Ok(Checkpoint {
246        id: row.get("id"),
247        thread_id: row.get("thread_id"),
248        agent_id: row.get("agent_id"),
249        parent_id: row.get("parent_id"),
250        branch_name: row.get("branch_name"),
251        state_snapshot,
252        state_diff,
253        memory_refs,
254        event_cursor: row.get("event_cursor"),
255        label: row.get("label"),
256        created_at: row.get("created_at"),
257        metadata,
258    })
259}
260
261fn row_to_write_provenance(
262    row: &sqlx::postgres::PgRow,
263) -> std::result::Result<WriteProvenance, sqlx::Error> {
264    let op_str: String = row.get("op");
265    let op = match op_str.as_str() {
266        "remember" => WriteOp::Remember,
267        "share" => WriteOp::Share,
268        other => {
269            return Err(sqlx::Error::Decode(
270                format!("unknown write op `{other}`").into(),
271            ));
272        }
273    };
274    let authored_at_str: String = row.get("authored_at");
275    let authored_at = chrono::DateTime::parse_from_rfc3339(&authored_at_str)
276        .map(|dt| dt.with_timezone(&chrono::Utc))
277        .map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
278    Ok(WriteProvenance {
279        id: row.get("id"),
280        memory_id: row.get("memory_id"),
281        principal: row.get("principal"),
282        capability_id: row.try_get("capability_id").unwrap_or(None),
283        session_id: row.try_get("session_id").unwrap_or(None),
284        op,
285        authored_at,
286        // `flags` (v6) may be absent on an older row → None → empty flag set.
287        flags: flags_from_storage(
288            &row.try_get::<Option<String>, _>("flags")
289                .unwrap_or(None)
290                .unwrap_or_default(),
291        ),
292        prev_hash: row.try_get("prev_hash").unwrap_or(None),
293        content_hash: row.get("content_hash"),
294    })
295}
296
297fn row_to_delegation(row: &sqlx::postgres::PgRow) -> std::result::Result<Delegation, sqlx::Error> {
298    let scope_type: String = row.get("scope_type");
299    let scope_value: Option<serde_json::Value> = row.try_get("scope_value").unwrap_or(None);
300
301    let scope = match scope_type.as_str() {
302        "by_tag" => {
303            let tags: Vec<String> = scope_value
304                .and_then(|v| serde_json::from_value(v).ok())
305                .unwrap_or_default();
306            DelegationScope::ByTag(tags)
307        }
308        "by_memory_id" => {
309            let id_strs: Vec<String> = scope_value
310                .and_then(|v| serde_json::from_value(v).ok())
311                .unwrap_or_default();
312            let uuids = id_strs
313                .into_iter()
314                .filter_map(|s| Uuid::parse_str(&s).ok())
315                .collect();
316            DelegationScope::ByMemoryId(uuids)
317        }
318        _ => DelegationScope::AllMemories,
319    };
320
321    Ok(Delegation {
322        id: row.get("id"),
323        delegator_id: row.get("delegator_id"),
324        delegate_id: row.get("delegate_id"),
325        permission: row
326            .get::<String, _>("permission")
327            .parse()
328            .unwrap_or(Permission::Read),
329        scope,
330        max_depth: row.get::<i32, _>("max_depth") as u32,
331        current_depth: row.get::<i32, _>("current_depth") as u32,
332        parent_delegation_id: row.get("parent_delegation_id"),
333        created_at: row.get("created_at"),
334        expires_at: row.get("expires_at"),
335        revoked_at: row.get("revoked_at"),
336    })
337}
338
339// ---------------------------------------------------------------------------
340// StorageBackend implementation
341// ---------------------------------------------------------------------------
342
343#[async_trait::async_trait]
344impl StorageBackend for PgStorage {
345    fn backend_name(&self) -> &'static str {
346        "postgres"
347    }
348
349    fn records_write_provenance(&self) -> bool {
350        true
351    }
352
353    async fn insert_write_provenance(&self, prov: &WriteProvenance) -> Result<()> {
354        sqlx::query(
355            r#"
356INSERT INTO write_provenance
357    (id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash)
358VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
359"#,
360        )
361        .bind(prov.id)
362        .bind(prov.memory_id)
363        .bind(&prov.principal)
364        .bind(prov.capability_id)
365        .bind(&prov.session_id)
366        .bind(prov.op.as_str())
367        .bind(prov.authored_at.to_rfc3339())
368        .bind(flags_to_storage(&prov.flags))
369        .bind(&prov.prev_hash)
370        .bind(&prov.content_hash)
371        .execute(&self.pool)
372        .await
373        .map_err(map_sqlx)?;
374        Ok(())
375    }
376
377    async fn get_write_provenance(&self, memory_id: Uuid) -> Result<Option<WriteProvenance>> {
378        let row = sqlx::query(
379            r#"
380SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash
381FROM write_provenance WHERE memory_id = $1 ORDER BY id DESC LIMIT 1
382"#,
383        )
384        .bind(memory_id)
385        .fetch_optional(&self.pool)
386        .await
387        .map_err(map_sqlx)?;
388        row.as_ref()
389            .map(row_to_write_provenance)
390            .transpose()
391            .map_err(map_sqlx)
392    }
393
394    async fn get_latest_provenance_hash(&self) -> Result<Option<Vec<u8>>> {
395        // UUID v7 ids are time-ordered, so `id DESC` is the append order.
396        let row = sqlx::query("SELECT content_hash FROM write_provenance ORDER BY id DESC LIMIT 1")
397            .fetch_optional(&self.pool)
398            .await
399            .map_err(map_sqlx)?;
400        Ok(row.map(|r| r.get::<Vec<u8>, _>("content_hash")))
401    }
402
403    async fn list_provenance_by_principal(
404        &self,
405        principal: &str,
406        limit: usize,
407    ) -> Result<Vec<WriteProvenance>> {
408        let rows = sqlx::query(
409            r#"
410SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash
411FROM write_provenance WHERE principal = $1 ORDER BY id DESC LIMIT $2
412"#,
413        )
414        .bind(principal)
415        .bind(limit as i64)
416        .fetch_all(&self.pool)
417        .await
418        .map_err(map_sqlx)?;
419        let mut out = Vec::with_capacity(rows.len());
420        for r in &rows {
421            out.push(row_to_write_provenance(r).map_err(map_sqlx)?);
422        }
423        Ok(out)
424    }
425
426    async fn list_provenance_by_session(
427        &self,
428        session_id: &str,
429        limit: usize,
430    ) -> Result<Vec<WriteProvenance>> {
431        let rows = sqlx::query(
432            r#"
433SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash
434FROM write_provenance WHERE session_id = $1 ORDER BY id DESC LIMIT $2
435"#,
436        )
437        .bind(session_id)
438        .bind(limit as i64)
439        .fetch_all(&self.pool)
440        .await
441        .map_err(map_sqlx)?;
442        let mut out = Vec::with_capacity(rows.len());
443        for r in &rows {
444            out.push(row_to_write_provenance(r).map_err(map_sqlx)?);
445        }
446        Ok(out)
447    }
448
449    async fn list_memory_ids_by_principal(&self, principal: &str) -> Result<Vec<Uuid>> {
450        let rows = sqlx::query(
451            "SELECT DISTINCT memory_id FROM write_provenance WHERE principal = $1 AND op = 'remember'",
452        )
453        .bind(principal)
454        .fetch_all(&self.pool)
455        .await
456        .map_err(map_sqlx)?;
457        Ok(rows.iter().map(|r| r.get::<Uuid, _>("memory_id")).collect())
458    }
459
460    async fn list_memory_ids_by_session(&self, session_id: &str) -> Result<Vec<Uuid>> {
461        let rows = sqlx::query(
462            "SELECT DISTINCT memory_id FROM write_provenance WHERE session_id = $1 AND op = 'remember'",
463        )
464        .bind(session_id)
465        .fetch_all(&self.pool)
466        .await
467        .map_err(map_sqlx)?;
468        Ok(rows.iter().map(|r| r.get::<Uuid, _>("memory_id")).collect())
469    }
470
471    async fn list_all_provenance(&self, limit: usize) -> Result<Vec<WriteProvenance>> {
472        let rows = sqlx::query(
473            r#"
474SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash
475FROM write_provenance ORDER BY id ASC LIMIT $1
476"#,
477        )
478        .bind(limit as i64)
479        .fetch_all(&self.pool)
480        .await
481        .map_err(map_sqlx)?;
482        let mut out = Vec::with_capacity(rows.len());
483        for r in &rows {
484            out.push(row_to_write_provenance(r).map_err(map_sqlx)?);
485        }
486        Ok(out)
487    }
488
489    // -----------------------------------------------------------------------
490    // Memory CRUD
491    // -----------------------------------------------------------------------
492
493    async fn insert_memory(&self, record: &MemoryRecord) -> Result<()> {
494        let embedding_param: Option<Vector> =
495            record.embedding.as_ref().map(|v| Vector::from(v.clone()));
496
497        let tags_slice: &[String] = &record.tags;
498
499        sqlx::query(
500            r#"
501INSERT INTO memories (
502    id, agent_id, content, memory_type, scope, importance,
503    tags, metadata, embedding,
504    content_hash, prev_hash, source_type, source_id,
505    consolidation_state, access_count, org_id, thread_id,
506    created_at, updated_at, last_accessed_at, expires_at,
507    deleted_at, decay_rate, created_by, version, prev_version_id,
508    quarantined, quarantine_reason, decay_function
509) VALUES (
510    $1, $2, $3, $4, $5, $6,
511    $7, $8, $9,
512    $10, $11, $12, $13,
513    $14, $15, $16, $17,
514    $18, $19, $20, $21,
515    $22, $23, $24, $25, $26,
516    $27, $28, $29
517)
518"#,
519        )
520        .bind(record.id)
521        .bind(&record.agent_id)
522        .bind(&record.content)
523        .bind(record.memory_type.to_string())
524        .bind(record.scope.to_string())
525        .bind(record.importance)
526        .bind(tags_slice)
527        .bind(&record.metadata)
528        .bind(&embedding_param)
529        .bind(&record.content_hash)
530        .bind(&record.prev_hash)
531        .bind(record.source_type.to_string())
532        .bind(&record.source_id)
533        .bind(record.consolidation_state.to_string())
534        .bind(record.access_count as i64)
535        .bind(&record.org_id)
536        .bind(&record.thread_id)
537        .bind(&record.created_at)
538        .bind(&record.updated_at)
539        .bind(&record.last_accessed_at)
540        .bind(&record.expires_at)
541        .bind(&record.deleted_at)
542        .bind(record.decay_rate)
543        .bind(&record.created_by)
544        .bind(record.version as i32)
545        .bind(record.prev_version_id)
546        .bind(record.quarantined)
547        .bind(&record.quarantine_reason)
548        .bind(&record.decay_function)
549        .execute(&self.pool)
550        .await
551        .map_err(map_sqlx)?;
552
553        Ok(())
554    }
555
556    async fn get_memory(&self, id: Uuid) -> Result<Option<MemoryRecord>> {
557        let sql = format!("SELECT {MEMORY_COLUMNS} FROM memories WHERE id = $1");
558        let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
559            .bind(id)
560            .fetch_optional(&self.pool)
561            .await
562            .map_err(map_sqlx)?;
563
564        match row {
565            Some(r) => Ok(Some(row_to_memory(&r).map_err(map_sqlx)?)),
566            None => Ok(None),
567        }
568    }
569
570    async fn update_memory(&self, record: &MemoryRecord) -> Result<()> {
571        let embedding_param: Option<Vector> =
572            record.embedding.as_ref().map(|v| Vector::from(v.clone()));
573
574        let tags_slice: &[String] = &record.tags;
575
576        let result = sqlx::query(
577            r#"
578UPDATE memories SET
579    agent_id = $1, content = $2, memory_type = $3, scope = $4,
580    importance = $5, tags = $6, metadata = $7,
581    embedding = $8,
582    content_hash = $9, prev_hash = $10, source_type = $11,
583    source_id = $12, consolidation_state = $13, access_count = $14,
584    org_id = $15, thread_id = $16, updated_at = $17,
585    last_accessed_at = $18, expires_at = $19, deleted_at = $20,
586    decay_rate = $21, created_by = $22, version = $23,
587    prev_version_id = $24, quarantined = $25, quarantine_reason = $26,
588    decay_function = $27
589WHERE id = $28
590"#,
591        )
592        .bind(&record.agent_id)
593        .bind(&record.content)
594        .bind(record.memory_type.to_string())
595        .bind(record.scope.to_string())
596        .bind(record.importance)
597        .bind(tags_slice)
598        .bind(&record.metadata)
599        .bind(&embedding_param)
600        .bind(&record.content_hash)
601        .bind(&record.prev_hash)
602        .bind(record.source_type.to_string())
603        .bind(&record.source_id)
604        .bind(record.consolidation_state.to_string())
605        .bind(record.access_count as i64)
606        .bind(&record.org_id)
607        .bind(&record.thread_id)
608        .bind(&record.updated_at)
609        .bind(&record.last_accessed_at)
610        .bind(&record.expires_at)
611        .bind(&record.deleted_at)
612        .bind(record.decay_rate)
613        .bind(&record.created_by)
614        .bind(record.version as i32)
615        .bind(record.prev_version_id)
616        .bind(record.quarantined)
617        .bind(&record.quarantine_reason)
618        .bind(&record.decay_function)
619        .bind(record.id)
620        .execute(&self.pool)
621        .await
622        .map_err(map_sqlx)?;
623
624        if result.rows_affected() == 0 {
625            return Err(Error::NotFound(format!("memory {} not found", record.id)));
626        }
627        Ok(())
628    }
629
630    async fn soft_delete_memory(&self, id: Uuid) -> Result<()> {
631        let now = chrono::Utc::now().to_rfc3339();
632        let result = sqlx::query(
633            "UPDATE memories SET deleted_at = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL",
634        )
635        .bind(&now)
636        .bind(&now)
637        .bind(id)
638        .execute(&self.pool)
639        .await
640        .map_err(map_sqlx)?;
641
642        if result.rows_affected() == 0 {
643            return Err(Error::NotFound(format!(
644                "memory {id} not found or already deleted"
645            )));
646        }
647        Ok(())
648    }
649
650    async fn hard_delete_memory(&self, id: Uuid) -> Result<()> {
651        let result = sqlx::query("DELETE FROM memories WHERE id = $1")
652            .bind(id)
653            .execute(&self.pool)
654            .await
655            .map_err(map_sqlx)?;
656
657        if result.rows_affected() == 0 {
658            return Err(Error::NotFound(format!("memory {id} not found")));
659        }
660
661        // Clean up ACLs for this memory
662        sqlx::query("DELETE FROM acls WHERE memory_id = $1")
663            .bind(id)
664            .execute(&self.pool)
665            .await
666            .map_err(map_sqlx)?;
667
668        Ok(())
669    }
670
671    async fn list_memories(
672        &self,
673        filter: &MemoryFilter,
674        limit: usize,
675        offset: usize,
676    ) -> Result<Vec<MemoryRecord>> {
677        let mut conditions: Vec<String> = Vec::new();
678        // We'll track bind-parameter index manually.
679        // The MEMORY_COLUMNS select doesn't use numbered params.
680        let mut param_idx: usize = 0;
681
682        // We accumulate bind values in a specific order and push them later
683        // via a dynamic query builder. Unfortunately sqlx's dynamic queries
684        // require us to build the SQL string with numbered placeholders and
685        // bind all values in order.
686
687        // We'll collect (sql_fragment, value_type) tuples, then bind them.
688        // Use a simpler approach: build the query string, then bind
689        // parameters positionally.
690
691        if !filter.include_deleted {
692            conditions.push("deleted_at IS NULL".to_string());
693        }
694
695        // We'll use an enum-based approach below to track what to bind.
696        #[derive(Debug)]
697        enum Param {
698            Str(String),
699            F32(f32),
700        }
701        let mut params: Vec<Param> = Vec::new();
702
703        if let Some(ref agent_id) = filter.agent_id {
704            param_idx += 1;
705            conditions.push(format!("agent_id = ${param_idx}"));
706            params.push(Param::Str(agent_id.clone()));
707        }
708        if let Some(memory_type) = filter.memory_type {
709            param_idx += 1;
710            conditions.push(format!("memory_type = ${param_idx}"));
711            params.push(Param::Str(memory_type.to_string()));
712        }
713        if let Some(scope) = filter.scope {
714            param_idx += 1;
715            conditions.push(format!("scope = ${param_idx}"));
716            params.push(Param::Str(scope.to_string()));
717        }
718        if let Some(min_importance) = filter.min_importance {
719            param_idx += 1;
720            conditions.push(format!("importance >= ${param_idx}"));
721            params.push(Param::F32(min_importance));
722        }
723        if let Some(ref org_id) = filter.org_id {
724            param_idx += 1;
725            conditions.push(format!("org_id = ${param_idx}"));
726            params.push(Param::Str(org_id.clone()));
727        }
728        if let Some(ref thread_id) = filter.thread_id {
729            param_idx += 1;
730            conditions.push(format!("thread_id = ${param_idx}"));
731            params.push(Param::Str(thread_id.clone()));
732        }
733
734        let where_clause = if conditions.is_empty() {
735            String::new()
736        } else {
737            format!("WHERE {}", conditions.join(" AND "))
738        };
739
740        let sql = format!(
741            "SELECT {MEMORY_COLUMNS} FROM memories {where_clause} ORDER BY created_at DESC LIMIT {limit} OFFSET {offset}"
742        );
743
744        let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
745        for p in &params {
746            match p {
747                Param::Str(s) => query = query.bind(s),
748                Param::F32(f) => query = query.bind(*f),
749            }
750        }
751
752        let rows = query.fetch_all(&self.pool).await.map_err(map_sqlx)?;
753        let mut results = Vec::with_capacity(rows.len());
754        for r in &rows {
755            results.push(row_to_memory(r).map_err(map_sqlx)?);
756        }
757        Ok(results)
758    }
759
760    async fn touch_memory(&self, id: Uuid) -> Result<()> {
761        let now = chrono::Utc::now().to_rfc3339();
762        sqlx::query(
763            "UPDATE memories SET access_count = access_count + 1, last_accessed_at = $1 WHERE id = $2",
764        )
765        .bind(&now)
766        .bind(id)
767        .execute(&self.pool)
768        .await
769        .map_err(map_sqlx)?;
770        Ok(())
771    }
772
773    // -----------------------------------------------------------------------
774    // ACL
775    // -----------------------------------------------------------------------
776
777    async fn insert_acl(&self, acl: &Acl) -> Result<()> {
778        sqlx::query(
779            r#"
780INSERT INTO acls (id, memory_id, principal_type, principal_id, permission, granted_by, created_at, expires_at)
781VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
782"#,
783        )
784        .bind(acl.id)
785        .bind(acl.memory_id)
786        .bind(acl.principal_type.to_string())
787        .bind(&acl.principal_id)
788        .bind(acl.permission.to_string())
789        .bind(&acl.granted_by)
790        .bind(&acl.created_at)
791        .bind(&acl.expires_at)
792        .execute(&self.pool)
793        .await
794        .map_err(map_sqlx)?;
795        Ok(())
796    }
797
798    async fn check_permission(
799        &self,
800        memory_id: Uuid,
801        principal_id: &str,
802        required: Permission,
803    ) -> Result<bool> {
804        // Check if the principal is the owner
805        let owner_row = sqlx::query("SELECT agent_id FROM memories WHERE id = $1")
806            .bind(memory_id)
807            .fetch_optional(&self.pool)
808            .await
809            .map_err(map_sqlx)?;
810
811        match owner_row {
812            None => return Err(Error::NotFound(format!("memory {memory_id} not found"))),
813            Some(row) => {
814                let owner: String = row.get("agent_id");
815                if owner == principal_id {
816                    return Ok(true);
817                }
818            }
819        }
820
821        // Check ACLs (direct grants)
822        let now = chrono::Utc::now().to_rfc3339();
823        let acl_rows = sqlx::query(
824            "SELECT permission FROM acls WHERE memory_id = $1 AND principal_id = $2 AND (expires_at IS NULL OR expires_at > $3)",
825        )
826        .bind(memory_id)
827        .bind(principal_id)
828        .bind(&now)
829        .fetch_all(&self.pool)
830        .await
831        .map_err(map_sqlx)?;
832
833        for row in &acl_rows {
834            let perm_str: String = row.get("permission");
835            if let Ok(perm) = perm_str.parse::<Permission>()
836                && perm.satisfies(required)
837            {
838                return Ok(true);
839            }
840        }
841
842        // Check public ACLs
843        let public_rows = sqlx::query(
844            "SELECT permission FROM acls WHERE memory_id = $1 AND principal_type = 'public' AND (expires_at IS NULL OR expires_at > $2)",
845        )
846        .bind(memory_id)
847        .bind(&now)
848        .fetch_all(&self.pool)
849        .await
850        .map_err(map_sqlx)?;
851
852        for row in &public_rows {
853            let perm_str: String = row.get("permission");
854            if let Ok(perm) = perm_str.parse::<Permission>()
855                && perm.satisfies(required)
856            {
857                return Ok(true);
858            }
859        }
860
861        // Check delegations
862        if self
863            .check_delegation(principal_id, memory_id, required)
864            .await?
865        {
866            return Ok(true);
867        }
868
869        Ok(false)
870    }
871
872    // -----------------------------------------------------------------------
873    // Relations
874    // -----------------------------------------------------------------------
875
876    async fn insert_relation(&self, relation: &Relation) -> Result<()> {
877        sqlx::query(
878            r#"
879INSERT INTO relations (id, source_id, target_id, relation_type, weight, metadata, created_at)
880VALUES ($1, $2, $3, $4, $5, $6, $7)
881"#,
882        )
883        .bind(relation.id)
884        .bind(relation.source_id)
885        .bind(relation.target_id)
886        .bind(&relation.relation_type)
887        .bind(relation.weight)
888        .bind(&relation.metadata)
889        .bind(&relation.created_at)
890        .execute(&self.pool)
891        .await
892        .map_err(map_sqlx)?;
893        Ok(())
894    }
895
896    async fn get_relations_from(&self, source_id: Uuid) -> Result<Vec<Relation>> {
897        let rows = sqlx::query(
898            "SELECT id, source_id, target_id, relation_type, weight, metadata, created_at FROM relations WHERE source_id = $1",
899        )
900        .bind(source_id)
901        .fetch_all(&self.pool)
902        .await
903        .map_err(map_sqlx)?;
904
905        let mut results = Vec::with_capacity(rows.len());
906        for r in &rows {
907            results.push(row_to_relation(r).map_err(map_sqlx)?);
908        }
909        Ok(results)
910    }
911
912    async fn get_relations_to(&self, target_id: Uuid) -> Result<Vec<Relation>> {
913        let rows = sqlx::query(
914            "SELECT id, source_id, target_id, relation_type, weight, metadata, created_at FROM relations WHERE target_id = $1",
915        )
916        .bind(target_id)
917        .fetch_all(&self.pool)
918        .await
919        .map_err(map_sqlx)?;
920
921        let mut results = Vec::with_capacity(rows.len());
922        for r in &rows {
923            results.push(row_to_relation(r).map_err(map_sqlx)?);
924        }
925        Ok(results)
926    }
927
928    async fn delete_relation(&self, id: Uuid) -> Result<()> {
929        let result = sqlx::query("DELETE FROM relations WHERE id = $1")
930            .bind(id)
931            .execute(&self.pool)
932            .await
933            .map_err(map_sqlx)?;
934
935        if result.rows_affected() == 0 {
936            return Err(Error::NotFound(format!("relation {id} not found")));
937        }
938        Ok(())
939    }
940
941    // -----------------------------------------------------------------------
942    // Chain linking
943    // -----------------------------------------------------------------------
944
945    async fn get_latest_memory_hash(
946        &self,
947        agent_id: &str,
948        thread_id: Option<&str>,
949    ) -> Result<Option<Vec<u8>>> {
950        let row = if let Some(tid) = thread_id {
951            sqlx::query(
952                "SELECT content_hash FROM memories WHERE agent_id = $1 AND thread_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
953            )
954            .bind(agent_id)
955            .bind(tid)
956            .fetch_optional(&self.pool)
957            .await
958            .map_err(map_sqlx)?
959        } else {
960            sqlx::query(
961                "SELECT content_hash FROM memories WHERE agent_id = $1 AND thread_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
962            )
963            .bind(agent_id)
964            .fetch_optional(&self.pool)
965            .await
966            .map_err(map_sqlx)?
967        };
968
969        Ok(row.map(|r| r.get::<Vec<u8>, _>("content_hash")))
970    }
971
972    async fn get_latest_event_hash(
973        &self,
974        agent_id: &str,
975        thread_id: Option<&str>,
976    ) -> Result<Option<Vec<u8>>> {
977        let row = if let Some(tid) = thread_id {
978            sqlx::query(
979                "SELECT content_hash FROM agent_events WHERE agent_id = $1 AND thread_id = $2 ORDER BY timestamp DESC LIMIT 1",
980            )
981            .bind(agent_id)
982            .bind(tid)
983            .fetch_optional(&self.pool)
984            .await
985            .map_err(map_sqlx)?
986        } else {
987            sqlx::query(
988                "SELECT content_hash FROM agent_events WHERE agent_id = $1 ORDER BY timestamp DESC LIMIT 1",
989            )
990            .bind(agent_id)
991            .fetch_optional(&self.pool)
992            .await
993            .map_err(map_sqlx)?
994        };
995        Ok(row.map(|r| r.get::<Vec<u8>, _>("content_hash")))
996    }
997
998    async fn get_sync_watermark(&self, key: &str) -> Result<Option<String>> {
999        let row = sqlx::query("SELECT value FROM sync_metadata WHERE key = $1")
1000            .bind(key)
1001            .fetch_optional(&self.pool)
1002            .await
1003            .map_err(map_sqlx)?;
1004        Ok(row.map(|r| r.get::<String, _>("value")))
1005    }
1006
1007    async fn set_sync_watermark(&self, key: &str, value: &str) -> Result<()> {
1008        let now = chrono::Utc::now().to_rfc3339();
1009        sqlx::query(
1010            "INSERT INTO sync_metadata (key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = $3",
1011        )
1012        .bind(key)
1013        .bind(value)
1014        .bind(now)
1015        .execute(&self.pool)
1016        .await
1017        .map_err(map_sqlx)?;
1018        Ok(())
1019    }
1020
1021    // -----------------------------------------------------------------------
1022    // Permission-safe ANN
1023    // -----------------------------------------------------------------------
1024
1025    async fn list_accessible_memory_ids(&self, agent_id: &str, limit: usize) -> Result<Vec<Uuid>> {
1026        let now = chrono::Utc::now().to_rfc3339();
1027        let rows = sqlx::query(
1028            r#"
1029SELECT id FROM memories
1030WHERE (
1031    agent_id = $1
1032    OR scope = 'public'
1033    OR id IN (
1034        SELECT memory_id FROM acls
1035        WHERE principal_id = $2 AND (expires_at IS NULL OR expires_at > $3)
1036    )
1037)
1038AND deleted_at IS NULL
1039LIMIT $4
1040"#,
1041        )
1042        .bind(agent_id)
1043        .bind(agent_id)
1044        .bind(&now)
1045        .bind(limit as i64)
1046        .fetch_all(&self.pool)
1047        .await
1048        .map_err(map_sqlx)?;
1049
1050        let ids: Vec<Uuid> = rows.iter().map(|r| r.get("id")).collect();
1051        Ok(ids)
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // Events
1056    // -----------------------------------------------------------------------
1057
1058    async fn insert_event(&self, event: &AgentEvent) -> Result<()> {
1059        let payload_json = &event.payload;
1060        let embedding_blob = serialize_embedding(&event.embedding);
1061
1062        sqlx::query(
1063            r#"
1064INSERT INTO agent_events (
1065    id, agent_id, thread_id, run_id, parent_event_id, event_type,
1066    payload, trace_id, span_id, model, tokens_input, tokens_output,
1067    latency_ms, cost_usd, "timestamp", logical_clock, content_hash,
1068    prev_hash, embedding
1069) VALUES (
1070    $1, $2, $3, $4, $5, $6,
1071    $7, $8, $9, $10, $11, $12,
1072    $13, $14, $15, $16, $17,
1073    $18, $19
1074)
1075"#,
1076        )
1077        .bind(event.id)
1078        .bind(&event.agent_id)
1079        .bind(&event.thread_id)
1080        .bind(&event.run_id)
1081        .bind(event.parent_event_id)
1082        .bind(event.event_type.to_string())
1083        .bind(payload_json)
1084        .bind(&event.trace_id)
1085        .bind(&event.span_id)
1086        .bind(&event.model)
1087        .bind(event.tokens_input)
1088        .bind(event.tokens_output)
1089        .bind(event.latency_ms)
1090        .bind(event.cost_usd)
1091        .bind(&event.timestamp)
1092        .bind(event.logical_clock)
1093        .bind(&event.content_hash)
1094        .bind(&event.prev_hash)
1095        .bind(&embedding_blob)
1096        .execute(&self.pool)
1097        .await
1098        .map_err(map_sqlx)?;
1099        Ok(())
1100    }
1101
1102    async fn list_events(
1103        &self,
1104        agent_id: &str,
1105        limit: usize,
1106        offset: usize,
1107    ) -> Result<Vec<AgentEvent>> {
1108        let rows = sqlx::query(
1109            r#"
1110SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type,
1111       payload, trace_id, span_id, model, tokens_input, tokens_output,
1112       latency_ms, cost_usd, "timestamp", logical_clock, content_hash,
1113       prev_hash, embedding
1114FROM agent_events
1115WHERE agent_id = $1
1116ORDER BY "timestamp" DESC
1117LIMIT $2 OFFSET $3
1118"#,
1119        )
1120        .bind(agent_id)
1121        .bind(limit as i64)
1122        .bind(offset as i64)
1123        .fetch_all(&self.pool)
1124        .await
1125        .map_err(map_sqlx)?;
1126
1127        let mut results = Vec::with_capacity(rows.len());
1128        for r in &rows {
1129            results.push(row_to_event(r).map_err(map_sqlx)?);
1130        }
1131        Ok(results)
1132    }
1133
1134    async fn get_events_by_thread(&self, thread_id: &str, limit: usize) -> Result<Vec<AgentEvent>> {
1135        let rows = sqlx::query(
1136            r#"
1137SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type,
1138       payload, trace_id, span_id, model, tokens_input, tokens_output,
1139       latency_ms, cost_usd, "timestamp", logical_clock, content_hash,
1140       prev_hash, embedding
1141FROM agent_events
1142WHERE thread_id = $1
1143ORDER BY "timestamp" ASC
1144LIMIT $2
1145"#,
1146        )
1147        .bind(thread_id)
1148        .bind(limit as i64)
1149        .fetch_all(&self.pool)
1150        .await
1151        .map_err(map_sqlx)?;
1152
1153        let mut results = Vec::with_capacity(rows.len());
1154        for r in &rows {
1155            results.push(row_to_event(r).map_err(map_sqlx)?);
1156        }
1157        Ok(results)
1158    }
1159
1160    async fn get_event(&self, id: Uuid) -> Result<Option<AgentEvent>> {
1161        let row = sqlx::query(
1162            r#"
1163SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type,
1164       payload, trace_id, span_id, model, tokens_input, tokens_output,
1165       latency_ms, cost_usd, "timestamp", logical_clock, content_hash,
1166       prev_hash, embedding
1167FROM agent_events
1168WHERE id = $1
1169"#,
1170        )
1171        .bind(id)
1172        .fetch_optional(&self.pool)
1173        .await
1174        .map_err(map_sqlx)?;
1175
1176        match row {
1177            Some(r) => Ok(Some(row_to_event(&r).map_err(map_sqlx)?)),
1178            None => Ok(None),
1179        }
1180    }
1181
1182    async fn list_child_events(
1183        &self,
1184        parent_event_id: Uuid,
1185        limit: usize,
1186    ) -> Result<Vec<AgentEvent>> {
1187        let rows = sqlx::query(
1188            r#"
1189SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type,
1190       payload, trace_id, span_id, model, tokens_input, tokens_output,
1191       latency_ms, cost_usd, "timestamp", logical_clock, content_hash,
1192       prev_hash, embedding
1193FROM agent_events
1194WHERE parent_event_id = $1
1195ORDER BY "timestamp" ASC
1196LIMIT $2
1197"#,
1198        )
1199        .bind(parent_event_id)
1200        .bind(limit as i64)
1201        .fetch_all(&self.pool)
1202        .await
1203        .map_err(map_sqlx)?;
1204
1205        let mut results = Vec::with_capacity(rows.len());
1206        for r in &rows {
1207            results.push(row_to_event(r).map_err(map_sqlx)?);
1208        }
1209        Ok(results)
1210    }
1211
1212    // -----------------------------------------------------------------------
1213    // Ordered listing
1214    // -----------------------------------------------------------------------
1215
1216    async fn list_memories_by_agent_ordered(
1217        &self,
1218        agent_id: &str,
1219        thread_id: Option<&str>,
1220        limit: usize,
1221    ) -> Result<Vec<MemoryRecord>> {
1222        let rows = if let Some(tid) = thread_id {
1223            let sql = format!(
1224                "SELECT {MEMORY_COLUMNS} FROM memories WHERE agent_id = $1 AND thread_id = $2 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $3"
1225            );
1226            sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1227                .bind(agent_id)
1228                .bind(tid)
1229                .bind(limit as i64)
1230                .fetch_all(&self.pool)
1231                .await
1232                .map_err(map_sqlx)?
1233        } else {
1234            let sql = format!(
1235                "SELECT {MEMORY_COLUMNS} FROM memories WHERE agent_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2"
1236            );
1237            sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1238                .bind(agent_id)
1239                .bind(limit as i64)
1240                .fetch_all(&self.pool)
1241                .await
1242                .map_err(map_sqlx)?
1243        };
1244
1245        let mut results = Vec::with_capacity(rows.len());
1246        for r in &rows {
1247            results.push(row_to_memory(r).map_err(map_sqlx)?);
1248        }
1249        Ok(results)
1250    }
1251
1252    // -----------------------------------------------------------------------
1253    // Sync support
1254    // -----------------------------------------------------------------------
1255
1256    async fn list_memories_since(
1257        &self,
1258        updated_after: &str,
1259        limit: usize,
1260    ) -> Result<Vec<MemoryRecord>> {
1261        let sql = format!(
1262            "SELECT {MEMORY_COLUMNS} FROM memories WHERE updated_at > $1 ORDER BY updated_at ASC LIMIT $2"
1263        );
1264        let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1265            .bind(updated_after)
1266            .bind(limit as i64)
1267            .fetch_all(&self.pool)
1268            .await
1269            .map_err(map_sqlx)?;
1270
1271        let mut results = Vec::with_capacity(rows.len());
1272        for r in &rows {
1273            results.push(row_to_memory(r).map_err(map_sqlx)?);
1274        }
1275        Ok(results)
1276    }
1277
1278    async fn upsert_memory(&self, record: &MemoryRecord) -> Result<()> {
1279        match self.update_memory(record).await {
1280            Ok(()) => Ok(()),
1281            Err(Error::NotFound(_)) => self.insert_memory(record).await,
1282            Err(e) => Err(e),
1283        }
1284    }
1285
1286    // -----------------------------------------------------------------------
1287    // Expired memory cleanup
1288    // -----------------------------------------------------------------------
1289
1290    async fn cleanup_expired(&self) -> Result<usize> {
1291        let now = chrono::Utc::now().to_rfc3339();
1292        let result = sqlx::query(
1293            "UPDATE memories SET deleted_at = $1 WHERE expires_at IS NOT NULL AND expires_at < $2 AND deleted_at IS NULL",
1294        )
1295        .bind(&now)
1296        .bind(&now)
1297        .execute(&self.pool)
1298        .await
1299        .map_err(map_sqlx)?;
1300
1301        Ok(result.rows_affected() as usize)
1302    }
1303
1304    // -----------------------------------------------------------------------
1305    // Delegations
1306    // -----------------------------------------------------------------------
1307
1308    async fn insert_delegation(&self, d: &Delegation) -> Result<()> {
1309        let scope_type = d.scope.to_string();
1310        let scope_value: serde_json::Value = match &d.scope {
1311            DelegationScope::AllMemories => serde_json::Value::Null,
1312            DelegationScope::ByTag(tags) => serde_json::json!(tags),
1313            DelegationScope::ByMemoryId(ids) => {
1314                serde_json::json!(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
1315            }
1316        };
1317
1318        sqlx::query(
1319            r#"
1320INSERT INTO delegations (
1321    id, delegator_id, delegate_id, permission, scope_type, scope_value,
1322    max_depth, current_depth, parent_delegation_id,
1323    created_at, expires_at, revoked_at
1324) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
1325"#,
1326        )
1327        .bind(d.id)
1328        .bind(&d.delegator_id)
1329        .bind(&d.delegate_id)
1330        .bind(d.permission.to_string())
1331        .bind(&scope_type)
1332        .bind(&scope_value)
1333        .bind(d.max_depth as i32)
1334        .bind(d.current_depth as i32)
1335        .bind(d.parent_delegation_id)
1336        .bind(&d.created_at)
1337        .bind(&d.expires_at)
1338        .bind(&d.revoked_at)
1339        .execute(&self.pool)
1340        .await
1341        .map_err(map_sqlx)?;
1342        Ok(())
1343    }
1344
1345    async fn list_delegations_for(&self, delegate_id: &str) -> Result<Vec<Delegation>> {
1346        let now = chrono::Utc::now().to_rfc3339();
1347        let rows = sqlx::query(
1348            r#"
1349SELECT id, delegator_id, delegate_id, permission, scope_type, scope_value,
1350       max_depth, current_depth, parent_delegation_id,
1351       created_at, expires_at, revoked_at
1352FROM delegations
1353WHERE delegate_id = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $2)
1354"#,
1355        )
1356        .bind(delegate_id)
1357        .bind(&now)
1358        .fetch_all(&self.pool)
1359        .await
1360        .map_err(map_sqlx)?;
1361
1362        let mut results = Vec::with_capacity(rows.len());
1363        for r in &rows {
1364            results.push(row_to_delegation(r).map_err(map_sqlx)?);
1365        }
1366        Ok(results)
1367    }
1368
1369    async fn revoke_delegation(&self, id: Uuid) -> Result<()> {
1370        let now = chrono::Utc::now().to_rfc3339();
1371        let result = sqlx::query(
1372            "UPDATE delegations SET revoked_at = $1 WHERE id = $2 AND revoked_at IS NULL",
1373        )
1374        .bind(&now)
1375        .bind(id)
1376        .execute(&self.pool)
1377        .await
1378        .map_err(map_sqlx)?;
1379
1380        if result.rows_affected() == 0 {
1381            return Err(Error::NotFound(format!(
1382                "delegation {id} not found or already revoked"
1383            )));
1384        }
1385        Ok(())
1386    }
1387
1388    async fn check_delegation(
1389        &self,
1390        delegate_id: &str,
1391        memory_id: Uuid,
1392        required: Permission,
1393    ) -> Result<bool> {
1394        let delegations = self.list_delegations_for(delegate_id).await?;
1395
1396        // Get the memory to inspect its tags for scope matching
1397        let memory = match self.get_memory(memory_id).await? {
1398            Some(m) => m,
1399            None => return Ok(false),
1400        };
1401
1402        for d in &delegations {
1403            if !d.permission.satisfies(required) {
1404                continue;
1405            }
1406            match &d.scope {
1407                DelegationScope::AllMemories => return Ok(true),
1408                DelegationScope::ByMemoryId(ids) => {
1409                    if ids.contains(&memory_id) {
1410                        return Ok(true);
1411                    }
1412                }
1413                DelegationScope::ByTag(tags) => {
1414                    if tags.iter().any(|t| memory.tags.contains(t)) {
1415                        return Ok(true);
1416                    }
1417                }
1418            }
1419        }
1420        Ok(false)
1421    }
1422
1423    // -----------------------------------------------------------------------
1424    // Agent Profiles
1425    // -----------------------------------------------------------------------
1426
1427    async fn insert_or_update_agent_profile(&self, profile: &AgentProfile) -> Result<()> {
1428        sqlx::query(
1429            r#"
1430INSERT INTO agent_profiles (agent_id, avg_importance, avg_content_length, total_memories, last_updated)
1431VALUES ($1, $2, $3, $4, $5)
1432ON CONFLICT (agent_id) DO UPDATE SET
1433    avg_importance = EXCLUDED.avg_importance,
1434    avg_content_length = EXCLUDED.avg_content_length,
1435    total_memories = EXCLUDED.total_memories,
1436    last_updated = EXCLUDED.last_updated
1437"#,
1438        )
1439        .bind(&profile.agent_id)
1440        .bind(profile.avg_importance)
1441        .bind(profile.avg_content_length)
1442        .bind(profile.total_memories as i64)
1443        .bind(&profile.last_updated)
1444        .execute(&self.pool)
1445        .await
1446        .map_err(map_sqlx)?;
1447        Ok(())
1448    }
1449
1450    async fn get_agent_profile(&self, agent_id: &str) -> Result<Option<AgentProfile>> {
1451        let row = sqlx::query(
1452            "SELECT agent_id, avg_importance, avg_content_length, total_memories, last_updated FROM agent_profiles WHERE agent_id = $1",
1453        )
1454        .bind(agent_id)
1455        .fetch_optional(&self.pool)
1456        .await
1457        .map_err(map_sqlx)?;
1458
1459        Ok(row.map(|r| AgentProfile {
1460            agent_id: r.get("agent_id"),
1461            avg_importance: r.get("avg_importance"),
1462            avg_content_length: r.get("avg_content_length"),
1463            total_memories: r.get::<i64, _>("total_memories") as u64,
1464            last_updated: r.get("last_updated"),
1465        }))
1466    }
1467
1468    // -----------------------------------------------------------------------
1469    // Embedding baselines (v0.3.3)
1470    // -----------------------------------------------------------------------
1471
1472    async fn insert_or_update_embedding_baseline(
1473        &self,
1474        baseline: &EmbeddingBaseline,
1475    ) -> Result<()> {
1476        let mu_json =
1477            serde_json::to_value(&baseline.mu).map_err(|e| Error::Storage(e.to_string()))?;
1478        let cov_json =
1479            serde_json::to_value(&baseline.cov_diag).map_err(|e| Error::Storage(e.to_string()))?;
1480        sqlx::query(
1481            r#"
1482INSERT INTO embedding_baseline (agent_id, mu, cov_diag, n, updated_at)
1483VALUES ($1, $2, $3, $4, $5)
1484ON CONFLICT (agent_id) DO UPDATE SET
1485    mu = EXCLUDED.mu,
1486    cov_diag = EXCLUDED.cov_diag,
1487    n = EXCLUDED.n,
1488    updated_at = EXCLUDED.updated_at
1489"#,
1490        )
1491        .bind(&baseline.agent_id)
1492        .bind(&mu_json)
1493        .bind(&cov_json)
1494        .bind(baseline.n as i64)
1495        .bind(&baseline.updated_at)
1496        .execute(&self.pool)
1497        .await
1498        .map_err(map_sqlx)?;
1499        Ok(())
1500    }
1501
1502    async fn get_embedding_baseline(&self, agent_id: &str) -> Result<Option<EmbeddingBaseline>> {
1503        let row = sqlx::query(
1504            "SELECT agent_id, mu, cov_diag, n, updated_at FROM embedding_baseline WHERE agent_id = $1",
1505        )
1506        .bind(agent_id)
1507        .fetch_optional(&self.pool)
1508        .await
1509        .map_err(map_sqlx)?;
1510
1511        match row {
1512            None => Ok(None),
1513            Some(r) => {
1514                let mu_val: serde_json::Value = r.get("mu");
1515                let cov_val: serde_json::Value = r.get("cov_diag");
1516                let mu: Vec<f32> =
1517                    serde_json::from_value(mu_val).map_err(|e| Error::Storage(e.to_string()))?;
1518                let cov_diag: Vec<f32> =
1519                    serde_json::from_value(cov_val).map_err(|e| Error::Storage(e.to_string()))?;
1520                Ok(Some(EmbeddingBaseline {
1521                    agent_id: r.get("agent_id"),
1522                    mu,
1523                    cov_diag,
1524                    n: r.get::<i64, _>("n") as u64,
1525                    updated_at: r.get("updated_at"),
1526                }))
1527            }
1528        }
1529    }
1530
1531    // -----------------------------------------------------------------------
1532    // Checkpoints
1533    // -----------------------------------------------------------------------
1534
1535    async fn insert_checkpoint(&self, cp: &Checkpoint) -> Result<()> {
1536        let memory_refs_strs: Vec<String> =
1537            cp.memory_refs.iter().map(|id| id.to_string()).collect();
1538        let refs_slice: &[String] = &memory_refs_strs;
1539
1540        sqlx::query(
1541            r#"
1542INSERT INTO checkpoints (
1543    id, thread_id, agent_id, parent_id, branch_name,
1544    state_snapshot, state_diff, memory_refs, event_cursor,
1545    label, created_at, metadata
1546) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
1547"#,
1548        )
1549        .bind(cp.id)
1550        .bind(&cp.thread_id)
1551        .bind(&cp.agent_id)
1552        .bind(cp.parent_id)
1553        .bind(&cp.branch_name)
1554        .bind(&cp.state_snapshot)
1555        .bind(&cp.state_diff)
1556        .bind(refs_slice)
1557        .bind(cp.event_cursor)
1558        .bind(&cp.label)
1559        .bind(&cp.created_at)
1560        .bind(&cp.metadata)
1561        .execute(&self.pool)
1562        .await
1563        .map_err(map_sqlx)?;
1564        Ok(())
1565    }
1566
1567    async fn get_checkpoint(&self, id: Uuid) -> Result<Option<Checkpoint>> {
1568        let row = sqlx::query(
1569            r#"
1570SELECT id, thread_id, agent_id, parent_id, branch_name,
1571       state_snapshot, state_diff, memory_refs, event_cursor,
1572       label, created_at, metadata
1573FROM checkpoints WHERE id = $1
1574"#,
1575        )
1576        .bind(id)
1577        .fetch_optional(&self.pool)
1578        .await
1579        .map_err(map_sqlx)?;
1580
1581        match row {
1582            Some(r) => Ok(Some(row_to_checkpoint(&r).map_err(map_sqlx)?)),
1583            None => Ok(None),
1584        }
1585    }
1586
1587    async fn list_checkpoints(
1588        &self,
1589        thread_id: &str,
1590        branch: Option<&str>,
1591        limit: usize,
1592    ) -> Result<Vec<Checkpoint>> {
1593        let rows = if let Some(branch_name) = branch {
1594            sqlx::query(
1595                r#"
1596SELECT id, thread_id, agent_id, parent_id, branch_name,
1597       state_snapshot, state_diff, memory_refs, event_cursor,
1598       label, created_at, metadata
1599FROM checkpoints
1600WHERE thread_id = $1 AND branch_name = $2
1601ORDER BY created_at DESC
1602LIMIT $3
1603"#,
1604            )
1605            .bind(thread_id)
1606            .bind(branch_name)
1607            .bind(limit as i64)
1608            .fetch_all(&self.pool)
1609            .await
1610            .map_err(map_sqlx)?
1611        } else {
1612            sqlx::query(
1613                r#"
1614SELECT id, thread_id, agent_id, parent_id, branch_name,
1615       state_snapshot, state_diff, memory_refs, event_cursor,
1616       label, created_at, metadata
1617FROM checkpoints
1618WHERE thread_id = $1
1619ORDER BY created_at DESC
1620LIMIT $2
1621"#,
1622            )
1623            .bind(thread_id)
1624            .bind(limit as i64)
1625            .fetch_all(&self.pool)
1626            .await
1627            .map_err(map_sqlx)?
1628        };
1629
1630        let mut results = Vec::with_capacity(rows.len());
1631        for r in &rows {
1632            results.push(row_to_checkpoint(r).map_err(map_sqlx)?);
1633        }
1634        Ok(results)
1635    }
1636
1637    async fn get_latest_checkpoint(
1638        &self,
1639        thread_id: &str,
1640        branch: &str,
1641    ) -> Result<Option<Checkpoint>> {
1642        let row = sqlx::query(
1643            r#"
1644SELECT id, thread_id, agent_id, parent_id, branch_name,
1645       state_snapshot, state_diff, memory_refs, event_cursor,
1646       label, created_at, metadata
1647FROM checkpoints
1648WHERE thread_id = $1 AND branch_name = $2
1649ORDER BY created_at DESC
1650LIMIT 1
1651"#,
1652        )
1653        .bind(thread_id)
1654        .bind(branch)
1655        .fetch_optional(&self.pool)
1656        .await
1657        .map_err(map_sqlx)?;
1658
1659        match row {
1660            Some(r) => Ok(Some(row_to_checkpoint(&r).map_err(map_sqlx)?)),
1661            None => Ok(None),
1662        }
1663    }
1664}