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::resolve(about, dimension_id).ok_or_else(|| {
371        ApplicationError::Validation(format!(
372            "memory dimension `{dimension_id}` belongs to another about; declare it bare or \
373             namespaced for `{about}`"
374        ))
375    })
376}
377
378fn normalize_ref(value: &str, dimension_aliases: &BTreeMap<String, String>) -> String {
379    dimension_aliases
380        .get(value)
381        .cloned()
382        .unwrap_or_else(|| value.to_string())
383}
384
385fn normalize_optional_member_ref(
386    about: &str,
387    path: &str,
388    value: Option<&str>,
389    dimension_aliases: &BTreeMap<String, String>,
390) -> Result<Option<String>, ApplicationError> {
391    value
392        .map(|value| {
393            let normalized = normalize_ref(value, dimension_aliases);
394            validate_supplied_member_ref(about, path, &normalized)
395                .map_err(ApplicationError::Validation)?;
396            Ok(normalized)
397        })
398        .transpose()
399}
400
401fn normalize_coordinate(
402    coordinate: &MemoryCoordinateData,
403    field: &str,
404    label: &str,
405    dimension_aliases: &BTreeMap<String, String>,
406    dimension_ids: &BTreeSet<String>,
407    declared_dimension_kinds: &BTreeMap<String, String>,
408) -> Result<MemoryCoordinateData, ApplicationError> {
409    require_non_empty(&coordinate.dimension, &format!("{field}.dimension"))?;
410    require_non_empty(&coordinate.scope_id, &format!("{field}.scope_id"))?;
411    let scope_id = normalize_ref(&coordinate.scope_id, dimension_aliases);
412    if !dimension_ids.contains(&scope_id) {
413        return Err(ApplicationError::Validation(format!(
414            "{label} coordinate references unknown dimension scope `{}`",
415            coordinate.scope_id
416        )));
417    }
418    if let Some(expected_kind) = declared_dimension_kinds.get(&scope_id)
419        && coordinate.dimension != *expected_kind
420    {
421        return Err(ApplicationError::Validation(format!(
422            "{label} coordinate dimension `{}` does not match declared kind `{expected_kind}` for scope `{}`",
423            coordinate.dimension, coordinate.scope_id
424        )));
425    }
426    validate_positive_optional(coordinate.sequence, &format!("{field}.sequence"))?;
427    validate_positive_optional(coordinate.rank, &format!("{field}.rank"))?;
428
429    let mut coordinate = coordinate.clone();
430    coordinate.scope_id = scope_id;
431    Ok(coordinate)
432}
433
434fn memory_changes(memory: &MemoryData) -> Result<Vec<UpdateContextChange>, ApplicationError> {
435    let mut changes = Vec::new();
436    for dimension in &memory.dimensions {
437        changes.push(change(
438            "memory_dimension",
439            &dimension.id,
440            serde_json::to_string(dimension),
441            "KMP memory dimension ingest",
442            vec![dimension.id.clone()],
443        )?);
444    }
445    for entry in &memory.entries {
446        let scopes = entry
447            .coordinates
448            .iter()
449            .map(|coordinate| coordinate.scope_id.clone())
450            .collect();
451        changes.push(change(
452            "memory_entry",
453            &entry.id,
454            serde_json::to_string(entry),
455            "KMP memory entry ingest",
456            scopes,
457        )?);
458    }
459    for relation in &memory.relations {
460        changes.push(change(
461            "memory_relation",
462            &format!(
463                "relation:{}:{}:{}",
464                relation.source_ref, relation.rel, relation.target_ref
465            ),
466            serde_json::to_string(relation),
467            relation
468                .why
469                .as_deref()
470                .filter(|value| !value.trim().is_empty())
471                .unwrap_or("KMP memory relation ingest"),
472            vec![relation.source_ref.clone(), relation.target_ref.clone()],
473        )?);
474    }
475    for evidence in &memory.evidence {
476        changes.push(change(
477            "memory_evidence",
478            &evidence.id,
479            serde_json::to_string(evidence),
480            evidence
481                .source
482                .as_deref()
483                .filter(|value| !value.trim().is_empty())
484                .unwrap_or("KMP memory evidence ingest"),
485            evidence.supports.clone(),
486        )?);
487    }
488
489    Ok(changes)
490}
491
492fn change(
493    entity_kind: &str,
494    entity_id: &str,
495    payload: Result<String, serde_json::Error>,
496    reason: &str,
497    scopes: Vec<String>,
498) -> Result<UpdateContextChange, ApplicationError> {
499    Ok(UpdateContextChange {
500        operation: "UPSERT".to_string(),
501        entity_kind: entity_kind.to_string(),
502        entity_id: entity_id.to_string(),
503        payload_json: payload.map_err(|error| {
504            ApplicationError::Validation(format!("memory payload could not serialize: {error}"))
505        })?,
506        reason: reason.to_string(),
507        scopes,
508    })
509}
510
511fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
512    if value.trim().is_empty() {
513        Err(ApplicationError::Validation(format!(
514            "{field} cannot be empty"
515        )))
516    } else {
517        Ok(())
518    }
519}
520
521fn insert_unique(
522    values: &mut BTreeSet<String>,
523    value: &str,
524    label: &str,
525) -> Result<(), ApplicationError> {
526    if !values.insert(value.to_string()) {
527        Err(ApplicationError::Validation(format!(
528            "duplicate {label} `{value}`"
529        )))
530    } else {
531        Ok(())
532    }
533}
534
535fn validate_positive_optional(value: Option<u32>, field: &str) -> Result<(), ApplicationError> {
536    if value == Some(0) {
537        Err(ApplicationError::Validation(format!(
538            "{field} must be greater than zero when set"
539        )))
540    } else {
541        Ok(())
542    }
543}
544
545/// Digest of the logical ingest, taken before translation.
546///
547/// Translation consults existing state (a dimension already declared is not
548/// re-created), so the same command translates differently after its own
549/// first apply. This digest is computed from what the caller *said*, which is
550/// the thing that must be equal for a replay to deserve a replayed answer.
551fn logical_digest(command: &MemoryIngestCommand) -> String {
552    use sha2::{Digest, Sha256};
553    let mut hasher = Sha256::new();
554    hasher.update(command.about.as_bytes());
555    hasher.update([0]);
556    let memory = serde_json::to_vec(&command.memory)
557        .expect("memory data serializes: it holds only strings, maps and integers");
558    hasher.update(&memory);
559    hasher.update([0]);
560    if let Some(provenance) = &command.provenance {
561        let provenance =
562            serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
563        hasher.update(&provenance);
564    }
565    format!("{:x}", hasher.finalize())
566}
567
568fn memory_id_from_idempotency_key(idempotency_key: &str) -> String {
569    idempotency_key
570        .strip_prefix("ingest:")
571        .map(|suffix| format!("memory:{suffix}"))
572        .unwrap_or_else(|| format!("memory:{idempotency_key}"))
573}
574
575#[cfg(test)]
576mod tests {
577    use std::collections::{BTreeMap, BTreeSet};
578
579    use crate::ApplicationError;
580    use crate::memory::{
581        ExistingMemoryRefs, MemoryCoordinateData, MemoryData, MemoryDimensionData, MemoryEntryData,
582        MemoryEvidenceData, MemoryIngestCommand, MemoryRelationData,
583    };
584
585    use super::translate_memory_ingest;
586
587    #[test]
588    fn translate_memory_ingest_creates_internal_memory_update_command() {
589        let command = sample_command();
590
591        let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
592            .expect("valid memory should translate");
593
594        assert_eq!(update.root_node_id, "question:830ce83f");
595        assert_eq!(update.role, "memory");
596        assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
597        assert_eq!(outcome.memory_id, "memory:app-test");
598        assert_eq!(outcome.accepted.entries, 1);
599        assert_eq!(outcome.accepted.relations, 1);
600        assert_eq!(outcome.accepted.evidence, 1);
601        assert_eq!(
602            update
603                .changes
604                .iter()
605                .map(|change| change.entity_kind.as_str())
606                .collect::<Vec<_>>(),
607            vec![
608                "memory_dimension",
609                "memory_entry",
610                "memory_relation",
611                "memory_evidence"
612            ]
613        );
614        assert_eq!(
615            update.changes[0].entity_id,
616            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
617        );
618        assert_eq!(
619            update.changes[1].scopes,
620            ["about:question:830ce83f:dimension:conversation:rachel-2026-04-12"]
621        );
622        assert_eq!(
623            update.changes[2].entity_id,
624            "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
625        );
626        let entry_payload: serde_json::Value =
627            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
628        assert_eq!(
629            entry_payload["coordinates"][0]["scope_id"],
630            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12"
631        );
632        assert!(
633            entry_payload["coordinates"][0]["ingested_at"]
634                .as_str()
635                .is_some_and(|value| value.starts_with("unix:")),
636            "the kernel must stamp when it learned every coordinate: {entry_payload}"
637        );
638    }
639
640    #[test]
641    fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
642        let mut command = sample_command();
643        command.memory.entries[0].coordinates[0].ingested_at =
644            Some("2026-04-12T15:01:00Z".to_string());
645
646        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
647            .expect("caller-supplied ingest clock should survive replay");
648        let entry_payload: serde_json::Value =
649            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
650
651        assert_eq!(
652            entry_payload["coordinates"][0]["ingested_at"],
653            "2026-04-12T15:01:00Z"
654        );
655    }
656
657    #[test]
658    fn translate_memory_ingest_accepts_an_already_namespaced_dimension_id() {
659        // Reads hand out the namespaced form, and the agent contract says to
660        // copy identifiers back byte-for-byte. Wrapping it again would name a
661        // second lane that reads back as the intended one.
662        let namespaced = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12";
663        let mut command = sample_command();
664        command.memory.dimensions[0].id = namespaced.to_string();
665        command.memory.entries[0].coordinates[0].scope_id = namespaced.to_string();
666        command.memory.relations[0].source_ref = namespaced.to_string();
667
668        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
669            .expect("a namespaced dimension id belongs to this about");
670
671        assert_eq!(update.changes[0].entity_id, namespaced);
672        let entry_payload: serde_json::Value =
673            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
674        assert_eq!(entry_payload["coordinates"][0]["scope_id"], namespaced);
675    }
676
677    #[test]
678    fn translate_memory_ingest_rejects_a_dimension_owned_by_another_about() {
679        let mut command = sample_command();
680        command.memory.dimensions[0].id =
681            "about:question:other:dimension:conversation:rachel-2026-04-12".to_string();
682
683        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
684            .expect_err("a foreign about's dimension is not ours to write");
685
686        assert_validation_contains(error, "belongs to another about");
687    }
688
689    #[test]
690    fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
691        let mut command = sample_command();
692        command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
693
694        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
695            .expect_err("unknown scope should fail");
696
697        assert_validation_contains(error, "unknown dimension scope");
698    }
699
700    #[test]
701    fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
702        let mut command = sample_command();
703        command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
704
705        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
706            .expect_err("coordinate kind mismatch should fail");
707
708        assert_validation_contains(error, "does not match declared kind `conversation`");
709    }
710
711    #[test]
712    fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
713        let mut command = sample_command();
714        let mut coordinate = command.memory.entries[0].coordinates[0].clone();
715        coordinate.dimension = "ceremony".to_string();
716        command.memory.relations[0].coordinate = Some(coordinate);
717
718        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
719            .expect_err("relation coordinate kind mismatch should fail");
720
721        assert_validation_contains(error, "does not match declared kind `conversation`");
722    }
723
724    #[test]
725    fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
726        let mut command = sample_command();
727        command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
728
729        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
730            .expect_err("unknown ref should fail");
731
732        assert_validation_contains(error, "references unknown refs");
733    }
734
735    /// The first write to a fresh about has nothing of its own to relate to.
736    ///
737    /// Strict `kmp_write_memory` demands a relation, every ref inside the
738    /// about is being created by the very ingest that declares it, and the
739    /// one node that certainly exists — the about's own anchor, which the
740    /// projection materialises and hangs `records` off — was refused as an
741    /// unknown ref. That made seeding a new about impossible through the
742    /// writer the skill presents as the default way to write. (#14)
743    #[test]
744    fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
745        let mut command = sample_command();
746        command.memory.relations[0].target_ref = command.about.clone();
747
748        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
749            .expect("an entry may relate to the about it belongs to");
750
751        assert!(
752            update
753                .changes
754                .iter()
755                .any(|change| change.entity_id.ends_with(&command.about)),
756            "the relation to the anchor must survive translation, got {:?}",
757            update
758                .changes
759                .iter()
760                .map(|change| change.entity_id.as_str())
761                .collect::<Vec<_>>()
762        );
763    }
764
765    #[test]
766    fn translate_memory_ingest_canonicalizes_known_relation_types() {
767        let mut command = sample_command();
768        command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
769
770        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
771            .expect("known relation aliases should canonicalize");
772
773        assert_eq!(
774            update.changes[2].entity_id,
775            "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
776        );
777    }
778
779    #[test]
780    fn translate_memory_ingest_requires_non_structural_relation_proof() {
781        let mut command = sample_command();
782        command.memory.relations[0].semantic_class = "causal".to_string();
783        command.memory.relations[0].why = None;
784        command.memory.relations[0].evidence = None;
785        command.memory.relations[0].confidence = None;
786
787        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
788            .expect_err("missing proof should fail");
789
790        assert_validation_contains(error, "require confidence");
791    }
792
793    #[test]
794    fn translate_memory_ingest_accepts_existing_materialized_refs() {
795        let mut command = sample_command();
796        command.memory.dimensions.clear();
797        command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
798        command.memory.relations[0].source_ref = "conversation:existing".to_string();
799        command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
800        command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
801        let dimension_ref = "about:question:830ce83f:dimension:conversation:existing".to_string();
802        let existing = ExistingMemoryRefs {
803            refs: [
804                dimension_ref.clone(),
805                "question:830ce83f:claim:existing".to_string(),
806            ]
807            .into_iter()
808            .collect(),
809            dimensions: [dimension_ref].into_iter().collect(),
810            ..ExistingMemoryRefs::default()
811        };
812
813        let (update, outcome) =
814            translate_memory_ingest(&command, &existing).expect("existing refs should validate");
815
816        assert_eq!(outcome.accepted.entries, 1);
817        assert_eq!(update.changes.len(), 3);
818    }
819
820    #[test]
821    fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
822        let command = sample_command();
823        let dimension_ref =
824            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
825        let existing = ExistingMemoryRefs {
826            refs: [dimension_ref.clone()].into_iter().collect(),
827            dimensions: [dimension_ref.clone()].into_iter().collect(),
828            ..ExistingMemoryRefs::default()
829        };
830
831        let (update, outcome) = translate_memory_ingest(&command, &existing)
832            .expect("existing dimension declaration should be idempotent");
833
834        assert_eq!(outcome.accepted.entries, 1);
835        assert_eq!(
836            update
837                .changes
838                .iter()
839                .map(|change| change.entity_kind.as_str())
840                .collect::<Vec<_>>(),
841            vec!["memory_entry", "memory_relation", "memory_evidence"]
842        );
843        assert_eq!(
844            update.changes[0].scopes,
845            std::slice::from_ref(&dimension_ref)
846        );
847        assert_eq!(
848            update.changes[1].entity_id,
849            "relation:about:question:830ce83f:dimension:conversation:rachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
850        );
851    }
852
853    #[test]
854    fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
855        let mut command = sample_command();
856        command.memory.dimensions.clear();
857        let dimension_ref =
858            "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
859        command.memory.relations[0].source_ref = dimension_ref.clone();
860        let existing = ExistingMemoryRefs {
861            refs: BTreeSet::new(),
862            dimensions: [dimension_ref].into_iter().collect(),
863            ..ExistingMemoryRefs::default()
864        };
865
866        translate_memory_ingest(&command, &existing)
867            .expect("existing dimensions should also be valid relation refs");
868    }
869
870    #[test]
871    fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
872        let mut command = sample_command();
873        command.memory.entries[0].coordinates[0].sequence = Some(0);
874
875        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
876            .expect_err("zero coordinate sequence should fail");
877
878        assert_validation_contains(error, "sequence must be greater than zero");
879    }
880
881    #[test]
882    fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
883        let mut command = sample_command();
884        command.memory.entries[0].coordinates[0].sequence = None;
885        let scope = "about:question:830ce83f:dimension:conversation:rachel-2026-04-12".to_string();
886        let existing = ExistingMemoryRefs {
887            max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
888            ..ExistingMemoryRefs::default()
889        };
890
891        let (update, _) = translate_memory_ingest(&command, &existing)
892            .expect("kernel should assign the next coordinate sequence");
893        let entry = update
894            .changes
895            .iter()
896            .find(|change| change.entity_kind == "memory_entry")
897            .expect("entry change");
898        let payload: serde_json::Value =
899            serde_json::from_str(&entry.payload_json).expect("entry payload");
900
901        assert_eq!(payload["coordinates"][0]["sequence"], 8);
902    }
903
904    #[test]
905    fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
906        const HOSTILE_REFS: &[&str] = &[
907            "incident:gamma:entry:observation:foreign",
908            "incident:beta",
909            "incident:alfa:entry:x\nincident:beta:entry:y",
910            "../../incident:beta:entry:x",
911        ];
912        const REF_FIELDS: &[&str] = &[
913            "entry.id",
914            "relation.from",
915            "relation.to",
916            "relation.decision_id",
917            "relation.caused_by_node_id",
918            "evidence.id",
919            "evidence.supports",
920        ];
921
922        for field in REF_FIELDS {
923            for hostile in HOSTILE_REFS {
924                let mut command = sample_command();
925                command.about = "incident:alfa".to_string();
926                command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
927                command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
928                command.memory.evidence[0].id =
929                    "evidence:incident:alfa:entry:observation:local:current".to_string();
930                command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
931
932                match *field {
933                    "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
934                    "relation.from" => {
935                        command.memory.relations[0].source_ref = (*hostile).to_string()
936                    }
937                    "relation.to" => {
938                        command.memory.relations[0].target_ref = (*hostile).to_string()
939                    }
940                    "relation.decision_id" => {
941                        command.memory.relations[0].decision_id = Some((*hostile).to_string())
942                    }
943                    "relation.caused_by_node_id" => {
944                        command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
945                    }
946                    "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
947                    "evidence.supports" => {
948                        command.memory.evidence[0].supports[0] = (*hostile).to_string()
949                    }
950                    unexpected => panic!("unknown test field {unexpected}"),
951                }
952
953                let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
954                    .expect_err("an ingest ref outside the about must be refused");
955                assert_validation_contains(
956                    error,
957                    if hostile.contains('/') || hostile.contains('\n') {
958                        "memory refs cannot contain"
959                    } else {
960                        "does not belong to about"
961                    },
962                );
963            }
964        }
965    }
966
967    fn sample_command() -> MemoryIngestCommand {
968        MemoryIngestCommand {
969            about: "question:830ce83f".to_string(),
970            memory: MemoryData {
971                dimensions: vec![MemoryDimensionData {
972                    id: "conversation:rachel-2026-04-12".to_string(),
973                    kind: "conversation".to_string(),
974                    title: Some("Rachel relocation discussion".to_string()),
975                    metadata: Default::default(),
976                }],
977                entries: vec![MemoryEntryData {
978                    id: "question:830ce83f:claim:rachel-denver".to_string(),
979                    kind: "claim".to_string(),
980                    text: "Rachel said she was moving to Denver.".to_string(),
981                    coordinates: vec![MemoryCoordinateData {
982                        dimension: "conversation".to_string(),
983                        scope_id: "conversation:rachel-2026-04-12".to_string(),
984                        occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
985                        observed_at: None,
986                        ingested_at: None,
987                        valid_from: None,
988                        valid_until: None,
989                        sequence: Some(1),
990                        rank: None,
991                        metadata: Default::default(),
992                    }],
993                    metadata: Default::default(),
994                }],
995                relations: vec![MemoryRelationData {
996                    source_ref: "conversation:rachel-2026-04-12".to_string(),
997                    target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
998                    rel: "contains_entry".to_string(),
999                    semantic_class: "structural".to_string(),
1000                    why: None,
1001                    evidence: None,
1002                    confidence: None,
1003                    sequence: Some(1),
1004                    motivation: None,
1005                    method: None,
1006                    decision_id: None,
1007                    caused_by_node_id: None,
1008                    coordinate: None,
1009                }],
1010                evidence: vec![MemoryEvidenceData {
1011                    id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
1012                    supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
1013                    text: "Conversation transcript line 1".to_string(),
1014                    source: Some("transcript:1".to_string()),
1015                    time: Some("2026-04-12T15:00:00Z".to_string()),
1016                    metadata: Default::default(),
1017                }],
1018            },
1019            provenance: None,
1020            idempotency_key: "ingest:app-test".to_string(),
1021            dry_run: false,
1022        }
1023    }
1024
1025    fn assert_validation_contains(error: ApplicationError, expected: &str) {
1026        match error {
1027            ApplicationError::Validation(message) => assert!(
1028                message.contains(expected),
1029                "expected `{message}` to contain `{expected}`"
1030            ),
1031            other => panic!("expected validation error, got {other:?}"),
1032        }
1033    }
1034}