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