Skip to main content

kmp_application/memory/
ingest.rs

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