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() && memory.relations.is_empty() {
211        return Err(ApplicationError::Validation(
212            "memory must contain at least one entry or relation".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 relation_only_ingest_validates_stored_endpoints_without_entry_changes() {
779        let mut command = sample_command();
780        let source = command.memory.entries[0].id.clone();
781        let target = "question:830ce83f:claim:another".to_owned();
782        command.memory.entries.clear();
783        command.memory.dimensions.clear();
784        let relation = &mut command.memory.relations[0];
785        relation.source_ref = source.clone();
786        relation.target_ref = target.clone();
787        relation.rel = "follows".into();
788        relation.semantic_class = "procedural".into();
789        relation.confidence = Some("high".into());
790        relation.why = Some("The second statement follows the first in the transcript.".into());
791        let mut existing = ExistingMemoryRefs {
792            refs: [source, target.clone()].into_iter().collect(),
793            dimensions: [
794                "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12".into(),
795            ]
796            .into_iter()
797            .collect(),
798            ..Default::default()
799        };
800        let (update, outcome) = translate_memory_ingest(&command, &existing).expect("attachment");
801        assert_eq!(outcome.accepted.entries, 0);
802        assert_eq!(outcome.accepted.relations, 1);
803        assert_eq!(
804            update
805                .changes
806                .iter()
807                .map(|change| change.entity_kind.as_str())
808                .collect::<Vec<_>>(),
809            ["memory_relation", "memory_evidence"]
810        );
811        existing.refs.remove(&target);
812        assert_validation_contains(
813            translate_memory_ingest(&command, &existing).expect_err("unknown endpoint"),
814            "unknown refs",
815        );
816        command.memory.relations.clear();
817        assert_validation_contains(
818            translate_memory_ingest(&command, &existing).expect_err("empty write"),
819            "at least one entry or relation",
820        );
821    }
822
823    #[test]
824    fn translate_memory_ingest_creates_internal_memory_update_command() {
825        let command = sample_command();
826
827        let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
828            .expect("valid memory should translate");
829
830        assert_eq!(update.root_node_id, "question:830ce83f");
831        assert_eq!(update.role, "memory");
832        assert_eq!(update.idempotency_key.as_deref(), Some("ingest:app-test"));
833        assert_eq!(outcome.memory_id, "memory:app-test");
834        assert_eq!(outcome.accepted.entries, 1);
835        assert_eq!(outcome.accepted.relations, 1);
836        assert_eq!(outcome.accepted.evidence, 1);
837        assert_eq!(
838            update
839                .changes
840                .iter()
841                .map(|change| change.entity_kind.as_str())
842                .collect::<Vec<_>>(),
843            vec![
844                "memory_dimension",
845                "memory_entry",
846                "memory_relation",
847                "memory_evidence"
848            ]
849        );
850        assert_eq!(
851            update.changes[0].entity_id,
852            "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
853        );
854        assert_eq!(
855            update.changes[1].scopes,
856            ["label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"]
857        );
858        assert_eq!(
859            update.changes[2].entity_id,
860            "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
861        );
862        let entry_payload: serde_json::Value =
863            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
864        assert_eq!(
865            entry_payload["coordinates"][0]["scope_id"],
866            "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
867        );
868        assert!(
869            entry_payload["coordinates"][0]["ingested_at"]
870                .as_str()
871                .is_some_and(|value| value.starts_with("unix:")),
872            "the kernel must stamp when it learned every coordinate: {entry_payload}"
873        );
874    }
875
876    /// A summary that will not carry retrieval is said at ingest, while the
877    /// writer can still fix it. The verdict is not stored: the reader makes
878    /// the same reading, so what is warned about here is what ranking will
879    /// not search.
880    #[test]
881    fn translate_memory_ingest_warns_about_a_search_summary_that_will_not_carry() {
882        let mut command = sample_command();
883        command.memory.entries[0].text =
884            "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
885        command.memory.entries[0].metadata.insert(
886            "summary_en".to_string(),
887            "Rachel said she was moving to Denver.".to_string(),
888        );
889
890        let (_, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
891            .expect("a degraded summary is a warning, not a refusal");
892
893        assert_eq!(
894            outcome.warnings,
895            [
896                "memory entry `question:830ce83f:claim:rachel-denver` carries a summary_en that will \
897                 not carry retrieval: drops identifiers the text carries: #469"
898            ]
899        );
900        assert_eq!(outcome.accepted.entries, 1);
901    }
902
903    #[test]
904    fn translate_memory_ingest_is_silent_about_a_search_summary_that_carries() {
905        let mut command = sample_command();
906        command.memory.entries[0].text =
907            "Rachel dijo que se mudaba a Denver por el ticket #469.".to_string();
908        command.memory.entries[0].metadata.insert(
909            "summary_en".to_string(),
910            "Rachel said she was moving to Denver because of ticket #469.".to_string(),
911        );
912
913        let (update, outcome) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
914            .expect("a faithful summary translates");
915
916        assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings);
917        let entry_payload: serde_json::Value =
918            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
919        assert_eq!(
920            entry_payload["metadata"]["summary_en"],
921            "Rachel said she was moving to Denver because of ticket #469.",
922            "the summary is stored as written, beside the text"
923        );
924    }
925
926    #[test]
927    fn translate_memory_ingest_preserves_a_replayed_ingest_clock() {
928        let mut command = sample_command();
929        command.memory.entries[0].coordinates[0].ingested_at =
930            Some("2026-04-12T15:01:00Z".to_string());
931
932        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
933            .expect("caller-supplied ingest clock should survive replay");
934        let entry_payload: serde_json::Value =
935            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
936
937        assert_eq!(
938            entry_payload["coordinates"][0]["ingested_at"],
939            "2026-04-12T15:01:00Z"
940        );
941    }
942
943    #[test]
944    fn translate_memory_ingest_accepts_an_already_namespaced_dimension_id() {
945        // Reads hand out the namespaced form, and the agent contract says to
946        // copy identifiers back byte-for-byte. Wrapping it again would name a
947        // second lane that reads back as the intended one.
948        let namespaced =
949            "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12";
950        let mut command = sample_command();
951        command.memory.dimensions[0].id = namespaced.to_string();
952        command.memory.entries[0].coordinates[0].scope_id = namespaced.to_string();
953        command.memory.relations[0].source_ref = namespaced.to_string();
954
955        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
956            .expect("a namespaced dimension id belongs to this about");
957
958        assert_eq!(update.changes[0].entity_id, namespaced);
959        let entry_payload: serde_json::Value =
960            serde_json::from_str(&update.changes[1].payload_json).expect("entry payload json");
961        assert_eq!(entry_payload["coordinates"][0]["scope_id"], namespaced);
962    }
963
964    #[test]
965    fn translate_memory_ingest_rejects_a_dimension_owned_by_another_about() {
966        let mut command = sample_command();
967        command.memory.dimensions[0].id =
968            "label:v1:question%3Aother:conversation:conversation%3Arachel-2026-04-12".to_string();
969
970        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
971            .expect_err("a foreign about's dimension is not ours to write");
972
973        assert_validation_contains(error, "belongs to another about");
974    }
975
976    #[test]
977    fn translate_memory_ingest_fails_fast_for_unknown_coordinate_dimension() {
978        let mut command = sample_command();
979        command.memory.entries[0].coordinates[0].scope_id = "conversation:missing".to_string();
980
981        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
982            .expect_err("unknown scope should fail");
983
984        assert_validation_contains(error, "unknown dimension");
985    }
986
987    #[test]
988    fn translate_memory_ingest_rejects_coordinate_kind_mismatch() {
989        let mut command = sample_command();
990        command.memory.entries[0].coordinates[0].dimension = "ceremony".to_string();
991
992        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
993            .expect_err("coordinate kind mismatch should fail");
994
995        assert_validation_contains(error, "unknown dimension `ceremony=");
996    }
997
998    #[test]
999    fn translate_memory_ingest_rejects_relation_coordinate_kind_mismatch() {
1000        let mut command = sample_command();
1001        let mut coordinate = command.memory.entries[0].coordinates[0].clone();
1002        coordinate.dimension = "ceremony".to_string();
1003        command.memory.relations[0].coordinate = Some(coordinate);
1004
1005        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1006            .expect_err("relation coordinate kind mismatch should fail");
1007
1008        assert_validation_contains(error, "unknown dimension `ceremony=");
1009    }
1010
1011    #[test]
1012    fn translate_memory_ingest_fails_fast_for_unknown_relation_endpoint() {
1013        let mut command = sample_command();
1014        command.memory.relations[0].target_ref = "question:830ce83f:claim:missing".to_string();
1015
1016        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1017            .expect_err("unknown ref should fail");
1018
1019        assert_validation_contains(error, "references unknown refs");
1020    }
1021
1022    /// The first write to a fresh about has nothing of its own to relate to.
1023    ///
1024    /// Strict `kmp_write_memory` demands a relation, every ref inside the
1025    /// about is being created by the very ingest that declares it, and the
1026    /// one node that certainly exists — the about's own anchor, which the
1027    /// projection materialises and hangs `records` off — was refused as an
1028    /// unknown ref. That made seeding a new about impossible through the
1029    /// writer the skill presents as the default way to write. (#14)
1030    #[test]
1031    fn translate_memory_ingest_accepts_a_relation_to_the_abouts_own_anchor() {
1032        let mut command = sample_command();
1033        command.memory.relations[0].rel = "uses_background".to_string();
1034        command.memory.relations[0].semantic_class = "evidential".to_string();
1035        command.memory.relations[0].confidence = Some("high".to_string());
1036        command.memory.relations[0].why =
1037            Some("The linked memory supplies the observation's context.".to_string());
1038
1039        command.memory.relations[0].target_ref = command.about.clone();
1040
1041        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1042            .expect("an entry may relate to the about it belongs to");
1043
1044        assert!(
1045            update
1046                .changes
1047                .iter()
1048                .any(|change| change.entity_id.ends_with(&command.about)),
1049            "the relation to the anchor must survive translation, got {:?}",
1050            update
1051                .changes
1052                .iter()
1053                .map(|change| change.entity_id.as_str())
1054                .collect::<Vec<_>>()
1055        );
1056    }
1057
1058    #[test]
1059    fn translate_memory_ingest_canonicalizes_known_relation_types() {
1060        let mut command = sample_command();
1061        command.memory.relations[0].rel = " CONTAINS-ENTRY ".to_string();
1062
1063        let (update, _) = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1064            .expect("known relation aliases should canonicalize");
1065
1066        assert_eq!(
1067            update.changes[2].entity_id,
1068            "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
1069        );
1070    }
1071
1072    #[test]
1073    fn translate_memory_ingest_requires_non_structural_relation_proof() {
1074        let mut command = sample_command();
1075        command.memory.relations[0].semantic_class = "causal".to_string();
1076        command.memory.relations[0].why = None;
1077        command.memory.relations[0].evidence = None;
1078        command.memory.relations[0].confidence = None;
1079
1080        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1081            .expect_err("missing proof should fail");
1082
1083        assert_validation_contains(error, "require confidence");
1084    }
1085
1086    #[test]
1087    fn translate_memory_ingest_accepts_existing_materialized_refs() {
1088        let mut command = sample_command();
1089        command.memory.relations[0].rel = "uses_background".to_string();
1090        command.memory.relations[0].semantic_class = "evidential".to_string();
1091        command.memory.relations[0].confidence = Some("high".to_string());
1092        command.memory.relations[0].why =
1093            Some("The linked memory supplies the observation's context.".to_string());
1094
1095        command.memory.dimensions.clear();
1096        command.memory.entries[0].coordinates[0].scope_id = "conversation:existing".to_string();
1097        command.memory.relations[0].source_ref = "conversation:existing".to_string();
1098        command.memory.relations[0].target_ref = "question:830ce83f:claim:existing".to_string();
1099        command.memory.evidence[0].supports = vec!["question:830ce83f:claim:existing".to_string()];
1100        let dimension_ref =
1101            "label:v1:question%3A830ce83f:conversation:conversation%3Aexisting".to_string();
1102        let existing = ExistingMemoryRefs {
1103            refs: [
1104                dimension_ref.clone(),
1105                "question:830ce83f:claim:existing".to_string(),
1106            ]
1107            .into_iter()
1108            .collect(),
1109            dimensions: [dimension_ref].into_iter().collect(),
1110            labels: BTreeSet::new(),
1111            ..ExistingMemoryRefs::default()
1112        };
1113
1114        let (update, outcome) =
1115            translate_memory_ingest(&command, &existing).expect("existing refs should validate");
1116
1117        assert_eq!(outcome.accepted.entries, 1);
1118        assert_eq!(update.changes.len(), 3);
1119    }
1120
1121    #[test]
1122    fn translate_memory_ingest_treats_existing_namespaced_dimension_as_idempotent() {
1123        let command = sample_command();
1124        let dimension_ref =
1125            "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1126                .to_string();
1127        let existing = ExistingMemoryRefs {
1128            refs: [dimension_ref.clone()].into_iter().collect(),
1129            dimensions: [dimension_ref.clone()].into_iter().collect(),
1130            labels: BTreeSet::new(),
1131            ..ExistingMemoryRefs::default()
1132        };
1133
1134        let (update, outcome) = translate_memory_ingest(&command, &existing)
1135            .expect("existing dimension declaration should be idempotent");
1136
1137        assert_eq!(outcome.accepted.entries, 1);
1138        assert_eq!(
1139            update
1140                .changes
1141                .iter()
1142                .map(|change| change.entity_kind.as_str())
1143                .collect::<Vec<_>>(),
1144            vec!["memory_entry", "memory_relation", "memory_evidence"]
1145        );
1146        assert_eq!(
1147            update.changes[0].scopes,
1148            std::slice::from_ref(&dimension_ref)
1149        );
1150        assert_eq!(
1151            update.changes[1].entity_id,
1152            "relation:label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12:contains_entry:question:830ce83f:claim:rachel-denver"
1153        );
1154    }
1155
1156    #[test]
1157    fn translate_memory_ingest_keeps_existing_dimensions_as_known_relation_refs() {
1158        let mut command = sample_command();
1159        command.memory.dimensions.clear();
1160        let dimension_ref =
1161            "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1162                .to_string();
1163        command.memory.relations[0].source_ref = dimension_ref.clone();
1164        let existing = ExistingMemoryRefs {
1165            refs: BTreeSet::new(),
1166            dimensions: [dimension_ref].into_iter().collect(),
1167            labels: BTreeSet::new(),
1168            ..ExistingMemoryRefs::default()
1169        };
1170
1171        translate_memory_ingest(&command, &existing)
1172            .expect("existing dimensions should also be valid relation refs");
1173    }
1174
1175    #[test]
1176    fn translate_memory_ingest_rejects_zero_coordinates_when_set() {
1177        let mut command = sample_command();
1178        command.memory.entries[0].coordinates[0].sequence = Some(0);
1179
1180        let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1181            .expect_err("zero coordinate sequence should fail");
1182
1183        assert_validation_contains(error, "sequence must be greater than zero");
1184    }
1185
1186    #[test]
1187    fn translate_memory_ingest_assigns_next_sequence_when_writer_omits_it() {
1188        let mut command = sample_command();
1189        command.memory.entries[0].coordinates[0].sequence = None;
1190        let scope = "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1191            .to_string();
1192        let existing = ExistingMemoryRefs {
1193            max_sequences: BTreeMap::from([(("conversation".to_string(), scope), 7)]),
1194            ..ExistingMemoryRefs::default()
1195        };
1196
1197        let (update, _) = translate_memory_ingest(&command, &existing)
1198            .expect("kernel should assign the next coordinate sequence");
1199        let entry = update
1200            .changes
1201            .iter()
1202            .find(|change| change.entity_kind == "memory_entry")
1203            .expect("entry change");
1204        let payload: serde_json::Value =
1205            serde_json::from_str(&entry.payload_json).expect("entry payload");
1206
1207        assert_eq!(payload["coordinates"][0]["sequence"], 8);
1208    }
1209
1210    #[test]
1211    fn translate_memory_ingest_bounds_every_caller_supplied_ref_field() {
1212        const HOSTILE_REFS: &[&str] = &[
1213            "incident:gamma:entry:observation:foreign",
1214            "incident:beta",
1215            "incident:alfa:entry:x\nincident:beta:entry:y",
1216            "../../incident:beta:entry:x",
1217        ];
1218        const REF_FIELDS: &[&str] = &[
1219            "entry.id",
1220            "relation.from",
1221            "relation.to",
1222            "relation.decision_id",
1223            "relation.caused_by_node_id",
1224            "evidence.id",
1225            "evidence.supports",
1226        ];
1227
1228        for field in REF_FIELDS {
1229            for hostile in HOSTILE_REFS {
1230                let mut command = sample_command();
1231                command.about = "incident:alfa".to_string();
1232                command.memory.entries[0].id = "incident:alfa:entry:observation:local".to_string();
1233                command.memory.relations[0].target_ref = command.memory.entries[0].id.clone();
1234                command.memory.evidence[0].id =
1235                    "evidence:incident:alfa:entry:observation:local:current".to_string();
1236                command.memory.evidence[0].supports = vec![command.memory.entries[0].id.clone()];
1237
1238                match *field {
1239                    "entry.id" => command.memory.entries[0].id = (*hostile).to_string(),
1240                    "relation.from" => {
1241                        command.memory.relations[0].source_ref = (*hostile).to_string()
1242                    }
1243                    "relation.to" => {
1244                        command.memory.relations[0].target_ref = (*hostile).to_string()
1245                    }
1246                    "relation.decision_id" => {
1247                        command.memory.relations[0].decision_id = Some((*hostile).to_string())
1248                    }
1249                    "relation.caused_by_node_id" => {
1250                        command.memory.relations[0].caused_by_node_id = Some((*hostile).to_string())
1251                    }
1252                    "evidence.id" => command.memory.evidence[0].id = (*hostile).to_string(),
1253                    "evidence.supports" => {
1254                        command.memory.evidence[0].supports[0] = (*hostile).to_string()
1255                    }
1256                    unexpected => panic!("unknown test field {unexpected}"),
1257                }
1258
1259                let error = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1260                    .expect_err("an ingest ref outside the about must be refused");
1261                assert_validation_contains(
1262                    error,
1263                    if hostile.contains('/') || hostile.contains('\n') {
1264                        "memory refs cannot contain"
1265                    } else {
1266                        "does not belong to about"
1267                    },
1268                );
1269            }
1270        }
1271    }
1272
1273    fn catalogue_with(kind: &str, value: &str) -> ExistingMemoryRefs {
1274        ExistingMemoryRefs {
1275            labels: BTreeSet::from([(kind.to_string(), value.to_string())]),
1276            ..ExistingMemoryRefs::default()
1277        }
1278    }
1279
1280    #[test]
1281    fn a_lax_ingest_writes_a_resembling_label_and_says_so() {
1282        let command = sample_command();
1283        let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1284
1285        let (_, outcome) =
1286            translate_memory_ingest(&command, &existing).expect("warn policy writes");
1287
1288        assert_eq!(outcome.resembling_labels.len(), 1);
1289        let resembling = &outcome.resembling_labels[0];
1290        assert_eq!(resembling.key, "conversation");
1291        assert_eq!(resembling.value, "conversation:rachel-2026-04-12");
1292        assert_eq!(resembling.existing_value, "conversation-rachel-2026-04-12");
1293        assert_eq!(resembling.kind, "same_label_spelled_differently");
1294        assert!(
1295            outcome
1296                .warnings
1297                .iter()
1298                .any(|warning| warning == &resembling.why),
1299            "the why is also a warning: {:?}",
1300            outcome.warnings
1301        );
1302    }
1303
1304    #[test]
1305    fn a_refusing_ingest_names_both_labels_and_the_way_to_insist() {
1306        let mut command = sample_command();
1307        command.label_policy = crate::memory::LabelPolicy::Refuse;
1308        let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1309
1310        let error = translate_memory_ingest(&command, &existing).expect_err("refused");
1311
1312        let message = match error {
1313            ApplicationError::Validation(message) => message,
1314            other => panic!("expected a validation error, got {other:?}"),
1315        };
1316        assert!(
1317            message.contains("`conversation=conversation:rachel-2026-04-12` resembles `conversation=conversation-rachel-2026-04-12`"),
1318            "{message}"
1319        );
1320        assert!(
1321            message.contains("same identifier up to case and separators"),
1322            "{message}"
1323        );
1324        assert!(message.contains("writer_intended_new"), "{message}");
1325    }
1326
1327    #[test]
1328    fn an_insisted_label_is_left_alone_and_the_insistence_is_not_stored() {
1329        let mut command = sample_command();
1330        command.label_policy = crate::memory::LabelPolicy::Refuse;
1331        command.memory.dimensions[0]
1332            .metadata
1333            .insert("writer_intended_new".to_string(), "true".to_string());
1334        let existing = catalogue_with("conversation", "conversation-rachel-2026-04-12");
1335
1336        let (update, outcome) =
1337            translate_memory_ingest(&command, &existing).expect("insisted label writes");
1338
1339        assert!(outcome.resembling_labels.is_empty());
1340        assert!(
1341            !update.changes[0]
1342                .payload_json
1343                .contains("writer_intended_new"),
1344            "the marker is read at translation and never stored: {}",
1345            update.changes[0].payload_json
1346        );
1347    }
1348
1349    #[test]
1350    fn a_label_the_about_already_holds_resembles_nothing() {
1351        let command = sample_command();
1352        let mut existing = catalogue_with("conversation", "conversation:rachel-2026-04-12");
1353        existing.dimensions.insert(
1354            "label:v1:question%3A830ce83f:conversation:conversation%3Arachel-2026-04-12"
1355                .to_string(),
1356        );
1357
1358        let (_, outcome) = translate_memory_ingest(&command, &existing).expect("reuse");
1359
1360        assert!(outcome.resembling_labels.is_empty());
1361        assert!(outcome.created_dimensions.is_empty());
1362    }
1363
1364    fn sample_command() -> MemoryIngestCommand {
1365        MemoryIngestCommand {
1366            receipt_context: None,
1367            default_observation_to_ingestion: false,
1368            neighborhood_review: None,
1369            about: "question:830ce83f".to_string(),
1370            memory: MemoryData {
1371                dimensions: vec![MemoryDimensionData {
1372                    id: "conversation:rachel-2026-04-12".to_string(),
1373                    kind: "conversation".to_string(),
1374                    title: Some("Rachel relocation discussion".to_string()),
1375                    metadata: Default::default(),
1376                }],
1377                entries: vec![MemoryEntryData {
1378                    id: "question:830ce83f:claim:rachel-denver".to_string(),
1379                    kind: "claim".to_string(),
1380                    text: "Rachel said she was moving to Denver.".to_string(),
1381                    coordinates: vec![MemoryCoordinateData {
1382                        dimension: "conversation".to_string(),
1383                        scope_id: "conversation:rachel-2026-04-12".to_string(),
1384                        occurred_at: Some("2026-04-12T15:00:00Z".to_string()),
1385                        observed_at: None,
1386                        ingested_at: None,
1387                        valid_from: None,
1388                        valid_until: None,
1389                        sequence: Some(1),
1390                        rank: None,
1391                        metadata: Default::default(),
1392                    }],
1393                    metadata: Default::default(),
1394                }],
1395                relations: vec![MemoryRelationData {
1396                    clocks: None,
1397                    source_ref: "conversation:rachel-2026-04-12".to_string(),
1398                    target_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1399                    rel: "contains_entry".to_string(),
1400                    semantic_class: "structural".to_string(),
1401                    why: None,
1402                    evidence: None,
1403                    confidence: None,
1404                    sequence: Some(1),
1405                    motivation: None,
1406                    method: None,
1407                    decision_id: None,
1408                    caused_by_node_id: None,
1409                    coordinate: None,
1410                }],
1411                evidence: vec![MemoryEvidenceData {
1412                    support_clocks: None,
1413                    id: "evidence:question:830ce83f:claim:rachel-denver".to_string(),
1414                    supports: vec!["question:830ce83f:claim:rachel-denver".to_string()],
1415                    text: "Conversation transcript line 1".to_string(),
1416                    source: Some("transcript:1".to_string()),
1417                    time: Some("2026-04-12T15:00:00Z".to_string()),
1418                    metadata: Default::default(),
1419                }],
1420            },
1421            provenance: None,
1422            idempotency_key: "ingest:app-test".to_string(),
1423            dry_run: false,
1424            label_policy: Default::default(),
1425        }
1426    }
1427
1428    fn assert_validation_contains(error: ApplicationError, expected: &str) {
1429        match error {
1430            ApplicationError::Validation(message) => assert!(
1431                message.contains(expected),
1432                "expected `{message}` to contain `{expected}`"
1433            ),
1434            other => panic!("expected validation error, got {other:?}"),
1435        }
1436    }
1437
1438    fn cross_about_relation(rel: &str, method: Option<&str>) -> MemoryRelationData {
1439        MemoryRelationData {
1440            clocks: None,
1441            source_ref: "question:830ce83f:claim:rachel-denver".to_string(),
1442            target_ref: "incident:platform:outcome:freeze".to_string(),
1443            rel: rel.to_string(),
1444            semantic_class: "evidential".to_string(),
1445            why: Some("Both record the same freeze.".to_string()),
1446            evidence: Some("kmp_relate proposal by identifier.".to_string()),
1447            confidence: Some("high".to_string()),
1448            sequence: None,
1449            motivation: None,
1450            method: method.map(str::to_string),
1451            decision_id: None,
1452            caused_by_node_id: None,
1453            coordinate: None,
1454        }
1455    }
1456
1457    /// The one relation that may cross an about: an equivalence stamped as
1458    /// declared from a `kmp_relate` proposal, with why and evidence, to a
1459    /// ref the service verified exists. The edge is written here; the other
1460    /// about is untouched.
1461    #[test]
1462    fn a_declared_equivalence_crosses_the_about_and_nothing_else_does() {
1463        let mut command = sample_command();
1464        command.memory.relations.push(cross_about_relation(
1465            "same_event_as",
1466            Some("kmp_relate:identifier"),
1467        ));
1468        let mut existing = ExistingMemoryRefs::default();
1469        existing
1470            .foreign
1471            .insert("incident:platform:outcome:freeze".to_string());
1472        let (update, _) = translate_memory_ingest(&command, &existing)
1473            .expect("a declared equivalence is written");
1474        assert!(
1475            update.changes.iter().any(|change| {
1476                change.payload_json.contains("same_event_as")
1477                    && change
1478                        .payload_json
1479                        .contains("incident:platform:outcome:freeze")
1480            }),
1481            "the equivalence is among the changes"
1482        );
1483
1484        let unverified = translate_memory_ingest(&command, &ExistingMemoryRefs::default())
1485            .expect_err("a ref no about holds is refused");
1486        assert!(
1487            unverified.to_string().contains("a ref no about holds"),
1488            "{unverified}"
1489        );
1490
1491        let mut unstamped = sample_command();
1492        unstamped
1493            .memory
1494            .relations
1495            .push(cross_about_relation("same_event_as", None));
1496        let error = translate_memory_ingest(&unstamped, &existing)
1497            .expect_err("without the proposal stamp the boundary holds");
1498        assert!(
1499            error.to_string().contains("does not belong to about"),
1500            "{error}"
1501        );
1502
1503        let mut follows = sample_command();
1504        follows.memory.relations.push(cross_about_relation(
1505            "follows",
1506            Some("kmp_relate:identifier"),
1507        ));
1508        let error = translate_memory_ingest(&follows, &existing)
1509            .expect_err("no other relation crosses an about");
1510        assert!(
1511            error.to_string().contains("does not belong to about"),
1512            "{error}"
1513        );
1514    }
1515}