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    // The about's own anchor is always a valid relation target. It is a real
93    // node — the projection materialises it and hangs `records` and
94    // `has_dimension` off it — but it was never in this set, so relating to
95    // it was refused as an unknown ref. That made the first write to a fresh
96    // about impossible: strict demands a relation, every ref inside the
97    // about is being created by this very ingest, and the one thing that
98    // certainly exists could not be named. (#14)
99    known_refs.insert(about.to_string());
100    let mut dimension_ids = existing.dimensions.clone();
101    let mut dimension_aliases = existing_dimension_aliases(about, existing);
102    let mut declared_dimension_kinds = BTreeMap::new();
103    let mut declared_dimension_refs = BTreeSet::new();
104    let mut dimensions = Vec::new();
105    for dimension in &memory.dimensions {
106        require_non_empty(&dimension.id, "memory.dimensions[].id")?;
107        require_non_empty(&dimension.kind, "memory.dimensions[].kind")?;
108        let dimension_identity = dimension_identity(about, &dimension.id)?;
109        let dimension_ref = dimension_identity.node_id();
110        declared_dimension_kinds.insert(dimension_ref.clone(), dimension.kind.clone());
111        insert_unique(
112            &mut declared_dimension_refs,
113            &dimension_ref,
114            "memory dimension",
115        )?;
116        if existing.dimensions.contains(&dimension_ref) {
117            dimension_aliases
118                .entry(dimension.id.clone())
119                .or_insert_with(|| dimension_ref.clone());
120            known_refs.insert(dimension_ref);
121            continue;
122        }
123        insert_unique(&mut dimension_ids, &dimension_ref, "memory dimension")?;
124        if dimension_aliases
125            .insert(dimension.id.clone(), dimension_ref.clone())
126            .is_some()
127        {
128            return Err(ApplicationError::Validation(format!(
129                "duplicate memory dimension `{}`",
130                dimension.id
131            )));
132        }
133        known_refs.insert(dimension_ref.clone());
134
135        let mut metadata = dimension.metadata.clone();
136        metadata
137            .entry("memory_about".to_string())
138            .or_insert_with(|| about.to_string());
139        metadata
140            .entry("memory_dimension_id".to_string())
141            .or_insert_with(|| dimension.id.clone());
142        dimensions.push(MemoryDimensionData {
143            id: dimension_ref,
144            kind: dimension.kind.clone(),
145            title: dimension.title.clone(),
146            metadata,
147        });
148    }
149
150    let mut entry_ids = BTreeSet::new();
151    let mut entries = Vec::new();
152    for entry in &memory.entries {
153        require_non_empty(&entry.id, "memory.entries[].id")?;
154        require_non_empty(&entry.kind, "memory.entries[].kind")?;
155        require_non_empty(&entry.text, "memory.entries[].text")?;
156        if entry.coordinates.is_empty() {
157            return Err(ApplicationError::Validation(format!(
158                "memory entry `{}` must include at least one coordinate",
159                entry.id
160            )));
161        }
162        insert_unique(&mut entry_ids, &entry.id, "memory entry")?;
163        known_refs.insert(entry.id.clone());
164
165        let mut coordinates = Vec::new();
166        for coordinate in &entry.coordinates {
167            coordinates.push(normalize_coordinate(
168                coordinate,
169                "memory.entries[].coordinates[]",
170                "memory entry",
171                &dimension_aliases,
172                &dimension_ids,
173                &declared_dimension_kinds,
174            )?);
175        }
176        let mut entry = entry.clone();
177        entry.coordinates = coordinates;
178        entries.push(entry);
179    }
180
181    let mut relations = Vec::new();
182    for relation in &memory.relations {
183        require_non_empty(&relation.source_ref, "memory.relations[].source_ref")?;
184        require_non_empty(&relation.target_ref, "memory.relations[].target_ref")?;
185        require_non_empty(&relation.rel, "memory.relations[].rel")?;
186        let relation_type = MemoryRelationType::new(&relation.rel).map_err(|error| {
187            ApplicationError::Validation(format!("memory relation type is invalid: {error}"))
188        })?;
189        let semantic_class =
190            RelationSemanticClass::parse(&relation.semantic_class).map_err(|error| {
191                ApplicationError::Validation(format!("memory relation class is invalid: {error}"))
192            })?;
193        let source_ref = normalize_ref(&relation.source_ref, &dimension_aliases);
194        let target_ref = normalize_ref(&relation.target_ref, &dimension_aliases);
195        if !known_refs.contains(&source_ref) || !known_refs.contains(&target_ref) {
196            return Err(ApplicationError::Validation(format!(
197                "memory relation `{}` -> `{}` references unknown refs",
198                relation.source_ref, relation.target_ref
199            )));
200        }
201        if semantic_class != RelationSemanticClass::Structural {
202            if relation
203                .confidence
204                .as_deref()
205                .unwrap_or("")
206                .trim()
207                .is_empty()
208            {
209                return Err(ApplicationError::Validation(
210                    "non-structural memory relations require confidence".to_string(),
211                ));
212            }
213            if relation.why.as_deref().unwrap_or("").trim().is_empty()
214                && relation.evidence.as_deref().unwrap_or("").trim().is_empty()
215            {
216                return Err(ApplicationError::Validation(
217                    "non-structural memory relations require why or evidence".to_string(),
218                ));
219            }
220        }
221        validate_positive_optional(relation.sequence, "memory.relations[].sequence")?;
222        let coordinate = relation
223            .coordinate
224            .as_ref()
225            .map(|coordinate| {
226                normalize_coordinate(
227                    coordinate,
228                    "memory.relations[].coordinate",
229                    "memory relation",
230                    &dimension_aliases,
231                    &dimension_ids,
232                    &declared_dimension_kinds,
233                )
234            })
235            .transpose()?;
236        let mut relation = relation.clone();
237        relation.source_ref = source_ref;
238        relation.target_ref = target_ref;
239        relation.rel = relation_type.as_str().to_string();
240        relation.coordinate = coordinate;
241        relations.push(relation);
242    }
243
244    let mut evidence_ids = BTreeSet::new();
245    let mut evidence_items = Vec::new();
246    for evidence in &memory.evidence {
247        require_non_empty(&evidence.id, "memory.evidence[].id")?;
248        require_non_empty(&evidence.text, "memory.evidence[].text")?;
249        insert_unique(&mut evidence_ids, &evidence.id, "memory evidence")?;
250        known_refs.insert(evidence.id.clone());
251        let mut supports = Vec::new();
252        for supported in &evidence.supports {
253            require_non_empty(supported, "memory.evidence[].supports[]")?;
254            let supported_ref = normalize_ref(supported, &dimension_aliases);
255            if !known_refs.contains(&supported_ref) {
256                return Err(ApplicationError::Validation(format!(
257                    "memory evidence `{}` supports unknown ref `{supported}`",
258                    evidence.id
259                )));
260            }
261            supports.push(supported_ref);
262        }
263        let mut evidence = evidence.clone();
264        evidence.supports = supports;
265        evidence_items.push(evidence);
266    }
267
268    Ok(MemoryData {
269        dimensions,
270        entries,
271        relations,
272        evidence: evidence_items,
273    })
274}
275
276fn existing_dimension_aliases(
277    about: &str,
278    existing: &ExistingMemoryRefs,
279) -> BTreeMap<String, String> {
280    existing
281        .dimensions
282        .iter()
283        .filter_map(|dimension_ref| {
284            let identity = MemoryDimensionIdentity::parse(dimension_ref)?;
285            (identity.about() == about)
286                .then(|| (identity.dimension_id().to_string(), dimension_ref.clone()))
287        })
288        .collect()
289}
290
291fn dimension_identity(
292    about: &str,
293    dimension_id: &str,
294) -> Result<MemoryDimensionIdentity, ApplicationError> {
295    MemoryDimensionIdentity::new(about, dimension_id)
296        .map_err(|error| ApplicationError::Validation(error.to_string()))
297}
298
299fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
300    dimension_aliases
301        .get(value)
302        .cloned()
303        .unwrap_or_else(|| value.to_string())
304}
305
306fn normalize_coordinate(
307    coordinate: &MemoryCoordinateData,
308    field: &str,
309    label: &str,
310    dimension_aliases: &BTreeMap<String, String>,
311    dimension_ids: &BTreeSet<String>,
312    declared_dimension_kinds: &BTreeMap<String, String>,
313) -> Result<MemoryCoordinateData, ApplicationError> {
314    require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
315    require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
316    let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
317    if !dimension_ids.contains(&scope_id) {
318        return Err(ApplicationError::Validation(format!(
319            "{label} coordinate references unknown dimension scope `{}`",
320            coordinate.scope_id
321        )));
322    }
323    if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
324        && coordinate.dimension != *expected_kind
325    {
326        return Err(ApplicationError::Validation(format!(
327            "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
328            coordinate.dimension, coordinate.scope_id
329        )));
330    }
331    validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
332    validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
333
334    let mut coordinate = coordinate.clone();
335    coordinate.scope_id = scope_id;
336    Ok(coordinate)
337}
338
339fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
340    let mut changes = Vec::new();
341    for dimension in &memory.dimensions {
342        changes.push(change(
343            "memory_dimension",
344            &dimension.id,
345            serde_json::to_string(dimension),
346            "KMP memory dimension ingest",
347            vec![dimension.id.clone()],
348        )?);
349    }
350    for entry in &memory.entries {
351        let scopes = entry
352            .coordinates
353            .iter()
354            .map(|coordinate| coordinate.scope_id.clone())
355            .collect();
356        changes.push(change(
357            "memory_entry",
358            &entry.id,
359            serde_json::to_string(entry),
360            "KMP memory entry ingest",
361            scopes,
362        )?);
363    }
364    for relation in &memory.relations {
365        changes.push(change(
366            "memory_relation",
367            &format!(
368                "relation:{}:{}:{}",
369                relation.source_ref, relation.rel, relation.target_ref
370            ),
371            serde_json::to_string(relation),
372            relation
373                .why
374                .as_deref()
375                .filter(|value| !value.trim().is_empty())
376                .unwrap_or("KMP memory relation ingest"),
377            vec![relation.source_ref.clone(), relation.target_ref.clone()],
378        )?);
379    }
380    for evidence in &memory.evidence {
381        changes.push(change(
382            "memory_evidence",
383            &evidence.id,
384            serde_json::to_string(evidence),
385            evidence
386                .source
387                .as_deref()
388                .filter(|value| !value.trim().is_empty())
389                .unwrap_or("KMP memory evidence ingest"),
390            evidence.supports.clone(),
391        )?);
392    }
393
394    Ok(changes)
395}
396
397fn change(
398    entity_kind: &str,
399    entity_id: &str,
400    payload: Result<String, serde_json::Error>,
401    reason: &str,
402    scopes: Vec<String>,
403) -> Result<UpdateContextChange, ApplicationError> {
404    Ok(UpdateContextChange {
405        operation: "UPSERT".to_string(),
406        entity_kind: entity_kind.to_string(),
407        entity_id: entity_id.to_string(),
408        payload_json: payload.map_err(|error| {
409            ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
410        })?,
411        reason: reason.to_string(),
412        scopes,
413    })
414}
415
416fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
417    if value.trim().is_empty() {
418        Err(ApplicationError::Validation(format!(
419            "{field} cannot be empty"
420        )))
421    } else {
422        Ok(())
423    }
424}
425
426fn insert_unique(
427    values: &mut BTreeSet<String>,
428    value: &str,
429    label: &str,
430) -> Result<(), ApplicationError> {
431    if !values.insert(value.to_string()) {
432        Err(ApplicationError::Validation(format!(
433            "duplicate {label} `{value}`"
434        )))
435    } else {
436        Ok(())
437    }
438}
439
440fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
441    if value == Some(0) {
442        Err(ApplicationError::Validation(format!(
443            "{field} must be greater than zero when set"
444        )))
445    } else {
446        Ok(())
447    }
448}
449
450/// Digest of the logical ingest, taken before translation.
451///
452/// Translation consults existing state (a dimension already declared is not
453/// re-created), so the same command translates differently after its own
454/// first apply. This digest is computed from what the caller *said*, which is
455/// the thing that must be equal for a replay to deserve a replayed answer.
456fn logical_digest(command: &MemoryIngestCommand) -> String {
457    use sha2::{Digest, Sha256};
458    let mut hasher = Sha256::new();
459    hasher.update(command.about.as_bytes());
460    hasher.update([0]);
461    let memory = serde_json::to_vec(&command.memory)
462        .expect("memory data serializes: it holds only strings, maps and integers");
463    hasher.update(&memory);
464    hasher.update([0]);
465    if let Some(provenance) = &command.provenance {
466        let provenance =
467            serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
468        hasher.update(&provenance);
469    }
470    format!("{:x}", hasher.finalize())
471}
472
473fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
474    idempotency_key
475        .strip_prefix("ingest:")
476        .map(|suffix| format!("memory:{suffix}"))
477        .unwrap_or_else(|| format!("memory:{idempotency_key}"))
478}
479
480#[cfg(test)]
481mod tests {
482    use std::collections::BTreeSet;
483
484    use crate::ApplicationError;
485    use crate::memory::{
486        ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
487        MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
488    };
489
490    use super::translate_memory_ingest;
491
492    #[test]
493    fn translate_memory_ingest_creates_internal_memory_update_command() {
494        let command = sample_command();
495
496        let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
497            .expect("valid memory should translate");
498
499        assert_eq!(update.root_node_id, "question:830ce83f");
500        assert_eq!(update.role, "memory");
501        assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
502        assert_eq!(outcome.memory_id, "memory:app-test");
503        assert_eq!(outcome.accepted.entries, 1);
504        assert_eq!(outcome.accepted.relations, 1);
505        assert_eq!(outcome.accepted.evidence, 1);
506        assert_eq!(
507            update
508                .changes
509                .iter()
510                .map(|change| change.entity_kind.as_str())
511                .collect::<Vec<_>>(),
512            vec![
513                "memory_dimension",
514                "memory_entry",
515                "memory_relation",
516                "memory_evidence"
517            ]
518        );
519        assert_eq!(
520            update.changes[0].entity_id,
521            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
522        );
523        assert_eq!(
524            update.changes[1].scopes,
525            ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
526        );
527        assert_eq!(
528            update.changes[2].entity_id,
529            "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
530        );
531        let entry_payload: serde_json::Value =
532            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
533        assert_eq!(
534            entry_payload["coordinates"][0]["scope_id"],
535            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
536        );
537    }
538
539    #[test]
540    fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
541        let mut command = sample_command();
542        command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
543
544        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
545            .expect_err("unknown scope should fail");
546
547        assert_validation_contains(error, "unknown dimension scope");
548    }
549
550    #[test]
551    fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
552        let mut command = sample_command();
553        command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
554
555        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
556            .expect_err("coordinate kind mismatch should fail");
557
558        assert_validation_contains(error, "does not match declared kind `conversation`");
559    }
560
561    #[test]
562    fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
563        let mut command = sample_command();
564        let mut coordinate = command.memory.entries[0].coordinates[0].clone();
565        coordinate.dimension = "ceremony".to_string();
566        command.memory.relations[0].coordinate = Some(coordinate);
567
568        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
569            .expect_err("relation coordinate kind mismatch should fail");
570
571        assert_validation_contains(error, "does not match declared kind `conversation`");
572    }
573
574    #[test]
575    fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
576        let mut command = sample_command();
577        command.memory.relations[0].target_ref = "claim:missing".to_string();
578
579        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
580            .expect_err("unknown ref should fail");
581
582        assert_validation_contains(error, "references unknown refs");
583    }
584
585    /// The first write to a fresh about has nothing of its own to relate to.
586    ///
587    /// Strict `kernel_write_memory` demands a relation, every ref inside the
588    /// about is being created by the very ingest that declares it, and the
589    /// one node that certainly exists — the about's own anchor, which the
590    /// projection materialises and hangs `records` off — was refused as an
591    /// unknown ref. That made seeding a new about impossible through the
592    /// writer the skill presents as the default way to write. (#14)
593    #[test]
594    fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
595        let mut command = sample_command();
596        command.memory.relations[0].target_ref = command.about.clone();
597
598        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
599            .expect("an entry may relate to the about it belongs to");
600
601        assert!(
602            update
603                .changes
604                .iter()
605                .any(|change| change.entity_id.ends_with(&command.about)),
606            "the relation to the anchor must survive translation, got {:?}",
607            update
608                .changes
609                .iter()
610                .map(|change| change.entity_id.as_str())
611                .collect::<Vec<_>>()
612        );
613    }
614
615    #[test]
616    fn translate_memory_ingest_canonicalizes_known_relation_types() {
617        let mut command = sample_command();
618        command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
619
620        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
621            .expect("known relation aliases should canonicalize");
622
623        assert_eq!(
624            update.changes[2].entity_id,
625            "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
626        );
627    }
628
629    #[test]
630    fn translate_memory_ingest_requires_non_structural_relation_proof() {
631        let mut command = sample_command();
632        command.memory.relations[0].semantic_class = "causal".to_string();
633        command.memory.relations[0].why = None;
634        command.memory.relations[0].evidence = None;
635        command.memory.relations[0].confidence = None;
636
637        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
638            .expect_err("missing proof should fail");
639
640        assert_validation_contains(error, "require confidence");
641    }
642
643    #[test]
644    fn translate_memory_ingest_accepts_existing_materialized_refs() {
645        let mut command = sample_command();
646        command.memory.dimensions.clear();
647        command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
648        command.memory.relations[0].source_ref = "conversation:existing".to_string();
649        command.memory.relations[0].target_ref = "claim:existing".to_string();
650        command.memory.evidence[0].supports = vec!["claim:existing".to_string()];
651        let existing = ExistingMemoryRefs {
652            refs: [
653                "conversation:existing".to_string(),
654                "claim:existing".to_string(),
655            ]
656            .into_iter()
657            .collect(),
658            dimensions: ["conversation:existing".to_string()].into_iter().collect(),
659        };
660
661        let (update, outcome) =
662            translate_memory_ingest(&command, &existing).expect("existing refs should validate");
663
664        assert_eq!(outcome.accepted.entries, 1);
665        assert_eq!(update.changes.len(), 3);
666    }
667
668    #[test]
669    fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
670        let command = sample_command();
671        let dimension_ref =
672            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
673        let existing = ExistingMemoryRefs {
674            refs: [dimension_ref.clone()].into_iter().collect(),
675            dimensions: [dimension_ref.clone()].into_iter().collect(),
676        };
677
678        let (update, outcome) = translate_memory_ingest(&command, &existing)
679            .expect("existing dimension declaration should be idempotent");
680
681        assert_eq!(outcome.accepted.entries, 1);
682        assert_eq!(
683            update
684                .changes
685                .iter()
686                .map(|change| change.entity_kind.as_str())
687                .collect::<Vec<_>>(),
688            vec!["memory_entry", "memory_relation", "memory_evidence"]
689        );
690        assert_eq!(
691            update.changes[0].scopes,
692            std::slice::from_ref(&dimension_ref)
693        );
694        assert_eq!(
695            update.changes[1].entity_id,
696            "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:claim:rachel-denver"
697        );
698    }
699
700    #[test]
701    fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
702        let mut command = sample_command();
703        command.memory.dimensions.clear();
704        let dimension_ref =
705            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
706        command.memory.relations[0].source_ref = dimension_ref.clone();
707        let existing = ExistingMemoryRefs {
708            refs: BTreeSet::new(),
709            dimensions: [dimension_ref].into_iter().collect(),
710        };
711
712        translate_memory_ingest(&command, &existing)
713            .expect("existing dimensions should also be valid relation refs");
714    }
715
716    #[test]
717    fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
718        let mut command = sample_command();
719        command.memory.entries[0].coordinates[0].sequence = Some(0);
720
721        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
722            .expect_err("zero coordinate sequence should fail");
723
724        assert_validation_contains(error, "sequence must be greater than zero");
725    }
726
727    fn sample_command() -> MemoryIngestCommand {
728        MemoryIngestCommand {
729            about: "question:830ce83f".to_string(),
730            memory: MemoryData {
731                dimensions: vec![MemoryDimensionData {
732                    id: "conversation:rachel-2026-04-12".to_string(),
733                    kind: "conversation".to_string(),
734                    title: Some("Rachel relocation discussion".to_string()),
735                    metadata: Default::default(),
736                }],
737                entries: vec![MemoryEntryData {
738                    id: "claim:rachel-denver".to_string(),
739                    kind: "claim".to_string(),
740                    text: "Rachel said she was moving to Denver.".to_string(),
741                    coordinates: vec![MemoryCoordinateData {
742                        dimension: "conversation".to_string(),
743                        scope_id: "conversation:rachel-2026-04-12".to_string(),
744                        occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
745                        observed_at: None,
746                        ingested_at: None,
747                        valid_from: None,
748                        valid_until: None,
749                        sequence: Some(1),
750                        rank: None,
751                        metadata: Default::default(),
752                    }],
753                    metadata: Default::default(),
754                }],
755                relations: vec![MemoryRelationData {
756                    source_ref: "conversation:rachel-2026-04-12".to_string(),
757                    target_ref: "claim:rachel-denver".to_string(),
758                    rel: "contains_entry".to_string(),
759                    semantic_class: "structural".to_string(),
760                    why: None,
761                    evidence: None,
762                    confidence: None,
763                    sequence: Some(1),
764                    motivation: None,
765                    method: None,
766                    decision_id: None,
767                    caused_by_node_id: None,
768                    coordinate: None,
769                }],
770                evidence: vec![MemoryEvidenceData {
771                    id: "evidence:rachel-denver".to_string(),
772                    supports: vec!["claim:rachel-denver".to_string()],
773                    text: "Conversation transcript line 1".to_string(),
774                    source: Some("transcript:1".to_string()),
775                    time: Some("2026-04-12T15:00:00Z".to_string()),
776                    metadata: Default::default(),
777                }],
778            },
779            provenance: None,
780            idempotency_key: "ingest:app-test".to_string(),
781            dry_run: false,
782        }
783    }
784
785    fn assert_validation_contains(error: ApplicationError, expected: &str) {
786        match error {
787            ApplicationError::Validation(message) => assert!(
788                message.contains(expected),
789                "expected `{message}` to contain `{expected}`"
790            ),
791            other => panic!("expected validation error, got {other:?}"),
792        }
793    }
794}