Skip to main content

kmp_application/memory/
ingest.rs

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