gemini_memory_rs/okf/
repository.rs

1//! The canonical memory repository.
2//!
3//! One logical writer per user namespace, an optimistic revision check, and a
4//! whole-namespace materialization on every commit. Rewriting each affected
5//! category file from the in-process record set (rather than patching files in
6//! place) means a record that changes status — and therefore changes which file
7//! it belongs in — cannot leave a stale copy behind.
8
9use async_trait::async_trait;
10use chrono::{DateTime, Datelike, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashMap};
13use std::sync::Arc;
14
15use super::document::OkfDocument;
16use super::record::{from_document, to_document};
17use super::store::{MemoryStore, OkfStore};
18use crate::core::{
19    CanonicalMemory, CanonicalPredicate, CommitReceipt, FactFingerprint, MemoryError, MemoryId,
20    MemoryKind, MemoryStatus, UserId,
21};
22
23/// Manifest schema version.
24pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
25
26/// A single write in a transaction.
27#[derive(Debug, Clone, PartialEq)]
28pub enum MemoryWrite {
29    /// Create or replace a record.
30    Put(Box<CanonicalMemory>),
31    /// Remove a record and leave a content-free tombstone.
32    Delete(MemoryId),
33}
34
35/// An all-or-nothing set of writes against one user's namespace.
36///
37/// A contradiction resolution writes two records — the new active fact and the
38/// superseded old one. Committing only one of them would leave the corpus
39/// asserting both, so transactions are the unit of commit.
40#[derive(Debug, Clone)]
41pub struct MemoryTransaction {
42    /// Whose namespace is being written.
43    pub user_id: UserId,
44    /// The revision the caller read, if it is enforcing one.
45    pub expected_revision: Option<u64>,
46    /// The writes to apply, in order.
47    pub writes: Vec<MemoryWrite>,
48    /// Deduplication key so a retried commit is not applied twice.
49    pub idempotency_key: String,
50}
51
52impl MemoryTransaction {
53    /// An empty transaction for a user.
54    pub fn new(user_id: UserId, idempotency_key: impl Into<String>) -> Self {
55        Self {
56            user_id,
57            expected_revision: None,
58            writes: Vec::new(),
59            idempotency_key: idempotency_key.into(),
60        }
61    }
62
63    /// Require the repository to still be at `revision`.
64    pub fn expecting(mut self, revision: u64) -> Self {
65        self.expected_revision = Some(revision);
66        self
67    }
68
69    /// Add a record write.
70    pub fn put(mut self, memory: CanonicalMemory) -> Self {
71        self.writes.push(MemoryWrite::Put(Box::new(memory)));
72        self
73    }
74
75    /// Add a deletion.
76    pub fn delete(mut self, id: MemoryId) -> Self {
77        self.writes.push(MemoryWrite::Delete(id));
78        self
79    }
80
81    /// Whether there is anything to do.
82    pub fn is_empty(&self) -> bool {
83        self.writes.is_empty()
84    }
85}
86
87/// Which existing records a reconciliation should be compared against.
88#[derive(Debug, Clone, Default)]
89pub struct ReconciliationSelector {
90    /// Exact fingerprint match.
91    pub fingerprint: Option<FactFingerprint>,
92    /// `subject|predicate` prefix match — the contradiction window.
93    pub subject_predicate: Option<String>,
94    /// Subject match — the wider window used to catch predicate drift.
95    pub subject: Option<String>,
96    /// Predicate match.
97    pub predicate: Option<CanonicalPredicate>,
98    /// Restrict to these kinds; empty means any.
99    pub kinds: Vec<MemoryKind>,
100    /// Restrict to these statuses; empty means active only.
101    pub statuses: Vec<MemoryStatus>,
102    /// Maximum records to return.
103    pub limit: usize,
104}
105
106impl ReconciliationSelector {
107    /// Look for the exact same fact.
108    pub fn by_fingerprint(fingerprint: FactFingerprint) -> Self {
109        Self {
110            fingerprint: Some(fingerprint),
111            limit: 8,
112            ..Default::default()
113        }
114    }
115
116    /// Look for anything asserted about the same subject and predicate.
117    pub fn by_subject_predicate(prefix: impl Into<String>) -> Self {
118        Self {
119            subject_predicate: Some(prefix.into()),
120            limit: 20,
121            ..Default::default()
122        }
123    }
124
125    /// Look for anything asserted about the same subject, whatever the
126    /// predicate is called.
127    ///
128    /// Extraction models rename predicates between sessions; this is the window
129    /// that lets reconciliation notice the same fact wearing a different name.
130    pub fn by_subject(subject: impl Into<String>) -> Self {
131        Self {
132            subject: Some(subject.into()),
133            limit: 40,
134            ..Default::default()
135        }
136    }
137
138    fn matches(&self, memory: &CanonicalMemory) -> bool {
139        let status_ok = if self.statuses.is_empty() {
140            memory.status == MemoryStatus::Active
141        } else {
142            self.statuses.contains(&memory.status)
143        };
144        if !status_ok {
145            return false;
146        }
147        if !self.kinds.is_empty() && !self.kinds.contains(&memory.kind) {
148            return false;
149        }
150        if let Some(fp) = &self.fingerprint
151            && &memory.fingerprint() != fp
152        {
153            return false;
154        }
155        if let Some(prefix) = &self.subject_predicate
156            && memory.fingerprint().subject_predicate() != prefix
157        {
158            return false;
159        }
160        if let Some(subject) = &self.subject
161            && memory.fingerprint().subject() != subject.as_str()
162        {
163            return false;
164        }
165        if let Some(predicate) = &self.predicate
166            && &memory.predicate != predicate
167        {
168            return false;
169        }
170        true
171    }
172}
173
174/// Read and write access to canonical memory.
175#[async_trait]
176pub trait MemoryRepository: Send + Sync {
177    /// Fetch one record.
178    async fn get(
179        &self,
180        user_id: &UserId,
181        memory_id: &MemoryId,
182    ) -> Result<Option<CanonicalMemory>, MemoryError>;
183
184    /// Find records a proposal should be reconciled against.
185    async fn find_candidates(
186        &self,
187        user_id: &UserId,
188        selector: &ReconciliationSelector,
189    ) -> Result<Vec<CanonicalMemory>, MemoryError>;
190
191    /// Every record in the namespace, whatever its status.
192    async fn all(&self, user_id: &UserId) -> Result<Vec<CanonicalMemory>, MemoryError>;
193
194    /// The namespace's current revision.
195    async fn revision(&self, user_id: &UserId) -> Result<u64, MemoryError>;
196
197    /// Apply a transaction atomically.
198    async fn commit(&self, transaction: MemoryTransaction) -> Result<CommitReceipt, MemoryError>;
199}
200
201/// A record's place in the manifest index.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct ManifestEntry {
204    /// Record identifier.
205    pub id: MemoryId,
206    /// The category file it lives in.
207    pub path: String,
208    /// Lifecycle state.
209    pub status: MemoryStatus,
210    /// Memory kind.
211    pub kind: MemoryKind,
212    /// Deduplication fingerprint.
213    pub fingerprint: String,
214}
215
216/// A content-free record of a deletion.
217///
218/// Tombstones exist so a deleted memory cannot silently reappear from a stale
219/// replica; they deliberately carry no statement text.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221pub struct Tombstone {
222    /// The record that was removed.
223    pub id: MemoryId,
224    /// When it was removed.
225    pub deleted_at: DateTime<Utc>,
226}
227
228/// The index written alongside a user's records.
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230pub struct MemoryManifest {
231    /// Manifest schema version.
232    pub schema_version: u32,
233    /// Monotonic revision, bumped on every commit.
234    pub revision: u64,
235    /// One entry per record.
236    pub records: Vec<ManifestEntry>,
237    /// Deletions, without content.
238    pub tombstones: Vec<Tombstone>,
239    /// Idempotency keys of commits already applied, most recent last.
240    ///
241    /// Durable, because the retry guarantee has to survive a restart to be
242    /// worth anything. Reconciliation is at-least-once: if the process dies
243    /// between a successful commit and the caller learning about it, the retry
244    /// arrives at a fresh repository. Without these keys it applies a second
245    /// time, advancing the revision and re-reinforcing evidence — so a crash
246    /// silently inflates a memory's confidence.
247    ///
248    /// Bounded: a key only has to outlive the retry window of the transaction
249    /// that produced it, and the manifest is rewritten on every commit.
250    #[serde(default)]
251    pub applied: Vec<String>,
252}
253
254/// How many idempotency keys the manifest carries forward.
255///
256/// Retries follow a failed commit within seconds; keeping the last few hundred
257/// covers that by a wide margin without letting the manifest grow forever.
258const APPLIED_KEY_HISTORY: usize = 256;
259
260impl Default for MemoryManifest {
261    fn default() -> Self {
262        Self {
263            schema_version: MANIFEST_SCHEMA_VERSION,
264            revision: 0,
265            records: Vec::new(),
266            tombstones: Vec::new(),
267            applied: Vec::new(),
268        }
269    }
270}
271
272/// Per-user in-process state, guarded by the namespace write lock.
273#[derive(Debug, Default)]
274struct Namespace {
275    loaded: bool,
276    revision: u64,
277    records: BTreeMap<MemoryId, CanonicalMemory>,
278    tombstones: Vec<Tombstone>,
279    applied: Vec<String>,
280    written_files: BTreeMap<String, String>,
281}
282
283/// An OKF repository projected onto an [`OkfStore`].
284pub struct OkfRepository<S: OkfStore> {
285    store: Arc<S>,
286    namespaces: tokio::sync::Mutex<HashMap<UserId, Namespace>>,
287}
288
289impl OkfRepository<MemoryStore> {
290    /// A repository backed by an in-process store.
291    pub fn in_memory() -> Self {
292        Self::new(Arc::new(MemoryStore::new()))
293    }
294}
295
296impl<S: OkfStore> OkfRepository<S> {
297    /// Wrap a store.
298    pub fn new(store: Arc<S>) -> Self {
299        Self {
300            store,
301            namespaces: tokio::sync::Mutex::new(HashMap::new()),
302        }
303    }
304
305    /// Borrow the underlying store.
306    pub fn store(&self) -> &Arc<S> {
307        &self.store
308    }
309
310    /// The directory prefix for a user.
311    fn user_prefix(user_id: &UserId) -> String {
312        format!("users/{}/", user_id.as_str())
313    }
314
315    fn manifest_path(user_id: &UserId) -> String {
316        format!("{}manifest.json", Self::user_prefix(user_id))
317    }
318
319    async fn ensure_loaded(
320        &self,
321        namespaces: &mut HashMap<UserId, Namespace>,
322        user_id: &UserId,
323    ) -> Result<(), MemoryError> {
324        let entry = namespaces.entry(user_id.clone()).or_default();
325        if entry.loaded {
326            return Ok(());
327        }
328
329        let prefix = Self::user_prefix(user_id);
330        let manifest_path = Self::manifest_path(user_id);
331        let manifest: MemoryManifest = match self.store.read(&manifest_path).await? {
332            Some(raw) => serde_json::from_str(&raw).map_err(|e| MemoryError::MalformedRecord {
333                path: manifest_path.clone(),
334                message: e.to_string(),
335            })?,
336            None => MemoryManifest::default(),
337        };
338
339        let mut records = BTreeMap::new();
340        let mut written_files = BTreeMap::new();
341        for path in self.store.list(&prefix).await? {
342            if !path.ends_with(".md") {
343                continue;
344            }
345            let Some(contents) = self.store.read(&path).await? else {
346                continue;
347            };
348            for doc in OkfDocument::parse_many(&contents, &path)? {
349                let memory = from_document(&doc, &path)?;
350                records.insert(memory.id.clone(), memory);
351            }
352            written_files.insert(path, contents);
353        }
354
355        *entry = Namespace {
356            loaded: true,
357            revision: manifest.revision,
358            records,
359            tombstones: manifest.tombstones,
360            applied: manifest.applied,
361            written_files,
362        };
363        Ok(())
364    }
365
366    /// Render the whole namespace to path → file contents.
367    fn materialize(namespace: &Namespace) -> Result<BTreeMap<String, String>, MemoryError> {
368        let mut grouped: BTreeMap<String, Vec<OkfDocument>> = BTreeMap::new();
369        for memory in namespace.records.values() {
370            let path = category_path(memory);
371            grouped.entry(path).or_default().push(to_document(memory));
372        }
373        let mut out = BTreeMap::new();
374        for (path, docs) in grouped {
375            out.insert(path.clone(), OkfDocument::render_many(&docs, &path)?);
376        }
377        Ok(out)
378    }
379}
380
381/// Which file a record belongs in (§25.1).
382pub fn category_path(memory: &CanonicalMemory) -> String {
383    let base = format!("users/{}/", memory.owner.as_str());
384    match memory.status {
385        MemoryStatus::Superseded | MemoryStatus::Expired => format!(
386            "{base}superseded/{:04}-{:02}.md",
387            memory.temporal.updated_at.year(),
388            memory.temporal.updated_at.month()
389        ),
390        MemoryStatus::Staged => format!("{base}staged/patterns.md"),
391        MemoryStatus::Deleted => format!("{base}tombstones/records.md"),
392        MemoryStatus::Active => match memory.kind {
393            MemoryKind::Identity => format!("{base}profile.md"),
394            MemoryKind::Preference | MemoryKind::LocationPreference => {
395                format!("{base}preferences.md")
396            }
397            MemoryKind::Relationship | MemoryKind::RelationshipPreference => {
398                format!("{base}relationships.md")
399            }
400            MemoryKind::Routine => format!("{base}routines.md"),
401            MemoryKind::Commitment => format!("{base}commitments.md"),
402            MemoryKind::CommunicationStyle => format!("{base}communication.md"),
403            MemoryKind::Project => format!("{base}projects.md"),
404            MemoryKind::StagedPattern => format!("{base}staged/patterns.md"),
405            MemoryKind::Episodic => format!(
406                "{base}episodes/{}.md",
407                memory.temporal.valid_from.format("%Y-%m-%d")
408            ),
409        },
410    }
411}
412
413#[async_trait]
414impl<S: OkfStore> MemoryRepository for OkfRepository<S> {
415    async fn get(
416        &self,
417        user_id: &UserId,
418        memory_id: &MemoryId,
419    ) -> Result<Option<CanonicalMemory>, MemoryError> {
420        let mut namespaces = self.namespaces.lock().await;
421        self.ensure_loaded(&mut namespaces, user_id).await?;
422        Ok(namespaces
423            .get(user_id)
424            .and_then(|ns| ns.records.get(memory_id))
425            .cloned())
426    }
427
428    async fn find_candidates(
429        &self,
430        user_id: &UserId,
431        selector: &ReconciliationSelector,
432    ) -> Result<Vec<CanonicalMemory>, MemoryError> {
433        let mut namespaces = self.namespaces.lock().await;
434        self.ensure_loaded(&mut namespaces, user_id).await?;
435        let namespace = namespaces.get(user_id).expect("namespace just loaded");
436        let limit = if selector.limit == 0 {
437            usize::MAX
438        } else {
439            selector.limit
440        };
441        Ok(namespace
442            .records
443            .values()
444            .filter(|m| selector.matches(m))
445            .take(limit)
446            .cloned()
447            .collect())
448    }
449
450    async fn all(&self, user_id: &UserId) -> Result<Vec<CanonicalMemory>, MemoryError> {
451        let mut namespaces = self.namespaces.lock().await;
452        self.ensure_loaded(&mut namespaces, user_id).await?;
453        Ok(namespaces
454            .get(user_id)
455            .map(|ns| ns.records.values().cloned().collect())
456            .unwrap_or_default())
457    }
458
459    async fn revision(&self, user_id: &UserId) -> Result<u64, MemoryError> {
460        let mut namespaces = self.namespaces.lock().await;
461        self.ensure_loaded(&mut namespaces, user_id).await?;
462        Ok(namespaces.get(user_id).map(|ns| ns.revision).unwrap_or(0))
463    }
464
465    async fn commit(&self, transaction: MemoryTransaction) -> Result<CommitReceipt, MemoryError> {
466        let mut namespaces = self.namespaces.lock().await;
467        self.ensure_loaded(&mut namespaces, &transaction.user_id)
468            .await?;
469        let namespace = namespaces
470            .get_mut(&transaction.user_id)
471            .expect("namespace just loaded");
472
473        // A retried commit returns the current revision rather than applying
474        // the same writes again.
475        if namespace.applied.contains(&transaction.idempotency_key) {
476            return Ok(CommitReceipt {
477                revision: namespace.revision,
478                written: Vec::new(),
479                deleted: Vec::new(),
480            });
481        }
482        if let Some(expected) = transaction.expected_revision
483            && expected != namespace.revision
484        {
485            return Err(MemoryError::RevisionConflict {
486                expected,
487                actual: namespace.revision,
488            });
489        }
490        if transaction.is_empty() {
491            namespace.applied.push(transaction.idempotency_key);
492            return Ok(CommitReceipt {
493                revision: namespace.revision,
494                written: Vec::new(),
495                deleted: Vec::new(),
496            });
497        }
498
499        let mut written = Vec::new();
500        let mut deleted = Vec::new();
501        let now = Utc::now();
502        for write in &transaction.writes {
503            match write {
504                MemoryWrite::Put(memory) => {
505                    if memory.owner != transaction.user_id {
506                        return Err(MemoryError::PolicyRefused(format!(
507                            "record {} belongs to {}, not {}",
508                            memory.id, memory.owner, transaction.user_id
509                        )));
510                    }
511                    namespace
512                        .records
513                        .insert(memory.id.clone(), (**memory).clone());
514                    written.push(memory.id.clone());
515                }
516                MemoryWrite::Delete(id) => {
517                    if namespace.records.remove(id).is_some() {
518                        namespace.tombstones.push(Tombstone {
519                            id: id.clone(),
520                            deleted_at: now,
521                        });
522                        deleted.push(id.clone());
523                    }
524                }
525            }
526        }
527
528        let desired = Self::materialize(namespace)?;
529        for (path, contents) in &desired {
530            if namespace.written_files.get(path) != Some(contents) {
531                self.store.write(path, contents).await?;
532            }
533        }
534        for path in namespace.written_files.keys() {
535            if !desired.contains_key(path) {
536                self.store.remove(path).await?;
537            }
538        }
539
540        // The manifest is written *before* any in-memory state is published.
541        // Publishing first and failing here would leave the revision advanced
542        // and the idempotency key recorded, so a retry would take the
543        // already-applied fast path and report success while the manifest
544        // stayed stale for ever.
545        let next_revision = namespace.revision + 1;
546        // The key this commit is about to apply goes into the same manifest
547        // write. Recording it only in memory would mean a restart forgets that
548        // this transaction already landed, and the retry would apply it twice.
549        let mut applied = namespace.applied.clone();
550        applied.push(transaction.idempotency_key.clone());
551        if applied.len() > APPLIED_KEY_HISTORY {
552            applied.drain(..applied.len() - APPLIED_KEY_HISTORY);
553        }
554        let manifest = MemoryManifest {
555            schema_version: MANIFEST_SCHEMA_VERSION,
556            revision: next_revision,
557            records: namespace
558                .records
559                .values()
560                .map(|m| ManifestEntry {
561                    id: m.id.clone(),
562                    path: category_path(m),
563                    status: m.status,
564                    kind: m.kind,
565                    fingerprint: m.fingerprint().to_string(),
566                })
567                .collect(),
568            tombstones: namespace.tombstones.clone(),
569            applied: applied.clone(),
570        };
571        self.store
572            .write(
573                &Self::manifest_path(&transaction.user_id),
574                &serde_json::to_string_pretty(&manifest)?,
575            )
576            .await?;
577
578        // Every durable write has landed; only now is the commit real.
579        namespace.revision = next_revision;
580        namespace.written_files = desired;
581        namespace.applied = applied;
582
583        Ok(CommitReceipt {
584            revision: namespace.revision,
585            written,
586            deleted,
587        })
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::core::{
595        EntityRef, EvidenceCounters, Explicitness, MemorySource, MemoryValue, PrivacyMetadata,
596        RetrievalMetadata, SessionId, TemporalMetadata, TemporalScope, TurnId,
597    };
598
599    fn memory(id: &str, kind: MemoryKind, statement: &str) -> CanonicalMemory {
600        let now = Utc::now();
601        CanonicalMemory {
602            id: MemoryId::new(id),
603            owner: UserId::new("usr_1"),
604            kind,
605            predicate: CanonicalPredicate::new("dietary_identity"),
606            status: MemoryStatus::Active,
607            confidence: 0.9,
608            subject: EntityRef::user(),
609            value: MemoryValue::Text(statement.to_string()),
610            statement: statement.to_string(),
611            evidence_summary: "stated".into(),
612            source: MemorySource::from_explicitness(
613                Explicitness::ExplicitStatement,
614                SessionId::new("ses_1"),
615                TurnId(1),
616            ),
617            temporal: TemporalMetadata::created_at(now),
618            retrieval: RetrievalMetadata {
619                subject: "user".into(),
620                ..Default::default()
621            },
622            evidence: EvidenceCounters::first(),
623            privacy: PrivacyMetadata::default(),
624            temporal_scope: TemporalScope::Persistent,
625            supersedes: Vec::new(),
626            superseded_by: None,
627            qualifier: None,
628        }
629    }
630
631    #[tokio::test]
632    async fn commit_writes_records_and_reloads_them_from_the_store() {
633        let store = Arc::new(MemoryStore::new());
634        let repo = OkfRepository::new(store.clone());
635        let user = UserId::new("usr_1");
636
637        let receipt = repo
638            .commit(MemoryTransaction::new(user.clone(), "tx-1").put(memory(
639                "mem_a",
640                MemoryKind::Preference,
641                "The user is pescatarian.",
642            )))
643            .await
644            .unwrap();
645        assert_eq!(receipt.revision, 1);
646        assert_eq!(receipt.written.len(), 1);
647
648        // A fresh repository over the same store sees the same corpus.
649        let reopened = OkfRepository::new(store.clone());
650        let loaded = reopened.all(&user).await.unwrap();
651        assert_eq!(loaded.len(), 1);
652        assert_eq!(loaded[0].statement, "The user is pescatarian.");
653        assert_eq!(reopened.revision(&user).await.unwrap(), 1);
654    }
655
656    #[tokio::test]
657    async fn records_land_in_the_category_file_for_their_kind() {
658        let store = Arc::new(MemoryStore::new());
659        let repo = OkfRepository::new(store.clone());
660        repo.commit(
661            MemoryTransaction::new(UserId::new("usr_1"), "tx-1")
662                .put(memory("mem_a", MemoryKind::Preference, "a"))
663                .put(memory("mem_b", MemoryKind::Relationship, "b")),
664        )
665        .await
666        .unwrap();
667
668        let paths = store.paths();
669        assert!(paths.contains(&"users/usr_1/preferences.md".to_string()));
670        assert!(paths.contains(&"users/usr_1/relationships.md".to_string()));
671    }
672
673    #[tokio::test]
674    async fn a_superseded_record_moves_out_of_its_active_file() {
675        let store = Arc::new(MemoryStore::new());
676        let repo = OkfRepository::new(store.clone());
677        let user = UserId::new("usr_1");
678
679        let mut old = memory("mem_old", MemoryKind::Preference, "The user is vegetarian.");
680        repo.commit(MemoryTransaction::new(user.clone(), "tx-1").put(old.clone()))
681            .await
682            .unwrap();
683
684        old.status = MemoryStatus::Superseded;
685        old.superseded_by = Some(MemoryId::new("mem_new"));
686        let new = memory(
687            "mem_new",
688            MemoryKind::Preference,
689            "The user is pescatarian.",
690        );
691        repo.commit(
692            MemoryTransaction::new(user.clone(), "tx-2")
693                .put(old)
694                .put(new),
695        )
696        .await
697        .unwrap();
698
699        let preferences = store
700            .read("users/usr_1/preferences.md")
701            .await
702            .unwrap()
703            .unwrap();
704        assert!(preferences.contains("pescatarian"));
705        assert!(
706            !preferences.contains("vegetarian"),
707            "superseded record must not linger in the active file"
708        );
709        assert!(store.paths().iter().any(|p| p.contains("superseded/")));
710    }
711
712    #[tokio::test]
713    async fn deleting_a_record_removes_content_and_leaves_a_bare_tombstone() {
714        let store = Arc::new(MemoryStore::new());
715        let repo = OkfRepository::new(store.clone());
716        let user = UserId::new("usr_1");
717
718        repo.commit(MemoryTransaction::new(user.clone(), "tx-1").put(memory(
719            "mem_a",
720            MemoryKind::Preference,
721            "secret preference",
722        )))
723        .await
724        .unwrap();
725        repo.commit(MemoryTransaction::new(user.clone(), "tx-2").delete(MemoryId::new("mem_a")))
726            .await
727            .unwrap();
728
729        assert!(repo.all(&user).await.unwrap().is_empty());
730        for path in store.paths() {
731            let contents = store.read(&path).await.unwrap().unwrap_or_default();
732            assert!(
733                !contents.contains("secret preference"),
734                "deleted content still present in {path}"
735            );
736        }
737        let manifest: MemoryManifest = serde_json::from_str(
738            &store
739                .read("users/usr_1/manifest.json")
740                .await
741                .unwrap()
742                .unwrap(),
743        )
744        .unwrap();
745        assert_eq!(manifest.tombstones.len(), 1);
746    }
747
748    #[tokio::test]
749    async fn a_retried_commit_is_applied_once() {
750        let repo = OkfRepository::in_memory();
751        let user = UserId::new("usr_1");
752        let tx = || {
753            MemoryTransaction::new(user.clone(), "same-key").put(memory(
754                "mem_a",
755                MemoryKind::Preference,
756                "a",
757            ))
758        };
759        let first = repo.commit(tx()).await.unwrap();
760        let second = repo.commit(tx()).await.unwrap();
761        assert_eq!(first.revision, second.revision);
762        assert!(second.written.is_empty());
763    }
764
765    #[tokio::test]
766    async fn a_retried_commit_is_applied_once_across_a_restart() {
767        // Reconciliation is at-least-once. If the process dies between a
768        // successful commit and the caller learning about it, the retry meets
769        // a freshly opened repository — which must still recognise the key, or
770        // the transaction applies twice and re-reinforces the same evidence.
771        let store = Arc::new(MemoryStore::new());
772        let user = UserId::new("usr_1");
773        let tx = || {
774            MemoryTransaction::new(user.clone(), "same-key").put(memory(
775                "mem_a",
776                MemoryKind::Preference,
777                "a",
778            ))
779        };
780
781        let first = OkfRepository::new(store.clone())
782            .commit(tx())
783            .await
784            .unwrap();
785
786        // A brand-new repository over the same store: nothing in memory.
787        let reopened = OkfRepository::new(store.clone());
788        let retried = reopened.commit(tx()).await.unwrap();
789
790        assert_eq!(
791            retried.revision, first.revision,
792            "the retry advanced the revision, so it was applied a second time"
793        );
794        assert!(
795            retried.written.is_empty(),
796            "the retry rewrote records: {:?}",
797            retried.written
798        );
799    }
800
801    #[tokio::test]
802    async fn a_stale_revision_is_refused() {
803        let repo = OkfRepository::in_memory();
804        let user = UserId::new("usr_1");
805        repo.commit(MemoryTransaction::new(user.clone(), "tx-1").put(memory(
806            "mem_a",
807            MemoryKind::Preference,
808            "a",
809        )))
810        .await
811        .unwrap();
812
813        let err = repo
814            .commit(
815                MemoryTransaction::new(user.clone(), "tx-2")
816                    .expecting(0)
817                    .put(memory("mem_b", MemoryKind::Preference, "b")),
818            )
819            .await
820            .unwrap_err();
821        assert!(matches!(err, MemoryError::RevisionConflict { .. }));
822    }
823
824    #[tokio::test]
825    async fn writing_into_another_users_namespace_is_refused() {
826        let repo = OkfRepository::in_memory();
827        let err = repo
828            .commit(
829                MemoryTransaction::new(UserId::new("usr_other"), "tx-1").put(memory(
830                    "mem_a",
831                    MemoryKind::Preference,
832                    "a",
833                )),
834            )
835            .await
836            .unwrap_err();
837        assert!(matches!(err, MemoryError::PolicyRefused(_)));
838    }
839
840    #[tokio::test]
841    async fn candidates_are_selected_by_subject_and_predicate() {
842        let repo = OkfRepository::in_memory();
843        let user = UserId::new("usr_1");
844        let target = memory("mem_a", MemoryKind::Preference, "vegetarian");
845        let prefix = target.fingerprint().subject_predicate().to_string();
846        repo.commit(MemoryTransaction::new(user.clone(), "tx-1").put(target))
847            .await
848            .unwrap();
849
850        let found = repo
851            .find_candidates(&user, &ReconciliationSelector::by_subject_predicate(prefix))
852            .await
853            .unwrap();
854        assert_eq!(found.len(), 1);
855
856        let none = repo
857            .find_candidates(
858                &user,
859                &ReconciliationSelector::by_subject_predicate("user|something_else"),
860            )
861            .await
862            .unwrap();
863        assert!(none.is_empty());
864    }
865}