Skip to main content

kmp_application/memory/
ingest.rs

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
442/// Digest of the logical ingest, taken before translation.
443///
444/// Translation consults existing state (a dimension already declared is not
445/// re-created), so the same command translates differently after its own
446/// first apply. This digest is computed from what the caller *said*, which is
447/// the thing that must be equal for a replay to deserve a replayed answer.
448fn 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}