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