1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use kmp_domain::{
5 DECLARED_FROM_RELATE_METHOD, INTENDED_NEW_LABEL_METADATA_KEY, MemoryRelationType,
6 RelationSemanticClass, SearchSummary, SearchSummaryFault, SourceKind, label_resemblances,
7};
8
9use crate::ApplicationError;
10use crate::commands::{UpdateContextChange, UpdateContextCommand};
11use crate::memory::{
12 LabelPolicy, MemoryAcceptedCounts, MemoryCoordinateData, MemoryData, MemoryDimensionData,
13 MemoryIngestCommand, MemoryIngestOutcome, MemoryRelationData, ResemblingLabelData,
14};
15
16use super::dimension_registry::DimensionRegistry;
17use super::ref_boundary::{
18 validate_ref_token, validate_supplied_entry_ref, validate_supplied_evidence_ref,
19 validate_supplied_member_ref,
20};
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct ExistingMemoryRefs {
24 pub refs: BTreeSet<String>,
25 pub dimensions: BTreeSet<String>,
26 pub labels: BTreeSet<(String, String)>,
29 pub foreign: BTreeSet<String>,
33 pub max_sequences: BTreeMap<(String, String), u32>,
36}
37
38pub fn translate_memory_ingest(
39 command: &MemoryIngestCommand,
40 existing: &ExistingMemoryRefs,
41) -> Result<(UpdateContextCommand, MemoryIngestOutcome), ApplicationError> {
42 validate_command(command)?;
43 let ingested_at = kernel_ingested_at();
44 let resolved;
45 let accepted_command = if command.default_observation_to_ingestion {
46 resolved = super::observation_defaults::resolve(command, &ingested_at);
47 &resolved
48 } else {
49 command
50 };
51 let memory = namespaced_memory(
52 &command.about,
53 &accepted_command.memory,
54 existing,
55 &ingested_at,
56 accepted_command
57 .provenance
58 .as_ref()
59 .and_then(|p| p.observed_at.as_deref()),
60 )?;
61 let created_dimensions = memory
65 .dimensions
66 .iter()
67 .map(|dimension| dimension.id.clone())
68 .filter(|id| !existing.dimensions.contains(id))
69 .collect::<Vec<_>>();
70 let resembling_labels = resembling_labels(&command.about, &command.memory, existing)?;
75 if command.label_policy == LabelPolicy::Refuse && !resembling_labels.is_empty() {
76 return Err(ApplicationError::Validation(format!(
77 "labels resemble ones the about already holds: {}. Reuse the existing label, or set the dimension metadata `{}: \"true\"` to insist on the new one",
78 resembling_labels
79 .iter()
80 .map(|label| label.why.clone())
81 .collect::<Vec<_>>()
82 .join(" "),
83 INTENDED_NEW_LABEL_METADATA_KEY
84 )));
85 }
86 let mut warnings = search_summary_warnings(&command.memory);
87 warnings.extend(resembling_labels.iter().map(|label| label.why.clone()));
88
89 let mut changes = memory_changes(&memory)?;
90 let mut outcome = MemoryIngestOutcome {
91 neighborhood: None,
92 replayed: false,
93 clocks: Some(super::WriteClocks::for_memory(&memory)),
94 receipt_ref: None,
95 about: command.about.clone(),
96 memory_id: memory_id_from_idempotency_key(&command.idempotency_key),
97 accepted: MemoryAcceptedCounts {
98 entries: command.memory.entries.len(),
99 relations: command.memory.relations.len(),
100 evidence: command.memory.evidence.len(),
101 },
102 read_after_write_ready: false,
103 warnings,
104 created_dimensions,
105 resembling_labels,
106 };
107
108 if let Some(receipt) = super::receipt::receipt_change(accepted_command, &memory, &outcome)? {
109 outcome.receipt_ref = Some(receipt.entity_id.clone());
110 changes.push(receipt);
111 }
112
113 Ok((
114 UpdateContextCommand {
115 root_node_id: command.about.clone(),
116 role: "memory".to_string(),
117 work_item_id: command.idempotency_key.clone(),
118 changes,
119 expected_revision: None,
120 expected_content_hash: None,
121 idempotency_key: Some(command.idempotency_key.clone()),
122 logical_digest: Some(logical_digest(command)),
123 requested_by: command
124 .provenance
125 .as_ref()
126 .map(|provenance| provenance.source_agent.clone()),
127 },
128 outcome,
129 ))
130}
131
132fn resembling_labels(
137 about: &str,
138 memory: &MemoryData,
139 existing: &ExistingMemoryRefs,
140) -> Result<Vec<ResemblingLabelData>, ApplicationError> {
141 let catalogue = existing
142 .labels
143 .iter()
144 .map(|(kind, value)| (kind.as_str(), value.as_str()))
145 .collect::<Vec<_>>();
146 let mut found = Vec::new();
147 let mut registry = DimensionRegistry::new(about, existing)?;
148 for dimension in &memory.dimensions {
149 let value = registry.value(&dimension.kind, &dimension.id)?;
150 let reference = registry.declare(&dimension.kind, &dimension.id)?;
151 if existing.dimensions.contains(&reference) {
152 continue;
153 }
154 if dimension
155 .metadata
156 .get(INTENDED_NEW_LABEL_METADATA_KEY)
157 .is_some_and(|value| value == "true")
158 {
159 continue;
160 }
161 for resemblance in label_resemblances(&dimension.kind, &value, catalogue.iter().copied()) {
162 found.push(ResemblingLabelData {
163 key: resemblance.key().to_string(),
164 value: resemblance.value().to_string(),
165 existing_key: resemblance.existing_key().to_string(),
166 existing_value: resemblance.existing_value().to_string(),
167 kind: resemblance.kind().name().to_string(),
168 why: resemblance.why(),
169 });
170 }
171 }
172 Ok(found)
173}
174
175fn validate_command(command: &MemoryIngestCommand) -> Result<(), ApplicationError> {
176 require_non_empty(&command.about, "about")?;
177 validate_ref_token("about", &command.about).map_err(ApplicationError::Validation)?;
178 require_non_empty(&command.idempotency_key, "idempotency_key")?;
179 if let Some(provenance) = command.provenance.as_ref() {
180 SourceKind::parse(&provenance.source_kind).map_err(|error| {
181 ApplicationError::Validation(format!(
182 "memory provenance source_kind is invalid: {error}"
183 ))
184 })?;
185 require_non_empty(&provenance.source_agent, "provenance.source_agent")?;
186 if !command.default_observation_to_ingestion || provenance.observed_at.is_some() {
187 require_non_empty(
188 provenance.observed_at.as_deref().unwrap_or_default(),
189 "provenance.observed_at",
190 )?;
191 }
192 }
193
194 Ok(())
195}
196
197fn namespaced_memory(
198 about: &str,
199 memory: &MemoryData,
200 existing: &ExistingMemoryRefs,
201 ingested_at: &str,
202 observed_at: Option<&str>,
203) -> Result<MemoryData, ApplicationError> {
204 if memory.dimensions.is_empty() && existing.dimensions.is_empty() {
205 return Err(ApplicationError::Validation(
206 "memory.dimensions must not be empty when no existing memory dimensions are available"
207 .to_string(),
208 ));
209 }
210 if memory.entries.is_empty() {
211 return Err(ApplicationError::Validation(
212 "memory.entries must not be empty".to_string(),
213 ));
214 }
215
216 let mut known_refs = existing.refs.clone();
217 known_refs.extend(existing.dimensions.iter().cloned());
218 known_refs.insert(about.to_string());
226 let mut dimension_ids = existing.dimensions.clone();
227 let mut dimension_registry = DimensionRegistry::new(about, existing)?;
228 let mut declared_dimension_refs = BTreeSet::new();
229 let mut max_sequences = existing.max_sequences.clone();
230 let mut dimensions = Vec::new();
231 for dimension in &memory.dimensions {
232 require_non_empty(&dimension.id, "memory.dimensions[].id")?;
233 require_non_empty(&dimension.kind, "memory.dimensions[].kind")?;
234 let dimension_value = dimension_registry.value(&dimension.kind, &dimension.id)?;
235 let dimension_ref = dimension_registry.declare(&dimension.kind, &dimension.id)?;
236 insert_unique(
237 &mut declared_dimension_refs,
238 &dimension_ref,
239 "memory dimension",
240 )?;
241 if existing.dimensions.contains(&dimension_ref) {
242 known_refs.insert(dimension_ref);
243 continue;
244 }
245 insert_unique(&mut dimension_ids, &dimension_ref, "memory dimension")?;
246 known_refs.insert(dimension_ref.clone());
247
248 let mut metadata = dimension.metadata.clone();
249 metadata.remove(INTENDED_NEW_LABEL_METADATA_KEY);
251 metadata
252 .entry("memory_about".to_string())
253 .or_insert_with(|| about.to_string());
254 metadata
255 .entry("memory_dimension_id".to_string())
256 .or_insert_with(|| dimension_value.clone());
257 dimensions.push(MemoryDimensionData {
258 id: dimension_ref,
259 kind: dimension.kind.clone(),
260 title: dimension.title.clone(),
261 metadata,
262 });
263 }
264
265 let mut entry_ids = BTreeSet::new();
266 let mut entries = Vec::new();
267 for entry in &memory.entries {
268 require_non_empty(&entry.id, "memory.entries[].id")?;
269 validate_supplied_entry_ref(about, "memory.entries[].id", &entry.id)
270 .map_err(ApplicationError::Validation)?;
271 require_non_empty(&entry.kind, "memory.entries[].kind")?;
272 require_non_empty(&entry.text, "memory.entries[].text")?;
273 if entry.coordinates.is_empty() {
274 return Err(ApplicationError::Validation(format!(
275 "memory entry `{}` must include at least one coordinate",
276 entry.id
277 )));
278 }
279 insert_unique(&mut entry_ids, &entry.id, "memory entry")?;
280 known_refs.insert(entry.id.clone());
281
282 let mut coordinates = Vec::new();
283 let mut memberships = BTreeSet::new();
284 for coordinate in &entry.coordinates {
285 let mut coordinate = normalize_coordinate(
286 coordinate,
287 "memory.entries[].coordinates[]",
288 "memory entry",
289 &dimension_registry,
290 )?;
291 coordinate
292 .ingested_at
293 .get_or_insert_with(|| ingested_at.to_string());
294 let sequence_key = (coordinate.dimension.clone(), coordinate.scope_id.clone());
295 if !memberships.insert(sequence_key.clone()) {
296 return Err(ApplicationError::Validation(format!(
297 "memory entry `{}` repeats label `{}={}`",
298 entry.id, coordinate.dimension, coordinate.scope_id
299 )));
300 }
301 let frontier = max_sequences.entry(sequence_key).or_default();
302 match coordinate.sequence {
303 Some(sequence) => *frontier = (*frontier).max(sequence),
304 None => {
305 *frontier = frontier.checked_add(1).ok_or_else(|| {
306 ApplicationError::Validation(
307 "memory coordinate sequence space is exhausted".to_string(),
308 )
309 })?;
310 coordinate.sequence = Some(*frontier);
311 }
312 }
313 coordinates.push(coordinate);
314 }
315 let mut entry = entry.clone();
316 entry.coordinates = coordinates;
317 entries.push(entry);
318 }
319
320 let mut relations = Vec::new();
321 for relation in &memory.relations {
322 require_non_empty(&relation.source_ref, "memory.relations[].source_ref")?;
323 require_non_empty(&relation.target_ref, "memory.relations[].target_ref")?;
324 require_non_empty(&relation.rel, "memory.relations[].rel")?;
325 let relation_type = MemoryRelationType::new(&relation.rel).map_err(|error| {
326 ApplicationError::Validation(format!("memory relation type is invalid: {error}"))
327 })?;
328 let semantic_class =
329 RelationSemanticClass::parse(&relation.semantic_class).map_err(|error| {
330 ApplicationError::Validation(format!("memory relation class is invalid: {error}"))
331 })?;
332 let source_ref = normalize_ref(&relation.source_ref, &dimension_registry)?;
333 let target_ref = normalize_ref(&relation.target_ref, &dimension_registry)?;
334 validate_supplied_member_ref(about, "memory.relations[].from", &source_ref)
335 .map_err(ApplicationError::Validation)?;
336 let crosses_abouts = crosses_abouts(about, relation, &relation_type, &target_ref);
341 if crosses_abouts {
342 validate_ref_token("memory.relations[].to", &target_ref)
343 .map_err(ApplicationError::Validation)?;
344 if !existing.foreign.contains(&target_ref) {
345 return Err(ApplicationError::Validation(format!(
346 "memory relation `{}` -> `{}` declares an equivalence with a ref no about holds",
347 relation.source_ref, relation.target_ref
348 )));
349 }
350 } else {
351 validate_supplied_member_ref(about, "memory.relations[].to", &target_ref)
352 .map_err(ApplicationError::Validation)?;
353 }
354 if !known_refs.contains(&source_ref)
355 || (!crosses_abouts && !known_refs.contains(&target_ref))
356 {
357 return Err(ApplicationError::Validation(format!(
358 "memory relation `{}` -> `{}` references unknown refs",
359 relation.source_ref, relation.target_ref
360 )));
361 }
362 if semantic_class != RelationSemanticClass::Structural {
363 if relation
364 .confidence
365 .as_deref()
366 .unwrap_or("")
367 .trim()
368 .is_empty()
369 {
370 return Err(ApplicationError::Validation(
371 "non-structural memory relations require confidence".to_string(),
372 ));
373 }
374 if relation.why.as_deref().unwrap_or("").trim().is_empty()
375 && relation.evidence.as_deref().unwrap_or("").trim().is_empty()
376 {
377 return Err(ApplicationError::Validation(
378 "non-structural memory relations require why or evidence".to_string(),
379 ));
380 }
381 }
382 validate_positive_optional(relation.sequence, "memory.relations[].sequence")?;
383 let mut coordinate = relation
384 .coordinate
385 .as_ref()
386 .map(|coordinate| {
387 normalize_coordinate(
388 coordinate,
389 "memory.relations[].coordinate",
390 "memory relation",
391 &dimension_registry,
392 )
393 })
394 .transpose()?
395 .map(|mut coordinate| {
396 coordinate
397 .ingested_at
398 .get_or_insert_with(|| ingested_at.to_string());
399 coordinate
400 });
401 if relation_type.as_str() == "contains_entry" && coordinate.is_none() {
405 coordinate = entries
406 .iter()
407 .find(|entry| entry.id == target_ref)
408 .and_then(|entry| {
409 entry
410 .coordinates
411 .iter()
412 .find(|position| position.scope_id == source_ref)
413 })
414 .cloned();
415 if coordinate.is_none() {
416 return Err(ApplicationError::Validation(format!(
417 "contains_entry from `{source_ref}` to `{target_ref}` has no coordinate and no matching entry membership in this write; provide the coordinate or use kmp_relabel to change an existing memory's labels"
418 )));
419 }
420 }
421 let mut relation = relation.clone();
422 if semantic_class != RelationSemanticClass::Structural {
423 relation.clocks = Some(super::resolve_relation_clocks::resolve_relation_clocks(
424 relation.clocks.as_ref(),
425 relation.coordinate.as_ref(),
426 observed_at,
427 ingested_at,
428 )?);
429 }
430 if semantic_class == RelationSemanticClass::Structural && relation.clocks.is_some() {
431 return Err(ApplicationError::Validation(
432 "structural relations carry their clocks in coordinate, not clocks".to_string(),
433 ));
434 }
435 if semantic_class != RelationSemanticClass::Structural
436 && let Some(coordinate) = coordinate.as_mut()
437 {
438 coordinate.occurred_at = None;
439 coordinate.observed_at = None;
440 coordinate.ingested_at = None;
441 coordinate.valid_from = None;
442 coordinate.valid_until = None;
443 }
444 relation.semantic_class = semantic_class.as_str().to_string();
445 relation.source_ref = source_ref;
446 relation.target_ref = target_ref;
447 relation.decision_id = normalize_optional_member_ref(
448 about,
449 "memory.relations[].decision_id",
450 relation.decision_id.as_deref(),
451 &dimension_registry,
452 )?;
453 relation.caused_by_node_id = normalize_optional_member_ref(
454 about,
455 "memory.relations[].caused_by_node_id",
456 relation.caused_by_node_id.as_deref(),
457 &dimension_registry,
458 )?;
459 relation.rel = relation_type.as_str().to_string();
460 relation.coordinate = coordinate;
461 relations.push(relation);
462 }
463
464 let mut evidence_ids = BTreeSet::new();
465 let mut evidence_items = Vec::new();
466 for evidence in &memory.evidence {
467 require_non_empty(&evidence.id, "memory.evidence[].id")?;
468 validate_supplied_evidence_ref(about, "memory.evidence[].id", &evidence.id)
469 .map_err(ApplicationError::Validation)?;
470 require_non_empty(&evidence.text, "memory.evidence[].text")?;
471 insert_unique(&mut evidence_ids, &evidence.id, "memory evidence")?;
472 known_refs.insert(evidence.id.clone());
473 let mut supports = Vec::new();
474 for supported in &evidence.supports {
475 require_non_empty(supported, "memory.evidence[].supports[]")?;
476 let supported_ref = normalize_ref(supported, &dimension_registry)?;
477 validate_supplied_member_ref(about, "memory.evidence[].supports[]", &supported_ref)
478 .map_err(ApplicationError::Validation)?;
479 if !known_refs.contains(&supported_ref) {
480 return Err(ApplicationError::Validation(format!(
481 "memory evidence `{}` supports unknown ref `{supported}`",
482 evidence.id
483 )));
484 }
485 supports.push(supported_ref);
486 }
487 let mut evidence = evidence.clone();
488 evidence.supports = supports;
489 if !evidence.supports.is_empty() {
490 evidence.support_clocks = Some(super::EvidenceSupportClocks::resolve(
491 evidence.support_clocks.as_ref(),
492 observed_at,
493 ingested_at,
494 )?);
495 }
496 evidence_items.push(evidence);
497 }
498
499 Ok(MemoryData {
500 dimensions,
501 entries,
502 relations,
503 evidence: evidence_items,
504 })
505}
506
507fn search_summary_warnings(memory: &MemoryData) -> Vec<String> {
514 memory
515 .entries
516 .iter()
517 .filter_map(|entry| {
518 let summary = entry.metadata.get(SearchSummary::METADATA_KEY)?;
519 SearchSummary::lint(&entry.text, summary)
520 .err()
521 .map(|faults| {
522 format!(
523 "memory entry `{}` carries a {} that will not carry retrieval: {}",
524 entry.id,
525 SearchSummary::METADATA_KEY,
526 SearchSummaryFault::describe(&faults)
527 )
528 })
529 })
530 .collect()
531}
532
533fn kernel_ingested_at() -> String {
538 let since_epoch = SystemTime::now()
539 .duration_since(UNIX_EPOCH)
540 .unwrap_or_default();
541 format!(
542 "unix:{:012}:{:09}",
543 since_epoch.as_secs() + 100_000_000_000,
544 since_epoch.subsec_nanos()
545 )
546}
547
548pub fn crosses_abouts(
552 about: &str,
553 relation: &MemoryRelationData,
554 relation_type: &MemoryRelationType,
555 target_ref: &str,
556) -> bool {
557 relation_type.may_cross_abouts()
558 && validate_supplied_member_ref(about, "memory.relations[].to", target_ref).is_err()
559 && relation.semantic_class.trim() == "evidential"
560 && !relation
561 .why
562 .as_deref()
563 .unwrap_or_default()
564 .trim()
565 .is_empty()
566 && !relation
567 .evidence
568 .as_deref()
569 .unwrap_or_default()
570 .trim()
571 .is_empty()
572 && relation
573 .method
574 .as_deref()
575 .is_some_and(|method| method.starts_with(DECLARED_FROM_RELATE_METHOD))
576}
577
578fn normalize_ref(value: &str, dimensions: &DimensionRegistry) -> Result<String, ApplicationError> {
579 dimensions.member(value)
580}
581
582fn normalize_optional_member_ref(
583 about: &str,
584 path: &str,
585 value: Option<&str>,
586 dimensions: &DimensionRegistry,
587) -> Result<Option<String>, ApplicationError> {
588 value
589 .map(|value| {
590 let normalized = normalize_ref(value, dimensions)?;
591 validate_supplied_member_ref(about, path, &normalized)
592 .map_err(ApplicationError::Validation)?;
593 Ok(normalized)
594 })
595 .transpose()
596}
597
598fn normalize_coordinate(
599 coordinate: &MemoryCoordinateData,
600 field: &str,
601 label: &str,
602 dimensions: &DimensionRegistry,
603) -> Result<MemoryCoordinateData, ApplicationError> {
604 require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
605 require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
606 let scope_id = dimensions
607 .coordinate(&coordinate.dimension, &coordinate.scope_id)
608 .map_err(|error| ApplicationError::Validation(format!("{label} {field}: {error}")))?;
609 validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
610 validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
611
612 let mut coordinate = coordinate.clone();
613 coordinate.scope_id = scope_id;
614 Ok(coordinate)
615}
616
617fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
618 let mut changes = Vec::new();
619 for dimension in &memory.dimensions {
620 changes.push(change(
621 "memory_dimension",
622 &dimension.id,
623 serde_json::to_string(dimension),
624 "KMP memory dimension ingest",
625 vec![dimension.id.clone()],
626 )?);
627 }
628 for entry in &memory.entries {
629 let scopes = entry
630 .coordinates
631 .iter()
632 .map(|coordinate| coordinate.scope_id.clone())
633 .collect();
634 changes.push(change(
635 "memory_entry",
636 &entry.id,
637 serde_json::to_string(entry),
638 "KMP memory entry ingest",
639 scopes,
640 )?);
641 }
642 for relation in &memory.relations {
643 changes.push(change(
644 "memory_relation",
645 &format!(
646 "relation:{}:{}:{}",
647 relation.source_ref, relation.rel, relation.target_ref
648 ),
649 serde_json::to_string(relation),
650 relation
651 .why
652 .as_deref()
653 .filter(|value| !value.trim().is_empty())
654 .unwrap_or("KMP memory relation ingest"),
655 vec![relation.source_ref.clone(), relation.target_ref.clone()],
656 )?);
657 }
658 for evidence in &memory.evidence {
659 changes.push(change(
660 "memory_evidence",
661 &evidence.id,
662 serde_json::to_string(evidence),
663 evidence
664 .source
665 .as_deref()
666 .filter(|value| !value.trim().is_empty())
667 .unwrap_or("KMP memory evidence ingest"),
668 evidence.supports.clone(),
669 )?);
670 }
671
672 Ok(changes)
673}
674
675fn change(
676 entity_kind: &str,
677 entity_id: &str,
678 payload: Result<String, serde_json::Error>,
679 reason: &str,
680 scopes: Vec<String>,
681) -> Result<UpdateContextChange, ApplicationError> {
682 Ok(UpdateContextChange {
683 operation: "UPSERT".to_string(),
684 entity_kind: entity_kind.to_string(),
685 entity_id: entity_id.to_string(),
686 payload_json: payload.map_err(|error| {
687 ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
688 })?,
689 reason: reason.to_string(),
690 scopes,
691 })
692}
693
694fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
695 if value.trim().is_empty() {
696 Err(ApplicationError::Validation(format!(
697 "{field} cannot be empty"
698 )))
699 } else {
700 Ok(())
701 }
702}
703
704fn insert_unique(
705 values: &mut BTreeSet<String>,
706 value: &str,
707 label: &str,
708) -> Result<(), ApplicationError> {
709 if !values.insert(value.to_string()) {
710 Err(ApplicationError::Validation(format!(
711 "duplicate {label} `{value}`"
712 )))
713 } else {
714 Ok(())
715 }
716}
717
718fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
719 if value == Some(0) {
720 Err(ApplicationError::Validation(format!(
721 "{field} must be greater than zero when set"
722 )))
723 } else {
724 Ok(())
725 }
726}
727
728pub(super) fn logical_digest(command: &MemoryIngestCommand) -> String {
735 use sha2::{Digest, Sha256};
736 let mut hasher = Sha256::new();
737 if command.default_observation_to_ingestion {
738 hasher.update(b"default_observation_to_ingestion\0");
739 }
740 hasher.update(command.about.as_bytes());
741 hasher.update([0]);
742 let memory = serde_json::to_vec(&command.memory)
743 .expect("memory data serializes: it holds only strings, maps and integers");
744 hasher.update(&memory);
745 hasher.update([0]);
746 if let Some(provenance) = &command.provenance {
747 let provenance =
748 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
749 hasher.update(&provenance);
750 }
751 if let Some(context) = &command.receipt_context {
752 hasher.update([0]);
753 hasher.update(context.to_string().as_bytes());
754 }
755 format!("{:x}", hasher.finalize())
756}
757
758fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
759 idempotency_key
760 .strip_prefix("ingest:")
761 .map(|suffix| format!("memory:{suffix}"))
762 .unwrap_or_else(|| format!("memory:{idempotency_key}"))
763}
764
765#[cfg(test)]
766mod tests {
767 use std::collections::{BTreeMap, BTreeSet};
768
769 use crate::ApplicationError;
770 use crate::memory::{
771 ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
772 MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
773 };
774
775 use super::translate_memory_ingest;
776
777 #[test]
778 fn translate_memory_ingest_creates_internal_memory_update_command() {
779 let command = sample_command();
780
781 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
782 .expect("valid memory should translate");
783
784 assert_eq!(update.root_node_id, "question:830ce83f");
785 assert_eq!(update.role, "memory");
786 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
787 assert_eq!(outcome.memory_id, "memory:app-test");
788 assert_eq!(outcome.accepted.entries, 1);
789 assert_eq!(outcome.accepted.relations, 1);
790 assert_eq!(outcome.accepted.evidence, 1);
791 assert_eq!(
792 update
793 .changes
794 .iter()
795 .map(|change| change.entity_kind.as_str())
796 .collect::<Vec<_>>(),
797 vec![
798 "memory_dimension",
799 "memory_entry",
800 "memory_relation",
801 "memory_evidence"
802 ]
803 );
804 assert_eq!(
805 update.changes[0].entity_id,
806 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
807 );
808 assert_eq!(
809 update.changes[1].scopes,
810 ["label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"]
811 );
812 assert_eq!(
813 update.changes[2].entity_id,
814 "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
815 );
816 let entry_payload: serde_json::Value =
817 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
818 assert_eq!(
819 entry_payload["coordinates"][0]["scope_id"],
820 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
821 );
822 assert!(
823 entry_payload["coordinates"][0]["ingested_at"]
824 .as_str()
825 .is_some_and(|value| value.starts_with("unix:")),
826 "the kernel must stamp when it learned every coordinate: {entry_payload}"
827 );
828 }
829
830 #[test]
835 fn translate_memory_ingest_warns_about_a_search_summary_that_will_not_carry() {
836 let mut command = sample_command();
837 command.memory.entries[0].text =
838 "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
839 command.memory.entries[0].metadata.insert(
840 "summary_en".to_string(),
841 "Rachel said she was moving to Denver.".to_string(),
842 );
843
844 let (_, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
845 .expect("a degraded summary is a warning, not a refusal");
846
847 assert_eq!(
848 outcome.warnings,
849 [
850 "memory entry `question:830ce83f:claim:rachel-denver` carries a summary_en that will \
851 not carry retrieval: drops identifiers the text carries: #469"
852 ]
853 );
854 assert_eq!(outcome.accepted.entries, 1);
855 }
856
857 #[test]
858 fn translate_memory_ingest_is_silent_about_a_search_summary_that_carries() {
859 let mut command = sample_command();
860 command.memory.entries[0].text =
861 "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
862 command.memory.entries[0].metadata.insert(
863 "summary_en".to_string(),
864 "Rachel said she was moving to Denver because of ticket #469.".to_string(),
865 );
866
867 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
868 .expect("a faithful summary translates");
869
870 assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
871 let entry_payload: serde_json::Value =
872 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
873 assert_eq!(
874 entry_payload["metadata"]["summary_en"],
875 "Rachel said she was moving to Denver because of ticket #469.",
876 "the summary is stored as written, beside the text"
877 );
878 }
879
880 #[test]
881 fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
882 let mut command = sample_command();
883 command.memory.entries[0].coordinates[0].ingested_at =
884 Some("2026-04-12T15:01:00Z".to_string());
885
886 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
887 .expect("caller-supplied ingest clock should survive replay");
888 let entry_payload: serde_json::Value =
889 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
890
891 assert_eq!(
892 entry_payload["coordinates"][0]["ingested_at"],
893 "2026-04-12T15:01:00Z"
894 );
895 }
896
897 #[test]
898 fn translate_memory_ingest_accepts_an_already_namespaced_dimension_id() {
899 let namespaced =
903 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12";
904 let mut command = sample_command();
905 command.memory.dimensions[0].id = namespaced.to_string();
906 command.memory.entries[0].coordinates[0].scope_id = namespaced.to_string();
907 command.memory.relations[0].source_ref = namespaced.to_string();
908
909 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
910 .expect("a namespaced dimension id belongs to this about");
911
912 assert_eq!(update.changes[0].entity_id, namespaced);
913 let entry_payload: serde_json::Value =
914 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
915 assert_eq!(entry_payload["coordinates"][0]["scope_id"], namespaced);
916 }
917
918 #[test]
919 fn translate_memory_ingest_rejects_a_dimension_owned_by_another_about() {
920 let mut command = sample_command();
921 command.memory.dimensions[0].id =
922 "label:v1:question%3Aother:conversation:conversation%3Arachel-2026-04-12".to_string();
923
924 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
925 .expect_err("a foreign about's dimension is not ours to write");
926
927 assert_validation_contains(error, "belongs to another about");
928 }
929
930 #[test]
931 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
932 let mut command = sample_command();
933 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
934
935 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
936 .expect_err("unknown scope should fail");
937
938 assert_validation_contains(error, "unknown dimension");
939 }
940
941 #[test]
942 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
943 let mut command = sample_command();
944 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
945
946 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
947 .expect_err("coordinate kind mismatch should fail");
948
949 assert_validation_contains(error, "unknown dimension `ceremony=");
950 }
951
952 #[test]
953 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
954 let mut command = sample_command();
955 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
956 coordinate.dimension = "ceremony".to_string();
957 command.memory.relations[0].coordinate = Some(coordinate);
958
959 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
960 .expect_err("relation coordinate kind mismatch should fail");
961
962 assert_validation_contains(error, "unknown dimension `ceremony=");
963 }
964
965 #[test]
966 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
967 let mut command = sample_command();
968 command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
969
970 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
971 .expect_err("unknown ref should fail");
972
973 assert_validation_contains(error, "references unknown refs");
974 }
975
976 #[test]
985 fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
986 let mut command = sample_command();
987 command.memory.relations[0].rel = "uses_background".to_string();
988 command.memory.relations[0].semantic_class = "evidential".to_string();
989 command.memory.relations[0].confidence = Some("high".to_string());
990 command.memory.relations[0].why =
991 Some("The linked memory supplies the observation's context.".to_string());
992
993 command.memory.relations[0].target_ref = command.about.clone();
994
995 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
996 .expect("an entry may relate to the about it belongs to");
997
998 assert!(
999 update
1000 .changes
1001 .iter()
1002 .any(|change| change.entity_id.ends_with(&command.about)),
1003 "the relation to the anchor must survive translation, got {:?}",
1004 update
1005 .changes
1006 .iter()
1007 .map(|change| change.entity_id.as_str())
1008 .collect::<Vec<_>>()
1009 );
1010 }
1011
1012 #[test]
1013 fn translate_memory_ingest_canonicalizes_known_relation_types() {
1014 let mut command = sample_command();
1015 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
1016
1017 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1018 .expect("known relation aliases should canonicalize");
1019
1020 assert_eq!(
1021 update.changes[2].entity_id,
1022 "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
1023 );
1024 }
1025
1026 #[test]
1027 fn translate_memory_ingest_requires_non_structural_relation_proof() {
1028 let mut command = sample_command();
1029 command.memory.relations[0].semantic_class = "causal".to_string();
1030 command.memory.relations[0].why = None;
1031 command.memory.relations[0].evidence = None;
1032 command.memory.relations[0].confidence = None;
1033
1034 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1035 .expect_err("missing proof should fail");
1036
1037 assert_validation_contains(error, "require confidence");
1038 }
1039
1040 #[test]
1041 fn translate_memory_ingest_accepts_existing_materialized_refs() {
1042 let mut command = sample_command();
1043 command.memory.relations[0].rel = "uses_background".to_string();
1044 command.memory.relations[0].semantic_class = "evidential".to_string();
1045 command.memory.relations[0].confidence = Some("high".to_string());
1046 command.memory.relations[0].why =
1047 Some("The linked memory supplies the observation's context.".to_string());
1048
1049 command.memory.dimensions.clear();
1050 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
1051 command.memory.relations[0].source_ref = "conversation:existing".to_string();
1052 command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
1053 command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
1054 let dimension_ref =
1055 "label:v1:question%3A830ce83f:conversation:conversation%3Aexisting".to_string();
1056 let existing = ExistingMemoryRefs {
1057 refs: [
1058 dimension_ref.clone(),
1059 "question:830ce83f:claim:existing".to_string(),
1060 ]
1061 .into_iter()
1062 .collect(),
1063 dimensions: [dimension_ref].into_iter().collect(),
1064 labels: BTreeSet::new(),
1065 ..ExistingMemoryRefs::default()
1066 };
1067
1068 let (update, outcome) =
1069 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
1070
1071 assert_eq!(outcome.accepted.entries, 1);
1072 assert_eq!(update.changes.len(), 3);
1073 }
1074
1075 #[test]
1076 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
1077 let command = sample_command();
1078 let dimension_ref =
1079 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1080 .to_string();
1081 let existing = ExistingMemoryRefs {
1082 refs: [dimension_ref.clone()].into_iter().collect(),
1083 dimensions: [dimension_ref.clone()].into_iter().collect(),
1084 labels: BTreeSet::new(),
1085 ..ExistingMemoryRefs::default()
1086 };
1087
1088 let (update, outcome) = translate_memory_ingest(&command, &existing)
1089 .expect("existing dimension declaration should be idempotent");
1090
1091 assert_eq!(outcome.accepted.entries, 1);
1092 assert_eq!(
1093 update
1094 .changes
1095 .iter()
1096 .map(|change| change.entity_kind.as_str())
1097 .collect::<Vec<_>>(),
1098 vec!["memory_entry", "memory_relation", "memory_evidence"]
1099 );
1100 assert_eq!(
1101 update.changes[0].scopes,
1102 std::slice::from_ref(&dimension_ref)
1103 );
1104 assert_eq!(
1105 update.changes[1].entity_id,
1106 "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
1107 );
1108 }
1109
1110 #[test]
1111 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
1112 let mut command = sample_command();
1113 command.memory.dimensions.clear();
1114 let dimension_ref =
1115 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1116 .to_string();
1117 command.memory.relations[0].source_ref = dimension_ref.clone();
1118 let existing = ExistingMemoryRefs {
1119 refs: BTreeSet::new(),
1120 dimensions: [dimension_ref].into_iter().collect(),
1121 labels: BTreeSet::new(),
1122 ..ExistingMemoryRefs::default()
1123 };
1124
1125 translate_memory_ingest(&command, &existing)
1126 .expect("existing dimensions should also be valid relation refs");
1127 }
1128
1129 #[test]
1130 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
1131 let mut command = sample_command();
1132 command.memory.entries[0].coordinates[0].sequence = Some(0);
1133
1134 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1135 .expect_err("zero coordinate sequence should fail");
1136
1137 assert_validation_contains(error, "sequence must be greater than zero");
1138 }
1139
1140 #[test]
1141 fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
1142 let mut command = sample_command();
1143 command.memory.entries[0].coordinates[0].sequence = None;
1144 let scope = "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1145 .to_string();
1146 let existing = ExistingMemoryRefs {
1147 max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
1148 ..ExistingMemoryRefs::default()
1149 };
1150
1151 let (update, _) = translate_memory_ingest(&command, &existing)
1152 .expect("kernel should assign the next coordinate sequence");
1153 let entry = update
1154 .changes
1155 .iter()
1156 .find(|change| change.entity_kind == "memory_entry")
1157 .expect("entry change");
1158 let payload: serde_json::Value =
1159 serde_json::from_str(&entry.payload_json).expect("entry payload");
1160
1161 assert_eq!(payload["coordinates"][0]["sequence"], 8);
1162 }
1163
1164 #[test]
1165 fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
1166 const HOSTILE_REFS: &[&str] = &[
1167 "incident:gamma:entry:observation:foreign",
1168 "incident:beta",
1169 "incident:alfa:entry:x\nincident:beta:entry:y",
1170 "../../incident:beta:entry:x",
1171 ];
1172 const REF_FIELDS: &[&str] = &[
1173 "entry.id",
1174 "relation.from",
1175 "relation.to",
1176 "relation.decision_id",
1177 "relation.caused_by_node_id",
1178 "evidence.id",
1179 "evidence.supports",
1180 ];
1181
1182 for field in REF_FIELDS {
1183 for hostile in HOSTILE_REFS {
1184 let mut command = sample_command();
1185 command.about = "incident:alfa".to_string();
1186 command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
1187 command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
1188 command.memory.evidence[0].id =
1189 "evidence:incident:alfa:entry:observation:local:current".to_string();
1190 command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
1191
1192 match *field {
1193 "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
1194 "relation.from" => {
1195 command.memory.relations[0].source_ref = (*hostile).to_string()
1196 }
1197 "relation.to" => {
1198 command.memory.relations[0].target_ref = (*hostile).to_string()
1199 }
1200 "relation.decision_id" => {
1201 command.memory.relations[0].decision_id = Some((*hostile).to_string())
1202 }
1203 "relation.caused_by_node_id" => {
1204 command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
1205 }
1206 "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
1207 "evidence.supports" => {
1208 command.memory.evidence[0].supports[0] = (*hostile).to_string()
1209 }
1210 unexpected => panic!("unknown test field {unexpected}"),
1211 }
1212
1213 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1214 .expect_err("an ingest ref outside the about must be refused");
1215 assert_validation_contains(
1216 error,
1217 if hostile.contains('/') || hostile.contains('\n') {
1218 "memory refs cannot contain"
1219 } else {
1220 "does not belong to about"
1221 },
1222 );
1223 }
1224 }
1225 }
1226
1227 fn catalogue_with(kind: &str, value: &str) -> ExistingMemoryRefs {
1228 ExistingMemoryRefs {
1229 labels: BTreeSet::from([(kind.to_string(), value.to_string())]),
1230 ..ExistingMemoryRefs::default()
1231 }
1232 }
1233
1234 #[test]
1235 fn a_lax_ingest_writes_a_resembling_label_and_says_so() {
1236 let command = sample_command();
1237 let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1238
1239 let (_, outcome) =
1240 translate_memory_ingest(&command, &existing).expect("warn policy writes");
1241
1242 assert_eq!(outcome.resembling_labels.len(), 1);
1243 let resembling = &outcome.resembling_labels[0];
1244 assert_eq!(resembling.key, "conversation");
1245 assert_eq!(resembling.value, "conversation:rachel-2026-04-12");
1246 assert_eq!(resembling.existing_value, "conversation-rachel-2026-04-12");
1247 assert_eq!(resembling.kind, "same_label_spelled_differently");
1248 assert!(
1249 outcome
1250 .warnings
1251 .iter()
1252 .any(|warning| warning == &resembling.why),
1253 "the why is also a warning: {:?}",
1254 outcome.warnings
1255 );
1256 }
1257
1258 #[test]
1259 fn a_refusing_ingest_names_both_labels_and_the_way_to_insist() {
1260 let mut command = sample_command();
1261 command.label_policy = crate::memory::LabelPolicy::Refuse;
1262 let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1263
1264 let error = translate_memory_ingest(&command, &existing).expect_err("refused");
1265
1266 let message = match error {
1267 ApplicationError::Validation(message) => message,
1268 other => panic!("expected a validation error, got {other:?}"),
1269 };
1270 assert!(
1271 message.contains("`conversation=conversation:rachel-2026-04-12` resembles `conversation=conversation-rachel-2026-04-12`"),
1272 "{message}"
1273 );
1274 assert!(
1275 message.contains("same identifier up to case and separators"),
1276 "{message}"
1277 );
1278 assert!(message.contains("writer_intended_new"), "{message}");
1279 }
1280
1281 #[test]
1282 fn an_insisted_label_is_left_alone_and_the_insistence_is_not_stored() {
1283 let mut command = sample_command();
1284 command.label_policy = crate::memory::LabelPolicy::Refuse;
1285 command.memory.dimensions[0]
1286 .metadata
1287 .insert("writer_intended_new".to_string(), "true".to_string());
1288 let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1289
1290 let (update, outcome) =
1291 translate_memory_ingest(&command, &existing).expect("insisted label writes");
1292
1293 assert!(outcome.resembling_labels.is_empty());
1294 assert!(
1295 !update.changes[0]
1296 .payload_json
1297 .contains("writer_intended_new"),
1298 "the marker is read at translation and never stored: {}",
1299 update.changes[0].payload_json
1300 );
1301 }
1302
1303 #[test]
1304 fn a_label_the_about_already_holds_resembles_nothing() {
1305 let command = sample_command();
1306 let mut existing = catalogue_with("conversation", "conversation:rachel-2026-04-12");
1307 existing.dimensions.insert(
1308 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1309 .to_string(),
1310 );
1311
1312 let (_, outcome) = translate_memory_ingest(&command, &existing).expect("reuse");
1313
1314 assert!(outcome.resembling_labels.is_empty());
1315 assert!(outcome.created_dimensions.is_empty());
1316 }
1317
1318 fn sample_command() -> MemoryIngestCommand {
1319 MemoryIngestCommand {
1320 receipt_context: None,
1321 default_observation_to_ingestion: false,
1322 neighborhood_review: None,
1323 about: "question:830ce83f".to_string(),
1324 memory: MemoryData {
1325 dimensions: vec![MemoryDimensionData {
1326 id: "conversation:rachel-2026-04-12".to_string(),
1327 kind: "conversation".to_string(),
1328 title: Some("Rachel relocation discussion".to_string()),
1329 metadata: Default::default(),
1330 }],
1331 entries: vec![MemoryEntryData {
1332 id: "question:830ce83f:claim:rachel-denver".to_string(),
1333 kind: "claim".to_string(),
1334 text: "Rachel said she was moving to Denver.".to_string(),
1335 coordinates: vec![MemoryCoordinateData {
1336 dimension: "conversation".to_string(),
1337 scope_id: "conversation:rachel-2026-04-12".to_string(),
1338 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
1339 observed_at: None,
1340 ingested_at: None,
1341 valid_from: None,
1342 valid_until: None,
1343 sequence: Some(1),
1344 rank: None,
1345 metadata: Default::default(),
1346 }],
1347 metadata: Default::default(),
1348 }],
1349 relations: vec![MemoryRelationData {
1350 clocks: None,
1351 source_ref: "conversation:rachel-2026-04-12".to_string(),
1352 target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1353 rel: "contains_entry".to_string(),
1354 semantic_class: "structural".to_string(),
1355 why: None,
1356 evidence: None,
1357 confidence: None,
1358 sequence: Some(1),
1359 motivation: None,
1360 method: None,
1361 decision_id: None,
1362 caused_by_node_id: None,
1363 coordinate: None,
1364 }],
1365 evidence: vec![MemoryEvidenceData {
1366 support_clocks: None,
1367 id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
1368 supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
1369 text: "Conversation transcript line 1".to_string(),
1370 source: Some("transcript:1".to_string()),
1371 time: Some("2026-04-12T15:00:00Z".to_string()),
1372 metadata: Default::default(),
1373 }],
1374 },
1375 provenance: None,
1376 idempotency_key: "ingest:app-test".to_string(),
1377 dry_run: false,
1378 label_policy: Default::default(),
1379 }
1380 }
1381
1382 fn assert_validation_contains(error: ApplicationError, expected: &str) {
1383 match error {
1384 ApplicationError::Validation(message) => assert!(
1385 message.contains(expected),
1386 "expected `{message}` to contain `{expected}`"
1387 ),
1388 other => panic!("expected validation error, got {other:?}"),
1389 }
1390 }
1391
1392 fn cross_about_relation(rel: &str, method: Option<&str>) -> MemoryRelationData {
1393 MemoryRelationData {
1394 clocks: None,
1395 source_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1396 target_ref: "incident:platform:outcome:freeze".to_string(),
1397 rel: rel.to_string(),
1398 semantic_class: "evidential".to_string(),
1399 why: Some("Both record the same freeze.".to_string()),
1400 evidence: Some("kmp_relate proposal by identifier.".to_string()),
1401 confidence: Some("high".to_string()),
1402 sequence: None,
1403 motivation: None,
1404 method: method.map(str::to_string),
1405 decision_id: None,
1406 caused_by_node_id: None,
1407 coordinate: None,
1408 }
1409 }
1410
1411 #[test]
1416 fn a_declared_equivalence_crosses_the_about_and_nothing_else_does() {
1417 let mut command = sample_command();
1418 command.memory.relations.push(cross_about_relation(
1419 "same_event_as",
1420 Some("kmp_relate:identifier"),
1421 ));
1422 let mut existing = ExistingMemoryRefs::default();
1423 existing
1424 .foreign
1425 .insert("incident:platform:outcome:freeze".to_string());
1426 let (update, _) = translate_memory_ingest(&command, &existing)
1427 .expect("a declared equivalence is written");
1428 assert!(
1429 update.changes.iter().any(|change| {
1430 change.payload_json.contains("same_event_as")
1431 && change
1432 .payload_json
1433 .contains("incident:platform:outcome:freeze")
1434 }),
1435 "the equivalence is among the changes"
1436 );
1437
1438 let unverified = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1439 .expect_err("a ref no about holds is refused");
1440 assert!(
1441 unverified.to_string().contains("a ref no about holds"),
1442 "{unverified}"
1443 );
1444
1445 let mut unstamped = sample_command();
1446 unstamped
1447 .memory
1448 .relations
1449 .push(cross_about_relation("same_event_as", None));
1450 let error = translate_memory_ingest(&unstamped, &existing)
1451 .expect_err("without the proposal stamp the boundary holds");
1452 assert!(
1453 error.to_string().contains("does not belong to about"),
1454 "{error}"
1455 );
1456
1457 let mut follows = sample_command();
1458 follows.memory.relations.push(cross_about_relation(
1459 "follows",
1460 Some("kmp_relate:identifier"),
1461 ));
1462 let error = translate_memory_ingest(&follows, &existing)
1463 .expect_err("no other relation crosses an about");
1464 assert!(
1465 error.to_string().contains("does not belong to about"),
1466 "{error}"
1467 );
1468 }
1469}