1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use kmp_domain::{MemoryDimensionIdentity, MemoryRelationType, RelationSemanticClass, SourceKind};
5
6use crate::ApplicationError;
7use crate::commands::{UpdateContextChange, UpdateContextCommand};
8use crate::memory::{
9 MemoryAcceptedCounts, MemoryCoordinateData, MemoryData, MemoryDimensionData,
10 MemoryIngestCommand, MemoryIngestOutcome,
11};
12
13use super::ref_boundary::{
14 validate_ref_token, validate_supplied_entry_ref, validate_supplied_evidence_ref,
15 validate_supplied_member_ref,
16};
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct ExistingMemoryRefs {
20 pub refs: BTreeSet<String>,
21 pub dimensions: BTreeSet<String>,
22 pub max_sequences: BTreeMap<(String, String), u32>,
25}
26
27pub fn translate_memory_ingest(
28 command: &MemoryIngestCommand,
29 existing: &ExistingMemoryRefs,
30) -> Result<(UpdateContextCommand, MemoryIngestOutcome), ApplicationError> {
31 validate_command(command)?;
32 let ingested_at = kernel_ingested_at();
33 let memory = namespaced_memory(&command.about, &command.memory, existing, &ingested_at)?;
34
35 let changes = memory_changes(&memory)?;
36 let outcome = MemoryIngestOutcome {
37 about: command.about.clone(),
38 memory_id: memory_id_from_idempotency_key(&command.idempotency_key),
39 accepted: MemoryAcceptedCounts {
40 entries: command.memory.entries.len(),
41 relations: command.memory.relations.len(),
42 evidence: command.memory.evidence.len(),
43 },
44 read_after_write_ready: false,
45 warnings: Vec::new(),
46 };
47
48 Ok((
49 UpdateContextCommand {
50 root_node_id: command.about.clone(),
51 role: "memory".to_string(),
52 work_item_id: command.idempotency_key.clone(),
53 changes,
54 expected_revision: None,
55 expected_content_hash: None,
56 idempotency_key: Some(command.idempotency_key.clone()),
57 logical_digest: Some(logical_digest(command)),
58 requested_by: command
59 .provenance
60 .as_ref()
61 .map(|provenance| provenance.source_agent.clone()),
62 },
63 outcome,
64 ))
65}
66
67fn validate_command(command: &MemoryIngestCommand) -> Result<(), ApplicationError> {
68 require_non_empty(&command.about, "about")?;
69 validate_ref_token("about", &command.about).map_err(ApplicationError::Validation)?;
70 require_non_empty(&command.idempotency_key, "idempotency_key")?;
71 if let Some(provenance) = command.provenance.as_ref() {
72 SourceKind::parse(&provenance.source_kind).map_err(|error| {
73 ApplicationError::Validation(format!(
74 "memory provenance source_kind is invalid: {error}"
75 ))
76 })?;
77 require_non_empty(&provenance.source_agent, "provenance.source_agent")?;
78 require_non_empty(&provenance.observed_at, "provenance.observed_at")?;
79 }
80
81 Ok(())
82}
83
84fn namespaced_memory(
85 about: &str,
86 memory: &MemoryData,
87 existing: &ExistingMemoryRefs,
88 ingested_at: &str,
89) -> Result<MemoryData, ApplicationError> {
90 if memory.dimensions.is_empty() && existing.dimensions.is_empty() {
91 return Err(ApplicationError::Validation(
92 "memory.dimensions must not be empty when no existing memory dimensions are available"
93 .to_string(),
94 ));
95 }
96 if memory.entries.is_empty() {
97 return Err(ApplicationError::Validation(
98 "memory.entries must not be empty".to_string(),
99 ));
100 }
101
102 let mut known_refs = existing.refs.clone();
103 known_refs.extend(existing.dimensions.iter().cloned());
104 known_refs.insert(about.to_string());
112 let mut dimension_ids = existing.dimensions.clone();
113 let mut dimension_aliases = existing_dimension_aliases(about, existing);
114 let mut declared_dimension_kinds = BTreeMap::new();
115 let mut declared_dimension_refs = BTreeSet::new();
116 let mut max_sequences = existing.max_sequences.clone();
117 let mut dimensions = Vec::new();
118 for dimension in &memory.dimensions {
119 require_non_empty(&dimension.id, "memory.dimensions[].id")?;
120 validate_ref_token("memory.dimensions[].id", &dimension.id)
121 .map_err(ApplicationError::Validation)?;
122 require_non_empty(&dimension.kind, "memory.dimensions[].kind")?;
123 let dimension_identity = dimension_identity(about, &dimension.id)?;
124 let dimension_ref = dimension_identity.node_id();
125 declared_dimension_kinds.insert(dimension_ref.clone(), dimension.kind.clone());
126 insert_unique(
127 &mut declared_dimension_refs,
128 &dimension_ref,
129 "memory dimension",
130 )?;
131 if existing.dimensions.contains(&dimension_ref) {
132 dimension_aliases
133 .entry(dimension.id.clone())
134 .or_insert_with(|| dimension_ref.clone());
135 known_refs.insert(dimension_ref);
136 continue;
137 }
138 insert_unique(&mut dimension_ids, &dimension_ref, "memory dimension")?;
139 if dimension_aliases
140 .insert(dimension.id.clone(), dimension_ref.clone())
141 .is_some()
142 {
143 return Err(ApplicationError::Validation(format!(
144 "duplicate memory dimension `{}`",
145 dimension.id
146 )));
147 }
148 known_refs.insert(dimension_ref.clone());
149
150 let mut metadata = dimension.metadata.clone();
151 metadata
152 .entry("memory_about".to_string())
153 .or_insert_with(|| about.to_string());
154 metadata
155 .entry("memory_dimension_id".to_string())
156 .or_insert_with(|| dimension.id.clone());
157 dimensions.push(MemoryDimensionData {
158 id: dimension_ref,
159 kind: dimension.kind.clone(),
160 title: dimension.title.clone(),
161 metadata,
162 });
163 }
164
165 let mut entry_ids = BTreeSet::new();
166 let mut entries = Vec::new();
167 for entry in &memory.entries {
168 require_non_empty(&entry.id, "memory.entries[].id")?;
169 validate_supplied_entry_ref(about, "memory.entries[].id", &entry.id)
170 .map_err(ApplicationError::Validation)?;
171 require_non_empty(&entry.kind, "memory.entries[].kind")?;
172 require_non_empty(&entry.text, "memory.entries[].text")?;
173 if entry.coordinates.is_empty() {
174 return Err(ApplicationError::Validation(format!(
175 "memory entry `{}` must include at least one coordinate",
176 entry.id
177 )));
178 }
179 insert_unique(&mut entry_ids, &entry.id, "memory entry")?;
180 known_refs.insert(entry.id.clone());
181
182 let mut coordinates = Vec::new();
183 for coordinate in &entry.coordinates {
184 let mut coordinate = normalize_coordinate(
185 coordinate,
186 "memory.entries[].coordinates[]",
187 "memory entry",
188 &dimension_aliases,
189 &dimension_ids,
190 &declared_dimension_kinds,
191 )?;
192 coordinate
193 .ingested_at
194 .get_or_insert_with(|| ingested_at.to_string());
195 let sequence_key = (coordinate.dimension.clone(), coordinate.scope_id.clone());
196 let frontier = max_sequences.entry(sequence_key).or_default();
197 match coordinate.sequence {
198 Some(sequence) => *frontier = (*frontier).max(sequence),
199 None => {
200 *frontier = frontier.checked_add(1).ok_or_else(|| {
201 ApplicationError::Validation(
202 "memory coordinate sequence space is exhausted".to_string(),
203 )
204 })?;
205 coordinate.sequence = Some(*frontier);
206 }
207 }
208 coordinates.push(coordinate);
209 }
210 let mut entry = entry.clone();
211 entry.coordinates = coordinates;
212 entries.push(entry);
213 }
214
215 let mut relations = Vec::new();
216 for relation in &memory.relations {
217 require_non_empty(&relation.source_ref, "memory.relations[].source_ref")?;
218 require_non_empty(&relation.target_ref, "memory.relations[].target_ref")?;
219 require_non_empty(&relation.rel, "memory.relations[].rel")?;
220 let relation_type = MemoryRelationType::new(&relation.rel).map_err(|error| {
221 ApplicationError::Validation(format!("memory relation type is invalid: {error}"))
222 })?;
223 let semantic_class =
224 RelationSemanticClass::parse(&relation.semantic_class).map_err(|error| {
225 ApplicationError::Validation(format!("memory relation class is invalid: {error}"))
226 })?;
227 let source_ref = normalize_ref(&relation.source_ref, &dimension_aliases);
228 let target_ref = normalize_ref(&relation.target_ref, &dimension_aliases);
229 validate_supplied_member_ref(about, "memory.relations[].from", &source_ref)
230 .map_err(ApplicationError::Validation)?;
231 validate_supplied_member_ref(about, "memory.relations[].to", &target_ref)
232 .map_err(ApplicationError::Validation)?;
233 if !known_refs.contains(&source_ref) || !known_refs.contains(&target_ref) {
234 return Err(ApplicationError::Validation(format!(
235 "memory relation `{}` -> `{}` references unknown refs",
236 relation.source_ref, relation.target_ref
237 )));
238 }
239 if semantic_class != RelationSemanticClass::Structural {
240 if relation
241 .confidence
242 .as_deref()
243 .unwrap_or("")
244 .trim()
245 .is_empty()
246 {
247 return Err(ApplicationError::Validation(
248 "non-structural memory relations require confidence".to_string(),
249 ));
250 }
251 if relation.why.as_deref().unwrap_or("").trim().is_empty()
252 && relation.evidence.as_deref().unwrap_or("").trim().is_empty()
253 {
254 return Err(ApplicationError::Validation(
255 "non-structural memory relations require why or evidence".to_string(),
256 ));
257 }
258 }
259 validate_positive_optional(relation.sequence, "memory.relations[].sequence")?;
260 let coordinate = relation
261 .coordinate
262 .as_ref()
263 .map(|coordinate| {
264 normalize_coordinate(
265 coordinate,
266 "memory.relations[].coordinate",
267 "memory relation",
268 &dimension_aliases,
269 &dimension_ids,
270 &declared_dimension_kinds,
271 )
272 })
273 .transpose()?
274 .map(|mut coordinate| {
275 coordinate
276 .ingested_at
277 .get_or_insert_with(|| ingested_at.to_string());
278 coordinate
279 });
280 let mut relation = relation.clone();
281 relation.source_ref = source_ref;
282 relation.target_ref = target_ref;
283 relation.decision_id = normalize_optional_member_ref(
284 about,
285 "memory.relations[].decision_id",
286 relation.decision_id.as_deref(),
287 &dimension_aliases,
288 )?;
289 relation.caused_by_node_id = normalize_optional_member_ref(
290 about,
291 "memory.relations[].caused_by_node_id",
292 relation.caused_by_node_id.as_deref(),
293 &dimension_aliases,
294 )?;
295 relation.rel = relation_type.as_str().to_string();
296 relation.coordinate = coordinate;
297 relations.push(relation);
298 }
299
300 let mut evidence_ids = BTreeSet::new();
301 let mut evidence_items = Vec::new();
302 for evidence in &memory.evidence {
303 require_non_empty(&evidence.id, "memory.evidence[].id")?;
304 validate_supplied_evidence_ref(about, "memory.evidence[].id", &evidence.id)
305 .map_err(ApplicationError::Validation)?;
306 require_non_empty(&evidence.text, "memory.evidence[].text")?;
307 insert_unique(&mut evidence_ids, &evidence.id, "memory evidence")?;
308 known_refs.insert(evidence.id.clone());
309 let mut supports = Vec::new();
310 for supported in &evidence.supports {
311 require_non_empty(supported, "memory.evidence[].supports[]")?;
312 let supported_ref = normalize_ref(supported, &dimension_aliases);
313 validate_supplied_member_ref(about, "memory.evidence[].supports[]", &supported_ref)
314 .map_err(ApplicationError::Validation)?;
315 if !known_refs.contains(&supported_ref) {
316 return Err(ApplicationError::Validation(format!(
317 "memory evidence `{}` supports unknown ref `{supported}`",
318 evidence.id
319 )));
320 }
321 supports.push(supported_ref);
322 }
323 let mut evidence = evidence.clone();
324 evidence.supports = supports;
325 evidence_items.push(evidence);
326 }
327
328 Ok(MemoryData {
329 dimensions,
330 entries,
331 relations,
332 evidence: evidence_items,
333 })
334}
335
336fn kernel_ingested_at() -> String {
341 let since_epoch = SystemTime::now()
342 .duration_since(UNIX_EPOCH)
343 .unwrap_or_default();
344 format!(
345 "unix:{:012}:{:09}",
346 since_epoch.as_secs() + 100_000_000_000,
347 since_epoch.subsec_nanos()
348 )
349}
350
351fn existing_dimension_aliases(
352 about: &str,
353 existing: &ExistingMemoryRefs,
354) -> BTreeMap<String, String> {
355 existing
356 .dimensions
357 .iter()
358 .filter_map(|dimension_ref| {
359 let identity = MemoryDimensionIdentity::parse(dimension_ref)?;
360 (identity.about() == about)
361 .then(|| (identity.dimension_id().to_string(), dimension_ref.clone()))
362 })
363 .collect()
364}
365
366fn dimension_identity(
367 about: &str,
368 dimension_id: &str,
369) -> Result<MemoryDimensionIdentity, ApplicationError> {
370 MemoryDimensionIdentity::new(about, dimension_id)
371 .map_err(|error| ApplicationError::Validation(error.to_string()))
372}
373
374fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
375 dimension_aliases
376 .get(value)
377 .cloned()
378 .unwrap_or_else(|| value.to_string())
379}
380
381fn normalize_optional_member_ref(
382 about: &str,
383 path: &str,
384 value: Option<&str>,
385 dimension_aliases: &BTreeMap<String, String>,
386) -> Result<Option<String>, ApplicationError> {
387 value
388 .map(|value| {
389 let normalized = normalize_ref(value, dimension_aliases);
390 validate_supplied_member_ref(about, path, &normalized)
391 .map_err(ApplicationError::Validation)?;
392 Ok(normalized)
393 })
394 .transpose()
395}
396
397fn normalize_coordinate(
398 coordinate: &MemoryCoordinateData,
399 field: &str,
400 label: &str,
401 dimension_aliases: &BTreeMap<String, String>,
402 dimension_ids: &BTreeSet<String>,
403 declared_dimension_kinds: &BTreeMap<String, String>,
404) -> Result<MemoryCoordinateData, ApplicationError> {
405 require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
406 require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
407 let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
408 if !dimension_ids.contains(&scope_id) {
409 return Err(ApplicationError::Validation(format!(
410 "{label} coordinate references unknown dimension scope `{}`",
411 coordinate.scope_id
412 )));
413 }
414 if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
415 && coordinate.dimension != *expected_kind
416 {
417 return Err(ApplicationError::Validation(format!(
418 "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
419 coordinate.dimension, coordinate.scope_id
420 )));
421 }
422 validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
423 validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
424
425 let mut coordinate = coordinate.clone();
426 coordinate.scope_id = scope_id;
427 Ok(coordinate)
428}
429
430fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
431 let mut changes = Vec::new();
432 for dimension in &memory.dimensions {
433 changes.push(change(
434 "memory_dimension",
435 &dimension.id,
436 serde_json::to_string(dimension),
437 "KMP memory dimension ingest",
438 vec![dimension.id.clone()],
439 )?);
440 }
441 for entry in &memory.entries {
442 let scopes = entry
443 .coordinates
444 .iter()
445 .map(|coordinate| coordinate.scope_id.clone())
446 .collect();
447 changes.push(change(
448 "memory_entry",
449 &entry.id,
450 serde_json::to_string(entry),
451 "KMP memory entry ingest",
452 scopes,
453 )?);
454 }
455 for relation in &memory.relations {
456 changes.push(change(
457 "memory_relation",
458 &format!(
459 "relation:{}:{}:{}",
460 relation.source_ref, relation.rel, relation.target_ref
461 ),
462 serde_json::to_string(relation),
463 relation
464 .why
465 .as_deref()
466 .filter(|value| !value.trim().is_empty())
467 .unwrap_or("KMP memory relation ingest"),
468 vec![relation.source_ref.clone(), relation.target_ref.clone()],
469 )?);
470 }
471 for evidence in &memory.evidence {
472 changes.push(change(
473 "memory_evidence",
474 &evidence.id,
475 serde_json::to_string(evidence),
476 evidence
477 .source
478 .as_deref()
479 .filter(|value| !value.trim().is_empty())
480 .unwrap_or("KMP memory evidence ingest"),
481 evidence.supports.clone(),
482 )?);
483 }
484
485 Ok(changes)
486}
487
488fn change(
489 entity_kind: &str,
490 entity_id: &str,
491 payload: Result<String, serde_json::Error>,
492 reason: &str,
493 scopes: Vec<String>,
494) -> Result<UpdateContextChange, ApplicationError> {
495 Ok(UpdateContextChange {
496 operation: "UPSERT".to_string(),
497 entity_kind: entity_kind.to_string(),
498 entity_id: entity_id.to_string(),
499 payload_json: payload.map_err(|error| {
500 ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
501 })?,
502 reason: reason.to_string(),
503 scopes,
504 })
505}
506
507fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
508 if value.trim().is_empty() {
509 Err(ApplicationError::Validation(format!(
510 "{field} cannot be empty"
511 )))
512 } else {
513 Ok(())
514 }
515}
516
517fn insert_unique(
518 values: &mut BTreeSet<String>,
519 value: &str,
520 label: &str,
521) -> Result<(), ApplicationError> {
522 if !values.insert(value.to_string()) {
523 Err(ApplicationError::Validation(format!(
524 "duplicate {label} `{value}`"
525 )))
526 } else {
527 Ok(())
528 }
529}
530
531fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
532 if value == Some(0) {
533 Err(ApplicationError::Validation(format!(
534 "{field} must be greater than zero when set"
535 )))
536 } else {
537 Ok(())
538 }
539}
540
541fn logical_digest(command: &MemoryIngestCommand) -> String {
548 use sha2::{Digest, Sha256};
549 let mut hasher = Sha256::new();
550 hasher.update(command.about.as_bytes());
551 hasher.update([0]);
552 let memory = serde_json::to_vec(&command.memory)
553 .expect("memory data serializes: it holds only strings, maps and integers");
554 hasher.update(&memory);
555 hasher.update([0]);
556 if let Some(provenance) = &command.provenance {
557 let provenance =
558 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
559 hasher.update(&provenance);
560 }
561 format!("{:x}", hasher.finalize())
562}
563
564fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
565 idempotency_key
566 .strip_prefix("ingest:")
567 .map(|suffix| format!("memory:{suffix}"))
568 .unwrap_or_else(|| format!("memory:{idempotency_key}"))
569}
570
571#[cfg(test)]
572mod tests {
573 use std::collections::{BTreeMap, BTreeSet};
574
575 use crate::ApplicationError;
576 use crate::memory::{
577 ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
578 MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
579 };
580
581 use super::translate_memory_ingest;
582
583 #[test]
584 fn translate_memory_ingest_creates_internal_memory_update_command() {
585 let command = sample_command();
586
587 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
588 .expect("valid memory should translate");
589
590 assert_eq!(update.root_node_id, "question:830ce83f");
591 assert_eq!(update.role, "memory");
592 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
593 assert_eq!(outcome.memory_id, "memory:app-test");
594 assert_eq!(outcome.accepted.entries, 1);
595 assert_eq!(outcome.accepted.relations, 1);
596 assert_eq!(outcome.accepted.evidence, 1);
597 assert_eq!(
598 update
599 .changes
600 .iter()
601 .map(|change| change.entity_kind.as_str())
602 .collect::<Vec<_>>(),
603 vec![
604 "memory_dimension",
605 "memory_entry",
606 "memory_relation",
607 "memory_evidence"
608 ]
609 );
610 assert_eq!(
611 update.changes[0].entity_id,
612 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
613 );
614 assert_eq!(
615 update.changes[1].scopes,
616 ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
617 );
618 assert_eq!(
619 update.changes[2].entity_id,
620 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
621 );
622 let entry_payload: serde_json::Value =
623 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
624 assert_eq!(
625 entry_payload["coordinates"][0]["scope_id"],
626 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
627 );
628 assert!(
629 entry_payload["coordinates"][0]["ingested_at"]
630 .as_str()
631 .is_some_and(|value| value.starts_with("unix:")),
632 "the kernel must stamp when it learned every coordinate: {entry_payload}"
633 );
634 }
635
636 #[test]
637 fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
638 let mut command = sample_command();
639 command.memory.entries[0].coordinates[0].ingested_at =
640 Some("2026-04-12T15:01:00Z".to_string());
641
642 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
643 .expect("caller-supplied ingest clock should survive replay");
644 let entry_payload: serde_json::Value =
645 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
646
647 assert_eq!(
648 entry_payload["coordinates"][0]["ingested_at"],
649 "2026-04-12T15:01:00Z"
650 );
651 }
652
653 #[test]
654 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
655 let mut command = sample_command();
656 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
657
658 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
659 .expect_err("unknown scope should fail");
660
661 assert_validation_contains(error, "unknown dimension scope");
662 }
663
664 #[test]
665 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
666 let mut command = sample_command();
667 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
668
669 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
670 .expect_err("coordinate kind mismatch should fail");
671
672 assert_validation_contains(error, "does not match declared kind `conversation`");
673 }
674
675 #[test]
676 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
677 let mut command = sample_command();
678 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
679 coordinate.dimension = "ceremony".to_string();
680 command.memory.relations[0].coordinate = Some(coordinate);
681
682 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
683 .expect_err("relation coordinate kind mismatch should fail");
684
685 assert_validation_contains(error, "does not match declared kind `conversation`");
686 }
687
688 #[test]
689 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
690 let mut command = sample_command();
691 command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
692
693 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
694 .expect_err("unknown ref should fail");
695
696 assert_validation_contains(error, "references unknown refs");
697 }
698
699 #[test]
708 fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
709 let mut command = sample_command();
710 command.memory.relations[0].target_ref = command.about.clone();
711
712 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
713 .expect("an entry may relate to the about it belongs to");
714
715 assert!(
716 update
717 .changes
718 .iter()
719 .any(|change| change.entity_id.ends_with(&command.about)),
720 "the relation to the anchor must survive translation, got {:?}",
721 update
722 .changes
723 .iter()
724 .map(|change| change.entity_id.as_str())
725 .collect::<Vec<_>>()
726 );
727 }
728
729 #[test]
730 fn translate_memory_ingest_canonicalizes_known_relation_types() {
731 let mut command = sample_command();
732 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
733
734 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
735 .expect("known relation aliases should canonicalize");
736
737 assert_eq!(
738 update.changes[2].entity_id,
739 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
740 );
741 }
742
743 #[test]
744 fn translate_memory_ingest_requires_non_structural_relation_proof() {
745 let mut command = sample_command();
746 command.memory.relations[0].semantic_class = "causal".to_string();
747 command.memory.relations[0].why = None;
748 command.memory.relations[0].evidence = None;
749 command.memory.relations[0].confidence = None;
750
751 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
752 .expect_err("missing proof should fail");
753
754 assert_validation_contains(error, "require confidence");
755 }
756
757 #[test]
758 fn translate_memory_ingest_accepts_existing_materialized_refs() {
759 let mut command = sample_command();
760 command.memory.dimensions.clear();
761 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
762 command.memory.relations[0].source_ref = "conversation:existing".to_string();
763 command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
764 command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
765 let dimension_ref = "about:question:830ce83f:dimension:conversation:existing".to_string();
766 let existing = ExistingMemoryRefs {
767 refs: [
768 dimension_ref.clone(),
769 "question:830ce83f:claim:existing".to_string(),
770 ]
771 .into_iter()
772 .collect(),
773 dimensions: [dimension_ref].into_iter().collect(),
774 ..ExistingMemoryRefs::default()
775 };
776
777 let (update, outcome) =
778 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
779
780 assert_eq!(outcome.accepted.entries, 1);
781 assert_eq!(update.changes.len(), 3);
782 }
783
784 #[test]
785 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
786 let command = sample_command();
787 let dimension_ref =
788 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
789 let existing = ExistingMemoryRefs {
790 refs: [dimension_ref.clone()].into_iter().collect(),
791 dimensions: [dimension_ref.clone()].into_iter().collect(),
792 ..ExistingMemoryRefs::default()
793 };
794
795 let (update, outcome) = translate_memory_ingest(&command, &existing)
796 .expect("existing dimension declaration should be idempotent");
797
798 assert_eq!(outcome.accepted.entries, 1);
799 assert_eq!(
800 update
801 .changes
802 .iter()
803 .map(|change| change.entity_kind.as_str())
804 .collect::<Vec<_>>(),
805 vec!["memory_entry", "memory_relation", "memory_evidence"]
806 );
807 assert_eq!(
808 update.changes[0].scopes,
809 std::slice::from_ref(&dimension_ref)
810 );
811 assert_eq!(
812 update.changes[1].entity_id,
813 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
814 );
815 }
816
817 #[test]
818 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
819 let mut command = sample_command();
820 command.memory.dimensions.clear();
821 let dimension_ref =
822 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
823 command.memory.relations[0].source_ref = dimension_ref.clone();
824 let existing = ExistingMemoryRefs {
825 refs: BTreeSet::new(),
826 dimensions: [dimension_ref].into_iter().collect(),
827 ..ExistingMemoryRefs::default()
828 };
829
830 translate_memory_ingest(&command, &existing)
831 .expect("existing dimensions should also be valid relation refs");
832 }
833
834 #[test]
835 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
836 let mut command = sample_command();
837 command.memory.entries[0].coordinates[0].sequence = Some(0);
838
839 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
840 .expect_err("zero coordinate sequence should fail");
841
842 assert_validation_contains(error, "sequence must be greater than zero");
843 }
844
845 #[test]
846 fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
847 let mut command = sample_command();
848 command.memory.entries[0].coordinates[0].sequence = None;
849 let scope = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
850 let existing = ExistingMemoryRefs {
851 max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
852 ..ExistingMemoryRefs::default()
853 };
854
855 let (update, _) = translate_memory_ingest(&command, &existing)
856 .expect("kernel should assign the next coordinate sequence");
857 let entry = update
858 .changes
859 .iter()
860 .find(|change| change.entity_kind == "memory_entry")
861 .expect("entry change");
862 let payload: serde_json::Value =
863 serde_json::from_str(&entry.payload_json).expect("entry payload");
864
865 assert_eq!(payload["coordinates"][0]["sequence"], 8);
866 }
867
868 #[test]
869 fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
870 const HOSTILE_REFS: &[&str] = &[
871 "incident:gamma:entry:observation:foreign",
872 "incident:beta",
873 "incident:alfa:entry:x\nincident:beta:entry:y",
874 "../../incident:beta:entry:x",
875 ];
876 const REF_FIELDS: &[&str] = &[
877 "entry.id",
878 "relation.from",
879 "relation.to",
880 "relation.decision_id",
881 "relation.caused_by_node_id",
882 "evidence.id",
883 "evidence.supports",
884 ];
885
886 for field in REF_FIELDS {
887 for hostile in HOSTILE_REFS {
888 let mut command = sample_command();
889 command.about = "incident:alfa".to_string();
890 command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
891 command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
892 command.memory.evidence[0].id =
893 "evidence:incident:alfa:entry:observation:local:current".to_string();
894 command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
895
896 match *field {
897 "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
898 "relation.from" => {
899 command.memory.relations[0].source_ref = (*hostile).to_string()
900 }
901 "relation.to" => {
902 command.memory.relations[0].target_ref = (*hostile).to_string()
903 }
904 "relation.decision_id" => {
905 command.memory.relations[0].decision_id = Some((*hostile).to_string())
906 }
907 "relation.caused_by_node_id" => {
908 command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
909 }
910 "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
911 "evidence.supports" => {
912 command.memory.evidence[0].supports[0] = (*hostile).to_string()
913 }
914 unexpected => panic!("unknown test field {unexpected}"),
915 }
916
917 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
918 .expect_err("an ingest ref outside the about must be refused");
919 assert_validation_contains(
920 error,
921 if hostile.contains('/') || hostile.contains('\n') {
922 "memory refs cannot contain"
923 } else {
924 "does not belong to about"
925 },
926 );
927 }
928 }
929 }
930
931 fn sample_command() -> MemoryIngestCommand {
932 MemoryIngestCommand {
933 about: "question:830ce83f".to_string(),
934 memory: MemoryData {
935 dimensions: vec![MemoryDimensionData {
936 id: "conversation:rachel-2026-04-12".to_string(),
937 kind: "conversation".to_string(),
938 title: Some("Rachel relocation discussion".to_string()),
939 metadata: Default::default(),
940 }],
941 entries: vec![MemoryEntryData {
942 id: "question:830ce83f:claim:rachel-denver".to_string(),
943 kind: "claim".to_string(),
944 text: "Rachel said she was moving to Denver.".to_string(),
945 coordinates: vec![MemoryCoordinateData {
946 dimension: "conversation".to_string(),
947 scope_id: "conversation:rachel-2026-04-12".to_string(),
948 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
949 observed_at: None,
950 ingested_at: None,
951 valid_from: None,
952 valid_until: None,
953 sequence: Some(1),
954 rank: None,
955 metadata: Default::default(),
956 }],
957 metadata: Default::default(),
958 }],
959 relations: vec![MemoryRelationData {
960 source_ref: "conversation:rachel-2026-04-12".to_string(),
961 target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
962 rel: "contains_entry".to_string(),
963 semantic_class: "structural".to_string(),
964 why: None,
965 evidence: None,
966 confidence: None,
967 sequence: Some(1),
968 motivation: None,
969 method: None,
970 decision_id: None,
971 caused_by_node_id: None,
972 coordinate: None,
973 }],
974 evidence: vec![MemoryEvidenceData {
975 id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
976 supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
977 text: "Conversation transcript line 1".to_string(),
978 source: Some("transcript:1".to_string()),
979 time: Some("2026-04-12T15:00:00Z".to_string()),
980 metadata: Default::default(),
981 }],
982 },
983 provenance: None,
984 idempotency_key: "ingest:app-test".to_string(),
985 dry_run: false,
986 }
987 }
988
989 fn assert_validation_contains(error: ApplicationError, expected: &str) {
990 match error {
991 ApplicationError::Validation(message) => assert!(
992 message.contains(expected),
993 "expected `{message}` to contain `{expected}`"
994 ),
995 other => panic!("expected validation error, got {other:?}"),
996 }
997 }
998}