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() && memory.relations.is_empty() {
211 return Err(ApplicationError::Validation(
212 "memory must contain at least one entry or relation".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 relation_only_ingest_validates_stored_endpoints_without_entry_changes() {
779 let mut command = sample_command();
780 let source = command.memory.entries[0].id.clone();
781 let target = "question:830ce83f:claim:another".to_owned();
782 command.memory.entries.clear();
783 command.memory.dimensions.clear();
784 let relation = &mut command.memory.relations[0];
785 relation.source_ref = source.clone();
786 relation.target_ref = target.clone();
787 relation.rel = "follows".into();
788 relation.semantic_class = "procedural".into();
789 relation.confidence = Some("high".into());
790 relation.why = Some("The second statement follows the first in the transcript.".into());
791 let mut existing = ExistingMemoryRefs {
792 refs: [source, target.clone()].into_iter().collect(),
793 dimensions: [
794 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12".into(),
795 ]
796 .into_iter()
797 .collect(),
798 ..Default::default()
799 };
800 let (update, outcome) = translate_memory_ingest(&command, &existing).expect("attachment");
801 assert_eq!(outcome.accepted.entries, 0);
802 assert_eq!(outcome.accepted.relations, 1);
803 assert_eq!(
804 update
805 .changes
806 .iter()
807 .map(|change| change.entity_kind.as_str())
808 .collect::<Vec<_>>(),
809 ["memory_relation", "memory_evidence"]
810 );
811 existing.refs.remove(&target);
812 assert_validation_contains(
813 translate_memory_ingest(&command, &existing).expect_err("unknown endpoint"),
814 "unknown refs",
815 );
816 command.memory.relations.clear();
817 assert_validation_contains(
818 translate_memory_ingest(&command, &existing).expect_err("empty write"),
819 "at least one entry or relation",
820 );
821 }
822
823 #[test]
824 fn translate_memory_ingest_creates_internal_memory_update_command() {
825 let command = sample_command();
826
827 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
828 .expect("valid memory should translate");
829
830 assert_eq!(update.root_node_id, "question:830ce83f");
831 assert_eq!(update.role, "memory");
832 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
833 assert_eq!(outcome.memory_id, "memory:app-test");
834 assert_eq!(outcome.accepted.entries, 1);
835 assert_eq!(outcome.accepted.relations, 1);
836 assert_eq!(outcome.accepted.evidence, 1);
837 assert_eq!(
838 update
839 .changes
840 .iter()
841 .map(|change| change.entity_kind.as_str())
842 .collect::<Vec<_>>(),
843 vec![
844 "memory_dimension",
845 "memory_entry",
846 "memory_relation",
847 "memory_evidence"
848 ]
849 );
850 assert_eq!(
851 update.changes[0].entity_id,
852 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
853 );
854 assert_eq!(
855 update.changes[1].scopes,
856 ["label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"]
857 );
858 assert_eq!(
859 update.changes[2].entity_id,
860 "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
861 );
862 let entry_payload: serde_json::Value =
863 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
864 assert_eq!(
865 entry_payload["coordinates"][0]["scope_id"],
866 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
867 );
868 assert!(
869 entry_payload["coordinates"][0]["ingested_at"]
870 .as_str()
871 .is_some_and(|value| value.starts_with("unix:")),
872 "the kernel must stamp when it learned every coordinate: {entry_payload}"
873 );
874 }
875
876 #[test]
881 fn translate_memory_ingest_warns_about_a_search_summary_that_will_not_carry() {
882 let mut command = sample_command();
883 command.memory.entries[0].text =
884 "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
885 command.memory.entries[0].metadata.insert(
886 "summary_en".to_string(),
887 "Rachel said she was moving to Denver.".to_string(),
888 );
889
890 let (_, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
891 .expect("a degraded summary is a warning, not a refusal");
892
893 assert_eq!(
894 outcome.warnings,
895 [
896 "memory entry `question:830ce83f:claim:rachel-denver` carries a summary_en that will \
897 not carry retrieval: drops identifiers the text carries: #469"
898 ]
899 );
900 assert_eq!(outcome.accepted.entries, 1);
901 }
902
903 #[test]
904 fn translate_memory_ingest_is_silent_about_a_search_summary_that_carries() {
905 let mut command = sample_command();
906 command.memory.entries[0].text =
907 "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
908 command.memory.entries[0].metadata.insert(
909 "summary_en".to_string(),
910 "Rachel said she was moving to Denver because of ticket #469.".to_string(),
911 );
912
913 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
914 .expect("a faithful summary translates");
915
916 assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
917 let entry_payload: serde_json::Value =
918 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
919 assert_eq!(
920 entry_payload["metadata"]["summary_en"],
921 "Rachel said she was moving to Denver because of ticket #469.",
922 "the summary is stored as written, beside the text"
923 );
924 }
925
926 #[test]
927 fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
928 let mut command = sample_command();
929 command.memory.entries[0].coordinates[0].ingested_at =
930 Some("2026-04-12T15:01:00Z".to_string());
931
932 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
933 .expect("caller-supplied ingest clock should survive replay");
934 let entry_payload: serde_json::Value =
935 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
936
937 assert_eq!(
938 entry_payload["coordinates"][0]["ingested_at"],
939 "2026-04-12T15:01:00Z"
940 );
941 }
942
943 #[test]
944 fn translate_memory_ingest_accepts_an_already_namespaced_dimension_id() {
945 let namespaced =
949 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12";
950 let mut command = sample_command();
951 command.memory.dimensions[0].id = namespaced.to_string();
952 command.memory.entries[0].coordinates[0].scope_id = namespaced.to_string();
953 command.memory.relations[0].source_ref = namespaced.to_string();
954
955 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
956 .expect("a namespaced dimension id belongs to this about");
957
958 assert_eq!(update.changes[0].entity_id, namespaced);
959 let entry_payload: serde_json::Value =
960 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
961 assert_eq!(entry_payload["coordinates"][0]["scope_id"], namespaced);
962 }
963
964 #[test]
965 fn translate_memory_ingest_rejects_a_dimension_owned_by_another_about() {
966 let mut command = sample_command();
967 command.memory.dimensions[0].id =
968 "label:v1:question%3Aother:conversation:conversation%3Arachel-2026-04-12".to_string();
969
970 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
971 .expect_err("a foreign about's dimension is not ours to write");
972
973 assert_validation_contains(error, "belongs to another about");
974 }
975
976 #[test]
977 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
978 let mut command = sample_command();
979 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
980
981 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
982 .expect_err("unknown scope should fail");
983
984 assert_validation_contains(error, "unknown dimension");
985 }
986
987 #[test]
988 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
989 let mut command = sample_command();
990 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
991
992 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
993 .expect_err("coordinate kind mismatch should fail");
994
995 assert_validation_contains(error, "unknown dimension `ceremony=");
996 }
997
998 #[test]
999 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
1000 let mut command = sample_command();
1001 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
1002 coordinate.dimension = "ceremony".to_string();
1003 command.memory.relations[0].coordinate = Some(coordinate);
1004
1005 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1006 .expect_err("relation coordinate kind mismatch should fail");
1007
1008 assert_validation_contains(error, "unknown dimension `ceremony=");
1009 }
1010
1011 #[test]
1012 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
1013 let mut command = sample_command();
1014 command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
1015
1016 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1017 .expect_err("unknown ref should fail");
1018
1019 assert_validation_contains(error, "references unknown refs");
1020 }
1021
1022 #[test]
1031 fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
1032 let mut command = sample_command();
1033 command.memory.relations[0].rel = "uses_background".to_string();
1034 command.memory.relations[0].semantic_class = "evidential".to_string();
1035 command.memory.relations[0].confidence = Some("high".to_string());
1036 command.memory.relations[0].why =
1037 Some("The linked memory supplies the observation's context.".to_string());
1038
1039 command.memory.relations[0].target_ref = command.about.clone();
1040
1041 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1042 .expect("an entry may relate to the about it belongs to");
1043
1044 assert!(
1045 update
1046 .changes
1047 .iter()
1048 .any(|change| change.entity_id.ends_with(&command.about)),
1049 "the relation to the anchor must survive translation, got {:?}",
1050 update
1051 .changes
1052 .iter()
1053 .map(|change| change.entity_id.as_str())
1054 .collect::<Vec<_>>()
1055 );
1056 }
1057
1058 #[test]
1059 fn translate_memory_ingest_canonicalizes_known_relation_types() {
1060 let mut command = sample_command();
1061 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
1062
1063 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1064 .expect("known relation aliases should canonicalize");
1065
1066 assert_eq!(
1067 update.changes[2].entity_id,
1068 "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
1069 );
1070 }
1071
1072 #[test]
1073 fn translate_memory_ingest_requires_non_structural_relation_proof() {
1074 let mut command = sample_command();
1075 command.memory.relations[0].semantic_class = "causal".to_string();
1076 command.memory.relations[0].why = None;
1077 command.memory.relations[0].evidence = None;
1078 command.memory.relations[0].confidence = None;
1079
1080 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1081 .expect_err("missing proof should fail");
1082
1083 assert_validation_contains(error, "require confidence");
1084 }
1085
1086 #[test]
1087 fn translate_memory_ingest_accepts_existing_materialized_refs() {
1088 let mut command = sample_command();
1089 command.memory.relations[0].rel = "uses_background".to_string();
1090 command.memory.relations[0].semantic_class = "evidential".to_string();
1091 command.memory.relations[0].confidence = Some("high".to_string());
1092 command.memory.relations[0].why =
1093 Some("The linked memory supplies the observation's context.".to_string());
1094
1095 command.memory.dimensions.clear();
1096 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
1097 command.memory.relations[0].source_ref = "conversation:existing".to_string();
1098 command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
1099 command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
1100 let dimension_ref =
1101 "label:v1:question%3A830ce83f:conversation:conversation%3Aexisting".to_string();
1102 let existing = ExistingMemoryRefs {
1103 refs: [
1104 dimension_ref.clone(),
1105 "question:830ce83f:claim:existing".to_string(),
1106 ]
1107 .into_iter()
1108 .collect(),
1109 dimensions: [dimension_ref].into_iter().collect(),
1110 labels: BTreeSet::new(),
1111 ..ExistingMemoryRefs::default()
1112 };
1113
1114 let (update, outcome) =
1115 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
1116
1117 assert_eq!(outcome.accepted.entries, 1);
1118 assert_eq!(update.changes.len(), 3);
1119 }
1120
1121 #[test]
1122 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
1123 let command = sample_command();
1124 let dimension_ref =
1125 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1126 .to_string();
1127 let existing = ExistingMemoryRefs {
1128 refs: [dimension_ref.clone()].into_iter().collect(),
1129 dimensions: [dimension_ref.clone()].into_iter().collect(),
1130 labels: BTreeSet::new(),
1131 ..ExistingMemoryRefs::default()
1132 };
1133
1134 let (update, outcome) = translate_memory_ingest(&command, &existing)
1135 .expect("existing dimension declaration should be idempotent");
1136
1137 assert_eq!(outcome.accepted.entries, 1);
1138 assert_eq!(
1139 update
1140 .changes
1141 .iter()
1142 .map(|change| change.entity_kind.as_str())
1143 .collect::<Vec<_>>(),
1144 vec!["memory_entry", "memory_relation", "memory_evidence"]
1145 );
1146 assert_eq!(
1147 update.changes[0].scopes,
1148 std::slice::from_ref(&dimension_ref)
1149 );
1150 assert_eq!(
1151 update.changes[1].entity_id,
1152 "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
1153 );
1154 }
1155
1156 #[test]
1157 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
1158 let mut command = sample_command();
1159 command.memory.dimensions.clear();
1160 let dimension_ref =
1161 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1162 .to_string();
1163 command.memory.relations[0].source_ref = dimension_ref.clone();
1164 let existing = ExistingMemoryRefs {
1165 refs: BTreeSet::new(),
1166 dimensions: [dimension_ref].into_iter().collect(),
1167 labels: BTreeSet::new(),
1168 ..ExistingMemoryRefs::default()
1169 };
1170
1171 translate_memory_ingest(&command, &existing)
1172 .expect("existing dimensions should also be valid relation refs");
1173 }
1174
1175 #[test]
1176 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
1177 let mut command = sample_command();
1178 command.memory.entries[0].coordinates[0].sequence = Some(0);
1179
1180 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1181 .expect_err("zero coordinate sequence should fail");
1182
1183 assert_validation_contains(error, "sequence must be greater than zero");
1184 }
1185
1186 #[test]
1187 fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
1188 let mut command = sample_command();
1189 command.memory.entries[0].coordinates[0].sequence = None;
1190 let scope = "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1191 .to_string();
1192 let existing = ExistingMemoryRefs {
1193 max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
1194 ..ExistingMemoryRefs::default()
1195 };
1196
1197 let (update, _) = translate_memory_ingest(&command, &existing)
1198 .expect("kernel should assign the next coordinate sequence");
1199 let entry = update
1200 .changes
1201 .iter()
1202 .find(|change| change.entity_kind == "memory_entry")
1203 .expect("entry change");
1204 let payload: serde_json::Value =
1205 serde_json::from_str(&entry.payload_json).expect("entry payload");
1206
1207 assert_eq!(payload["coordinates"][0]["sequence"], 8);
1208 }
1209
1210 #[test]
1211 fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
1212 const HOSTILE_REFS: &[&str] = &[
1213 "incident:gamma:entry:observation:foreign",
1214 "incident:beta",
1215 "incident:alfa:entry:x\nincident:beta:entry:y",
1216 "../../incident:beta:entry:x",
1217 ];
1218 const REF_FIELDS: &[&str] = &[
1219 "entry.id",
1220 "relation.from",
1221 "relation.to",
1222 "relation.decision_id",
1223 "relation.caused_by_node_id",
1224 "evidence.id",
1225 "evidence.supports",
1226 ];
1227
1228 for field in REF_FIELDS {
1229 for hostile in HOSTILE_REFS {
1230 let mut command = sample_command();
1231 command.about = "incident:alfa".to_string();
1232 command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
1233 command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
1234 command.memory.evidence[0].id =
1235 "evidence:incident:alfa:entry:observation:local:current".to_string();
1236 command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
1237
1238 match *field {
1239 "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
1240 "relation.from" => {
1241 command.memory.relations[0].source_ref = (*hostile).to_string()
1242 }
1243 "relation.to" => {
1244 command.memory.relations[0].target_ref = (*hostile).to_string()
1245 }
1246 "relation.decision_id" => {
1247 command.memory.relations[0].decision_id = Some((*hostile).to_string())
1248 }
1249 "relation.caused_by_node_id" => {
1250 command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
1251 }
1252 "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
1253 "evidence.supports" => {
1254 command.memory.evidence[0].supports[0] = (*hostile).to_string()
1255 }
1256 unexpected => panic!("unknown test field {unexpected}"),
1257 }
1258
1259 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1260 .expect_err("an ingest ref outside the about must be refused");
1261 assert_validation_contains(
1262 error,
1263 if hostile.contains('/') || hostile.contains('\n') {
1264 "memory refs cannot contain"
1265 } else {
1266 "does not belong to about"
1267 },
1268 );
1269 }
1270 }
1271 }
1272
1273 fn catalogue_with(kind: &str, value: &str) -> ExistingMemoryRefs {
1274 ExistingMemoryRefs {
1275 labels: BTreeSet::from([(kind.to_string(), value.to_string())]),
1276 ..ExistingMemoryRefs::default()
1277 }
1278 }
1279
1280 #[test]
1281 fn a_lax_ingest_writes_a_resembling_label_and_says_so() {
1282 let command = sample_command();
1283 let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1284
1285 let (_, outcome) =
1286 translate_memory_ingest(&command, &existing).expect("warn policy writes");
1287
1288 assert_eq!(outcome.resembling_labels.len(), 1);
1289 let resembling = &outcome.resembling_labels[0];
1290 assert_eq!(resembling.key, "conversation");
1291 assert_eq!(resembling.value, "conversation:rachel-2026-04-12");
1292 assert_eq!(resembling.existing_value, "conversation-rachel-2026-04-12");
1293 assert_eq!(resembling.kind, "same_label_spelled_differently");
1294 assert!(
1295 outcome
1296 .warnings
1297 .iter()
1298 .any(|warning| warning == &resembling.why),
1299 "the why is also a warning: {:?}",
1300 outcome.warnings
1301 );
1302 }
1303
1304 #[test]
1305 fn a_refusing_ingest_names_both_labels_and_the_way_to_insist() {
1306 let mut command = sample_command();
1307 command.label_policy = crate::memory::LabelPolicy::Refuse;
1308 let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1309
1310 let error = translate_memory_ingest(&command, &existing).expect_err("refused");
1311
1312 let message = match error {
1313 ApplicationError::Validation(message) => message,
1314 other => panic!("expected a validation error, got {other:?}"),
1315 };
1316 assert!(
1317 message.contains("`conversation=conversation:rachel-2026-04-12` resembles `conversation=conversation-rachel-2026-04-12`"),
1318 "{message}"
1319 );
1320 assert!(
1321 message.contains("same identifier up to case and separators"),
1322 "{message}"
1323 );
1324 assert!(message.contains("writer_intended_new"), "{message}");
1325 }
1326
1327 #[test]
1328 fn an_insisted_label_is_left_alone_and_the_insistence_is_not_stored() {
1329 let mut command = sample_command();
1330 command.label_policy = crate::memory::LabelPolicy::Refuse;
1331 command.memory.dimensions[0]
1332 .metadata
1333 .insert("writer_intended_new".to_string(), "true".to_string());
1334 let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1335
1336 let (update, outcome) =
1337 translate_memory_ingest(&command, &existing).expect("insisted label writes");
1338
1339 assert!(outcome.resembling_labels.is_empty());
1340 assert!(
1341 !update.changes[0]
1342 .payload_json
1343 .contains("writer_intended_new"),
1344 "the marker is read at translation and never stored: {}",
1345 update.changes[0].payload_json
1346 );
1347 }
1348
1349 #[test]
1350 fn a_label_the_about_already_holds_resembles_nothing() {
1351 let command = sample_command();
1352 let mut existing = catalogue_with("conversation", "conversation:rachel-2026-04-12");
1353 existing.dimensions.insert(
1354 "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1355 .to_string(),
1356 );
1357
1358 let (_, outcome) = translate_memory_ingest(&command, &existing).expect("reuse");
1359
1360 assert!(outcome.resembling_labels.is_empty());
1361 assert!(outcome.created_dimensions.is_empty());
1362 }
1363
1364 fn sample_command() -> MemoryIngestCommand {
1365 MemoryIngestCommand {
1366 receipt_context: None,
1367 default_observation_to_ingestion: false,
1368 neighborhood_review: None,
1369 about: "question:830ce83f".to_string(),
1370 memory: MemoryData {
1371 dimensions: vec![MemoryDimensionData {
1372 id: "conversation:rachel-2026-04-12".to_string(),
1373 kind: "conversation".to_string(),
1374 title: Some("Rachel relocation discussion".to_string()),
1375 metadata: Default::default(),
1376 }],
1377 entries: vec![MemoryEntryData {
1378 id: "question:830ce83f:claim:rachel-denver".to_string(),
1379 kind: "claim".to_string(),
1380 text: "Rachel said she was moving to Denver.".to_string(),
1381 coordinates: vec![MemoryCoordinateData {
1382 dimension: "conversation".to_string(),
1383 scope_id: "conversation:rachel-2026-04-12".to_string(),
1384 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
1385 observed_at: None,
1386 ingested_at: None,
1387 valid_from: None,
1388 valid_until: None,
1389 sequence: Some(1),
1390 rank: None,
1391 metadata: Default::default(),
1392 }],
1393 metadata: Default::default(),
1394 }],
1395 relations: vec![MemoryRelationData {
1396 clocks: None,
1397 source_ref: "conversation:rachel-2026-04-12".to_string(),
1398 target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1399 rel: "contains_entry".to_string(),
1400 semantic_class: "structural".to_string(),
1401 why: None,
1402 evidence: None,
1403 confidence: None,
1404 sequence: Some(1),
1405 motivation: None,
1406 method: None,
1407 decision_id: None,
1408 caused_by_node_id: None,
1409 coordinate: None,
1410 }],
1411 evidence: vec![MemoryEvidenceData {
1412 support_clocks: None,
1413 id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
1414 supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
1415 text: "Conversation transcript line 1".to_string(),
1416 source: Some("transcript:1".to_string()),
1417 time: Some("2026-04-12T15:00:00Z".to_string()),
1418 metadata: Default::default(),
1419 }],
1420 },
1421 provenance: None,
1422 idempotency_key: "ingest:app-test".to_string(),
1423 dry_run: false,
1424 label_policy: Default::default(),
1425 }
1426 }
1427
1428 fn assert_validation_contains(error: ApplicationError, expected: &str) {
1429 match error {
1430 ApplicationError::Validation(message) => assert!(
1431 message.contains(expected),
1432 "expected `{message}` to contain `{expected}`"
1433 ),
1434 other => panic!("expected validation error, got {other:?}"),
1435 }
1436 }
1437
1438 fn cross_about_relation(rel: &str, method: Option<&str>) -> MemoryRelationData {
1439 MemoryRelationData {
1440 clocks: None,
1441 source_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1442 target_ref: "incident:platform:outcome:freeze".to_string(),
1443 rel: rel.to_string(),
1444 semantic_class: "evidential".to_string(),
1445 why: Some("Both record the same freeze.".to_string()),
1446 evidence: Some("kmp_relate proposal by identifier.".to_string()),
1447 confidence: Some("high".to_string()),
1448 sequence: None,
1449 motivation: None,
1450 method: method.map(str::to_string),
1451 decision_id: None,
1452 caused_by_node_id: None,
1453 coordinate: None,
1454 }
1455 }
1456
1457 #[test]
1462 fn a_declared_equivalence_crosses_the_about_and_nothing_else_does() {
1463 let mut command = sample_command();
1464 command.memory.relations.push(cross_about_relation(
1465 "same_event_as",
1466 Some("kmp_relate:identifier"),
1467 ));
1468 let mut existing = ExistingMemoryRefs::default();
1469 existing
1470 .foreign
1471 .insert("incident:platform:outcome:freeze".to_string());
1472 let (update, _) = translate_memory_ingest(&command, &existing)
1473 .expect("a declared equivalence is written");
1474 assert!(
1475 update.changes.iter().any(|change| {
1476 change.payload_json.contains("same_event_as")
1477 && change
1478 .payload_json
1479 .contains("incident:platform:outcome:freeze")
1480 }),
1481 "the equivalence is among the changes"
1482 );
1483
1484 let unverified = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1485 .expect_err("a ref no about holds is refused");
1486 assert!(
1487 unverified.to_string().contains("a ref no about holds"),
1488 "{unverified}"
1489 );
1490
1491 let mut unstamped = sample_command();
1492 unstamped
1493 .memory
1494 .relations
1495 .push(cross_about_relation("same_event_as", None));
1496 let error = translate_memory_ingest(&unstamped, &existing)
1497 .expect_err("without the proposal stamp the boundary holds");
1498 assert!(
1499 error.to_string().contains("does not belong to about"),
1500 "{error}"
1501 );
1502
1503 let mut follows = sample_command();
1504 follows.memory.relations.push(cross_about_relation(
1505 "follows",
1506 Some("kmp_relate:identifier"),
1507 ));
1508 let error = translate_memory_ingest(&follows, &existing)
1509 .expect_err("no other relation crosses an about");
1510 assert!(
1511 error.to_string().contains("does not belong to about"),
1512 "{error}"
1513 );
1514 }
1515}