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