1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use kmp_domain::{
5 DECLARED_FROM_RELATE_METHOD, MemoryDimensionIdentity, MemoryRelationType,
6 RelationSemanticClass, SearchSummary, SearchSummaryFault, SourceKind,
7};
8
9use crate::ApplicationError;
10use crate::commands::{UpdateContextChange, UpdateContextCommand};
11use crate::memory::{
12 MemoryAcceptedCounts, MemoryCoordinateData, MemoryData, MemoryDimensionData,
13 MemoryIngestCommand, MemoryIngestOutcome, MemoryRelationData,
14};
15
16use super::ref_boundary::{
17 validate_ref_token, validate_supplied_entry_ref, validate_supplied_evidence_ref,
18 validate_supplied_member_ref,
19};
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ExistingMemoryRefs {
23 pub refs: BTreeSet<String>,
24 pub dimensions: BTreeSet<String>,
25 pub foreign: BTreeSet<String>,
29 pub max_sequences: BTreeMap<(String, String), u32>,
32}
33
34pub fn translate_memory_ingest(
35 command: &MemoryIngestCommand,
36 existing: &ExistingMemoryRefs,
37) -> Result<(UpdateContextCommand, MemoryIngestOutcome), ApplicationError> {
38 validate_command(command)?;
39 let ingested_at = kernel_ingested_at();
40 let memory = namespaced_memory(&command.about, &command.memory, existing, &ingested_at)?;
41
42 let changes = memory_changes(&memory)?;
43 let outcome = MemoryIngestOutcome {
44 about: command.about.clone(),
45 memory_id: memory_id_from_idempotency_key(&command.idempotency_key),
46 accepted: MemoryAcceptedCounts {
47 entries: command.memory.entries.len(),
48 relations: command.memory.relations.len(),
49 evidence: command.memory.evidence.len(),
50 },
51 read_after_write_ready: false,
52 warnings: search_summary_warnings(&command.memory),
53 };
54
55 Ok((
56 UpdateContextCommand {
57 root_node_id: command.about.clone(),
58 role: "memory".to_string(),
59 work_item_id: command.idempotency_key.clone(),
60 changes,
61 expected_revision: None,
62 expected_content_hash: None,
63 idempotency_key: Some(command.idempotency_key.clone()),
64 logical_digest: Some(logical_digest(command)),
65 requested_by: command
66 .provenance
67 .as_ref()
68 .map(|provenance| provenance.source_agent.clone()),
69 },
70 outcome,
71 ))
72}
73
74fn validate_command(command: &MemoryIngestCommand) -> Result<(), ApplicationError> {
75 require_non_empty(&command.about, "about")?;
76 validate_ref_token("about", &command.about).map_err(ApplicationError::Validation)?;
77 require_non_empty(&command.idempotency_key, "idempotency_key")?;
78 if let Some(provenance) = command.provenance.as_ref() {
79 SourceKind::parse(&provenance.source_kind).map_err(|error| {
80 ApplicationError::Validation(format!(
81 "memory provenance source_kind is invalid: {error}"
82 ))
83 })?;
84 require_non_empty(&provenance.source_agent, "provenance.source_agent")?;
85 require_non_empty(&provenance.observed_at, "provenance.observed_at")?;
86 }
87
88 Ok(())
89}
90
91fn namespaced_memory(
92 about: &str,
93 memory: &MemoryData,
94 existing: &ExistingMemoryRefs,
95 ingested_at: &str,
96) -> Result<MemoryData, ApplicationError> {
97 if memory.dimensions.is_empty() && existing.dimensions.is_empty() {
98 return Err(ApplicationError::Validation(
99 "memory.dimensions must not be empty when no existing memory dimensions are available"
100 .to_string(),
101 ));
102 }
103 if memory.entries.is_empty() {
104 return Err(ApplicationError::Validation(
105 "memory.entries must not be empty".to_string(),
106 ));
107 }
108
109 let mut known_refs = existing.refs.clone();
110 known_refs.extend(existing.dimensions.iter().cloned());
111 known_refs.insert(about.to_string());
119 let mut dimension_ids = existing.dimensions.clone();
120 let mut dimension_aliases = existing_dimension_aliases(about, existing);
121 let mut declared_dimension_kinds = BTreeMap::new();
122 let mut declared_dimension_refs = BTreeSet::new();
123 let mut max_sequences = existing.max_sequences.clone();
124 let mut dimensions = Vec::new();
125 for dimension in &memory.dimensions {
126 require_non_empty(&dimension.id, "memory.dimensions[].id")?;
127 validate_ref_token("memory.dimensions[].id", &dimension.id)
128 .map_err(ApplicationError::Validation)?;
129 require_non_empty(&dimension.kind, "memory.dimensions[].kind")?;
130 let dimension_identity = dimension_identity(about, &dimension.id)?;
131 let dimension_ref = dimension_identity.node_id();
132 declared_dimension_kinds.insert(dimension_ref.clone(), dimension.kind.clone());
133 insert_unique(
134 &mut declared_dimension_refs,
135 &dimension_ref,
136 "memory dimension",
137 )?;
138 if existing.dimensions.contains(&dimension_ref) {
139 dimension_aliases
140 .entry(dimension.id.clone())
141 .or_insert_with(|| dimension_ref.clone());
142 known_refs.insert(dimension_ref);
143 continue;
144 }
145 insert_unique(&mut dimension_ids, &dimension_ref, "memory dimension")?;
146 if dimension_aliases
147 .insert(dimension.id.clone(), dimension_ref.clone())
148 .is_some()
149 {
150 return Err(ApplicationError::Validation(format!(
151 "duplicate memory dimension `{}`",
152 dimension.id
153 )));
154 }
155 known_refs.insert(dimension_ref.clone());
156
157 let mut metadata = dimension.metadata.clone();
158 metadata
159 .entry("memory_about".to_string())
160 .or_insert_with(|| about.to_string());
161 metadata
162 .entry("memory_dimension_id".to_string())
163 .or_insert_with(|| dimension.id.clone());
164 dimensions.push(MemoryDimensionData {
165 id: dimension_ref,
166 kind: dimension.kind.clone(),
167 title: dimension.title.clone(),
168 metadata,
169 });
170 }
171
172 let mut entry_ids = BTreeSet::new();
173 let mut entries = Vec::new();
174 for entry in &memory.entries {
175 require_non_empty(&entry.id, "memory.entries[].id")?;
176 validate_supplied_entry_ref(about, "memory.entries[].id", &entry.id)
177 .map_err(ApplicationError::Validation)?;
178 require_non_empty(&entry.kind, "memory.entries[].kind")?;
179 require_non_empty(&entry.text, "memory.entries[].text")?;
180 if entry.coordinates.is_empty() {
181 return Err(ApplicationError::Validation(format!(
182 "memory entry `{}` must include at least one coordinate",
183 entry.id
184 )));
185 }
186 insert_unique(&mut entry_ids, &entry.id, "memory entry")?;
187 known_refs.insert(entry.id.clone());
188
189 let mut coordinates = Vec::new();
190 for coordinate in &entry.coordinates {
191 let mut coordinate = normalize_coordinate(
192 coordinate,
193 "memory.entries[].coordinates[]",
194 "memory entry",
195 &dimension_aliases,
196 &dimension_ids,
197 &declared_dimension_kinds,
198 )?;
199 coordinate
200 .ingested_at
201 .get_or_insert_with(|| ingested_at.to_string());
202 let sequence_key = (coordinate.dimension.clone(), coordinate.scope_id.clone());
203 let frontier = max_sequences.entry(sequence_key).or_default();
204 match coordinate.sequence {
205 Some(sequence) => *frontier = (*frontier).max(sequence),
206 None => {
207 *frontier = frontier.checked_add(1).ok_or_else(|| {
208 ApplicationError::Validation(
209 "memory coordinate sequence space is exhausted".to_string(),
210 )
211 })?;
212 coordinate.sequence = Some(*frontier);
213 }
214 }
215 coordinates.push(coordinate);
216 }
217 let mut entry = entry.clone();
218 entry.coordinates = coordinates;
219 entries.push(entry);
220 }
221
222 let mut relations = Vec::new();
223 for relation in &memory.relations {
224 require_non_empty(&relation.source_ref, "memory.relations[].source_ref")?;
225 require_non_empty(&relation.target_ref, "memory.relations[].target_ref")?;
226 require_non_empty(&relation.rel, "memory.relations[].rel")?;
227 let relation_type = MemoryRelationType::new(&relation.rel).map_err(|error| {
228 ApplicationError::Validation(format!("memory relation type is invalid: {error}"))
229 })?;
230 let semantic_class =
231 RelationSemanticClass::parse(&relation.semantic_class).map_err(|error| {
232 ApplicationError::Validation(format!("memory relation class is invalid: {error}"))
233 })?;
234 let source_ref = normalize_ref(&relation.source_ref, &dimension_aliases);
235 let target_ref = normalize_ref(&relation.target_ref, &dimension_aliases);
236 validate_supplied_member_ref(about, "memory.relations[].from", &source_ref)
237 .map_err(ApplicationError::Validation)?;
238 let crosses_abouts = crosses_abouts(about, relation, &relation_type, &target_ref);
243 if crosses_abouts {
244 validate_ref_token("memory.relations[].to", &target_ref)
245 .map_err(ApplicationError::Validation)?;
246 if !existing.foreign.contains(&target_ref) {
247 return Err(ApplicationError::Validation(format!(
248 "memory relation `{}` -> `{}` declares an equivalence with a ref no about holds",
249 relation.source_ref, relation.target_ref
250 )));
251 }
252 } else {
253 validate_supplied_member_ref(about, "memory.relations[].to", &target_ref)
254 .map_err(ApplicationError::Validation)?;
255 }
256 if !known_refs.contains(&source_ref)
257 || (!crosses_abouts && !known_refs.contains(&target_ref))
258 {
259 return Err(ApplicationError::Validation(format!(
260 "memory relation `{}` -> `{}` references unknown refs",
261 relation.source_ref, relation.target_ref
262 )));
263 }
264 if semantic_class != RelationSemanticClass::Structural {
265 if relation
266 .confidence
267 .as_deref()
268 .unwrap_or("")
269 .trim()
270 .is_empty()
271 {
272 return Err(ApplicationError::Validation(
273 "non-structural memory relations require confidence".to_string(),
274 ));
275 }
276 if relation.why.as_deref().unwrap_or("").trim().is_empty()
277 && relation.evidence.as_deref().unwrap_or("").trim().is_empty()
278 {
279 return Err(ApplicationError::Validation(
280 "non-structural memory relations require why or evidence".to_string(),
281 ));
282 }
283 }
284 validate_positive_optional(relation.sequence, "memory.relations[].sequence")?;
285 let coordinate = relation
286 .coordinate
287 .as_ref()
288 .map(|coordinate| {
289 normalize_coordinate(
290 coordinate,
291 "memory.relations[].coordinate",
292 "memory relation",
293 &dimension_aliases,
294 &dimension_ids,
295 &declared_dimension_kinds,
296 )
297 })
298 .transpose()?
299 .map(|mut coordinate| {
300 coordinate
301 .ingested_at
302 .get_or_insert_with(|| ingested_at.to_string());
303 coordinate
304 });
305 let mut relation = relation.clone();
306 relation.source_ref = source_ref;
307 relation.target_ref = target_ref;
308 relation.decision_id = normalize_optional_member_ref(
309 about,
310 "memory.relations[].decision_id",
311 relation.decision_id.as_deref(),
312 &dimension_aliases,
313 )?;
314 relation.caused_by_node_id = normalize_optional_member_ref(
315 about,
316 "memory.relations[].caused_by_node_id",
317 relation.caused_by_node_id.as_deref(),
318 &dimension_aliases,
319 )?;
320 relation.rel = relation_type.as_str().to_string();
321 relation.coordinate = coordinate;
322 relations.push(relation);
323 }
324
325 let mut evidence_ids = BTreeSet::new();
326 let mut evidence_items = Vec::new();
327 for evidence in &memory.evidence {
328 require_non_empty(&evidence.id, "memory.evidence[].id")?;
329 validate_supplied_evidence_ref(about, "memory.evidence[].id", &evidence.id)
330 .map_err(ApplicationError::Validation)?;
331 require_non_empty(&evidence.text, "memory.evidence[].text")?;
332 insert_unique(&mut evidence_ids, &evidence.id, "memory evidence")?;
333 known_refs.insert(evidence.id.clone());
334 let mut supports = Vec::new();
335 for supported in &evidence.supports {
336 require_non_empty(supported, "memory.evidence[].supports[]")?;
337 let supported_ref = normalize_ref(supported, &dimension_aliases);
338 validate_supplied_member_ref(about, "memory.evidence[].supports[]", &supported_ref)
339 .map_err(ApplicationError::Validation)?;
340 if !known_refs.contains(&supported_ref) {
341 return Err(ApplicationError::Validation(format!(
342 "memory evidence `{}` supports unknown ref `{supported}`",
343 evidence.id
344 )));
345 }
346 supports.push(supported_ref);
347 }
348 let mut evidence = evidence.clone();
349 evidence.supports = supports;
350 evidence_items.push(evidence);
351 }
352
353 Ok(MemoryData {
354 dimensions,
355 entries,
356 relations,
357 evidence: evidence_items,
358 })
359}
360
361fn search_summary_warnings(memory: &MemoryData) -> Vec<String> {
368 memory
369 .entries
370 .iter()
371 .filter_map(|entry| {
372 let summary = entry.metadata.get(SearchSummary::METADATA_KEY)?;
373 SearchSummary::lint(&entry.text, summary)
374 .err()
375 .map(|faults| {
376 format!(
377 "memory entry `{}` carries a {} that will not carry retrieval: {}",
378 entry.id,
379 SearchSummary::METADATA_KEY,
380 SearchSummaryFault::describe(&faults)
381 )
382 })
383 })
384 .collect()
385}
386
387fn kernel_ingested_at() -> String {
392 let since_epoch = SystemTime::now()
393 .duration_since(UNIX_EPOCH)
394 .unwrap_or_default();
395 format!(
396 "unix:{:012}:{:09}",
397 since_epoch.as_secs() + 100_000_000_000,
398 since_epoch.subsec_nanos()
399 )
400}
401
402fn existing_dimension_aliases(
403 about: &str,
404 existing: &ExistingMemoryRefs,
405) -> BTreeMap<String, String> {
406 existing
407 .dimensions
408 .iter()
409 .filter_map(|dimension_ref| {
410 let identity = MemoryDimensionIdentity::parse(dimension_ref)?;
411 (identity.about() == about)
412 .then(|| (identity.dimension_id().to_string(), dimension_ref.clone()))
413 })
414 .collect()
415}
416
417fn dimension_identity(
418 about: &str,
419 dimension_id: &str,
420) -> Result<MemoryDimensionIdentity, ApplicationError> {
421 MemoryDimensionIdentity::resolve(about, dimension_id).ok_or_else(|| {
422 ApplicationError::Validation(format!(
423 "memory dimension `{dimension_id}` belongs to another about; declare it bare or \
424 namespaced for `{about}`"
425 ))
426 })
427}
428
429pub fn crosses_abouts(
433 about: &str,
434 relation: &MemoryRelationData,
435 relation_type: &MemoryRelationType,
436 target_ref: &str,
437) -> bool {
438 relation_type.may_cross_abouts()
439 && validate_supplied_member_ref(about, "memory.relations[].to", target_ref).is_err()
440 && relation.semantic_class.trim() == "evidential"
441 && !relation
442 .why
443 .as_deref()
444 .unwrap_or_default()
445 .trim()
446 .is_empty()
447 && !relation
448 .evidence
449 .as_deref()
450 .unwrap_or_default()
451 .trim()
452 .is_empty()
453 && relation
454 .method
455 .as_deref()
456 .is_some_and(|method| method.starts_with(DECLARED_FROM_RELATE_METHOD))
457}
458
459fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
460 dimension_aliases
461 .get(value)
462 .cloned()
463 .unwrap_or_else(|| value.to_string())
464}
465
466fn normalize_optional_member_ref(
467 about: &str,
468 path: &str,
469 value: Option<&str>,
470 dimension_aliases: &BTreeMap<String, String>,
471) -> Result<Option<String>, ApplicationError> {
472 value
473 .map(|value| {
474 let normalized = normalize_ref(value, dimension_aliases);
475 validate_supplied_member_ref(about, path, &normalized)
476 .map_err(ApplicationError::Validation)?;
477 Ok(normalized)
478 })
479 .transpose()
480}
481
482fn normalize_coordinate(
483 coordinate: &MemoryCoordinateData,
484 field: &str,
485 label: &str,
486 dimension_aliases: &BTreeMap<String, String>,
487 dimension_ids: &BTreeSet<String>,
488 declared_dimension_kinds: &BTreeMap<String, String>,
489) -> Result<MemoryCoordinateData, ApplicationError> {
490 require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
491 require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
492 let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
493 if !dimension_ids.contains(&scope_id) {
494 return Err(ApplicationError::Validation(format!(
495 "{label} coordinate references unknown dimension scope `{}`",
496 coordinate.scope_id
497 )));
498 }
499 if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
500 && coordinate.dimension != *expected_kind
501 {
502 return Err(ApplicationError::Validation(format!(
503 "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
504 coordinate.dimension, coordinate.scope_id
505 )));
506 }
507 validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
508 validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
509
510 let mut coordinate = coordinate.clone();
511 coordinate.scope_id = scope_id;
512 Ok(coordinate)
513}
514
515fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
516 let mut changes = Vec::new();
517 for dimension in &memory.dimensions {
518 changes.push(change(
519 "memory_dimension",
520 &dimension.id,
521 serde_json::to_string(dimension),
522 "KMP memory dimension ingest",
523 vec![dimension.id.clone()],
524 )?);
525 }
526 for entry in &memory.entries {
527 let scopes = entry
528 .coordinates
529 .iter()
530 .map(|coordinate| coordinate.scope_id.clone())
531 .collect();
532 changes.push(change(
533 "memory_entry",
534 &entry.id,
535 serde_json::to_string(entry),
536 "KMP memory entry ingest",
537 scopes,
538 )?);
539 }
540 for relation in &memory.relations {
541 changes.push(change(
542 "memory_relation",
543 &format!(
544 "relation:{}:{}:{}",
545 relation.source_ref, relation.rel, relation.target_ref
546 ),
547 serde_json::to_string(relation),
548 relation
549 .why
550 .as_deref()
551 .filter(|value| !value.trim().is_empty())
552 .unwrap_or("KMP memory relation ingest"),
553 vec![relation.source_ref.clone(), relation.target_ref.clone()],
554 )?);
555 }
556 for evidence in &memory.evidence {
557 changes.push(change(
558 "memory_evidence",
559 &evidence.id,
560 serde_json::to_string(evidence),
561 evidence
562 .source
563 .as_deref()
564 .filter(|value| !value.trim().is_empty())
565 .unwrap_or("KMP memory evidence ingest"),
566 evidence.supports.clone(),
567 )?);
568 }
569
570 Ok(changes)
571}
572
573fn change(
574 entity_kind: &str,
575 entity_id: &str,
576 payload: Result<String, serde_json::Error>,
577 reason: &str,
578 scopes: Vec<String>,
579) -> Result<UpdateContextChange, ApplicationError> {
580 Ok(UpdateContextChange {
581 operation: "UPSERT".to_string(),
582 entity_kind: entity_kind.to_string(),
583 entity_id: entity_id.to_string(),
584 payload_json: payload.map_err(|error| {
585 ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
586 })?,
587 reason: reason.to_string(),
588 scopes,
589 })
590}
591
592fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
593 if value.trim().is_empty() {
594 Err(ApplicationError::Validation(format!(
595 "{field} cannot be empty"
596 )))
597 } else {
598 Ok(())
599 }
600}
601
602fn insert_unique(
603 values: &mut BTreeSet<String>,
604 value: &str,
605 label: &str,
606) -> Result<(), ApplicationError> {
607 if !values.insert(value.to_string()) {
608 Err(ApplicationError::Validation(format!(
609 "duplicate {label} `{value}`"
610 )))
611 } else {
612 Ok(())
613 }
614}
615
616fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
617 if value == Some(0) {
618 Err(ApplicationError::Validation(format!(
619 "{field} must be greater than zero when set"
620 )))
621 } else {
622 Ok(())
623 }
624}
625
626fn logical_digest(command: &MemoryIngestCommand) -> String {
633 use sha2::{Digest, Sha256};
634 let mut hasher = Sha256::new();
635 hasher.update(command.about.as_bytes());
636 hasher.update([0]);
637 let memory = serde_json::to_vec(&command.memory)
638 .expect("memory data serializes: it holds only strings, maps and integers");
639 hasher.update(&memory);
640 hasher.update([0]);
641 if let Some(provenance) = &command.provenance {
642 let provenance =
643 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
644 hasher.update(&provenance);
645 }
646 format!("{:x}", hasher.finalize())
647}
648
649fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
650 idempotency_key
651 .strip_prefix("ingest:")
652 .map(|suffix| format!("memory:{suffix}"))
653 .unwrap_or_else(|| format!("memory:{idempotency_key}"))
654}
655
656#[cfg(test)]
657mod tests {
658 use std::collections::{BTreeMap, BTreeSet};
659
660 use crate::ApplicationError;
661 use crate::memory::{
662 ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
663 MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
664 };
665
666 use super::translate_memory_ingest;
667
668 #[test]
669 fn translate_memory_ingest_creates_internal_memory_update_command() {
670 let command = sample_command();
671
672 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
673 .expect("valid memory should translate");
674
675 assert_eq!(update.root_node_id, "question:830ce83f");
676 assert_eq!(update.role, "memory");
677 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
678 assert_eq!(outcome.memory_id, "memory:app-test");
679 assert_eq!(outcome.accepted.entries, 1);
680 assert_eq!(outcome.accepted.relations, 1);
681 assert_eq!(outcome.accepted.evidence, 1);
682 assert_eq!(
683 update
684 .changes
685 .iter()
686 .map(|change| change.entity_kind.as_str())
687 .collect::<Vec<_>>(),
688 vec![
689 "memory_dimension",
690 "memory_entry",
691 "memory_relation",
692 "memory_evidence"
693 ]
694 );
695 assert_eq!(
696 update.changes[0].entity_id,
697 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
698 );
699 assert_eq!(
700 update.changes[1].scopes,
701 ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
702 );
703 assert_eq!(
704 update.changes[2].entity_id,
705 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
706 );
707 let entry_payload: serde_json::Value =
708 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
709 assert_eq!(
710 entry_payload["coordinates"][0]["scope_id"],
711 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
712 );
713 assert!(
714 entry_payload["coordinates"][0]["ingested_at"]
715 .as_str()
716 .is_some_and(|value| value.starts_with("unix:")),
717 "the kernel must stamp when it learned every coordinate: {entry_payload}"
718 );
719 }
720
721 #[test]
726 fn translate_memory_ingest_warns_about_a_search_summary_that_will_not_carry() {
727 let mut command = sample_command();
728 command.memory.entries[0].text =
729 "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
730 command.memory.entries[0].metadata.insert(
731 "summary_en".to_string(),
732 "Rachel said she was moving to Denver.".to_string(),
733 );
734
735 let (_, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
736 .expect("a degraded summary is a warning, not a refusal");
737
738 assert_eq!(
739 outcome.warnings,
740 [
741 "memory entry `question:830ce83f:claim:rachel-denver` carries a summary_en that will \
742 not carry retrieval: drops identifiers the text carries: #469"
743 ]
744 );
745 assert_eq!(outcome.accepted.entries, 1);
746 }
747
748 #[test]
749 fn translate_memory_ingest_is_silent_about_a_search_summary_that_carries() {
750 let mut command = sample_command();
751 command.memory.entries[0].text =
752 "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
753 command.memory.entries[0].metadata.insert(
754 "summary_en".to_string(),
755 "Rachel said she was moving to Denver because of ticket #469.".to_string(),
756 );
757
758 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
759 .expect("a faithful summary translates");
760
761 assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
762 let entry_payload: serde_json::Value =
763 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
764 assert_eq!(
765 entry_payload["metadata"]["summary_en"],
766 "Rachel said she was moving to Denver because of ticket #469.",
767 "the summary is stored as written, beside the text"
768 );
769 }
770
771 #[test]
772 fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
773 let mut command = sample_command();
774 command.memory.entries[0].coordinates[0].ingested_at =
775 Some("2026-04-12T15:01:00Z".to_string());
776
777 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
778 .expect("caller-supplied ingest clock should survive replay");
779 let entry_payload: serde_json::Value =
780 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
781
782 assert_eq!(
783 entry_payload["coordinates"][0]["ingested_at"],
784 "2026-04-12T15:01:00Z"
785 );
786 }
787
788 #[test]
789 fn translate_memory_ingest_accepts_an_already_namespaced_dimension_id() {
790 let namespaced = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12";
794 let mut command = sample_command();
795 command.memory.dimensions[0].id = namespaced.to_string();
796 command.memory.entries[0].coordinates[0].scope_id = namespaced.to_string();
797 command.memory.relations[0].source_ref = namespaced.to_string();
798
799 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
800 .expect("a namespaced dimension id belongs to this about");
801
802 assert_eq!(update.changes[0].entity_id, namespaced);
803 let entry_payload: serde_json::Value =
804 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
805 assert_eq!(entry_payload["coordinates"][0]["scope_id"], namespaced);
806 }
807
808 #[test]
809 fn translate_memory_ingest_rejects_a_dimension_owned_by_another_about() {
810 let mut command = sample_command();
811 command.memory.dimensions[0].id =
812 "about:question:other:dimension:conversation:rachel-2026-04-12".to_string();
813
814 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
815 .expect_err("a foreign about's dimension is not ours to write");
816
817 assert_validation_contains(error, "belongs to another about");
818 }
819
820 #[test]
821 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
822 let mut command = sample_command();
823 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
824
825 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
826 .expect_err("unknown scope should fail");
827
828 assert_validation_contains(error, "unknown dimension scope");
829 }
830
831 #[test]
832 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
833 let mut command = sample_command();
834 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
835
836 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
837 .expect_err("coordinate kind mismatch should fail");
838
839 assert_validation_contains(error, "does not match declared kind `conversation`");
840 }
841
842 #[test]
843 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
844 let mut command = sample_command();
845 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
846 coordinate.dimension = "ceremony".to_string();
847 command.memory.relations[0].coordinate = Some(coordinate);
848
849 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
850 .expect_err("relation coordinate kind mismatch should fail");
851
852 assert_validation_contains(error, "does not match declared kind `conversation`");
853 }
854
855 #[test]
856 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
857 let mut command = sample_command();
858 command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
859
860 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
861 .expect_err("unknown ref should fail");
862
863 assert_validation_contains(error, "references unknown refs");
864 }
865
866 #[test]
875 fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
876 let mut command = sample_command();
877 command.memory.relations[0].target_ref = command.about.clone();
878
879 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
880 .expect("an entry may relate to the about it belongs to");
881
882 assert!(
883 update
884 .changes
885 .iter()
886 .any(|change| change.entity_id.ends_with(&command.about)),
887 "the relation to the anchor must survive translation, got {:?}",
888 update
889 .changes
890 .iter()
891 .map(|change| change.entity_id.as_str())
892 .collect::<Vec<_>>()
893 );
894 }
895
896 #[test]
897 fn translate_memory_ingest_canonicalizes_known_relation_types() {
898 let mut command = sample_command();
899 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
900
901 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
902 .expect("known relation aliases should canonicalize");
903
904 assert_eq!(
905 update.changes[2].entity_id,
906 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
907 );
908 }
909
910 #[test]
911 fn translate_memory_ingest_requires_non_structural_relation_proof() {
912 let mut command = sample_command();
913 command.memory.relations[0].semantic_class = "causal".to_string();
914 command.memory.relations[0].why = None;
915 command.memory.relations[0].evidence = None;
916 command.memory.relations[0].confidence = None;
917
918 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
919 .expect_err("missing proof should fail");
920
921 assert_validation_contains(error, "require confidence");
922 }
923
924 #[test]
925 fn translate_memory_ingest_accepts_existing_materialized_refs() {
926 let mut command = sample_command();
927 command.memory.dimensions.clear();
928 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
929 command.memory.relations[0].source_ref = "conversation:existing".to_string();
930 command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
931 command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
932 let dimension_ref = "about:question:830ce83f:dimension:conversation:existing".to_string();
933 let existing = ExistingMemoryRefs {
934 refs: [
935 dimension_ref.clone(),
936 "question:830ce83f:claim:existing".to_string(),
937 ]
938 .into_iter()
939 .collect(),
940 dimensions: [dimension_ref].into_iter().collect(),
941 ..ExistingMemoryRefs::default()
942 };
943
944 let (update, outcome) =
945 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
946
947 assert_eq!(outcome.accepted.entries, 1);
948 assert_eq!(update.changes.len(), 3);
949 }
950
951 #[test]
952 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
953 let command = sample_command();
954 let dimension_ref =
955 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
956 let existing = ExistingMemoryRefs {
957 refs: [dimension_ref.clone()].into_iter().collect(),
958 dimensions: [dimension_ref.clone()].into_iter().collect(),
959 ..ExistingMemoryRefs::default()
960 };
961
962 let (update, outcome) = translate_memory_ingest(&command, &existing)
963 .expect("existing dimension declaration should be idempotent");
964
965 assert_eq!(outcome.accepted.entries, 1);
966 assert_eq!(
967 update
968 .changes
969 .iter()
970 .map(|change| change.entity_kind.as_str())
971 .collect::<Vec<_>>(),
972 vec!["memory_entry", "memory_relation", "memory_evidence"]
973 );
974 assert_eq!(
975 update.changes[0].scopes,
976 std::slice::from_ref(&dimension_ref)
977 );
978 assert_eq!(
979 update.changes[1].entity_id,
980 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
981 );
982 }
983
984 #[test]
985 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
986 let mut command = sample_command();
987 command.memory.dimensions.clear();
988 let dimension_ref =
989 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
990 command.memory.relations[0].source_ref = dimension_ref.clone();
991 let existing = ExistingMemoryRefs {
992 refs: BTreeSet::new(),
993 dimensions: [dimension_ref].into_iter().collect(),
994 ..ExistingMemoryRefs::default()
995 };
996
997 translate_memory_ingest(&command, &existing)
998 .expect("existing dimensions should also be valid relation refs");
999 }
1000
1001 #[test]
1002 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
1003 let mut command = sample_command();
1004 command.memory.entries[0].coordinates[0].sequence = Some(0);
1005
1006 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1007 .expect_err("zero coordinate sequence should fail");
1008
1009 assert_validation_contains(error, "sequence must be greater than zero");
1010 }
1011
1012 #[test]
1013 fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
1014 let mut command = sample_command();
1015 command.memory.entries[0].coordinates[0].sequence = None;
1016 let scope = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
1017 let existing = ExistingMemoryRefs {
1018 max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
1019 ..ExistingMemoryRefs::default()
1020 };
1021
1022 let (update, _) = translate_memory_ingest(&command, &existing)
1023 .expect("kernel should assign the next coordinate sequence");
1024 let entry = update
1025 .changes
1026 .iter()
1027 .find(|change| change.entity_kind == "memory_entry")
1028 .expect("entry change");
1029 let payload: serde_json::Value =
1030 serde_json::from_str(&entry.payload_json).expect("entry payload");
1031
1032 assert_eq!(payload["coordinates"][0]["sequence"], 8);
1033 }
1034
1035 #[test]
1036 fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
1037 const HOSTILE_REFS: &[&str] = &[
1038 "incident:gamma:entry:observation:foreign",
1039 "incident:beta",
1040 "incident:alfa:entry:x\nincident:beta:entry:y",
1041 "../../incident:beta:entry:x",
1042 ];
1043 const REF_FIELDS: &[&str] = &[
1044 "entry.id",
1045 "relation.from",
1046 "relation.to",
1047 "relation.decision_id",
1048 "relation.caused_by_node_id",
1049 "evidence.id",
1050 "evidence.supports",
1051 ];
1052
1053 for field in REF_FIELDS {
1054 for hostile in HOSTILE_REFS {
1055 let mut command = sample_command();
1056 command.about = "incident:alfa".to_string();
1057 command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
1058 command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
1059 command.memory.evidence[0].id =
1060 "evidence:incident:alfa:entry:observation:local:current".to_string();
1061 command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
1062
1063 match *field {
1064 "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
1065 "relation.from" => {
1066 command.memory.relations[0].source_ref = (*hostile).to_string()
1067 }
1068 "relation.to" => {
1069 command.memory.relations[0].target_ref = (*hostile).to_string()
1070 }
1071 "relation.decision_id" => {
1072 command.memory.relations[0].decision_id = Some((*hostile).to_string())
1073 }
1074 "relation.caused_by_node_id" => {
1075 command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
1076 }
1077 "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
1078 "evidence.supports" => {
1079 command.memory.evidence[0].supports[0] = (*hostile).to_string()
1080 }
1081 unexpected => panic!("unknown test field {unexpected}"),
1082 }
1083
1084 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1085 .expect_err("an ingest ref outside the about must be refused");
1086 assert_validation_contains(
1087 error,
1088 if hostile.contains('/') || hostile.contains('\n') {
1089 "memory refs cannot contain"
1090 } else {
1091 "does not belong to about"
1092 },
1093 );
1094 }
1095 }
1096 }
1097
1098 fn sample_command() -> MemoryIngestCommand {
1099 MemoryIngestCommand {
1100 about: "question:830ce83f".to_string(),
1101 memory: MemoryData {
1102 dimensions: vec![MemoryDimensionData {
1103 id: "conversation:rachel-2026-04-12".to_string(),
1104 kind: "conversation".to_string(),
1105 title: Some("Rachel relocation discussion".to_string()),
1106 metadata: Default::default(),
1107 }],
1108 entries: vec![MemoryEntryData {
1109 id: "question:830ce83f:claim:rachel-denver".to_string(),
1110 kind: "claim".to_string(),
1111 text: "Rachel said she was moving to Denver.".to_string(),
1112 coordinates: vec![MemoryCoordinateData {
1113 dimension: "conversation".to_string(),
1114 scope_id: "conversation:rachel-2026-04-12".to_string(),
1115 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
1116 observed_at: None,
1117 ingested_at: None,
1118 valid_from: None,
1119 valid_until: None,
1120 sequence: Some(1),
1121 rank: None,
1122 metadata: Default::default(),
1123 }],
1124 metadata: Default::default(),
1125 }],
1126 relations: vec![MemoryRelationData {
1127 source_ref: "conversation:rachel-2026-04-12".to_string(),
1128 target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1129 rel: "contains_entry".to_string(),
1130 semantic_class: "structural".to_string(),
1131 why: None,
1132 evidence: None,
1133 confidence: None,
1134 sequence: Some(1),
1135 motivation: None,
1136 method: None,
1137 decision_id: None,
1138 caused_by_node_id: None,
1139 coordinate: None,
1140 }],
1141 evidence: vec![MemoryEvidenceData {
1142 id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
1143 supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
1144 text: "Conversation transcript line 1".to_string(),
1145 source: Some("transcript:1".to_string()),
1146 time: Some("2026-04-12T15:00:00Z".to_string()),
1147 metadata: Default::default(),
1148 }],
1149 },
1150 provenance: None,
1151 idempotency_key: "ingest:app-test".to_string(),
1152 dry_run: false,
1153 }
1154 }
1155
1156 fn assert_validation_contains(error: ApplicationError, expected: &str) {
1157 match error {
1158 ApplicationError::Validation(message) => assert!(
1159 message.contains(expected),
1160 "expected `{message}` to contain `{expected}`"
1161 ),
1162 other => panic!("expected validation error, got {other:?}"),
1163 }
1164 }
1165
1166 fn cross_about_relation(rel: &str, method: Option<&str>) -> MemoryRelationData {
1167 MemoryRelationData {
1168 source_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1169 target_ref: "incident:platform:outcome:freeze".to_string(),
1170 rel: rel.to_string(),
1171 semantic_class: "evidential".to_string(),
1172 why: Some("Both record the same freeze.".to_string()),
1173 evidence: Some("kmp_relate proposal by identifier.".to_string()),
1174 confidence: Some("high".to_string()),
1175 sequence: None,
1176 motivation: None,
1177 method: method.map(str::to_string),
1178 decision_id: None,
1179 caused_by_node_id: None,
1180 coordinate: None,
1181 }
1182 }
1183
1184 #[test]
1189 fn a_declared_equivalence_crosses_the_about_and_nothing_else_does() {
1190 let mut command = sample_command();
1191 command.memory.relations.push(cross_about_relation(
1192 "same_event_as",
1193 Some("kmp_relate:identifier"),
1194 ));
1195 let mut existing = ExistingMemoryRefs::default();
1196 existing
1197 .foreign
1198 .insert("incident:platform:outcome:freeze".to_string());
1199 let (update, _) = translate_memory_ingest(&command, &existing)
1200 .expect("a declared equivalence is written");
1201 assert!(
1202 update.changes.iter().any(|change| {
1203 change.payload_json.contains("same_event_as")
1204 && change
1205 .payload_json
1206 .contains("incident:platform:outcome:freeze")
1207 }),
1208 "the equivalence is among the changes"
1209 );
1210
1211 let unverified = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1212 .expect_err("a ref no about holds is refused");
1213 assert!(
1214 unverified.to_string().contains("a ref no about holds"),
1215 "{unverified}"
1216 );
1217
1218 let mut unstamped = sample_command();
1219 unstamped
1220 .memory
1221 .relations
1222 .push(cross_about_relation("same_event_as", None));
1223 let error = translate_memory_ingest(&unstamped, &existing)
1224 .expect_err("without the proposal stamp the boundary holds");
1225 assert!(
1226 error.to_string().contains("does not belong to about"),
1227 "{error}"
1228 );
1229
1230 let mut follows = sample_command();
1231 follows.memory.relations.push(cross_about_relation(
1232 "follows",
1233 Some("kmp_relate:identifier"),
1234 ));
1235 let error = translate_memory_ingest(&follows, &existing)
1236 .expect_err("no other relation crosses an about");
1237 assert!(
1238 error.to_string().contains("does not belong to about"),
1239 "{error}"
1240 );
1241 }
1242}