1use std::collections::{BTreeMap, BTreeSet};
2
3use kmp_domain::{MemoryDimensionIdentity, MemoryRelationType, RelationSemanticClass, SourceKind};
4
5use crate::ApplicationError;
6use crate::commands::{UpdateContextChange, UpdateContextCommand};
7use crate::memory::{
8 MemoryAcceptedCounts, MemoryCoordinateData, MemoryData, MemoryDimensionData,
9 MemoryIngestCommand, MemoryIngestOutcome,
10};
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct ExistingMemoryRefs {
14 pub refs: BTreeSet<String>,
15 pub dimensions: BTreeSet<String>,
16}
17
18pub fn translate_memory_ingest(
19 command: &MemoryIngestCommand,
20 existing: &ExistingMemoryRefs,
21) -> Result<(UpdateContextCommand, MemoryIngestOutcome), ApplicationError> {
22 validate_command(command)?;
23 let memory = namespaced_memory(&command.about, &command.memory, existing)?;
24
25 let changes = memory_changes(&memory)?;
26 let outcome = MemoryIngestOutcome {
27 about: command.about.clone(),
28 memory_id: memory_id_from_idempotency_key(&command.idempotency_key),
29 accepted: MemoryAcceptedCounts {
30 entries: command.memory.entries.len(),
31 relations: command.memory.relations.len(),
32 evidence: command.memory.evidence.len(),
33 },
34 read_after_write_ready: false,
35 warnings: Vec::new(),
36 };
37
38 Ok((
39 UpdateContextCommand {
40 root_node_id: command.about.clone(),
41 role: "memory".to_string(),
42 work_item_id: command.idempotency_key.clone(),
43 changes,
44 expected_revision: None,
45 expected_content_hash: None,
46 idempotency_key: Some(command.idempotency_key.clone()),
47 logical_digest: Some(logical_digest(command)),
48 requested_by: command
49 .provenance
50 .as_ref()
51 .map(|provenance| provenance.source_agent.clone()),
52 },
53 outcome,
54 ))
55}
56
57fn validate_command(command: &MemoryIngestCommand) -> Result<(), ApplicationError> {
58 require_non_empty(&command.about, "about")?;
59 require_non_empty(&command.idempotency_key, "idempotency_key")?;
60 if let Some(provenance) = command.provenance.as_ref() {
61 SourceKind::parse(&provenance.source_kind).map_err(|error| {
62 ApplicationError::Validation(format!(
63 "memory provenance source_kind is invalid: {error}"
64 ))
65 })?;
66 require_non_empty(&provenance.source_agent, "provenance.source_agent")?;
67 require_non_empty(&provenance.observed_at, "provenance.observed_at")?;
68 }
69
70 Ok(())
71}
72
73fn namespaced_memory(
74 about: &str,
75 memory: &MemoryData,
76 existing: &ExistingMemoryRefs,
77) -> Result<MemoryData, ApplicationError> {
78 if memory.dimensions.is_empty() && existing.dimensions.is_empty() {
79 return Err(ApplicationError::Validation(
80 "memory.dimensions must not be empty when no existing memory dimensions are available"
81 .to_string(),
82 ));
83 }
84 if memory.entries.is_empty() {
85 return Err(ApplicationError::Validation(
86 "memory.entries must not be empty".to_string(),
87 ));
88 }
89
90 let mut known_refs = existing.refs.clone();
91 known_refs.extend(existing.dimensions.iter().cloned());
92 let mut dimension_ids = existing.dimensions.clone();
93 let mut dimension_aliases = existing_dimension_aliases(about, existing);
94 let mut declared_dimension_kinds = BTreeMap::new();
95 let mut declared_dimension_refs = BTreeSet::new();
96 let mut dimensions = Vec::new();
97 for dimension in &memory.dimensions {
98 require_non_empty(&dimension.id, "memory.dimensions[].id")?;
99 require_non_empty(&dimension.kind, "memory.dimensions[].kind")?;
100 let dimension_identity = dimension_identity(about, &dimension.id)?;
101 let dimension_ref = dimension_identity.node_id();
102 declared_dimension_kinds.insert(dimension_ref.clone(), dimension.kind.clone());
103 insert_unique(
104 &mut declared_dimension_refs,
105 &dimension_ref,
106 "memory dimension",
107 )?;
108 if existing.dimensions.contains(&dimension_ref) {
109 dimension_aliases
110 .entry(dimension.id.clone())
111 .or_insert_with(|| dimension_ref.clone());
112 known_refs.insert(dimension_ref);
113 continue;
114 }
115 insert_unique(&mut dimension_ids, &dimension_ref, "memory dimension")?;
116 if dimension_aliases
117 .insert(dimension.id.clone(), dimension_ref.clone())
118 .is_some()
119 {
120 return Err(ApplicationError::Validation(format!(
121 "duplicate memory dimension `{}`",
122 dimension.id
123 )));
124 }
125 known_refs.insert(dimension_ref.clone());
126
127 let mut metadata = dimension.metadata.clone();
128 metadata
129 .entry("memory_about".to_string())
130 .or_insert_with(|| about.to_string());
131 metadata
132 .entry("memory_dimension_id".to_string())
133 .or_insert_with(|| dimension.id.clone());
134 dimensions.push(MemoryDimensionData {
135 id: dimension_ref,
136 kind: dimension.kind.clone(),
137 title: dimension.title.clone(),
138 metadata,
139 });
140 }
141
142 let mut entry_ids = BTreeSet::new();
143 let mut entries = Vec::new();
144 for entry in &memory.entries {
145 require_non_empty(&entry.id, "memory.entries[].id")?;
146 require_non_empty(&entry.kind, "memory.entries[].kind")?;
147 require_non_empty(&entry.text, "memory.entries[].text")?;
148 if entry.coordinates.is_empty() {
149 return Err(ApplicationError::Validation(format!(
150 "memory entry `{}` must include at least one coordinate",
151 entry.id
152 )));
153 }
154 insert_unique(&mut entry_ids, &entry.id, "memory entry")?;
155 known_refs.insert(entry.id.clone());
156
157 let mut coordinates = Vec::new();
158 for coordinate in &entry.coordinates {
159 coordinates.push(normalize_coordinate(
160 coordinate,
161 "memory.entries[].coordinates[]",
162 "memory entry",
163 &dimension_aliases,
164 &dimension_ids,
165 &declared_dimension_kinds,
166 )?);
167 }
168 let mut entry = entry.clone();
169 entry.coordinates = coordinates;
170 entries.push(entry);
171 }
172
173 let mut relations = Vec::new();
174 for relation in &memory.relations {
175 require_non_empty(&relation.source_ref, "memory.relations[].source_ref")?;
176 require_non_empty(&relation.target_ref, "memory.relations[].target_ref")?;
177 require_non_empty(&relation.rel, "memory.relations[].rel")?;
178 let relation_type = MemoryRelationType::new(&relation.rel).map_err(|error| {
179 ApplicationError::Validation(format!("memory relation type is invalid: {error}"))
180 })?;
181 let semantic_class =
182 RelationSemanticClass::parse(&relation.semantic_class).map_err(|error| {
183 ApplicationError::Validation(format!("memory relation class is invalid: {error}"))
184 })?;
185 let source_ref = normalize_ref(&relation.source_ref, &dimension_aliases);
186 let target_ref = normalize_ref(&relation.target_ref, &dimension_aliases);
187 if !known_refs.contains(&source_ref) || !known_refs.contains(&target_ref) {
188 return Err(ApplicationError::Validation(format!(
189 "memory relation `{}` -> `{}` references unknown refs",
190 relation.source_ref, relation.target_ref
191 )));
192 }
193 if semantic_class != RelationSemanticClass::Structural {
194 if relation
195 .confidence
196 .as_deref()
197 .unwrap_or("")
198 .trim()
199 .is_empty()
200 {
201 return Err(ApplicationError::Validation(
202 "non-structural memory relations require confidence".to_string(),
203 ));
204 }
205 if relation.why.as_deref().unwrap_or("").trim().is_empty()
206 && relation.evidence.as_deref().unwrap_or("").trim().is_empty()
207 {
208 return Err(ApplicationError::Validation(
209 "non-structural memory relations require why or evidence".to_string(),
210 ));
211 }
212 }
213 validate_positive_optional(relation.sequence, "memory.relations[].sequence")?;
214 let coordinate = relation
215 .coordinate
216 .as_ref()
217 .map(|coordinate| {
218 normalize_coordinate(
219 coordinate,
220 "memory.relations[].coordinate",
221 "memory relation",
222 &dimension_aliases,
223 &dimension_ids,
224 &declared_dimension_kinds,
225 )
226 })
227 .transpose()?;
228 let mut relation = relation.clone();
229 relation.source_ref = source_ref;
230 relation.target_ref = target_ref;
231 relation.rel = relation_type.as_str().to_string();
232 relation.coordinate = coordinate;
233 relations.push(relation);
234 }
235
236 let mut evidence_ids = BTreeSet::new();
237 let mut evidence_items = Vec::new();
238 for evidence in &memory.evidence {
239 require_non_empty(&evidence.id, "memory.evidence[].id")?;
240 require_non_empty(&evidence.text, "memory.evidence[].text")?;
241 insert_unique(&mut evidence_ids, &evidence.id, "memory evidence")?;
242 known_refs.insert(evidence.id.clone());
243 let mut supports = Vec::new();
244 for supported in &evidence.supports {
245 require_non_empty(supported, "memory.evidence[].supports[]")?;
246 let supported_ref = normalize_ref(supported, &dimension_aliases);
247 if !known_refs.contains(&supported_ref) {
248 return Err(ApplicationError::Validation(format!(
249 "memory evidence `{}` supports unknown ref `{supported}`",
250 evidence.id
251 )));
252 }
253 supports.push(supported_ref);
254 }
255 let mut evidence = evidence.clone();
256 evidence.supports = supports;
257 evidence_items.push(evidence);
258 }
259
260 Ok(MemoryData {
261 dimensions,
262 entries,
263 relations,
264 evidence: evidence_items,
265 })
266}
267
268fn existing_dimension_aliases(
269 about: &str,
270 existing: &ExistingMemoryRefs,
271) -> BTreeMap<String, String> {
272 existing
273 .dimensions
274 .iter()
275 .filter_map(|dimension_ref| {
276 let identity = MemoryDimensionIdentity::parse(dimension_ref)?;
277 (identity.about() == about)
278 .then(|| (identity.dimension_id().to_string(), dimension_ref.clone()))
279 })
280 .collect()
281}
282
283fn dimension_identity(
284 about: &str,
285 dimension_id: &str,
286) -> Result<MemoryDimensionIdentity, ApplicationError> {
287 MemoryDimensionIdentity::new(about, dimension_id)
288 .map_err(|error| ApplicationError::Validation(error.to_string()))
289}
290
291fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
292 dimension_aliases
293 .get(value)
294 .cloned()
295 .unwrap_or_else(|| value.to_string())
296}
297
298fn normalize_coordinate(
299 coordinate: &MemoryCoordinateData,
300 field: &str,
301 label: &str,
302 dimension_aliases: &BTreeMap<String, String>,
303 dimension_ids: &BTreeSet<String>,
304 declared_dimension_kinds: &BTreeMap<String, String>,
305) -> Result<MemoryCoordinateData, ApplicationError> {
306 require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
307 require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
308 let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
309 if !dimension_ids.contains(&scope_id) {
310 return Err(ApplicationError::Validation(format!(
311 "{label} coordinate references unknown dimension scope `{}`",
312 coordinate.scope_id
313 )));
314 }
315 if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
316 && coordinate.dimension != *expected_kind
317 {
318 return Err(ApplicationError::Validation(format!(
319 "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
320 coordinate.dimension, coordinate.scope_id
321 )));
322 }
323 validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
324 validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
325
326 let mut coordinate = coordinate.clone();
327 coordinate.scope_id = scope_id;
328 Ok(coordinate)
329}
330
331fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
332 let mut changes = Vec::new();
333 for dimension in &memory.dimensions {
334 changes.push(change(
335 "memory_dimension",
336 &dimension.id,
337 serde_json::to_string(dimension),
338 "KMP memory dimension ingest",
339 vec![dimension.id.clone()],
340 )?);
341 }
342 for entry in &memory.entries {
343 let scopes = entry
344 .coordinates
345 .iter()
346 .map(|coordinate| coordinate.scope_id.clone())
347 .collect();
348 changes.push(change(
349 "memory_entry",
350 &entry.id,
351 serde_json::to_string(entry),
352 "KMP memory entry ingest",
353 scopes,
354 )?);
355 }
356 for relation in &memory.relations {
357 changes.push(change(
358 "memory_relation",
359 &format!(
360 "relation:{}:{}:{}",
361 relation.source_ref, relation.rel, relation.target_ref
362 ),
363 serde_json::to_string(relation),
364 relation
365 .why
366 .as_deref()
367 .filter(|value| !value.trim().is_empty())
368 .unwrap_or("KMP memory relation ingest"),
369 vec![relation.source_ref.clone(), relation.target_ref.clone()],
370 )?);
371 }
372 for evidence in &memory.evidence {
373 changes.push(change(
374 "memory_evidence",
375 &evidence.id,
376 serde_json::to_string(evidence),
377 evidence
378 .source
379 .as_deref()
380 .filter(|value| !value.trim().is_empty())
381 .unwrap_or("KMP memory evidence ingest"),
382 evidence.supports.clone(),
383 )?);
384 }
385
386 Ok(changes)
387}
388
389fn change(
390 entity_kind: &str,
391 entity_id: &str,
392 payload: Result<String, serde_json::Error>,
393 reason: &str,
394 scopes: Vec<String>,
395) -> Result<UpdateContextChange, ApplicationError> {
396 Ok(UpdateContextChange {
397 operation: "UPSERT".to_string(),
398 entity_kind: entity_kind.to_string(),
399 entity_id: entity_id.to_string(),
400 payload_json: payload.map_err(|error| {
401 ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
402 })?,
403 reason: reason.to_string(),
404 scopes,
405 })
406}
407
408fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
409 if value.trim().is_empty() {
410 Err(ApplicationError::Validation(format!(
411 "{field} cannot be empty"
412 )))
413 } else {
414 Ok(())
415 }
416}
417
418fn insert_unique(
419 values: &mut BTreeSet<String>,
420 value: &str,
421 label: &str,
422) -> Result<(), ApplicationError> {
423 if !values.insert(value.to_string()) {
424 Err(ApplicationError::Validation(format!(
425 "duplicate {label} `{value}`"
426 )))
427 } else {
428 Ok(())
429 }
430}
431
432fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
433 if value == Some(0) {
434 Err(ApplicationError::Validation(format!(
435 "{field} must be greater than zero when set"
436 )))
437 } else {
438 Ok(())
439 }
440}
441
442fn logical_digest(command: &MemoryIngestCommand) -> String {
449 use sha2::{Digest, Sha256};
450 let mut hasher = Sha256::new();
451 hasher.update(command.about.as_bytes());
452 hasher.update([0]);
453 let memory = serde_json::to_vec(&command.memory)
454 .expect("memory data serializes: it holds only strings, maps and integers");
455 hasher.update(&memory);
456 hasher.update([0]);
457 if let Some(provenance) = &command.provenance {
458 let provenance =
459 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
460 hasher.update(&provenance);
461 }
462 format!("{:x}", hasher.finalize())
463}
464
465fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
466 idempotency_key
467 .strip_prefix("ingest:")
468 .map(|suffix| format!("memory:{suffix}"))
469 .unwrap_or_else(|| format!("memory:{idempotency_key}"))
470}
471
472#[cfg(test)]
473mod tests {
474 use std::collections::BTreeSet;
475
476 use crate::ApplicationError;
477 use crate::memory::{
478 ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
479 MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
480 };
481
482 use super::translate_memory_ingest;
483
484 #[test]
485 fn translate_memory_ingest_creates_internal_memory_update_command() {
486 let command = sample_command();
487
488 let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
489 .expect("valid memory should translate");
490
491 assert_eq!(update.root_node_id, "question:830ce83f");
492 assert_eq!(update.role, "memory");
493 assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
494 assert_eq!(outcome.memory_id, "memory:app-test");
495 assert_eq!(outcome.accepted.entries, 1);
496 assert_eq!(outcome.accepted.relations, 1);
497 assert_eq!(outcome.accepted.evidence, 1);
498 assert_eq!(
499 update
500 .changes
501 .iter()
502 .map(|change| change.entity_kind.as_str())
503 .collect::<Vec<_>>(),
504 vec![
505 "memory_dimension",
506 "memory_entry",
507 "memory_relation",
508 "memory_evidence"
509 ]
510 );
511 assert_eq!(
512 update.changes[0].entity_id,
513 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
514 );
515 assert_eq!(
516 update.changes[1].scopes,
517 ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
518 );
519 assert_eq!(
520 update.changes[2].entity_id,
521 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
522 );
523 let entry_payload: serde_json::Value =
524 serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
525 assert_eq!(
526 entry_payload["coordinates"][0]["scope_id"],
527 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
528 );
529 }
530
531 #[test]
532 fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
533 let mut command = sample_command();
534 command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
535
536 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
537 .expect_err("unknown scope should fail");
538
539 assert_validation_contains(error, "unknown dimension scope");
540 }
541
542 #[test]
543 fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
544 let mut command = sample_command();
545 command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
546
547 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
548 .expect_err("coordinate kind mismatch should fail");
549
550 assert_validation_contains(error, "does not match declared kind `conversation`");
551 }
552
553 #[test]
554 fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
555 let mut command = sample_command();
556 let mut coordinate = command.memory.entries[0].coordinates[0].clone();
557 coordinate.dimension = "ceremony".to_string();
558 command.memory.relations[0].coordinate = Some(coordinate);
559
560 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
561 .expect_err("relation coordinate kind mismatch should fail");
562
563 assert_validation_contains(error, "does not match declared kind `conversation`");
564 }
565
566 #[test]
567 fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
568 let mut command = sample_command();
569 command.memory.relations[0].target_ref = "claim:missing".to_string();
570
571 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
572 .expect_err("unknown ref should fail");
573
574 assert_validation_contains(error, "references unknown refs");
575 }
576
577 #[test]
578 fn translate_memory_ingest_canonicalizes_known_relation_types() {
579 let mut command = sample_command();
580 command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
581
582 let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
583 .expect("known relation aliases should canonicalize");
584
585 assert_eq!(
586 update.changes[2].entity_id,
587 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
588 );
589 }
590
591 #[test]
592 fn translate_memory_ingest_requires_non_structural_relation_proof() {
593 let mut command = sample_command();
594 command.memory.relations[0].semantic_class = "causal".to_string();
595 command.memory.relations[0].why = None;
596 command.memory.relations[0].evidence = None;
597 command.memory.relations[0].confidence = None;
598
599 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
600 .expect_err("missing proof should fail");
601
602 assert_validation_contains(error, "require confidence");
603 }
604
605 #[test]
606 fn translate_memory_ingest_accepts_existing_materialized_refs() {
607 let mut command = sample_command();
608 command.memory.dimensions.clear();
609 command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
610 command.memory.relations[0].source_ref = "conversation:existing".to_string();
611 command.memory.relations[0].target_ref = "claim:existing".to_string();
612 command.memory.evidence[0].supports = vec!["claim:existing".to_string()];
613 let existing = ExistingMemoryRefs {
614 refs: [
615 "conversation:existing".to_string(),
616 "claim:existing".to_string(),
617 ]
618 .into_iter()
619 .collect(),
620 dimensions: ["conversation:existing".to_string()].into_iter().collect(),
621 };
622
623 let (update, outcome) =
624 translate_memory_ingest(&command, &existing).expect("existing refs should validate");
625
626 assert_eq!(outcome.accepted.entries, 1);
627 assert_eq!(update.changes.len(), 3);
628 }
629
630 #[test]
631 fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
632 let command = sample_command();
633 let dimension_ref =
634 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
635 let existing = ExistingMemoryRefs {
636 refs: [dimension_ref.clone()].into_iter().collect(),
637 dimensions: [dimension_ref.clone()].into_iter().collect(),
638 };
639
640 let (update, outcome) = translate_memory_ingest(&command, &existing)
641 .expect("existing dimension declaration should be idempotent");
642
643 assert_eq!(outcome.accepted.entries, 1);
644 assert_eq!(
645 update
646 .changes
647 .iter()
648 .map(|change| change.entity_kind.as_str())
649 .collect::<Vec<_>>(),
650 vec!["memory_entry", "memory_relation", "memory_evidence"]
651 );
652 assert_eq!(
653 update.changes[0].scopes,
654 std::slice::from_ref(&dimension_ref)
655 );
656 assert_eq!(
657 update.changes[1].entity_id,
658 "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
659 );
660 }
661
662 #[test]
663 fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
664 let mut command = sample_command();
665 command.memory.dimensions.clear();
666 let dimension_ref =
667 "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
668 command.memory.relations[0].source_ref = dimension_ref.clone();
669 let existing = ExistingMemoryRefs {
670 refs: BTreeSet::new(),
671 dimensions: [dimension_ref].into_iter().collect(),
672 };
673
674 translate_memory_ingest(&command, &existing)
675 .expect("existing dimensions should also be valid relation refs");
676 }
677
678 #[test]
679 fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
680 let mut command = sample_command();
681 command.memory.entries[0].coordinates[0].sequence = Some(0);
682
683 let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
684 .expect_err("zero coordinate sequence should fail");
685
686 assert_validation_contains(error, "sequence must be greater than zero");
687 }
688
689 fn sample_command() -> MemoryIngestCommand {
690 MemoryIngestCommand {
691 about: "question:830ce83f".to_string(),
692 memory: MemoryData {
693 dimensions: vec![MemoryDimensionData {
694 id: "conversation:rachel-2026-04-12".to_string(),
695 kind: "conversation".to_string(),
696 title: Some("Rachel relocation discussion".to_string()),
697 metadata: Default::default(),
698 }],
699 entries: vec![MemoryEntryData {
700 id: "claim:rachel-denver".to_string(),
701 kind: "claim".to_string(),
702 text: "Rachel said she was moving to Denver.".to_string(),
703 coordinates: vec![MemoryCoordinateData {
704 dimension: "conversation".to_string(),
705 scope_id: "conversation:rachel-2026-04-12".to_string(),
706 occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
707 observed_at: None,
708 ingested_at: None,
709 valid_from: None,
710 valid_until: None,
711 sequence: Some(1),
712 rank: None,
713 metadata: Default::default(),
714 }],
715 metadata: Default::default(),
716 }],
717 relations: vec![MemoryRelationData {
718 source_ref: "conversation:rachel-2026-04-12".to_string(),
719 target_ref: "claim:rachel-denver".to_string(),
720 rel: "contains_entry".to_string(),
721 semantic_class: "structural".to_string(),
722 why: None,
723 evidence: None,
724 confidence: None,
725 sequence: Some(1),
726 motivation: None,
727 method: None,
728 decision_id: None,
729 caused_by_node_id: None,
730 coordinate: None,
731 }],
732 evidence: vec![MemoryEvidenceData {
733 id: "evidence:rachel-denver".to_string(),
734 supports: vec!["claim:rachel-denver".to_string()],
735 text: "Conversation transcript line 1".to_string(),
736 source: Some("transcript:1".to_string()),
737 time: Some("2026-04-12T15:00:00Z".to_string()),
738 metadata: Default::default(),
739 }],
740 },
741 provenance: None,
742 idempotency_key: "ingest:app-test".to_string(),
743 dry_run: false,
744 }
745 }
746
747 fn assert_validation_contains(error: ApplicationError, expected: &str) {
748 match error {
749 ApplicationError::Validation(message) => assert!(
750 message.contains(expected),
751 "expected `{message}` to contain `{expected}`"
752 ),
753 other => panic!("expected validation error, got {other:?}"),
754 }
755 }
756}