1use 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
110const 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}