Skip to main content

kmp_application/memory/
ingest.rs

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