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