Skip to main content

agentplane/store/
postgres_memory.rs

1//! Governed memory on `PostgreSQL`.
2//!
3//! This is the memory backend for the topology `PostgreSQL` exists to serve:
4//! several plane instances sharing one store. Version allocation, current-row
5//! replacement, and derivation-edge replacement therefore happen under one
6//! row lock and one transaction rather than through process-local arbitration.
7
8use async_trait::async_trait;
9use tokio_postgres::Row;
10
11use crate::core::StoreError;
12use crate::memory::{MemoryItem, MemoryStore, Recall, access_expiry};
13
14use super::postgres::{PostgresStore, be};
15
16pub(super) const MEMORY_SCHEMA: &str = "
17CREATE TABLE IF NOT EXISTS memory_items (
18    tenant      TEXT   NOT NULL,
19    id          TEXT   NOT NULL,
20    version     BIGINT NOT NULL CHECK (version > 0),
21    subject     TEXT   NOT NULL,
22    purpose     TEXT   NOT NULL,
23    created_at  BIGINT NOT NULL,
24    expires_at  BIGINT,
25    current     BOOLEAN NOT NULL,
26    -- Lower sorts first: 0 trusted, 1 untrusted. A ranking key belongs in the
27    -- index rather than in a sort, for the same reason the tenant does.
28    trust_rank  SMALLINT NOT NULL,
29    item        JSONB  NOT NULL,
30    PRIMARY KEY (tenant, id, version)
31);
32
33-- The arbiter for concurrent revisions: one current version per tenant/id.
34CREATE UNIQUE INDEX IF NOT EXISTS memory_one_current
35    ON memory_items (tenant, id) WHERE current;
36
37-- Current memories for one governed scope: most trusted first, then newest.
38--
39-- `trust_rank` leads `created_at` because recall truncates, and truncating by
40-- recency alone is an eviction an attacker steers — anything able to write an
41-- untrusted memory writes `limit` of them and the trusted ones silently lose.
42CREATE INDEX IF NOT EXISTS memory_by_subject
43    ON memory_items (tenant, subject, purpose, trust_rank, created_at DESC, id)
44    WHERE current;
45
46-- Derivation edges, **per version on both ends**. Supersession does not
47-- un-absorb anything: a summary re-derived from other sources still contains
48-- what its superseded version read, and that version stays readable through
49-- version(). Id-level edges were replaced on every revision, so a cascade from
50-- the original source no longer found the superseded summary that had absorbed
51-- it. Per-version edges make the traversal see the union of what was ever
52-- derived, and let it erase exactly the superseded versions that named a
53-- doomed source while sparing a current version that did not.
54CREATE TABLE IF NOT EXISTS memory_derived (
55    tenant          TEXT   NOT NULL,
56    source_id       TEXT   NOT NULL,
57    source_version  BIGINT NOT NULL,
58    derived_id      TEXT   NOT NULL,
59    derived_version BIGINT NOT NULL,
60    PRIMARY KEY (tenant, source_id, source_version, derived_id, derived_version)
61);
62
63-- The reverse lookup erasure needs: finding the edges pointing at a memory
64-- without scanning the tenant's whole edge table inside the erasure
65-- transaction.
66CREATE INDEX IF NOT EXISTS memory_derived_by_target
67    ON memory_derived (tenant, derived_id, derived_version);
68
69CREATE TABLE IF NOT EXISTS memory_forgotten (
70    tenant TEXT NOT NULL,
71    id     TEXT NOT NULL,
72    PRIMARY KEY (tenant, id)
73);
74
75CREATE TABLE IF NOT EXISTS memory_legal_holds (
76    tenant TEXT NOT NULL,
77    id     TEXT NOT NULL,
78    PRIMARY KEY (tenant, id)
79);
80
81CREATE TABLE IF NOT EXISTS memory_access_expiry (
82    tenant     TEXT   NOT NULL,
83    id         TEXT   NOT NULL,
84    expires_at BIGINT NOT NULL,
85    PRIMARY KEY (tenant, id)
86);
87
88CREATE INDEX IF NOT EXISTS memory_expiry
89    ON memory_items (tenant, expires_at, id)
90    WHERE current AND expires_at IS NOT NULL;
91";
92
93fn decode(row: &Row) -> Result<MemoryItem, StoreError> {
94    serde_json::from_value(row.get("item")).map_err(|error| StoreError::Backend(error.to_string()))
95}
96
97fn version_u64(version: i64) -> Result<u64, StoreError> {
98    u64::try_from(version)
99        .map_err(|_| StoreError::Backend(format!("invalid memory version {version}")))
100}
101
102fn version_i64(version: u64) -> Result<i64, StoreError> {
103    i64::try_from(version).map_err(|_| {
104        StoreError::Backend(format!(
105            "memory version {version} exceeds PostgreSQL BIGINT"
106        ))
107    })
108}
109
110/// Lower sorts first. Explicit rather than relying on the enum's own order, so
111/// a new level has to be given a rank rather than inheriting one.
112const fn trust_rank(trust: crate::core::Trust) -> i16 {
113    match trust {
114        crate::core::Trust::Trusted => 0,
115        crate::core::Trust::Untrusted => 1,
116    }
117}
118
119#[async_trait]
120impl MemoryStore for PostgresStore {
121    fn tenant(&self) -> &str {
122        self.tenant_str()
123    }
124
125    fn seals(&self) -> bool {
126        false
127    }
128
129    #[allow(clippy::too_many_lines)]
130    async fn remember(&self, item: &MemoryItem) -> Result<u64, StoreError> {
131        let mut client = self.pool_ref().get().await.map_err(|error| {
132            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
133        })?;
134        let tx = client.transaction().await.map_err(|error| be(&error))?;
135        let tenant = self.tenant_name();
136
137        // A shared graph lock excludes cascading erasure while allowing normal
138        // memory writes to proceed concurrently. The shared subject lock lets
139        // unrelated ids in the subject proceed while excluding
140        // `forget_subject`. ID locks cover the first write, where no current row
141        // exists for `FOR UPDATE`, and every derivation source is locked too so
142        // it cannot disappear between validation and commit.
143        tx.query_one(
144            "SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
145            &[&format!("memory-graph:{}:{tenant}", tenant.len())],
146        )
147        .await
148        .map_err(|error| be(&error))?;
149        tx.query_one(
150            "SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
151            &[&format!(
152                "memory-subject:{}:{tenant}{}",
153                tenant.len(),
154                item.subject
155            )],
156        )
157        .await
158        .map_err(|error| be(&error))?;
159        let mut locked_ids = std::collections::BTreeSet::from([item.id.as_str()]);
160        locked_ids.extend(item.derived_from.iter().map(|source| source.id.as_str()));
161        for id in locked_ids {
162            tx.query_one(
163                "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
164                &[&format!("memory-id:{}:{tenant}{id}", tenant.len())],
165            )
166            .await
167            .map_err(|error| be(&error))?;
168        }
169
170        let previous = tx
171            .query_opt(
172                "SELECT version, item FROM memory_items
173                 WHERE tenant = $1 AND id = $2 AND current
174                 FOR UPDATE",
175                &[&tenant, &item.id],
176            )
177            .await
178            .map_err(|error| be(&error))?;
179
180        if previous.is_none()
181            && tx
182                .query_opt(
183                    "SELECT 1 FROM memory_forgotten WHERE tenant = $1 AND id = $2",
184                    &[&tenant, &item.id],
185                )
186                .await
187                .map_err(|error| be(&error))?
188                .is_some()
189        {
190            return Err(StoreError::Backend(format!(
191                "memory id '{}' was forgotten and cannot be reused",
192                item.id
193            )));
194        }
195
196        if let Some(row) = previous.as_ref() {
197            let prior = decode(row)?;
198            if prior.subject != item.subject || prior.purpose != item.purpose {
199                return Err(StoreError::Backend(format!(
200                    "memory id '{}' is scoped to subject '{}' and purpose '{}'; use a new id \
201                     instead of moving it to subject '{}' and purpose '{}'",
202                    item.id, prior.subject, prior.purpose, item.subject, item.purpose
203                )));
204            }
205        }
206
207        for source in &item.derived_from {
208            let source_version = version_i64(source.version)?;
209            let row = tx
210                .query_opt(
211                    "SELECT item FROM memory_items
212                     WHERE tenant = $1 AND id = $2 AND version = $3",
213                    &[&tenant, &source.id, &source_version],
214                )
215                .await
216                .map_err(|error| be(&error))?
217                .ok_or_else(|| {
218                    StoreError::Backend(format!(
219                        "derived memory '{}' names missing source '{}' version {}",
220                        item.id, source.id, source.version
221                    ))
222                })?;
223            let source_item = decode(&row)?;
224            if source_item.selection_digest() != source.digest {
225                return Err(StoreError::Backend(format!(
226                    "derived memory '{}' names a changed source '{}' version {}",
227                    item.id, source.id, source.version
228                )));
229            }
230            if source_item.subject != item.subject {
231                return Err(StoreError::Backend(format!(
232                    "derived memory '{}' must stay in source subject '{}' rather than '{}'",
233                    item.id, source_item.subject, item.subject
234                )));
235            }
236        }
237
238        let version = match &previous {
239            Some(row) => version_u64(row.get::<_, i64>("version"))?
240                .checked_add(1)
241                .ok_or_else(|| StoreError::Backend("memory version overflow".to_owned()))?,
242            None => 1,
243        };
244
245        if let Some(row) = previous {
246            let previous_version = row.get::<_, i64>("version");
247            let mut previous_item = decode(&row)?;
248            previous_item.superseded_at = Some(item.created_at);
249            let previous_json = serde_json::to_value(previous_item)
250                .map_err(|error| StoreError::Backend(error.to_string()))?;
251            tx.execute(
252                "UPDATE memory_items SET current = FALSE, item = $4
253                 WHERE tenant = $1 AND id = $2 AND version = $3",
254                &[&tenant, &item.id, &previous_version, &previous_json],
255            )
256            .await
257            .map_err(|error| be(&error))?;
258        }
259
260        let mut stored = item.clone();
261        stored.version = version;
262        stored.superseded_at = None;
263        let json = serde_json::to_value(&stored)
264            .map_err(|error| StoreError::Backend(error.to_string()))?;
265        tx.execute(
266            "INSERT INTO memory_items
267                (tenant, id, version, subject, purpose, created_at, expires_at, current,
268                 trust_rank, item)
269             VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE, $8, $9)",
270            &[
271                &tenant,
272                &stored.id,
273                &version_i64(version)?,
274                &stored.subject,
275                &stored.purpose,
276                &stored.created_at.unix_timestamp(),
277                &stored
278                    .expires_at
279                    .map(crate::core::Timestamp::unix_timestamp),
280                &trust_rank(stored.trust),
281                &json,
282            ],
283        )
284        .await
285        .map_err(|error| be(&error))?;
286
287        // Sliding retention starts at the write, not at the first touch.
288        // Initialized lazily, an item with a window and no fixed expiry would
289        // be *immortal* until somebody touched it — opt-in garbage that never
290        // collects. The write is itself an access, so the window opens here
291        // and each journaled touch slides it; a version written without the
292        // window drops the row, because retention is a property of what is
293        // currently believed.
294        match stored.access_retention_seconds {
295            Some(window) => {
296                let expiry = access_expiry(stored.created_at, window);
297                tx.execute(
298                    "INSERT INTO memory_access_expiry (tenant, id, expires_at)
299                     VALUES ($1, $2, $3)
300                     ON CONFLICT (tenant, id) DO UPDATE
301                     SET expires_at =
302                         GREATEST(memory_access_expiry.expires_at, EXCLUDED.expires_at)",
303                    &[&tenant, &stored.id, &expiry],
304                )
305                .await
306                .map_err(|error| be(&error))?;
307            }
308            None => {
309                tx.execute(
310                    "DELETE FROM memory_access_expiry WHERE tenant = $1 AND id = $2",
311                    &[&tenant, &stored.id],
312                )
313                .await
314                .map_err(|error| be(&error))?;
315            }
316        }
317
318        // One edge per source, keyed by this version on the derived end and
319        // the exact version read on the source end. Earlier versions' edges
320        // deliberately stay: a superseded summary still contains what it
321        // absorbed and remains readable through version(), so its lineage must
322        // stay traversable for as long as the version itself exists. Erasure —
323        // not supersession — is what removes edges.
324        for source in &stored.derived_from {
325            tx.execute(
326                "INSERT INTO memory_derived
327                    (tenant, source_id, source_version, derived_id, derived_version)
328                 VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
329                &[
330                    &tenant,
331                    &source.id,
332                    &version_i64(source.version)?,
333                    &stored.id,
334                    &version_i64(version)?,
335                ],
336            )
337            .await
338            .map_err(|error| be(&error))?;
339        }
340
341        tx.commit().await.map_err(|error| be(&error))?;
342        Ok(version)
343    }
344
345    async fn recall(&self, query: &Recall) -> Result<Vec<MemoryItem>, StoreError> {
346        let client = self.pool_ref().get().await.map_err(|error| {
347            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
348        })?;
349        let tenant = self.tenant_name();
350        let limit = i64::try_from(query.limit)
351            .map_err(|_| StoreError::Backend("memory recall limit exceeds BIGINT".to_owned()))?;
352        // `expires_at` is a hard ceiling: sliding access retention may shorten
353        // a life below it, never extend one past it — so the effective expiry
354        // is the *earlier* of the two (`LEAST`, with absent sides coalesced to
355        // +infinity so they never win).
356        // The id breaks ties `COLLATE "C"` — by byte, as redb orders its keys —
357        // because a truncated page is decided by the tie-break, and a database
358        // collation would keep a different memory than the other backend at
359        // the same rank and second.
360        let rows = client
361            .query(
362                                "SELECT item.item FROM memory_items item
363                                 LEFT JOIN memory_access_expiry access
364                                     ON access.tenant = item.tenant AND access.id = item.id
365                                 WHERE item.tenant = $1 AND item.subject = $2 AND item.current
366                                     AND ($3::TEXT IS NULL OR item.purpose = $3)
367                                     AND ($4::BIGINT IS NULL
368                                                OR (item.expires_at IS NULL AND access.expires_at IS NULL)
369                                                OR LEAST(
370                                                        COALESCE(item.expires_at, 9223372036854775807),
371                                                        COALESCE(access.expires_at, 9223372036854775807)
372                                                ) > $4)
373                                 ORDER BY item.trust_rank ASC, item.created_at DESC, item.id COLLATE \"C\" ASC LIMIT $5",
374                                &[
375                                        &tenant,
376                                        &query.subject,
377                                        &query.purpose,
378                                        &query.as_of.map(crate::core::Timestamp::unix_timestamp),
379                                        &limit,
380                                ],
381            )
382            .await
383            .map_err(|error| be(&error))?;
384        rows.iter().map(decode).collect()
385    }
386
387    async fn version(&self, id: &str, version: u64) -> Result<Option<MemoryItem>, StoreError> {
388        let client = self.pool_ref().get().await.map_err(|error| {
389            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
390        })?;
391        let tenant = self.tenant_name();
392        client
393            .query_opt(
394                "SELECT item FROM memory_items WHERE tenant = $1 AND id = $2 AND version = $3",
395                &[&tenant, &id, &version_i64(version)?],
396            )
397            .await
398            .map_err(|error| be(&error))?
399            .as_ref()
400            .map(decode)
401            .transpose()
402    }
403
404    async fn current(
405        &self,
406        id: &str,
407        as_of: Option<crate::core::Timestamp>,
408    ) -> Result<Option<MemoryItem>, StoreError> {
409        let client = self.pool_ref().get().await.map_err(|error| {
410            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
411        })?;
412        let tenant = self.tenant_name();
413        // The same effective-expiry rule `recall` applies, restated for one id:
414        // the hard ceiling and the sliding window race to the earlier instant,
415        // and the cutoff is inclusive.
416        client
417            .query_opt(
418                "SELECT item.item FROM memory_items item
419                 LEFT JOIN memory_access_expiry access
420                     ON access.tenant = item.tenant AND access.id = item.id
421                 WHERE item.tenant = $1 AND item.id = $2 AND item.current
422                     AND ($3::BIGINT IS NULL
423                                OR (item.expires_at IS NULL AND access.expires_at IS NULL)
424                                OR LEAST(
425                                        COALESCE(item.expires_at, 9223372036854775807),
426                                        COALESCE(access.expires_at, 9223372036854775807)
427                                ) > $3)",
428                &[
429                    &tenant,
430                    &id,
431                    &as_of.map(crate::core::Timestamp::unix_timestamp),
432                ],
433            )
434            .await
435            .map_err(|error| be(&error))?
436            .as_ref()
437            .map(decode)
438            .transpose()
439    }
440
441    async fn subject_ids(&self, subject: &str) -> Result<Vec<String>, StoreError> {
442        let client = self.pool_ref().get().await.map_err(|error| {
443            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
444        })?;
445        let tenant = self.tenant_name();
446        // No LIMIT, deliberately: this is the erasure path's enumeration, and
447        // a page size here would be a page size on how much of a subject an
448        // erasure reaches. The recall limit's BIGINT ceiling does not apply
449        // because there is no limit to convert.
450        let rows = client
451            .query(
452                "SELECT DISTINCT id FROM memory_items
453                 WHERE tenant = $1 AND subject = $2 AND current
454                 ORDER BY id",
455                &[&tenant, &subject],
456            )
457            .await
458            .map_err(|error| be(&error))?;
459        Ok(rows.iter().map(|row| row.get("id")).collect())
460    }
461
462    async fn forget(&self, id: &str) -> Result<(), StoreError> {
463        let mut client = self.pool_ref().get().await.map_err(|error| {
464            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
465        })?;
466        let tx = client.transaction().await.map_err(|error| be(&error))?;
467        let tenant = self.tenant_name();
468        tx.query_one(
469            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
470            &[&format!("memory-lifecycle:{}:{tenant}", tenant.len())],
471        )
472        .await
473        .map_err(|error| be(&error))?;
474        tx.query_one(
475            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
476            &[&format!("memory-id:{}:{tenant}{id}", tenant.len())],
477        )
478        .await
479        .map_err(|error| be(&error))?;
480        if tx
481            .query_opt(
482                "SELECT 1 FROM memory_legal_holds WHERE tenant = $1 AND id = $2",
483                &[&tenant, &id],
484            )
485            .await
486            .map_err(|error| be(&error))?
487            .is_some()
488        {
489            return Err(StoreError::UnderLegalHold { id: id.to_owned() });
490        }
491        // Edges deliberately stay — both directions. Outgoing, so a correction
492        // that later becomes an erasure request can still find this memory's
493        // derivatives; incoming, so a cascade from further *upstream* routes
494        // through this tombstone to reach them — A → B → C with B forgotten
495        // here must still let a cascade from poisoned A find C. The read path
496        // keeps a kept edge harmless (`derivatives` joins on a current item),
497        // and the tombstone prevents id reuse from attaching this lineage to
498        // unrelated future content.
499        let removed = tx
500            .execute(
501                "DELETE FROM memory_items WHERE tenant = $1 AND id = $2",
502                &[&tenant, &id],
503            )
504            .await
505            .map_err(|error| be(&error))?;
506        // The sliding-retention row goes with the memory it describes. Left
507        // behind it is residue about an erased id, so every erasure path
508        // removes it rather than leaving it to the expiry sweep.
509        tx.execute(
510            "DELETE FROM memory_access_expiry WHERE tenant = $1 AND id = $2",
511            &[&tenant, &id],
512        )
513        .await
514        .map_err(|error| be(&error))?;
515        if removed > 0 {
516            tx.execute(
517                "INSERT INTO memory_forgotten (tenant, id) VALUES ($1, $2)
518                 ON CONFLICT DO NOTHING",
519                &[&tenant, &id],
520            )
521            .await
522            .map_err(|error| be(&error))?;
523        }
524        tx.commit().await.map_err(|error| be(&error))
525    }
526
527    #[allow(clippy::too_many_lines)]
528    async fn forget_cascading(&self, id: &str) -> Result<crate::memory::Cascade, StoreError> {
529        let mut client = self.pool_ref().get().await.map_err(|error| {
530            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
531        })?;
532        let tx = client.transaction().await.map_err(|error| be(&error))?;
533        let tenant = self.tenant_name();
534        tx.query_one(
535            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
536            &[&format!("memory-lifecycle:{}:{tenant}", tenant.len())],
537        )
538        .await
539        .map_err(|error| be(&error))?;
540
541        // Exclusive against the shared lock every memory write takes. The
542        // derivation graph is therefore stable for the complete traversal and
543        // deletion, not merely for each query in it.
544        tx.query_one(
545            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
546            &[&format!("memory-graph:{}:{tenant}", tenant.len())],
547        )
548        .await
549        .map_err(|error| be(&error))?;
550
551        // The traversal is **version-granular**. Edges are kept per derivative
552        // version, and a doomed source propagates to exactly the derivative
553        // versions that read it:
554        //
555        //   * a derivative whose *current* version absorbed a doomed node is
556        //     doomed as a whole id — its content is believed now, so the id,
557        //     every version, and everything derived onward all go;
558        //   * a derivative whose only absorbing versions are **superseded**
559        //     loses those versions and keeps its current one — a summary
560        //     honestly re-derived from clean sources is not destroyed by its
561        //     own history, but the history that named the doomed source stops
562        //     being readable through version().
563        //
564        // Every edge target is enqueued, **including tombstoned ones**:
565        // `forget` deliberately keeps a forgotten memory's edges so a cascade
566        // from further upstream can route through the tombstone. A tombstoned
567        // node has nothing to remove, but its descendants do.
568        let mut id_queue = vec![id.to_owned()];
569        let mut version_queue: Vec<(String, i64)> = Vec::new();
570        let mut doomed = std::collections::BTreeSet::new();
571        let mut doomed_versions: std::collections::BTreeSet<(String, i64)> =
572            std::collections::BTreeSet::new();
573        loop {
574            if let Some(source) = id_queue.pop() {
575                if !doomed.insert(source.clone()) {
576                    continue;
577                }
578                // Every outgoing edge, from every version of a fully doomed
579                // id.
580                let rows = tx
581                    .query(
582                        "SELECT derived_id, derived_version FROM memory_derived
583                         WHERE tenant = $1 AND source_id = $2",
584                        &[&tenant, &source],
585                    )
586                    .await
587                    .map_err(|error| be(&error))?;
588                version_queue.extend(
589                    rows.iter()
590                        .map(|row| (row.get::<_, String>(0), row.get::<_, i64>(1))),
591                );
592            } else if let Some((derived_id, derived_version)) = version_queue.pop() {
593                if doomed.contains(&derived_id) {
594                    continue;
595                }
596                let current_version: Option<i64> = tx
597                    .query_opt(
598                        "SELECT version FROM memory_items
599                         WHERE tenant = $1 AND id = $2 AND current",
600                        &[&tenant, &derived_id],
601                    )
602                    .await
603                    .map_err(|error| be(&error))?
604                    .map(|row| row.get(0));
605                if current_version == Some(derived_version) {
606                    id_queue.push(derived_id);
607                } else {
608                    if !doomed_versions.insert((derived_id.clone(), derived_version)) {
609                        continue;
610                    }
611                    // Only this version's own onward lineage: what a
612                    // superseded summary was itself read into.
613                    let rows = tx
614                        .query(
615                            "SELECT derived_id, derived_version FROM memory_derived
616                             WHERE tenant = $1 AND source_id = $2 AND source_version = $3",
617                            &[&tenant, &derived_id, &derived_version],
618                        )
619                        .await
620                        .map_err(|error| be(&error))?;
621                    version_queue.extend(
622                        rows.iter()
623                            .map(|row| (row.get::<_, String>(0), row.get::<_, i64>(1))),
624                    );
625                }
626            } else {
627                break;
628            }
629        }
630
631        // A hold blocks every erasure path, version-level included: an id
632        // under hold must not lose even a superseded version.
633        let held_candidates = doomed
634            .iter()
635            .chain(doomed_versions.iter().map(|(memory_id, _)| memory_id));
636        for memory_id in held_candidates {
637            if tx
638                .query_opt(
639                    "SELECT 1 FROM memory_legal_holds WHERE tenant = $1 AND id = $2",
640                    &[&tenant, memory_id],
641                )
642                .await
643                .map_err(|error| be(&error))?
644                .is_some()
645            {
646                return Err(StoreError::UnderLegalHold {
647                    id: memory_id.clone(),
648                });
649            }
650        }
651
652        // Named per node that actually held rows, so a tombstoned
653        // intermediate the traversal passed through is not reported as an
654        // erasure it did not perform.
655        let mut erased = Vec::new();
656        for memory_id in &doomed {
657            tx.execute(
658                "DELETE FROM memory_derived
659                 WHERE tenant = $1 AND (source_id = $2 OR derived_id = $2)",
660                &[&tenant, memory_id],
661            )
662            .await
663            .map_err(|error| be(&error))?;
664            let removed = tx
665                .query(
666                    "DELETE FROM memory_items WHERE tenant = $1 AND id = $2 RETURNING version",
667                    &[&tenant, memory_id],
668                )
669                .await
670                .map_err(|error| be(&error))?;
671            if let Some(highest) = removed.iter().map(|row| row.get::<_, i64>(0)).max() {
672                erased.push((memory_id.clone(), highest.cast_unsigned()));
673            }
674            // Sliding-retention residue goes with the id — every erasure path
675            // removes it.
676            tx.execute(
677                "DELETE FROM memory_access_expiry WHERE tenant = $1 AND id = $2",
678                &[&tenant, memory_id],
679            )
680            .await
681            .map_err(|error| be(&error))?;
682            tx.execute(
683                "INSERT INTO memory_forgotten (tenant, id) VALUES ($1, $2)
684                 ON CONFLICT DO NOTHING",
685                &[&tenant, memory_id],
686            )
687            .await
688            .map_err(|error| be(&error))?;
689        }
690
691        // Superseded versions that absorbed a doomed source, on ids that stay
692        // alive. Only the version row goes: the id keeps its current entry and
693        // — no tombstone — its future. `AND NOT current` is belt on top of the
694        // traversal's own check; the graph lock makes the two agree.
695        let mut partly: std::collections::BTreeMap<&str, Vec<u64>> =
696            std::collections::BTreeMap::new();
697        for (memory_id, version) in &doomed_versions {
698            if doomed.contains(memory_id) {
699                continue;
700            }
701            tx.execute(
702                "DELETE FROM memory_derived
703                 WHERE tenant = $1
704                   AND ((source_id = $2 AND source_version = $3)
705                     OR (derived_id = $2 AND derived_version = $3))",
706                &[&tenant, memory_id, version],
707            )
708            .await
709            .map_err(|error| be(&error))?;
710            let rows = tx
711                .execute(
712                    "DELETE FROM memory_items
713                     WHERE tenant = $1 AND id = $2 AND version = $3 AND NOT current",
714                    &[&tenant, memory_id, version],
715                )
716                .await
717                .map_err(|error| be(&error))?;
718            if rows > 0 {
719                partly
720                    .entry(memory_id.as_str())
721                    .or_default()
722                    .push(version.cast_unsigned());
723            }
724        }
725        let trimmed: Vec<(String, Vec<u64>)> = partly
726            .into_iter()
727            .map(|(id, mut versions)| {
728                versions.sort_unstable();
729                versions.dedup();
730                (id.to_owned(), versions)
731            })
732            .collect();
733
734        tx.commit().await.map_err(|error| be(&error))?;
735        Ok(crate::memory::Cascade { erased, trimmed })
736    }
737
738    async fn forget_subject(&self, subject: &str) -> Result<usize, StoreError> {
739        let mut client = self.pool_ref().get().await.map_err(|error| {
740            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
741        })?;
742        let tx = client.transaction().await.map_err(|error| be(&error))?;
743        let tenant = self.tenant_name();
744        tx.query_one(
745            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
746            &[&format!("memory-lifecycle:{}:{tenant}", tenant.len())],
747        )
748        .await
749        .map_err(|error| be(&error))?;
750        tx.query_one(
751            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
752            &[&format!(
753                "memory-subject:{}:{tenant}{subject}",
754                tenant.len()
755            )],
756        )
757        .await
758        .map_err(|error| be(&error))?;
759        let rows = tx
760            .query(
761                "SELECT id FROM memory_items
762                 WHERE tenant = $1 AND subject = $2 AND current FOR UPDATE",
763                &[&tenant, &subject],
764            )
765            .await
766            .map_err(|error| be(&error))?;
767        let ids: Vec<String> = rows.iter().map(|row| row.get("id")).collect();
768        for id in &ids {
769            if tx
770                .query_opt(
771                    "SELECT 1 FROM memory_legal_holds WHERE tenant = $1 AND id = $2",
772                    &[&tenant, id],
773                )
774                .await
775                .map_err(|error| be(&error))?
776                .is_some()
777            {
778                return Err(StoreError::UnderLegalHold { id: id.clone() });
779            }
780        }
781        for id in &ids {
782            // Incoming edges only, through the by-target index: a derivative
783            // must stay in its source's subject, so every source of this id is
784            // in this same erasure and the edge is intra-subject cleanup, not
785            // lineage a later cascade could still need.
786            tx.execute(
787                "DELETE FROM memory_derived WHERE tenant = $1 AND derived_id = $2",
788                &[&tenant, id],
789            )
790            .await
791            .map_err(|error| be(&error))?;
792            // Sliding-retention residue goes with the id — every erasure path
793            // removes it.
794            tx.execute(
795                "DELETE FROM memory_access_expiry WHERE tenant = $1 AND id = $2",
796                &[&tenant, id],
797            )
798            .await
799            .map_err(|error| be(&error))?;
800            tx.execute(
801                "INSERT INTO memory_forgotten (tenant, id) VALUES ($1, $2)
802                 ON CONFLICT DO NOTHING",
803                &[&tenant, id],
804            )
805            .await
806            .map_err(|error| be(&error))?;
807        }
808        tx.execute(
809            "DELETE FROM memory_items WHERE tenant = $1 AND subject = $2",
810            &[&tenant, &subject],
811        )
812        .await
813        .map_err(|error| be(&error))?;
814        tx.commit().await.map_err(|error| be(&error))?;
815        Ok(ids.len())
816    }
817
818    async fn set_legal_hold(&self, id: &str, held: bool) -> Result<(), StoreError> {
819        let mut client = self.pool_ref().get().await.map_err(|error| {
820            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
821        })?;
822        let tx = client.transaction().await.map_err(|error| be(&error))?;
823        let tenant = self.tenant_name();
824        tx.query_one(
825            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
826            &[&format!("memory-lifecycle:{}:{tenant}", tenant.len())],
827        )
828        .await
829        .map_err(|error| be(&error))?;
830        let exists = tx
831            .query_opt(
832                "SELECT 1 FROM memory_items
833                 WHERE tenant = $1 AND id = $2 AND current FOR UPDATE",
834                &[&tenant, &id],
835            )
836            .await
837            .map_err(|error| be(&error))?
838            .is_some();
839        if held && !exists {
840            return Err(StoreError::Backend(format!(
841                "cannot hold missing memory '{id}'"
842            )));
843        }
844        if held {
845            tx.execute(
846                "INSERT INTO memory_legal_holds (tenant, id) VALUES ($1, $2)
847                 ON CONFLICT DO NOTHING",
848                &[&tenant, &id],
849            )
850            .await
851            .map_err(|error| be(&error))?;
852        } else {
853            tx.execute(
854                "DELETE FROM memory_legal_holds WHERE tenant = $1 AND id = $2",
855                &[&tenant, &id],
856            )
857            .await
858            .map_err(|error| be(&error))?;
859        }
860        tx.commit().await.map_err(|error| be(&error))
861    }
862
863    async fn legal_holds(
864        &self,
865        after: Option<&str>,
866        limit: usize,
867    ) -> Result<Vec<String>, StoreError> {
868        let client = self.pool_ref().get().await.map_err(|error| {
869            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
870        })?;
871        let tenant = self.tenant_name();
872        let after = after.unwrap_or("").to_owned();
873        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
874        let rows = client
875            .query(
876                "SELECT id FROM memory_legal_holds
877                  WHERE tenant = $1 AND id COLLATE \"C\" > $2
878                  ORDER BY id COLLATE \"C\"
879                  LIMIT $3",
880                &[&tenant, &after, &limit],
881            )
882            .await
883            .map_err(|error| StoreError::Backend(error.to_string()))?;
884        Ok(rows.into_iter().map(|r| r.get::<_, String>(0)).collect())
885    }
886
887    async fn legal_hold(&self, id: &str) -> Result<bool, StoreError> {
888        let client = self.pool_ref().get().await.map_err(|error| {
889            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
890        })?;
891        let tenant = self.tenant_name();
892        client
893            .query_opt(
894                "SELECT 1 FROM memory_legal_holds WHERE tenant = $1 AND id = $2",
895                &[&tenant, &id],
896            )
897            .await
898            .map(|row| row.is_some())
899            .map_err(|error| be(&error))
900    }
901
902    async fn sweep_expired(
903        &self,
904        at: crate::core::Timestamp,
905    ) -> Result<Vec<(String, u64)>, StoreError> {
906        let mut client = self.pool_ref().get().await.map_err(|error| {
907            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
908        })?;
909        let tx = client.transaction().await.map_err(|error| be(&error))?;
910        let tenant = self.tenant_name();
911        tx.query_one(
912            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
913            &[&format!("memory-lifecycle:{}:{tenant}", tenant.len())],
914        )
915        .await
916        .map_err(|error| be(&error))?;
917        // Exclusive against the shared graph lock every memory write takes,
918        // exactly as `forget_cascading` is — the sweep is an erasure, and an
919        // erasure that held only the lifecycle lock raced `remember`: a new
920        // derivative could be created under a source mid-expiry, committing a
921        // summary whose source this transaction was already erasing.
922        tx.query_one(
923            "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
924            &[&format!("memory-graph:{}:{tenant}", tenant.len())],
925        )
926        .await
927        .map_err(|error| be(&error))?;
928        let rows = tx
929            .query(
930                "SELECT item.id, item.version
931                 FROM memory_items item
932                                 LEFT JOIN memory_access_expiry access
933                                     ON access.tenant = item.tenant AND access.id = item.id
934                 LEFT JOIN memory_legal_holds hold
935                   ON hold.tenant = item.tenant AND hold.id = item.id
936                 WHERE item.tenant = $1 AND item.current
937                                     AND (item.expires_at IS NOT NULL OR access.expires_at IS NOT NULL)
938                                     -- The recall rule, applied to erasure: the
939                                     -- ceiling wins, so a touched-up window never
940                                     -- carries an item past its immutable expires_at.
941                                     AND LEAST(
942                                             COALESCE(item.expires_at, 9223372036854775807),
943                                             COALESCE(access.expires_at, 9223372036854775807)
944                                     ) <= $2
945                   AND hold.id IS NULL
946                 FOR UPDATE OF item",
947                &[&tenant, &at.unix_timestamp()],
948            )
949            .await
950            .map_err(|error| be(&error))?;
951        // The current version is the highest: versions only grow.
952        let swept: Vec<(String, u64)> = rows
953            .iter()
954            .map(|row| (row.get("id"), row.get::<_, i64>("version").cast_unsigned()))
955            .collect();
956        for (id, _) in &swept {
957            tx.execute(
958                "DELETE FROM memory_access_expiry WHERE tenant = $1 AND id = $2",
959                &[&tenant, id],
960            )
961            .await
962            .map_err(|error| be(&error))?;
963            // Derivation edges deliberately stay — both directions, exactly as
964            // `forget` keeps them. An expired memory becomes a tombstone, not a
965            // hole in the graph: with U → E → D and E expired here, a later
966            // `forget_cascading(U)` must still route *through* E to reach D,
967            // and this sweep once deleted E's incoming edges — severing exactly
968            // that path, so the poisoned source's summary-of-a-summary outlived
969            // the erasure. The read path keeps a kept edge harmless
970            // (`derivatives` joins on a current row), and the tombstone
971            // prevents id reuse from attaching this lineage to unrelated
972            // future content.
973            tx.execute(
974                "DELETE FROM memory_items WHERE tenant = $1 AND id = $2",
975                &[&tenant, id],
976            )
977            .await
978            .map_err(|error| be(&error))?;
979            tx.execute(
980                "INSERT INTO memory_forgotten (tenant, id) VALUES ($1, $2)
981                 ON CONFLICT DO NOTHING",
982                &[&tenant, id],
983            )
984            .await
985            .map_err(|error| be(&error))?;
986        }
987        tx.commit().await.map_err(|error| be(&error))?;
988        Ok(swept)
989    }
990
991    async fn touch(&self, ids: &[String], at: crate::core::Timestamp) -> Result<(), StoreError> {
992        let mut client = self.pool_ref().get().await.map_err(|error| {
993            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
994        })?;
995        let tx = client.transaction().await.map_err(|error| be(&error))?;
996        let tenant = self.tenant_name();
997        for id in ids {
998            let Some(row) = tx
999                .query_opt(
1000                    "SELECT item FROM memory_items
1001                     WHERE tenant = $1 AND id = $2 AND current FOR UPDATE",
1002                    &[&tenant, id],
1003                )
1004                .await
1005                .map_err(|error| be(&error))?
1006            else {
1007                continue;
1008            };
1009            let item = decode(&row)?;
1010            let Some(window) = item.access_retention_seconds else {
1011                continue;
1012            };
1013            let expiry = access_expiry(at, window);
1014            tx.execute(
1015                "INSERT INTO memory_access_expiry (tenant, id, expires_at)
1016                 VALUES ($1, $2, $3)
1017                 ON CONFLICT (tenant, id) DO UPDATE
1018                 SET expires_at = GREATEST(memory_access_expiry.expires_at, EXCLUDED.expires_at)",
1019                &[&tenant, id, &expiry],
1020            )
1021            .await
1022            .map_err(|error| be(&error))?;
1023        }
1024        tx.commit().await.map_err(|error| be(&error))
1025    }
1026
1027    async fn derivatives(&self, id: &str) -> Result<Vec<MemoryItem>, StoreError> {
1028        let client = self.pool_ref().get().await.map_err(|error| {
1029            StoreError::Backend(format!("PostgreSQL pool unavailable: {error}"))
1030        })?;
1031        let tenant = self.tenant_name();
1032        // Edges are per-version, so only an edge written by the *current*
1033        // version of the derivative counts here: a summary re-derived from
1034        // other sources is no longer a live derivative of this one, even
1035        // though its superseded versions keep their lineage for erasure.
1036        // EXISTS rather than JOIN because one current version may hold several
1037        // edges to different versions of the same source, and a join would
1038        // return it once per edge.
1039        let rows = client
1040            .query(
1041                "SELECT item.item
1042                 FROM memory_items item
1043                 WHERE item.tenant = $1 AND item.current
1044                   AND EXISTS (
1045                       SELECT 1 FROM memory_derived edge
1046                       WHERE edge.tenant = item.tenant
1047                         AND edge.source_id = $2
1048                         AND edge.derived_id = item.id
1049                         AND edge.derived_version = item.version)
1050                 ORDER BY item.created_at DESC, item.id COLLATE \"C\" ASC",
1051                &[&tenant, &id],
1052            )
1053            .await
1054            .map_err(|error| be(&error))?;
1055        rows.iter().map(decode).collect()
1056    }
1057}