1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum MemoryKind {
14 User,
16 Feedback,
18 Project,
20 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#[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#[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#[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#[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#[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#[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#[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 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
249pub fn memory_retention_score(
257 record: &MemoryRecord,
258 current_turn: u64,
259 stale_discount_ppm: u32,
260) -> i64 {
261 use crate::mm::value::{deterministic_retention_score, RetentionFeatures, RetentionKind};
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 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#[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#[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#[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#[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#[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 pub fn validate(&self, record: &MemoryRecord) -> Result<(), MemoryValidationError> {
419 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 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 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 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
481pub fn validate_memory_write(record: &MemoryRecord) -> Result<(), MemoryValidationError> {
483 MemoryValidation::default().validate(record)
484}
485
486#[derive(Debug, Clone)]
499pub struct MemoryPolicy {
500 pub memory_path: String,
501 pub stale_warning_days: u32,
502 pub retrieval_top_k: usize,
503 pub validation_enabled: bool,
504 pub max_content_bytes: Option<u32>,
505 pub max_name_length: Option<usize>,
506 pub promotion_recall_threshold: Option<u64>,
509}
510
511impl Default for MemoryPolicy {
512 fn default() -> Self {
513 Self {
514 memory_path: String::new(),
515 stale_warning_days: 2,
516 retrieval_top_k: 5,
517 validation_enabled: true,
518 max_content_bytes: None,
519 max_name_length: None,
520 promotion_recall_threshold: None,
521 }
522 }
523}
524
525impl MemoryPolicy {
526 pub fn validation(&self) -> MemoryValidation {
529 let mut v = MemoryValidation::default();
530 if let Some(bytes) = self.max_content_bytes {
531 v.max_size_bytes = bytes;
532 }
533 if let Some(len) = self.max_name_length {
534 v.max_name_length = len;
535 }
536 v
537 }
538
539 pub fn clamp_top_k(&self, requested: usize) -> usize {
541 requested.min(self.retrieval_top_k)
542 }
543}
544
545impl Default for MemoryValidation {
547 fn default() -> Self {
548 Self {
549 max_size_bytes: 10_000,
550 max_name_length: 100,
551 required_fields: vec![
552 "record_id".into(),
553 "scope.tenant_id".into(),
554 "scope.namespace".into(),
555 "name".into(),
556 "description".into(),
557 ],
558 forbidden_patterns: Vec::new(),
561 }
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn scope(namespace: &str) -> MemoryScope {
570 MemoryScope::new("tenant-a", namespace)
571 }
572
573 fn record(record_id: &str, namespace: &str, name: &str, content: &str) -> MemoryRecord {
574 MemoryRecord {
575 record_id: record_id.into(),
576 scope: scope(namespace),
577 name: name.into(),
578 kind: MemoryKind::Project,
579 content: content.into(),
580 description: format!("description for {name}"),
581 provenance: MemoryProvenance {
582 session_id: Some("session-1".into()),
583 author: MemoryAuthor::Extraction,
584 trust: MemoryTrustLevel::Untrusted,
585 evidence_refs: vec!["turn:1".into()],
586 },
587 created_at: 10,
588 updated_at: 10,
589 last_recalled_at: None,
590 recall_count: 0,
591 confidence: 0.8,
592 links: Vec::new(),
593 pinned: false,
594 ttl_days: Some(30),
595 }
596 }
597
598 #[test]
599 fn memory_kind_labels_correct() {
600 assert_eq!(MemoryKind::User.label(), "user");
601 assert_eq!(MemoryKind::Feedback.label(), "feedback");
602 assert_eq!(MemoryKind::Project.label(), "project");
603 assert_eq!(MemoryKind::Reference.label(), "reference");
604 }
605
606 #[test]
607 fn validation_passes_for_valid_request() {
608 let validation = MemoryValidation::default();
609 let record = record("mem-1", "project:p1", "test-memory", "This is fine");
610 assert!(validation.validate(&record).is_ok());
611 }
612
613 #[test]
614 fn validation_rejects_missing_name() {
615 let validation = MemoryValidation::default();
616 let mut record = record("mem-1", "project:p1", "name", "content");
617 record.name.clear();
618 assert!(matches!(
619 validation.validate(&record),
620 Err(MemoryValidationError::MissingRequiredField { field }) if field == "name"
621 ));
622 }
623
624 #[test]
625 fn validation_rejects_host_configured_forbidden_pattern() {
626 let mut validation = MemoryValidation::default();
629 assert!(validation.forbidden_patterns.is_empty(), "no defaults");
630 validation
631 .forbidden_patterns
632 .push(("TODO:".into(), "transient tasks do not belong in memory"));
633 let record = record("mem-1", "project:p1", "bad-memory", "TODO: ship it");
634 assert!(matches!(
635 validation.validate(&record),
636 Err(MemoryValidationError::ForbiddenPattern { .. })
637 ));
638 }
639
640 #[test]
641 fn validation_rejects_oversized_content() {
642 let validation = MemoryValidation::default();
643 let record = record("mem-1", "project:p1", "huge-memory", &"x".repeat(20_000));
644 assert!(matches!(
645 validation.validate(&record),
646 Err(MemoryValidationError::ContentTooLarge { .. })
647 ));
648 }
649
650 #[test]
651 fn memory_query_defaults_top_k_to_5() {
652 let query = MemoryQuery {
653 scope: scope("project:p1"),
654 query: "test".into(),
655 ..Default::default()
656 };
657 assert_eq!(query.top_k, 5);
658 }
659
660 #[test]
661 fn legacy_memory_wire_shapes_are_rejected() {
662 assert!(
663 serde_json::from_value::<MemoryQuery>(serde_json::json!({
664 "current_context": "legacy",
665 "top_k": 5
666 }))
667 .is_err()
668 );
669 assert!(
670 serde_json::from_value::<MemoryRecord>(serde_json::json!({
671 "metadata": { "name": "legacy" },
672 "content": "old write request"
673 }))
674 .is_err()
675 );
676 }
677
678 #[test]
679 fn memory_record_wire_shape_carries_identity_scope_provenance_and_lifecycle() {
680 let value = serde_json::to_value(record("mem-1", "project:p1", "build", "use cargo"))
681 .expect("record serializes");
682
683 assert_eq!(value["record_id"], "mem-1");
684 assert_eq!(value["scope"]["tenant_id"], "tenant-a");
685 assert_eq!(value["scope"]["namespace"], "project:p1");
686 assert_eq!(value["kind"], "project");
687 assert_eq!(value["provenance"]["author"], "extraction");
688 assert_eq!(value["provenance"]["trust"], "untrusted");
689 assert_eq!(value["recall_count"], 0);
690 assert_eq!(value["ttl_days"], 30);
691 }
692
693 #[test]
694 fn scored_recall_updates_lifecycle_and_suggests_promotion() {
695 let mut store = MemoryRecordStore::default();
696 let record = record("recall-me", "agent-a", "preferences", "Use terse answers");
697 store.upsert(record).unwrap();
698
699 let recalled = store.record_recall("recall-me", 42).expect("record exists");
700 assert_eq!(recalled.recall_count, 1);
701 assert_eq!(recalled.last_recalled_at, Some(42));
702 assert!(!store.promotion_suggested("recall-me", 2));
703
704 store.record_recall("recall-me", 43).unwrap();
705 assert!(store.promotion_suggested("recall-me", 2));
706 }
707
708 #[test]
709 fn memory_retention_score_ranks_recalled_and_pinned_above_cold() {
710 let mut hot = record("hot", "agent-a", "hot", "frequently useful fact");
713 hot.recall_count = 3;
714 hot.last_recalled_at = Some(9);
715 let cold = record("cold", "agent-a", "cold", "never referenced fact");
716 assert!(
717 memory_retention_score(&hot, 10, 0) > memory_retention_score(&cold, 10, 0),
718 "a recalled record beats a cold one"
719 );
720
721 let mut pinned = cold.clone();
722 pinned.pinned = true;
723 assert_eq!(memory_retention_score(&pinned, 10, 0), i64::MAX, "pin is absolute");
724
725 assert!(memory_retention_score(&cold, 10, 500_000) < memory_retention_score(&cold, 10, 0));
727 }
728
729 #[test]
730 fn memory_key_is_scope_kind_and_name() {
731 let project = record("mem-project", "project:p1", "build", "cargo");
732 let other_scope = record("mem-project-2", "project:p2", "build", "npm");
733 let mut other_kind = project.clone();
734 other_kind.record_id = "mem-user".into();
735 other_kind.kind = MemoryKind::User;
736
737 assert_ne!(project.key(), other_scope.key());
738 assert_ne!(project.key(), other_kind.key());
739 assert_eq!(project.key().name, "build");
740 }
741
742 #[test]
743 fn scoped_upsert_preserves_stable_identity_and_recall_lifecycle() {
744 let mut store = MemoryRecordStore::default();
745 let mut existing = record("stable-id", "project:p1", "build", "cargo build");
746 existing.recall_count = 7;
747 existing.last_recalled_at = Some(80);
748 assert!(matches!(
749 store.upsert(existing).unwrap(),
750 MemoryUpsertOutcome::Inserted { .. }
751 ));
752
753 let mut replacement = record("incoming-id", "project:p1", "build", "cargo nextest");
754 replacement.created_at = 90;
755 replacement.updated_at = 100;
756 replacement.provenance.author = MemoryAuthor::Host;
757 replacement.provenance.trust = MemoryTrustLevel::HostVerified;
758 let outcome = store.upsert(replacement).unwrap();
759
760 assert_eq!(
761 outcome,
762 MemoryUpsertOutcome::Updated {
763 record_id: "stable-id".into()
764 }
765 );
766 let stored = store
767 .get(&scope("project:p1"), MemoryKind::Project, "build")
768 .unwrap();
769 assert_eq!(stored.record_id, "stable-id");
770 assert_eq!(stored.created_at, 10);
771 assert_eq!(stored.updated_at, 100);
772 assert_eq!(stored.content, "cargo nextest");
773 assert_eq!(stored.recall_count, 7);
774 assert_eq!(stored.last_recalled_at, Some(80));
775 assert_eq!(stored.provenance.author, MemoryAuthor::Host);
776 assert_eq!(stored.provenance.trust, MemoryTrustLevel::HostVerified);
777
778 let mut stale_update = record("another-id", "project:p1", "build", "older fact");
779 stale_update.updated_at = 50;
780 store.upsert(stale_update).unwrap();
781 assert_eq!(
782 store
783 .get(&scope("project:p1"), MemoryKind::Project, "build")
784 .unwrap()
785 .updated_at,
786 100,
787 "upsert cannot move the lifecycle clock backwards"
788 );
789 }
790
791 #[test]
792 fn same_name_in_a_different_scope_inserts_a_distinct_record() {
793 let mut store = MemoryRecordStore::default();
794 store
795 .upsert(record("mem-p1", "project:p1", "build", "cargo"))
796 .unwrap();
797 store
798 .upsert(record("mem-p2", "project:p2", "build", "npm"))
799 .unwrap();
800
801 assert_eq!(store.len(), 2);
802 assert_eq!(
803 store
804 .get(&scope("project:p2"), MemoryKind::Project, "build")
805 .unwrap()
806 .record_id,
807 "mem-p2"
808 );
809 }
810
811 #[test]
812 fn record_id_collision_across_keys_is_rejected() {
813 let mut store = MemoryRecordStore::default();
814 store
815 .upsert(record("same-id", "project:p1", "build", "cargo"))
816 .unwrap();
817
818 let error = store
819 .upsert(record("same-id", "project:p2", "deploy", "ship"))
820 .expect_err("record id cannot alias another scoped key");
821
822 assert!(matches!(
823 error,
824 MemoryUpsertError::RecordIdConflict { record_id, .. } if record_id == "same-id"
825 ));
826 assert_eq!(store.len(), 1);
827 }
828}