Skip to main content

deepstrike_core/mm/
memory.rs

1//! Long-term memory management (Phase 7).
2//!
3//! Kernel defines memory types and validation rules; SDKs perform I/O and selection.
4//! No I/O in this module — pure classification and validation logic.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10/// Memory kind (4 types, mirroring Claude Code's taxonomy).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum MemoryKind {
14    /// User profile: who they are, expertise level, role.
15    User,
16    /// Behavior preference: what they like/dislike, approved patterns.
17    Feedback,
18    /// Project context: what's happening, milestones, phases.
19    Project,
20    /// External pointer: where to find things (tickets, docs).
21    Reference,
22}
23
24impl MemoryKind {
25    pub fn label(self) -> &'static str {
26        match self {
27            Self::User => "user",
28            Self::Feedback => "feedback",
29            Self::Project => "project",
30            Self::Reference => "reference",
31        }
32    }
33}
34
35/// Isolation boundary for a memory record.
36///
37/// Both components participate in identity so records cannot collide across tenants or
38/// application-defined namespaces.
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct MemoryScope {
42    pub tenant_id: String,
43    pub namespace: String,
44}
45
46impl MemoryScope {
47    pub fn new(tenant_id: impl Into<String>, namespace: impl Into<String>) -> Self {
48        Self {
49            tenant_id: tenant_id.into(),
50            namespace: namespace.into(),
51        }
52    }
53}
54
55/// Stable logical key used for memory upserts.
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct MemoryKey {
59    pub scope: MemoryScope,
60    pub kind: MemoryKind,
61    pub name: String,
62}
63
64/// Principal responsible for producing a memory record.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum MemoryAuthor {
68    Model,
69    Host,
70    Extraction,
71}
72
73/// Explicit trust classification kept separate from authorship.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum MemoryTrustLevel {
77    Untrusted,
78    UserAsserted,
79    HostVerified,
80}
81
82/// Origin and evidence attached to a memory record.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct MemoryProvenance {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub session_id: Option<String>,
88    pub author: MemoryAuthor,
89    pub trust: MemoryTrustLevel,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub evidence_refs: Vec<String>,
92}
93
94/// A durable fact with stable identity, provenance, and lifecycle state.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct MemoryRecord {
98    pub record_id: String,
99    pub scope: MemoryScope,
100    pub name: String,
101    pub kind: MemoryKind,
102    pub content: String,
103    pub description: String,
104    pub provenance: MemoryProvenance,
105    pub created_at: u64,
106    pub updated_at: u64,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub last_recalled_at: Option<u64>,
109    #[serde(default)]
110    pub recall_count: u64,
111    pub confidence: f64,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub links: Vec<String>,
114    #[serde(default)]
115    pub pinned: bool,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub ttl_days: Option<u32>,
118}
119
120impl MemoryRecord {
121    pub fn key(&self) -> MemoryKey {
122        MemoryKey {
123            scope: self.scope.clone(),
124            kind: self.kind,
125            name: self.name.clone(),
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum MemoryUpsertOutcome {
132    Inserted { record_id: String },
133    Updated { record_id: String },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum MemoryUpsertError {
138    RecordIdConflict {
139        record_id: String,
140        existing_key: MemoryKey,
141        incoming_key: MemoryKey,
142    },
143}
144
145impl MemoryUpsertError {
146    pub fn record_id(&self) -> &str {
147        match self {
148            Self::RecordIdConflict { record_id, .. } => record_id,
149        }
150    }
151}
152
153/// Pure in-kernel helper implementing scoped, identity-preserving upsert semantics.
154#[derive(Debug, Clone, Default)]
155pub struct MemoryRecordStore {
156    records: BTreeMap<MemoryKey, MemoryRecord>,
157    keys_by_id: BTreeMap<String, MemoryKey>,
158}
159
160impl MemoryRecordStore {
161    pub fn upsert(
162        &mut self,
163        mut incoming: MemoryRecord,
164    ) -> Result<MemoryUpsertOutcome, MemoryUpsertError> {
165        let key = incoming.key();
166
167        if let Some(existing) = self.records.get(&key) {
168            if incoming.record_id != existing.record_id {
169                if let Some(existing_key) = self.keys_by_id.get(&incoming.record_id) {
170                    if existing_key != &key {
171                        return Err(MemoryUpsertError::RecordIdConflict {
172                            record_id: incoming.record_id,
173                            existing_key: existing_key.clone(),
174                            incoming_key: key,
175                        });
176                    }
177                }
178            }
179
180            let stable_id = existing.record_id.clone();
181            incoming.record_id = stable_id.clone();
182            incoming.created_at = existing.created_at;
183            incoming.updated_at = incoming.updated_at.max(existing.updated_at);
184            incoming.last_recalled_at = existing.last_recalled_at;
185            incoming.recall_count = existing.recall_count;
186
187            self.records.insert(key, incoming);
188            return Ok(MemoryUpsertOutcome::Updated {
189                record_id: stable_id,
190            });
191        }
192
193        if let Some(existing_key) = self.keys_by_id.get(&incoming.record_id) {
194            return Err(MemoryUpsertError::RecordIdConflict {
195                record_id: incoming.record_id,
196                existing_key: existing_key.clone(),
197                incoming_key: key,
198            });
199        }
200
201        let record_id = incoming.record_id.clone();
202        self.keys_by_id.insert(record_id.clone(), key.clone());
203        self.records.insert(key, incoming);
204        Ok(MemoryUpsertOutcome::Inserted { record_id })
205    }
206
207    pub fn get(&self, scope: &MemoryScope, kind: MemoryKind, name: &str) -> Option<&MemoryRecord> {
208        self.records.get(&MemoryKey {
209            scope: scope.clone(),
210            kind,
211            name: name.to_owned(),
212        })
213    }
214
215    pub fn get_by_id(&self, record_id: &str) -> Option<&MemoryRecord> {
216        self.keys_by_id
217            .get(record_id)
218            .and_then(|key| self.records.get(key))
219    }
220
221    pub fn len(&self) -> usize {
222        self.records.len()
223    }
224
225    pub fn is_empty(&self) -> bool {
226        self.records.is_empty()
227    }
228
229    /// Commit a successful recall as part of the journaled query-result transaction.
230    ///
231    /// `recalled_at` is a monotonic turn counter, not a wall clock: recall lifecycle is a
232    /// deterministic kernel fact so it must replay byte-for-byte. Retention scoring consumes it as a
233    /// step, and the TTL/staleness discount (which does need a clock) is applied host-side at recall
234    /// ranking time.
235    pub fn record_recall(&mut self, record_id: &str, recalled_at: u64) -> Option<&MemoryRecord> {
236        let key = self.keys_by_id.get(record_id)?.clone();
237        let record = self.records.get_mut(&key)?;
238        record.recall_count = record.recall_count.saturating_add(1);
239        record.last_recalled_at = Some(recalled_at);
240        Some(record)
241    }
242
243    pub fn promotion_suggested(&self, record_id: &str, threshold: u64) -> bool {
244        self.get_by_id(record_id)
245            .is_some_and(|record| !record.pinned && record.recall_count >= threshold)
246    }
247}
248
249/// Deterministic retention score for a durable record, evaluated at `current_turn`.
250///
251/// This is the canonical reference the host mirrors when it bounds its own store: durable memory and
252/// context knowledge rank by the same "value" definition ([`deterministic_retention_score`]). The
253/// host owns the full cross-session record set, so it — not the kernel — enforces the capacity bound
254/// and applies the day-based TTL/staleness discount (which needs a wall clock the kernel lacks). The
255/// kernel's job is the recall/promotion signal derived from the routed hits.
256pub fn memory_retention_score(
257    record: &MemoryRecord,
258    current_turn: u64,
259    stale_discount_ppm: u32,
260) -> i64 {
261    use crate::mm::value::{RetentionFeatures, RetentionKind, deterministic_retention_score};
262    let kind = match record.kind {
263        MemoryKind::User => RetentionKind::User,
264        MemoryKind::Feedback => RetentionKind::Feedback,
265        MemoryKind::Project => RetentionKind::Project,
266        MemoryKind::Reference => RetentionKind::Reference,
267    };
268    let confidence_ppm = (record.confidence.clamp(0.0, 1.0) * 1_000_000.0) as u32;
269    // Rough 4-bytes-per-token proxy keeps the size penalty on the same scale as context units.
270    let tokens = (record.content.len() / 4).min(u32::MAX as usize) as u32;
271    deterministic_retention_score(RetentionFeatures {
272        pinned: record.pinned,
273        use_count: record.recall_count,
274        last_used_step: record.last_recalled_at,
275        current_step: current_turn,
276        lease_remaining_steps: None,
277        kind,
278        tokens,
279        confidence_ppm,
280        stale_discount_ppm,
281    })
282}
283
284/// Scoped recall request. The host owns retrieval; the kernel validates this deterministic wire and
285/// caps `top_k` through the canonical operation memory policy.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct MemoryQuery {
289    pub scope: MemoryScope,
290    pub query: String,
291    #[serde(default = "default_top_k")]
292    pub top_k: usize,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub kinds: Vec<MemoryKind>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub min_score: Option<f64>,
297}
298
299fn default_top_k() -> usize {
300    5
301}
302
303impl Default for MemoryQuery {
304    fn default() -> Self {
305        Self {
306            scope: MemoryScope::new(String::new(), String::new()),
307            query: String::new(),
308            top_k: default_top_k(),
309            kinds: Vec::new(),
310            min_score: None,
311        }
312    }
313}
314
315impl MemoryQuery {
316    pub fn validate(&self) -> Result<(), String> {
317        if self.scope.tenant_id.is_empty() || self.scope.namespace.is_empty() {
318            return Err("memory query scope tenant_id and namespace must be non-empty".into());
319        }
320        if self.query.trim().is_empty() {
321            return Err("memory query text must be non-empty".into());
322        }
323        if self.top_k == 0 {
324            return Err("memory query top_k must be greater than zero".into());
325        }
326        if self
327            .min_score
328            .is_some_and(|score| !score.is_finite() || !(0.0..=1.0).contains(&score))
329        {
330            return Err("memory query min_score must be finite and between zero and one".into());
331        }
332        Ok(())
333    }
334
335    pub fn validate_hits(&self, hits: &[MemoryRecall], requested_k: usize) -> Result<(), String> {
336        if hits.len() > requested_k {
337            return Err(format!(
338                "memory query returned {} hits but requested at most {requested_k}",
339                hits.len()
340            ));
341        }
342        let mut record_ids = std::collections::BTreeSet::new();
343        for hit in hits {
344            if hit.record.scope != self.scope {
345                return Err(format!(
346                    "memory recall {} escaped the requested scope",
347                    hit.record.record_id
348                ));
349            }
350            if hit.record.record_id.is_empty() || !record_ids.insert(hit.record.record_id.as_str())
351            {
352                return Err("memory recall record_id must be non-empty and unique".into());
353            }
354            if !hit.score.is_finite() || !(0.0..=1.0).contains(&hit.score) {
355                return Err(format!(
356                    "memory recall {} score must be finite and between zero and one",
357                    hit.record.record_id
358                ));
359            }
360            if self.min_score.is_some_and(|minimum| hit.score < minimum) {
361                return Err(format!(
362                    "memory recall {} score is below min_score",
363                    hit.record.record_id
364                ));
365            }
366            if !self.kinds.is_empty() && !self.kinds.contains(&hit.record.kind) {
367                return Err(format!(
368                    "memory recall {} kind was not requested",
369                    hit.record.record_id
370                ));
371            }
372        }
373        Ok(())
374    }
375}
376
377/// One scored host recall. `score` is relevance, distinct from the record's stored confidence.
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct MemoryRecall {
381    pub record: MemoryRecord,
382    pub score: f64,
383    pub why: String,
384}
385
386/// Journaled recall lifecycle for one record (M3). Carried on `MemoryRecalled` so the host mirrors
387/// the kernel-authoritative counts into its durable store.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct MemoryRecallLifecycle {
391    pub record_id: String,
392    pub recall_count: u64,
393    pub last_recalled_at: u64,
394}
395
396/// Memory validation error.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(tag = "error_kind", rename_all = "snake_case")]
399pub enum MemoryValidationError {
400    MissingRequiredField { field: String },
401    ContentTooLarge { size: u32, limit: u32 },
402    ForbiddenPattern { pattern: String, reason: String },
403    InvalidKind { kind: String },
404    NameTooLong { length: usize, limit: usize },
405}
406
407/// Memory validation rules (kernel-enforced).
408#[derive(Debug, Clone)]
409pub struct MemoryValidation {
410    pub max_size_bytes: u32,
411    pub max_name_length: usize,
412    pub required_fields: Vec<String>,
413    pub forbidden_patterns: Vec<(String, &'static str)>,
414}
415
416impl MemoryValidation {
417    /// Validate a memory write request.
418    pub fn validate(&self, record: &MemoryRecord) -> Result<(), MemoryValidationError> {
419        // Check required fields
420        for field in &self.required_fields {
421            match field.as_str() {
422                "record_id" if record.record_id.is_empty() => {
423                    return Err(MemoryValidationError::MissingRequiredField {
424                        field: "record_id".into(),
425                    });
426                }
427                "scope.tenant_id" if record.scope.tenant_id.is_empty() => {
428                    return Err(MemoryValidationError::MissingRequiredField {
429                        field: "scope.tenant_id".into(),
430                    });
431                }
432                "scope.namespace" if record.scope.namespace.is_empty() => {
433                    return Err(MemoryValidationError::MissingRequiredField {
434                        field: "scope.namespace".into(),
435                    });
436                }
437                "name" if record.name.is_empty() => {
438                    return Err(MemoryValidationError::MissingRequiredField {
439                        field: "name".into(),
440                    });
441                }
442                "description" if record.description.is_empty() => {
443                    return Err(MemoryValidationError::MissingRequiredField {
444                        field: "description".into(),
445                    });
446                }
447                _ => {}
448            }
449        }
450
451        // Check name length
452        if record.name.len() > self.max_name_length {
453            return Err(MemoryValidationError::NameTooLong {
454                length: record.name.len(),
455                limit: self.max_name_length,
456            });
457        }
458
459        // Check content size
460        if record.content.len() > self.max_size_bytes as usize {
461            return Err(MemoryValidationError::ContentTooLarge {
462                size: record.content.len() as u32,
463                limit: self.max_size_bytes,
464            });
465        }
466
467        // Check forbidden patterns
468        for (pattern, reason) in &self.forbidden_patterns {
469            if record.content.contains(pattern) {
470                return Err(MemoryValidationError::ForbiddenPattern {
471                    pattern: pattern.clone(),
472                    reason: reason.to_string(),
473                });
474            }
475        }
476
477        Ok(())
478    }
479}
480
481/// Validate a memory write request with default validation rules.
482pub fn validate_memory_write(record: &MemoryRecord) -> Result<(), MemoryValidationError> {
483    MemoryValidation::default().validate(record)
484}
485
486/// Default validation rules (aligned with Claude Code's "what NOT to store").
487impl Default for MemoryValidation {
488    fn default() -> Self {
489        Self {
490            max_size_bytes: 10_000,
491            max_name_length: 100,
492            required_fields: vec![
493                "record_id".into(),
494                "scope.tenant_id".into(),
495                "scope.namespace".into(),
496                "name".into(),
497                "description".into(),
498            ],
499            // P13: no baked-in content heuristics — what belongs in memory is host/model
500            // judgment. Hosts can apply additional checks before submitting a proposal.
501            forbidden_patterns: Vec::new(),
502        }
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn scope(namespace: &str) -> MemoryScope {
511        MemoryScope::new("tenant-a", namespace)
512    }
513
514    fn record(record_id: &str, namespace: &str, name: &str, content: &str) -> MemoryRecord {
515        MemoryRecord {
516            record_id: record_id.into(),
517            scope: scope(namespace),
518            name: name.into(),
519            kind: MemoryKind::Project,
520            content: content.into(),
521            description: format!("description for {name}"),
522            provenance: MemoryProvenance {
523                session_id: Some("session-1".into()),
524                author: MemoryAuthor::Extraction,
525                trust: MemoryTrustLevel::Untrusted,
526                evidence_refs: vec!["turn:1".into()],
527            },
528            created_at: 10,
529            updated_at: 10,
530            last_recalled_at: None,
531            recall_count: 0,
532            confidence: 0.8,
533            links: Vec::new(),
534            pinned: false,
535            ttl_days: Some(30),
536        }
537    }
538
539    #[test]
540    fn memory_kind_labels_correct() {
541        assert_eq!(MemoryKind::User.label(), "user");
542        assert_eq!(MemoryKind::Feedback.label(), "feedback");
543        assert_eq!(MemoryKind::Project.label(), "project");
544        assert_eq!(MemoryKind::Reference.label(), "reference");
545    }
546
547    #[test]
548    fn validation_passes_for_valid_request() {
549        let validation = MemoryValidation::default();
550        let record = record("mem-1", "project:p1", "test-memory", "This is fine");
551        assert!(validation.validate(&record).is_ok());
552    }
553
554    #[test]
555    fn validation_rejects_missing_name() {
556        let validation = MemoryValidation::default();
557        let mut record = record("mem-1", "project:p1", "name", "content");
558        record.name.clear();
559        assert!(matches!(
560            validation.validate(&record),
561            Err(MemoryValidationError::MissingRequiredField { field }) if field == "name"
562        ));
563    }
564
565    #[test]
566    fn validation_rejects_host_configured_forbidden_pattern() {
567        // P13: no baked-in content heuristics — the mechanism only bites when a HOST
568        // configures additional forbidden-prefix checks in its own executor policy.
569        let mut validation = MemoryValidation::default();
570        assert!(validation.forbidden_patterns.is_empty(), "no defaults");
571        validation
572            .forbidden_patterns
573            .push(("TODO:".into(), "transient tasks do not belong in memory"));
574        let record = record("mem-1", "project:p1", "bad-memory", "TODO: ship it");
575        assert!(matches!(
576            validation.validate(&record),
577            Err(MemoryValidationError::ForbiddenPattern { .. })
578        ));
579    }
580
581    #[test]
582    fn validation_rejects_oversized_content() {
583        let validation = MemoryValidation::default();
584        let record = record("mem-1", "project:p1", "huge-memory", &"x".repeat(20_000));
585        assert!(matches!(
586            validation.validate(&record),
587            Err(MemoryValidationError::ContentTooLarge { .. })
588        ));
589    }
590
591    #[test]
592    fn memory_query_defaults_top_k_to_5() {
593        let query = MemoryQuery {
594            scope: scope("project:p1"),
595            query: "test".into(),
596            ..Default::default()
597        };
598        assert_eq!(query.top_k, 5);
599    }
600
601    #[test]
602    fn legacy_memory_wire_shapes_are_rejected() {
603        assert!(
604            serde_json::from_value::<MemoryQuery>(serde_json::json!({
605                "current_context": "legacy",
606                "top_k": 5
607            }))
608            .is_err()
609        );
610        assert!(
611            serde_json::from_value::<MemoryRecord>(serde_json::json!({
612                "metadata": { "name": "legacy" },
613                "content": "old write request"
614            }))
615            .is_err()
616        );
617    }
618
619    #[test]
620    fn memory_record_wire_shape_carries_identity_scope_provenance_and_lifecycle() {
621        let value = serde_json::to_value(record("mem-1", "project:p1", "build", "use cargo"))
622            .expect("record serializes");
623
624        assert_eq!(value["record_id"], "mem-1");
625        assert_eq!(value["scope"]["tenant_id"], "tenant-a");
626        assert_eq!(value["scope"]["namespace"], "project:p1");
627        assert_eq!(value["kind"], "project");
628        assert_eq!(value["provenance"]["author"], "extraction");
629        assert_eq!(value["provenance"]["trust"], "untrusted");
630        assert_eq!(value["recall_count"], 0);
631        assert_eq!(value["ttl_days"], 30);
632    }
633
634    #[test]
635    fn scored_recall_updates_lifecycle_and_suggests_promotion() {
636        let mut store = MemoryRecordStore::default();
637        let record = record("recall-me", "agent-a", "preferences", "Use terse answers");
638        store.upsert(record).unwrap();
639
640        let recalled = store.record_recall("recall-me", 42).expect("record exists");
641        assert_eq!(recalled.recall_count, 1);
642        assert_eq!(recalled.last_recalled_at, Some(42));
643        assert!(!store.promotion_suggested("recall-me", 2));
644
645        store.record_recall("recall-me", 43).unwrap();
646        assert!(store.promotion_suggested("recall-me", 2));
647    }
648
649    #[test]
650    fn memory_retention_score_ranks_recalled_and_pinned_above_cold() {
651        // Reference formula the host mirrors when bounding its store: a recalled record outranks an
652        // untouched one, and a pin is absolute.
653        let mut hot = record("hot", "agent-a", "hot", "frequently useful fact");
654        hot.recall_count = 3;
655        hot.last_recalled_at = Some(9);
656        let cold = record("cold", "agent-a", "cold", "never referenced fact");
657        assert!(
658            memory_retention_score(&hot, 10, 0) > memory_retention_score(&cold, 10, 0),
659            "a recalled record beats a cold one"
660        );
661
662        let mut pinned = cold.clone();
663        pinned.pinned = true;
664        assert_eq!(
665            memory_retention_score(&pinned, 10, 0),
666            i64::MAX,
667            "pin is absolute"
668        );
669
670        // The TTL/staleness discount (host-supplied, clock-based) lowers the score.
671        assert!(memory_retention_score(&cold, 10, 500_000) < memory_retention_score(&cold, 10, 0));
672    }
673
674    #[test]
675    fn memory_key_is_scope_kind_and_name() {
676        let project = record("mem-project", "project:p1", "build", "cargo");
677        let other_scope = record("mem-project-2", "project:p2", "build", "npm");
678        let mut other_kind = project.clone();
679        other_kind.record_id = "mem-user".into();
680        other_kind.kind = MemoryKind::User;
681
682        assert_ne!(project.key(), other_scope.key());
683        assert_ne!(project.key(), other_kind.key());
684        assert_eq!(project.key().name, "build");
685    }
686
687    #[test]
688    fn scoped_upsert_preserves_stable_identity_and_recall_lifecycle() {
689        let mut store = MemoryRecordStore::default();
690        let mut existing = record("stable-id", "project:p1", "build", "cargo build");
691        existing.recall_count = 7;
692        existing.last_recalled_at = Some(80);
693        assert!(matches!(
694            store.upsert(existing).unwrap(),
695            MemoryUpsertOutcome::Inserted { .. }
696        ));
697
698        let mut replacement = record("incoming-id", "project:p1", "build", "cargo nextest");
699        replacement.created_at = 90;
700        replacement.updated_at = 100;
701        replacement.provenance.author = MemoryAuthor::Host;
702        replacement.provenance.trust = MemoryTrustLevel::HostVerified;
703        let outcome = store.upsert(replacement).unwrap();
704
705        assert_eq!(
706            outcome,
707            MemoryUpsertOutcome::Updated {
708                record_id: "stable-id".into()
709            }
710        );
711        let stored = store
712            .get(&scope("project:p1"), MemoryKind::Project, "build")
713            .unwrap();
714        assert_eq!(stored.record_id, "stable-id");
715        assert_eq!(stored.created_at, 10);
716        assert_eq!(stored.updated_at, 100);
717        assert_eq!(stored.content, "cargo nextest");
718        assert_eq!(stored.recall_count, 7);
719        assert_eq!(stored.last_recalled_at, Some(80));
720        assert_eq!(stored.provenance.author, MemoryAuthor::Host);
721        assert_eq!(stored.provenance.trust, MemoryTrustLevel::HostVerified);
722
723        let mut stale_update = record("another-id", "project:p1", "build", "older fact");
724        stale_update.updated_at = 50;
725        store.upsert(stale_update).unwrap();
726        assert_eq!(
727            store
728                .get(&scope("project:p1"), MemoryKind::Project, "build")
729                .unwrap()
730                .updated_at,
731            100,
732            "upsert cannot move the lifecycle clock backwards"
733        );
734    }
735
736    #[test]
737    fn same_name_in_a_different_scope_inserts_a_distinct_record() {
738        let mut store = MemoryRecordStore::default();
739        store
740            .upsert(record("mem-p1", "project:p1", "build", "cargo"))
741            .unwrap();
742        store
743            .upsert(record("mem-p2", "project:p2", "build", "npm"))
744            .unwrap();
745
746        assert_eq!(store.len(), 2);
747        assert_eq!(
748            store
749                .get(&scope("project:p2"), MemoryKind::Project, "build")
750                .unwrap()
751                .record_id,
752            "mem-p2"
753        );
754    }
755
756    #[test]
757    fn record_id_collision_across_keys_is_rejected() {
758        let mut store = MemoryRecordStore::default();
759        store
760            .upsert(record("same-id", "project:p1", "build", "cargo"))
761            .unwrap();
762
763        let error = store
764            .upsert(record("same-id", "project:p2", "deploy", "ship"))
765            .expect_err("record id cannot alias another scoped key");
766
767        assert!(matches!(
768            error,
769            MemoryUpsertError::RecordIdConflict { record_id, .. } if record_id == "same-id"
770        ));
771        assert_eq!(store.len(), 1);
772    }
773}