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