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::resolve(about, dimension_id).ok_or_else(|| {
371 ApplicationError::Validation(format!(
372 "memory dimension `{dimension_id}` belongs to another about; declare it bare or \
373 namespaced for `{about}`"
374 ))
375 })
376}
377
378fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
379 dimension_aliases
380 .get(value)
381 .cloned()
382 .unwrap_or_else(|| value.to_string())
383}
384
385fn normalize_optional_member_ref(
386 about: &str,
387 path: &str,
388 value: Option<&str>,
389 dimension_aliases: &BTreeMap<String, String>,
390) -> Result<Option<String>, ApplicationError> {
391 value
392 .map(|value| {
393 let normalized = normalize_ref(value, dimension_aliases);
394 validate_supplied_member_ref(about, path, &normalized)
395 .map_err(ApplicationError::Validation)?;
396 Ok(normalized)
397 })
398 .transpose()
399}
400
401fn normalize_coordinate(
402 coordinate: &MemoryCoordinateData,
403 field: &str,
404 label: &str,
405 dimension_aliases: &BTreeMap<String, String>,
406 dimension_ids: &BTreeSet<String>,
407 declared_dimension_kinds: &BTreeMap<String, String>,
408) -> Result<MemoryCoordinateData, ApplicationError> {
409 require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
410 require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
411 let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
412 if !dimension_ids.contains(&scope_id) {
413 return Err(ApplicationError::Validation(format!(
414 "{label} coordinate references unknown dimension scope `{}`",
415 coordinate.scope_id
416 )));
417 }
418 if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
419 && coordinate.dimension != *expected_kind
420 {
421 return Err(ApplicationError::Validation(format!(
422 "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
423 coordinate.dimension, coordinate.scope_id
424 )));
425 }
426 validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
427 validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
428
429 let mut coordinate = coordinate.clone();
430 coordinate.scope_id = scope_id;
431 Ok(coordinate)
432}
433
434fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
435 let mut changes = Vec::new();
436 for dimension in &memory.dimensions {
437 changes.push(change(
438 "memory_dimension",
439 &dimension.id,
440 serde_json::to_string(dimension),
441 "KMP memory dimension ingest",
442 vec![dimension.id.clone()],
443 )?);
444 }
445 for entry in &memory.entries {
446 let scopes = entry
447 .coordinates
448 .iter()
449 .map(|coordinate| coordinate.scope_id.clone())
450 .collect();
451 changes.push(change(
452 "memory_entry",
453 &entry.id,
454 serde_json::to_string(entry),
455 "KMP memory entry ingest",
456 scopes,
457 )?);
458 }
459 for relation in &memory.relations {
460 changes.push(change(
461 "memory_relation",
462 &format!(
463 "relation:{}:{}:{}",
464 relation.source_ref, relation.rel, relation.target_ref
465 ),
466 serde_json::to_string(relation),
467 relation
468 .why
469 .as_deref()
470 .filter(|value| !value.trim().is_empty())
471 .unwrap_or("KMP memory relation ingest"),
472 vec![relation.source_ref.clone(), relation.target_ref.clone()],
473 )?);
474 }
475 for evidence in &memory.evidence {
476 changes.push(change(
477 "memory_evidence",
478 &evidence.id,
479 serde_json::to_string(evidence),
480 evidence
481 .source
482 .as_deref()
483 .filter(|value| !value.trim().is_empty())
484 .unwrap_or("KMP memory evidence ingest"),
485 evidence.supports.clone(),
486 )?);
487 }
488
489 Ok(changes)
490}
491
492fn change(
493 entity_kind: &str,
494 entity_id: &str,
495 payload: Result<String, serde_json::Error>,
496 reason: &str,
497 scopes: Vec<String>,
498) -> Result<UpdateContextChange, ApplicationError> {
499 Ok(UpdateContextChange {
500 operation: "UPSERT".to_string(),
501 entity_kind: entity_kind.to_string(),
502 entity_id: entity_id.to_string(),
503 payload_json: payload.map_err(|error| {
504 ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
505 })?,
506 reason: reason.to_string(),
507 scopes,
508 })
509}
510
511fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
512 if value.trim().is_empty() {
513 Err(ApplicationError::Validation(format!(
514 "{field} cannot be empty"
515 )))
516 } else {
517 Ok(())
518 }
519}
520
521fn insert_unique(
522 values: &mut BTreeSet<String>,
523 value: &str,
524 label: &str,
525) -> Result<(), ApplicationError> {
526 if !values.insert(value.to_string()) {
527 Err(ApplicationError::Validation(format!(
528 "duplicate {label} `{value}`"
529 )))
530 } else {
531 Ok(())
532 }
533}
534
535fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
536 if value == Some(0) {
537 Err(ApplicationError::Validation(format!(
538 "{field} must be greater than zero when set"
539 )))
540 } else {
541 Ok(())
542 }
543}
544
545fn logical_digest(command: &MemoryIngestCommand) -> String {
552 use sha2::{Digest, Sha256};
553 let mut hasher = Sha256::new();
554 hasher.update(command.about.as_bytes());
555 hasher.update([0]);
556 let memory = serde_json::to_vec(&command.memory)
557 .expect("memory data serializes: it holds only strings, maps and integers");
558 hasher.update(&memory);
559 hasher.update([0]);
560 if let Some(provenance) = &command.provenance {
561 let provenance =
562 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
563 hasher.update(&provenance);
564 }
565 format!("{:x}", hasher.finalize())
566}
567
568fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
569 idempotency_key
570 .strip_prefix("ingest:")
571 .map(|suffix| format!("memory:{suffix}"))
572 .unwrap_or_else(|| format!("memory:{idempotency_key}"))
573}
574
575#[cfg(test)]
576mod tests {
577 use std::collections::{BTreeMap, BTreeSet};
578
579 use crate::ApplicationError;
580 use crate::memory::{
581 ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
582 MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
583 };
584
585 use super::translate_memory_ingest;
586
587 #[test]
588 fn translate_memory_ingest_creates_internal_memory_update_command() {
589 let command = sample_command();
590
591 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
592 .expect("valid memory should translate");
593
594 assert_eq!(update.root_node_id, "question:830ce83f");
595 assert_eq!(update.role, "memory");
596 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
597 assert_eq!(outcome.memory_id, "memory:app-test");
598 assert_eq!(outcome.accepted.entries, 1);
599 assert_eq!(outcome.accepted.relations, 1);
600 assert_eq!(outcome.accepted.evidence, 1);
601 assert_eq!(
602 update
603 .changes
604 .iter()
605 .map(|change| change.entity_kind.as_str())
606 .collect::<Vec<_>>(),
607 vec![
608 "memory_dimension",
609 "memory_entry",
610 "memory_relation",
611 "memory_evidence"
612 ]
613 );
614 assert_eq!(
615 update.changes[0].entity_id,
616 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
617 );
618 assert_eq!(
619 update.changes[1].scopes,
620 ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
621 );
622 assert_eq!(
623 update.changes[2].entity_id,
624 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
625 );
626 let entry_payload: serde_json::Value =
627 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
628 assert_eq!(
629 entry_payload["coordinates"][0]["scope_id"],
630 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
631 );
632 assert!(
633 entry_payload["coordinates"][0]["ingested_at"]
634 .as_str()
635 .is_some_and(|value| value.starts_with("unix:")),
636 "the kernel must stamp when it learned every coordinate: {entry_payload}"
637 );
638 }
639
640 #[test]
641 fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
642 let mut command = sample_command();
643 command.memory.entries[0].coordinates[0].ingested_at =
644 Some("2026-04-12T15:01:00Z".to_string());
645
646 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
647 .expect("caller-supplied ingest clock should survive replay");
648 let entry_payload: serde_json::Value =
649 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
650
651 assert_eq!(
652 entry_payload["coordinates"][0]["ingested_at"],
653 "2026-04-12T15:01:00Z"
654 );
655 }
656
657 #[test]
658 fn translate_memory_ingest_accepts_an_already_namespaced_dimension_id() {
659 let namespaced = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12";
663 let mut command = sample_command();
664 command.memory.dimensions[0].id = namespaced.to_string();
665 command.memory.entries[0].coordinates[0].scope_id = namespaced.to_string();
666 command.memory.relations[0].source_ref = namespaced.to_string();
667
668 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
669 .expect("a namespaced dimension id belongs to this about");
670
671 assert_eq!(update.changes[0].entity_id, namespaced);
672 let entry_payload: serde_json::Value =
673 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
674 assert_eq!(entry_payload["coordinates"][0]["scope_id"], namespaced);
675 }
676
677 #[test]
678 fn translate_memory_ingest_rejects_a_dimension_owned_by_another_about() {
679 let mut command = sample_command();
680 command.memory.dimensions[0].id =
681 "about:question:other:dimension:conversation:rachel-2026-04-12".to_string();
682
683 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
684 .expect_err("a foreign about's dimension is not ours to write");
685
686 assert_validation_contains(error, "belongs to another about");
687 }
688
689 #[test]
690 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
691 let mut command = sample_command();
692 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
693
694 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
695 .expect_err("unknown scope should fail");
696
697 assert_validation_contains(error, "unknown dimension scope");
698 }
699
700 #[test]
701 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
702 let mut command = sample_command();
703 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
704
705 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
706 .expect_err("coordinate kind mismatch should fail");
707
708 assert_validation_contains(error, "does not match declared kind `conversation`");
709 }
710
711 #[test]
712 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
713 let mut command = sample_command();
714 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
715 coordinate.dimension = "ceremony".to_string();
716 command.memory.relations[0].coordinate = Some(coordinate);
717
718 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
719 .expect_err("relation coordinate kind mismatch should fail");
720
721 assert_validation_contains(error, "does not match declared kind `conversation`");
722 }
723
724 #[test]
725 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
726 let mut command = sample_command();
727 command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
728
729 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
730 .expect_err("unknown ref should fail");
731
732 assert_validation_contains(error, "references unknown refs");
733 }
734
735 #[test]
744 fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
745 let mut command = sample_command();
746 command.memory.relations[0].target_ref = command.about.clone();
747
748 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
749 .expect("an entry may relate to the about it belongs to");
750
751 assert!(
752 update
753 .changes
754 .iter()
755 .any(|change| change.entity_id.ends_with(&command.about)),
756 "the relation to the anchor must survive translation, got {:?}",
757 update
758 .changes
759 .iter()
760 .map(|change| change.entity_id.as_str())
761 .collect::<Vec<_>>()
762 );
763 }
764
765 #[test]
766 fn translate_memory_ingest_canonicalizes_known_relation_types() {
767 let mut command = sample_command();
768 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
769
770 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
771 .expect("known relation aliases should canonicalize");
772
773 assert_eq!(
774 update.changes[2].entity_id,
775 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
776 );
777 }
778
779 #[test]
780 fn translate_memory_ingest_requires_non_structural_relation_proof() {
781 let mut command = sample_command();
782 command.memory.relations[0].semantic_class = "causal".to_string();
783 command.memory.relations[0].why = None;
784 command.memory.relations[0].evidence = None;
785 command.memory.relations[0].confidence = None;
786
787 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
788 .expect_err("missing proof should fail");
789
790 assert_validation_contains(error, "require confidence");
791 }
792
793 #[test]
794 fn translate_memory_ingest_accepts_existing_materialized_refs() {
795 let mut command = sample_command();
796 command.memory.dimensions.clear();
797 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
798 command.memory.relations[0].source_ref = "conversation:existing".to_string();
799 command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
800 command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
801 let dimension_ref = "about:question:830ce83f:dimension:conversation:existing".to_string();
802 let existing = ExistingMemoryRefs {
803 refs: [
804 dimension_ref.clone(),
805 "question:830ce83f:claim:existing".to_string(),
806 ]
807 .into_iter()
808 .collect(),
809 dimensions: [dimension_ref].into_iter().collect(),
810 ..ExistingMemoryRefs::default()
811 };
812
813 let (update, outcome) =
814 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
815
816 assert_eq!(outcome.accepted.entries, 1);
817 assert_eq!(update.changes.len(), 3);
818 }
819
820 #[test]
821 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
822 let command = sample_command();
823 let dimension_ref =
824 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
825 let existing = ExistingMemoryRefs {
826 refs: [dimension_ref.clone()].into_iter().collect(),
827 dimensions: [dimension_ref.clone()].into_iter().collect(),
828 ..ExistingMemoryRefs::default()
829 };
830
831 let (update, outcome) = translate_memory_ingest(&command, &existing)
832 .expect("existing dimension declaration should be idempotent");
833
834 assert_eq!(outcome.accepted.entries, 1);
835 assert_eq!(
836 update
837 .changes
838 .iter()
839 .map(|change| change.entity_kind.as_str())
840 .collect::<Vec<_>>(),
841 vec!["memory_entry", "memory_relation", "memory_evidence"]
842 );
843 assert_eq!(
844 update.changes[0].scopes,
845 std::slice::from_ref(&dimension_ref)
846 );
847 assert_eq!(
848 update.changes[1].entity_id,
849 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
850 );
851 }
852
853 #[test]
854 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
855 let mut command = sample_command();
856 command.memory.dimensions.clear();
857 let dimension_ref =
858 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
859 command.memory.relations[0].source_ref = dimension_ref.clone();
860 let existing = ExistingMemoryRefs {
861 refs: BTreeSet::new(),
862 dimensions: [dimension_ref].into_iter().collect(),
863 ..ExistingMemoryRefs::default()
864 };
865
866 translate_memory_ingest(&command, &existing)
867 .expect("existing dimensions should also be valid relation refs");
868 }
869
870 #[test]
871 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
872 let mut command = sample_command();
873 command.memory.entries[0].coordinates[0].sequence = Some(0);
874
875 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
876 .expect_err("zero coordinate sequence should fail");
877
878 assert_validation_contains(error, "sequence must be greater than zero");
879 }
880
881 #[test]
882 fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
883 let mut command = sample_command();
884 command.memory.entries[0].coordinates[0].sequence = None;
885 let scope = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
886 let existing = ExistingMemoryRefs {
887 max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
888 ..ExistingMemoryRefs::default()
889 };
890
891 let (update, _) = translate_memory_ingest(&command, &existing)
892 .expect("kernel should assign the next coordinate sequence");
893 let entry = update
894 .changes
895 .iter()
896 .find(|change| change.entity_kind == "memory_entry")
897 .expect("entry change");
898 let payload: serde_json::Value =
899 serde_json::from_str(&entry.payload_json).expect("entry payload");
900
901 assert_eq!(payload["coordinates"][0]["sequence"], 8);
902 }
903
904 #[test]
905 fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
906 const HOSTILE_REFS: &[&str] = &[
907 "incident:gamma:entry:observation:foreign",
908 "incident:beta",
909 "incident:alfa:entry:x\nincident:beta:entry:y",
910 "../../incident:beta:entry:x",
911 ];
912 const REF_FIELDS: &[&str] = &[
913 "entry.id",
914 "relation.from",
915 "relation.to",
916 "relation.decision_id",
917 "relation.caused_by_node_id",
918 "evidence.id",
919 "evidence.supports",
920 ];
921
922 for field in REF_FIELDS {
923 for hostile in HOSTILE_REFS {
924 let mut command = sample_command();
925 command.about = "incident:alfa".to_string();
926 command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
927 command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
928 command.memory.evidence[0].id =
929 "evidence:incident:alfa:entry:observation:local:current".to_string();
930 command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
931
932 match *field {
933 "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
934 "relation.from" => {
935 command.memory.relations[0].source_ref = (*hostile).to_string()
936 }
937 "relation.to" => {
938 command.memory.relations[0].target_ref = (*hostile).to_string()
939 }
940 "relation.decision_id" => {
941 command.memory.relations[0].decision_id = Some((*hostile).to_string())
942 }
943 "relation.caused_by_node_id" => {
944 command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
945 }
946 "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
947 "evidence.supports" => {
948 command.memory.evidence[0].supports[0] = (*hostile).to_string()
949 }
950 unexpected => panic!("unknown test field {unexpected}"),
951 }
952
953 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
954 .expect_err("an ingest ref outside the about must be refused");
955 assert_validation_contains(
956 error,
957 if hostile.contains('/') || hostile.contains('\n') {
958 "memory refs cannot contain"
959 } else {
960 "does not belong to about"
961 },
962 );
963 }
964 }
965 }
966
967 fn sample_command() -> MemoryIngestCommand {
968 MemoryIngestCommand {
969 about: "question:830ce83f".to_string(),
970 memory: MemoryData {
971 dimensions: vec![MemoryDimensionData {
972 id: "conversation:rachel-2026-04-12".to_string(),
973 kind: "conversation".to_string(),
974 title: Some("Rachel relocation discussion".to_string()),
975 metadata: Default::default(),
976 }],
977 entries: vec![MemoryEntryData {
978 id: "question:830ce83f:claim:rachel-denver".to_string(),
979 kind: "claim".to_string(),
980 text: "Rachel said she was moving to Denver.".to_string(),
981 coordinates: vec![MemoryCoordinateData {
982 dimension: "conversation".to_string(),
983 scope_id: "conversation:rachel-2026-04-12".to_string(),
984 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
985 observed_at: None,
986 ingested_at: None,
987 valid_from: None,
988 valid_until: None,
989 sequence: Some(1),
990 rank: None,
991 metadata: Default::default(),
992 }],
993 metadata: Default::default(),
994 }],
995 relations: vec![MemoryRelationData {
996 source_ref: "conversation:rachel-2026-04-12".to_string(),
997 target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
998 rel: "contains_entry".to_string(),
999 semantic_class: "structural".to_string(),
1000 why: None,
1001 evidence: None,
1002 confidence: None,
1003 sequence: Some(1),
1004 motivation: None,
1005 method: None,
1006 decision_id: None,
1007 caused_by_node_id: None,
1008 coordinate: None,
1009 }],
1010 evidence: vec![MemoryEvidenceData {
1011 id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
1012 supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
1013 text: "Conversation transcript line 1".to_string(),
1014 source: Some("transcript:1".to_string()),
1015 time: Some("2026-04-12T15:00:00Z".to_string()),
1016 metadata: Default::default(),
1017 }],
1018 },
1019 provenance: None,
1020 idempotency_key: "ingest:app-test".to_string(),
1021 dry_run: false,
1022 }
1023 }
1024
1025 fn assert_validation_contains(error: ApplicationError, expected: &str) {
1026 match error {
1027 ApplicationError::Validation(message) => assert!(
1028 message.contains(expected),
1029 "expected `{message}` to contain `{expected}`"
1030 ),
1031 other => panic!("expected validation error, got {other:?}"),
1032 }
1033 }
1034}