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