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