Skip to main content

memstead_base/engine/mutation/
retype.rs

1//! `Engine::retype_entity` — change an entity's type in place.
2//!
3//! The identity triple (`mem` / `id` / `type`) is reserved on `update`
4//! by decision: `type` set refuses `READ_ONLY_FIELD`. A type change is
5//! its own verb because it is its own validation regime — the existing
6//! sections and metadata have to satisfy the TARGET type, and every edge
7//! touching the entity, incoming and outgoing, same-mem and cross-mem,
8//! has to fit the target type's relationship pins — and its own
9//! provenance kind. The id, the file path, and every incoming edge stay:
10//! nothing is deleted and re-created, so history and provenance survive.
11//!
12//! Report-all refusal: every unknown section, missing required section,
13//! unknown or invalid metadata value, unsatisfied block-tier constraint,
14//! and shape-violating edge is collected and returned in ONE envelope
15//! (`RetypeRefused`), with the target's declared sections, its catch-all,
16//! and a proposed `section_map` in the recovery payload, so a second
17//! attempt can be the right one. Nothing is written before the set is
18//! empty.
19//!
20//! The edge re-check on both directions is mandatory, not thoroughness:
21//! the loader drops a shape-invalid edge at the next boot with only a
22//! `PARSED_RELATION_INVALID` warning, so a retype that skipped it would
23//! amputate the graph on restart. Referrers that live in a lazy (deferred,
24//! unloaded) mem are not in the store; they are probed through storage —
25//! the same backend the relate path probes deferred targets through — and
26//! when a deferred mem cannot be enumerated the retype refuses naming it,
27//! never proceeding on the assumption that its edges are fine.
28
29use std::collections::BTreeMap;
30use std::path::Path;
31
32use indexmap::IndexMap;
33
34use crate::engine_fallback_type;
35use crate::entity::EntityId;
36use crate::entity::parser::parse_markdown;
37use crate::entity::store_builder::push_entities_into_store;
38use crate::ops::WarningHint;
39use crate::provenance::{Provenance, ProvenanceKind};
40use crate::runtime_validator::{
41    CatchAllContext, CrossMemRelCheck, READ_ONLY_METADATA_KEYS, ValidationError,
42    missing_required_fields, missing_required_sections, parse_metadata_value,
43    validate_cross_mem_edge, validate_rel_shape, validate_section_content, validate_section_keys,
44};
45use crate::vcs::{Actor, ClientId, CommitContext};
46use crate::workspace::MountCapability;
47
48use super::super::{Engine, EngineError, RetypeEntityArgs, RetypeEntityOutcome};
49use super::unknown_type_error;
50use crate::engine::outcomes::{RetypeEdge, RetypeEdgeDirection, RetypeProblem};
51
52impl Engine {
53    /// Change `args.id`'s type to `args.target_type` in place. See the
54    /// module docs for the contract; the outcome names the old and new
55    /// type, the renamed sections, the edges re-checked, and states that
56    /// check records and derivation baselines on the entity are stale
57    /// (its content hash moved).
58    pub fn retype_entity(
59        &mut self,
60        args: RetypeEntityArgs,
61        actor: Actor,
62        client: Option<&ClientId>,
63        note: Option<&str>,
64    ) -> Result<RetypeEntityOutcome, EngineError> {
65        // A short id resolves (or refuses) before anything reads its mem.
66        let mut args = args;
67        let (resolved, short_hint) = self.resolve_entity_id(&args.id)?;
68        args.id = resolved;
69        let id = &args.id;
70        let mem = id.mem().to_string();
71
72        let mount_idx = self
73            .mounts
74            .iter()
75            .position(|m| m.mount.mem == mem)
76            .ok_or_else(|| self.unknown_mem_error(&mem))?;
77        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
78            return Err(EngineError::ReadOnlyMount(mem));
79        }
80
81        // Reload-before-operation, so the CAS compare and the edge walk
82        // run against current truth.
83        let mut drift_warnings: Vec<WarningHint> = short_hint.into_iter().collect();
84        drift_warnings.extend(self.reload_if_stale(Some(&mem)));
85
86        let entity = self
87            .store
88            .get(id)
89            .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?
90            .clone();
91        if entity.stub {
92            return Err(EngineError::StubNotUpdatable { id: id.to_string() });
93        }
94        if !args.dry_run
95            && let Some(expected) = args.expected_hash.as_deref()
96            && entity.content_hash != expected
97        {
98            return Err(EngineError::HashMismatch {
99                id: id.to_string(),
100                current: entity.content_hash.clone(),
101                is_stub: false,
102            });
103        }
104
105        let schema = self
106            .schemas
107            .get(&mem)
108            .expect("schema present for every registered mount")
109            .clone();
110        let target_def = schema
111            .get_type(&args.target_type)
112            .ok_or_else(|| unknown_type_error(&schema, &args.target_type))?;
113        if entity.entity_type == args.target_type {
114            return Err(EngineError::RetypeNoOp {
115                id: id.to_string(),
116                entity_type: entity.entity_type.clone(),
117            });
118        }
119
120        let mut problems: Vec<RetypeProblem> = Vec::new();
121        let mut warnings: Vec<WarningHint> = Vec::new();
122
123        // ----- Sections: apply the map, then validate against the target -----
124        let mut next = entity.clone();
125        next.entity_type = args.target_type.clone();
126        let mut sections_renamed: Vec<(String, String)> = Vec::new();
127        {
128            let mut mapped: IndexMap<String, String> = IndexMap::new();
129            let mut taken: BTreeMap<String, String> = BTreeMap::new(); // new key -> old key
130            for (key, body) in &entity.sections {
131                let new_key = args
132                    .section_map
133                    .get(key.as_str())
134                    .cloned()
135                    .unwrap_or_else(|| key.clone());
136                if let Some(prev) = taken.get(&new_key) {
137                    problems.push(RetypeProblem::SectionMapCollision {
138                        from: key.clone(),
139                        to: new_key.clone(),
140                        also_from: prev.clone(),
141                    });
142                    continue;
143                }
144                taken.insert(new_key.clone(), key.clone());
145                if new_key != *key {
146                    sections_renamed.push((key.clone(), new_key.clone()));
147                }
148                mapped.insert(new_key, body.clone());
149            }
150            for from in args.section_map.keys() {
151                if !entity.sections.contains_key(from.as_str()) {
152                    problems.push(RetypeProblem::SectionMapSourceMissing {
153                        key: from.clone(),
154                        present: entity.sections.keys().cloned().collect(),
155                    });
156                }
157            }
158            next.sections = mapped;
159        }
160        // Every mapped key against the target's declared sections — one
161        // problem per unknown key (the validator refuses on the first, so
162        // it is asked one key at a time).
163        for key in next.sections.keys() {
164            if let Err(ValidationError::UnknownSection {
165                key,
166                declared,
167                suggestion,
168                ..
169            }) = validate_section_keys(std::iter::once(key.as_str()), target_def.as_ref())
170            {
171                problems.push(RetypeProblem::UnknownSection {
172                    key,
173                    declared,
174                    suggestion,
175                });
176            }
177        }
178        // Body content under the target's catch-all posture.
179        {
180            let declared_headings: Vec<&str> = target_def
181                .sections
182                .iter()
183                .map(|s| s.heading.as_str())
184                .collect();
185            let catch_all = target_def.catch_all_section().map(|s| CatchAllContext {
186                key: s.key.as_str(),
187                entity_type: target_def.name.as_str(),
188                declared_headings: &declared_headings,
189            });
190            if let Err(e) = validate_section_content(
191                next.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())),
192                catch_all,
193            ) {
194                problems.push(RetypeProblem::Validation {
195                    code: e.code(),
196                    message: e.to_string(),
197                    details: e.details(),
198                });
199            }
200        }
201        for missing in missing_required_sections(target_def.as_ref(), &next.sections) {
202            problems.push(RetypeProblem::MissingRequiredSection {
203                key: missing.key,
204                heading: missing.heading,
205                write_rules: missing.write_rules,
206            });
207        }
208
209        // ----- Metadata: every carried value must parse for the target -----
210        {
211            let mut parsed: IndexMap<String, crate::entity::MetadataValue> = IndexMap::new();
212            let mut supplied: IndexMap<String, String> = IndexMap::new();
213            for (key, value) in &entity.metadata {
214                if args.drop_metadata.iter().any(|k| k == key) {
215                    continue;
216                }
217                if READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
218                    // The identity triple is engine-authoritative: carried
219                    // as is, with `type` set to the target — the one field
220                    // this verb exists to move.
221                    let carried = if key == "type" {
222                        crate::entity::MetadataValue::String(args.target_type.clone())
223                    } else {
224                        value.clone()
225                    };
226                    parsed.insert(key.clone(), carried);
227                    continue;
228                }
229                let raw = value.to_frontmatter_string();
230                match parse_metadata_value(key, &raw, target_def.as_ref()) {
231                    Ok(v) => {
232                        parsed.insert(key.clone(), v);
233                        supplied.insert(key.clone(), raw);
234                    }
235                    Err(e) => {
236                        // A field the target does not declare has one honest
237                        // exit: the caller drops it by name.
238                        let message = if e.code() == "UNKNOWN_METADATA_FIELD" {
239                            format!("{e}; drop it explicitly with drop_metadata [{key}]")
240                        } else {
241                            e.to_string()
242                        };
243                        problems.push(RetypeProblem::Validation {
244                            code: e.code(),
245                            message,
246                            details: e.details(),
247                        })
248                    }
249                }
250            }
251            // The target's defaults fill fields the entity never carried,
252            // the way create seeds them.
253            for field in &target_def.metadata_fields {
254                if parsed.contains_key(field.key.as_str()) || supplied.contains_key(&field.key) {
255                    continue;
256                }
257                if let Some(default) = &field.default_value
258                    && let Ok(v) = parse_metadata_value(&field.key, default, target_def.as_ref())
259                {
260                    parsed.insert(field.key.clone(), v);
261                    supplied.insert(field.key.clone(), default.clone());
262                }
263            }
264            for missing in missing_required_fields(target_def.as_ref(), &supplied) {
265                problems.push(RetypeProblem::MissingRequiredField {
266                    key: missing.key,
267                    description: missing.description,
268                    enum_values: missing.enum_values,
269                });
270            }
271            next.metadata = parsed;
272        }
273
274        // ----- Edges: both directions, same-mem and cross-mem -----
275        let mut edges_rechecked = 0usize;
276        // Outgoing: this entity is the source; its type changes.
277        for rel in &next.relationships {
278            edges_rechecked += 1;
279            let target_type = self
280                .store
281                .get(&rel.target)
282                .filter(|e| !e.stub)
283                .map(|e| e.entity_type.clone());
284            let target_mem = rel.target.mem();
285            let violation = self.edge_shape_violation(
286                &schema,
287                &mem,
288                target_mem,
289                &rel.rel_type,
290                &args.target_type,
291                target_type.as_deref(),
292            );
293            if let Some(e) = violation {
294                problems.push(RetypeProblem::EdgeShape(RetypeEdge {
295                    direction: RetypeEdgeDirection::Outgoing,
296                    from: id.to_string(),
297                    to: rel.target.to_string(),
298                    rel_type: rel.rel_type.clone(),
299                    cross_mem: target_mem != mem,
300                    detail: e.details(),
301                }));
302            }
303        }
304        // Incoming, loaded referrers: this entity is the target.
305        let incoming: Vec<(EntityId, String)> = self
306            .store
307            .incoming(id)
308            .iter()
309            .map(|e| (e.from.clone(), e.rel_type.clone()))
310            .collect();
311        for (from, rel_type) in incoming {
312            let Some(referrer) = self.store.get(&from) else {
313                continue;
314            };
315            edges_rechecked += 1;
316            let from_mem = from.mem().to_string();
317            let referrer_type = referrer.entity_type.clone();
318            // The edge's source schema is the referrer's; the target is this
319            // mem. Same-schema cross-mem edges take the shared schema's pins
320            // exactly as relate and the loader do.
321            let violation = match self.schemas.get(&from_mem).cloned() {
322                Some(referrer_schema) => self.edge_shape_violation(
323                    &referrer_schema,
324                    &from_mem,
325                    &mem,
326                    &rel_type,
327                    &referrer_type,
328                    Some(&args.target_type),
329                ),
330                None => None,
331            };
332            if let Some(e) = violation {
333                problems.push(RetypeProblem::EdgeShape(RetypeEdge {
334                    direction: RetypeEdgeDirection::Incoming,
335                    from: from.to_string(),
336                    to: id.to_string(),
337                    rel_type,
338                    cross_mem: from_mem != mem,
339                    detail: e.details(),
340                }));
341            }
342        }
343        // Incoming, deferred referrers: a lazy mem's entities are not in
344        // the store. Enumerate its storage and check every relation that
345        // names this entity. A mem that cannot be enumerated refuses.
346        edges_rechecked += self.recheck_deferred_referrers(id, &args.target_type, &mut problems)?;
347
348        // ----- Block-tier constraints of the target type -----
349        let unsatisfied =
350            crate::ops::health::unsatisfied_required_outgoing(&next, target_def.as_ref());
351        if !unsatisfied.is_empty() {
352            let blocked: Vec<_> = unsatisfied
353                .iter()
354                .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
355                .cloned()
356                .collect();
357            if !blocked.is_empty() {
358                problems.push(RetypeProblem::RequiredOutgoingUnsatisfied(blocked));
359            }
360            warnings.push(WarningHint::MissingRequiredOutgoing {
361                entity_type: args.target_type.clone(),
362                entity_id: id.clone(),
363                missing: unsatisfied,
364            });
365        }
366        {
367            let check_provider = self.check_standing_provider();
368            let violated = crate::ops::health::unsatisfied_constraints(
369                &self.store,
370                &next,
371                target_def.as_ref(),
372                Some(id),
373                Some(&check_provider),
374            );
375            if !violated.is_empty() {
376                let blocked: Vec<_> = violated
377                    .iter()
378                    .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
379                    .cloned()
380                    .collect();
381                if !blocked.is_empty() {
382                    problems.push(RetypeProblem::ConstraintUnsatisfied(blocked));
383                }
384                warnings.push(WarningHint::ConstraintUnsatisfied {
385                    entity_type: args.target_type.clone(),
386                    entity_id: id.clone(),
387                    violations: violated,
388                });
389            }
390        }
391
392        if !problems.is_empty() {
393            let mut declared: Vec<String> =
394                target_def.sections.iter().map(|s| s.key.clone()).collect();
395            declared.sort();
396            let catch_all = target_def.catch_all_section().map(|s| s.key.clone());
397            // A proposed map: every entity section the target does not
398            // declare, pointed at the closest declared key or the catch-all.
399            let mut proposed: BTreeMap<String, String> = BTreeMap::new();
400            for key in entity.sections.keys() {
401                if key == "relationships" || target_def.section(key).is_some() {
402                    continue;
403                }
404                if let Some(to) = target_def
405                    .suggest_section(key)
406                    .or_else(|| catch_all.clone())
407                {
408                    proposed.insert(key.clone(), to);
409                }
410            }
411            return Err(EngineError::RetypeRefused {
412                id: id.to_string(),
413                from_type: entity.entity_type.clone(),
414                to_type: args.target_type.clone(),
415                problems,
416                target_sections: declared,
417                target_catch_all: catch_all,
418                proposed_section_map: proposed,
419            });
420        }
421
422        // ----- Render, and either preview or write -----
423        let today = self.now_iso();
424        super::auto_stamp_timestamps(&mut next, target_def.as_ref(), &today);
425        let markdown = super::render_for_write(&next, target_def.as_ref())?;
426        let staleness_note = |from: &str, to: &str| {
427            format!(
428                "check records and derivation baselines on {id} are stale: its content hash \
429                 moved from {from} to {to} with the type change; re-check and re-baseline \
430                 deliberately"
431            )
432        };
433        if args.dry_run {
434            let parsed = parse_markdown(&markdown, &entity.file_path, target_def.as_ref(), &mem)
435                .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
436            let prospective = parsed.entity.content_hash.clone();
437            return Ok(RetypeEntityOutcome {
438                id: id.clone(),
439                file_path: entity.file_path.clone(),
440                old_type: entity.entity_type.clone(),
441                new_type: args.target_type.clone(),
442                content_hash: entity.content_hash.clone(),
443                prospective_hash: Some(prospective.clone()),
444                write_id: String::new(),
445                sections_renamed,
446                edges_rechecked,
447                checks_stale: true,
448                staleness_note: staleness_note(&entity.content_hash, &prospective),
449                warnings,
450            });
451        }
452
453        let backend = self.mounts[mount_idx].backend.as_ref();
454        backend.write_entity(Path::new(&entity.file_path), markdown.as_bytes())?;
455        // `memstead: <verb> <id>` — the subject grammar the history reader
456        // attributes touches by; the type change rides the outcome and the
457        // provenance verb, not the subject.
458        let commit_subject = format!("memstead: retype {id}");
459        let ctx = CommitContext {
460            actor,
461            client: client.cloned(),
462            tool: Some("retype_entity"),
463            note: note.map(String::from),
464            role: self.current_role,
465            identity: self.current_identity.clone(),
466            logical_operation_id: None,
467            entity_ids: None,
468        };
469        let write_id = backend.commit(&commit_subject, &ctx)?;
470        backend.append_provenance(
471            &Provenance::new(
472                std::time::SystemTime::now(),
473                ProvenanceKind::Retype,
474                Some(id.to_string()),
475                actor,
476                client.cloned(),
477                note.map(String::from),
478            )
479            .with_role(self.current_role)
480            .with_identity(self.current_identity.clone()),
481        )?;
482        self.record_self_write(mount_idx, &write_id);
483        let stamp_warnings = self.stamp_mutation_versions(mount_idx);
484
485        let parse_result = parse_markdown(&markdown, &entity.file_path, target_def.as_ref(), &mem)
486            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
487        let content_hash = parse_result.entity.content_hash.clone();
488        let fallback = engine_fallback_type();
489        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
490        crate::entity::store_builder::remap_alias_target_edge_sources(
491            &mut self.store,
492            &self.schemas,
493        );
494        self.invalidate_communities();
495        self.maintain_search_indexes(std::slice::from_ref(id));
496
497        let mut outcome_warnings = Vec::new();
498        outcome_warnings.append(&mut drift_warnings);
499        outcome_warnings.extend(stamp_warnings);
500        outcome_warnings.extend(warnings);
501        if let Some(w) = self.note_missing_warning("retype_entity", note) {
502            outcome_warnings.push(w);
503        }
504
505        Ok(RetypeEntityOutcome {
506            id: id.clone(),
507            file_path: entity.file_path.clone(),
508            old_type: entity.entity_type.clone(),
509            new_type: args.target_type.clone(),
510            staleness_note: staleness_note(&entity.content_hash, &content_hash),
511            content_hash,
512            prospective_hash: None,
513            write_id,
514            sections_renamed,
515            edges_rechecked,
516            checks_stale: true,
517            warnings: outcome_warnings,
518        })
519    }
520
521    /// The shape violation, if any, of one edge `from_type --rel_type-->
522    /// to_type` whose source entity lives in `source_mem` (schema
523    /// `source_schema`) and whose target lives in `target_mem` — the rule
524    /// relate applies at write time and the loader at boot, so the three
525    /// can never disagree: a same-mem edge, and a cross-mem edge between
526    /// mems pinning the SAME schema, is judged by that schema's own
527    /// relationship pins; a cross-mem edge between different schemas is
528    /// judged by the source schema's `cross_mem_relationships` entry for
529    /// the target schema. An edge whose entry is not declared at all is
530    /// not a shape violation (it already exists; the loader drops it on
531    /// its own grounds), and a target mem whose schema cannot be resolved
532    /// falls back to the intra-mem rule, as relate does.
533    fn edge_shape_violation(
534        &self,
535        source_schema: &memstead_schema::Schema,
536        source_mem: &str,
537        target_mem: &str,
538        rel_type: &str,
539        from_type: &str,
540        to_type: Option<&str>,
541    ) -> Option<ValidationError> {
542        let target_ref: Option<memstead_schema::SchemaRef> = if source_mem == target_mem {
543            None
544        } else {
545            super::target_schema_ref_for_routing(self, target_mem)
546        };
547        let cross_mem_different = match (&target_ref, source_schema.id()) {
548            (Some(target), (src_name, _)) => target.name != src_name,
549            (None, _) => false,
550        };
551        if cross_mem_different {
552            let target_ref = target_ref.expect("Some when cross_mem_different");
553            match validate_cross_mem_edge(rel_type, from_type, to_type, source_schema, &target_ref)
554            {
555                CrossMemRelCheck::Ok | CrossMemRelCheck::EdgeNotDeclared => None,
556                CrossMemRelCheck::Invalid(e) => Some(e),
557            }
558        } else {
559            validate_rel_shape(rel_type, from_type, to_type, source_schema).err()
560        }
561    }
562
563    /// Re-check the edges that reach `id` from mems whose content is not
564    /// in the store — deferred (lazy, unloaded) mounts. Their entities
565    /// are enumerated and read through the mount's own backend, the same
566    /// storage the relate path probes deferred targets through; every
567    /// relation naming `id` is shape-checked against the target type.
568    /// Returns the number of edges examined; a mem whose storage cannot
569    /// be enumerated refuses typed rather than being assumed fine.
570    fn recheck_deferred_referrers(
571        &self,
572        id: &EntityId,
573        target_type: &str,
574        problems: &mut Vec<RetypeProblem>,
575    ) -> Result<usize, EngineError> {
576        let mut examined = 0usize;
577        let id_str = id.to_string();
578        let fallback = engine_fallback_type();
579        for mounted in self.mounts.iter().filter(|m| m.deferred) {
580            let referrer_mem = mounted.mount.mem.clone();
581            let paths = mounted.backend.list_entities().map_err(|e| {
582                EngineError::RetypeReferrerUnprobeable {
583                    id: id_str.clone(),
584                    mem: referrer_mem.clone(),
585                    reason: e.to_string(),
586                }
587            })?;
588            let referrer_schema = self.schemas.get(&referrer_mem);
589            for path in paths {
590                let rel_path = path.to_string_lossy().into_owned();
591                let Some(bytes) =
592                    mounted
593                        .backend
594                        .read_entity(Path::new(&rel_path))
595                        .map_err(|e| EngineError::RetypeReferrerUnprobeable {
596                            id: id_str.clone(),
597                            mem: referrer_mem.clone(),
598                            reason: format!("{rel_path}: {e}"),
599                        })?
600                else {
601                    continue;
602                };
603                let text = String::from_utf8_lossy(&bytes);
604                // Cheap gate before parsing: a relation row names the
605                // entity as `mem--slug` or `mem:slug`; both carry the slug.
606                if !text.contains(id.name()) {
607                    continue;
608                }
609                // Parse under the referrer's own type when its schema is
610                // known, the fallback type otherwise: relationships are
611                // parsed identically either way.
612                let declared_type = crate::entity::parser::peek_type_from_frontmatter(&text);
613                let type_def = referrer_schema
614                    .and_then(|s| declared_type.as_deref().and_then(|t| s.get_type(t)))
615                    .unwrap_or_else(|| fallback.clone());
616                let Ok(parsed) = parse_markdown(&text, &rel_path, type_def.as_ref(), &referrer_mem)
617                else {
618                    continue;
619                };
620                for rel in &parsed.entity.relationships {
621                    if rel.target != *id {
622                        continue;
623                    }
624                    examined += 1;
625                    let violation = match referrer_schema {
626                        Some(referrer_schema) => self.edge_shape_violation(
627                            referrer_schema,
628                            &referrer_mem,
629                            id.mem(),
630                            &rel.rel_type,
631                            &parsed.entity.entity_type,
632                            Some(target_type),
633                        ),
634                        None => None,
635                    };
636                    if let Some(e) = violation {
637                        problems.push(RetypeProblem::EdgeShape(RetypeEdge {
638                            direction: RetypeEdgeDirection::Incoming,
639                            from: parsed.entity.id.to_string(),
640                            to: id_str.clone(),
641                            rel_type: rel.rel_type.clone(),
642                            cross_mem: true,
643                            detail: e.details(),
644                        }));
645                    }
646                }
647            }
648        }
649        Ok(examined)
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use std::collections::BTreeMap;
656    use std::path::Path;
657
658    use indexmap::IndexMap;
659    use memstead_schema::SchemaRef;
660    use memstead_schema::workspace_config::CrossLinkValue;
661    use tempfile::TempDir;
662
663    use crate::backend::MemBackend;
664    use crate::engine::outcomes::{RetypeEdgeDirection, RetypeProblem};
665    use crate::engine::test_helpers::*;
666    use crate::engine::{
667        CreateEntityArgs, Engine, EngineError, RelateEntityArgs, RetypeEntityArgs,
668    };
669    use crate::ops::WarningHint;
670    use crate::storage::FilesystemMemWriter;
671    use crate::workspace::{
672        Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
673    };
674
675    const MAIN_MANIFEST: &str = r#"name: rt
676version: 0.1.0
677description: retype fixture
678when_to_use: tests
679types:
680  - claim
681  - finding
682relationships:
683  mode: strict
684  definitions:
685    - name: SUPPORTS
686      description: pinned both ends to claim
687      default_weight: 1.0
688      source_types: [claim]
689      target_types: [claim]
690    - name: CITES
691      description: unpinned
692      default_weight: 1.0
693    - name: _default
694      description: fallback
695      default_weight: 1.0
696community:
697  resolution: 1.0
698  seed: 42
699"#;
700
701    const PEER_MANIFEST: &str = r#"name: rt-peer
702version: 0.1.0
703description: peer fixture
704when_to_use: tests
705types:
706  - remark
707relationships:
708  mode: strict
709  definitions:
710    - name: _default
711      description: fallback
712      default_weight: 1.0
713cross_mem_relationships:
714  - to_schema: rt
715    definitions:
716      - name: COMMENTS_ON
717        description: pinned to claim on the target side
718        default_weight: 1.0
719        source_types: [remark]
720        target_types: [claim]
721community:
722  resolution: 1.0
723  seed: 42
724"#;
725
726    fn type_yaml(name: &str, main_section: &str, main_heading: &str) -> String {
727        format!(
728            r#"name: {name}
729description: t
730when_to_use: Here
731sections:
732  - key: {main_section}
733    heading: {main_heading}
734    required: true
735    search_weight: 10.0
736    write_rules: []
737  - key: notes
738    heading: Notes
739    required: false
740    search_weight: 1.0
741    catch_all: true
742    write_rules: []
743metadata_fields: []
744title_weight: 100.0
745text_fields:
746  - {main_section}
747hierarchy_relationship: _default
748no_self_loop_relationships: []
749updatable_fields:
750  - title
751  - {main_section}
752  - notes
753health_required_fields:
754  - {main_section}
755staleness_threshold_days: 90
756write_rules: []
757"#
758        )
759    }
760
761    fn write_schema(root: &Path, name: &str, manifest: &str, types: &[(&str, String)]) {
762        let dir = root.join(name);
763        std::fs::create_dir_all(dir.join("types")).unwrap();
764        std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
765        for (t, body) in types {
766            std::fs::write(dir.join("types").join(format!("{t}.yaml")), body).unwrap();
767        }
768    }
769
770    fn mount(
771        mem: &str,
772        path: std::path::PathBuf,
773        pin: SchemaRef,
774        lifecycle: MountLifecycle,
775    ) -> Mount {
776        Mount {
777            mem: mem.to_string(),
778            schema: Some(pin),
779            storage: MountStorage::Folder { path },
780            capability: MountCapability::Write,
781            lifecycle,
782            cross_linkable: true,
783            migration_target: None,
784        }
785    }
786
787    struct Fixture {
788        _tmp: TempDir,
789        schemas_dir: std::path::PathBuf,
790        main_dir: std::path::PathBuf,
791        peer_dir: std::path::PathBuf,
792    }
793
794    fn fixture() -> Fixture {
795        let tmp = TempDir::new().unwrap();
796        let schemas_dir = tmp.path().join("schemas");
797        std::fs::create_dir_all(&schemas_dir).unwrap();
798        write_schema(
799            &schemas_dir,
800            "rt",
801            MAIN_MANIFEST,
802            &[
803                ("claim", type_yaml("claim", "statement", "Statement")),
804                ("finding", type_yaml("finding", "conclusion", "Conclusion")),
805            ],
806        );
807        write_schema(
808            &schemas_dir,
809            "rt-peer",
810            PEER_MANIFEST,
811            &[("remark", type_yaml("remark", "body", "Body"))],
812        );
813        let main_dir = tmp.path().join("mem-main");
814        let peer_dir = tmp.path().join("mem-peer");
815        std::fs::create_dir_all(&main_dir).unwrap();
816        std::fs::create_dir_all(&peer_dir).unwrap();
817        Fixture {
818            _tmp: tmp,
819            schemas_dir,
820            main_dir,
821            peer_dir,
822        }
823    }
824
825    fn boot(f: &Fixture, peer_lifecycle: MountLifecycle) -> Engine {
826        let main_pin = SchemaRef::new("rt", semver::Version::new(0, 1, 0));
827        let peer_pin = SchemaRef::new("rt-peer", semver::Version::new(0, 1, 0));
828        let mut engine = Engine::from_mounts_with_schemas_dir(
829            vec![
830                (
831                    mount("main", f.main_dir.clone(), main_pin, MountLifecycle::Eager),
832                    Box::new(FilesystemMemWriter::new(f.main_dir.clone())) as Box<dyn MemBackend>,
833                ),
834                (
835                    mount("peer", f.peer_dir.clone(), peer_pin, peer_lifecycle),
836                    Box::new(FilesystemMemWriter::new(f.peer_dir.clone())) as Box<dyn MemBackend>,
837                ),
838            ],
839            Some(&f.schemas_dir),
840        )
841        .expect("engine boots");
842        let mut settings = WorkspaceSettings::default();
843        let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
844        links.insert("peer".to_string(), CrossLinkValue::Wildcard);
845        settings.cross_mem_links = links;
846        engine.set_settings(settings);
847        engine
848    }
849
850    fn create(
851        engine: &mut Engine,
852        mem: &str,
853        title: &str,
854        ty: &str,
855        section: &str,
856    ) -> crate::EntityId {
857        let (actor, client) = cli_actor();
858        engine
859            .create_entity(
860                CreateEntityArgs {
861                    anchors: Vec::new(),
862                    mem: mem.to_string(),
863                    title: title.to_string(),
864                    entity_type: ty.to_string(),
865                    sections: IndexMap::from_iter([
866                        (section.to_string(), format!("{title} says so.")),
867                        ("notes".to_string(), "Some notes.".to_string()),
868                    ]),
869                    metadata: IndexMap::new(),
870                    relations: Vec::new(),
871                    dry_run: false,
872                },
873                actor,
874                Some(&client),
875                None,
876            )
877            .expect("creates")
878            .id
879    }
880
881    fn relate(engine: &mut Engine, from: &crate::EntityId, rel: &str, to: &crate::EntityId) {
882        let (actor, client) = cli_actor();
883        engine
884            .relate_entity(
885                RelateEntityArgs {
886                    source: from.clone(),
887                    expected_hash: None,
888                    rel_type: rel.to_string(),
889                    target: to.clone(),
890                    remove: false,
891                    description: None,
892                    dry_run: false,
893                },
894                actor,
895                Some(&client),
896                None,
897            )
898            .expect("relates");
899    }
900
901    fn retype(
902        engine: &mut Engine,
903        id: &crate::EntityId,
904        target: &str,
905        map: &[(&str, &str)],
906    ) -> Result<crate::engine::RetypeEntityOutcome, EngineError> {
907        let (actor, client) = cli_actor();
908        let hash = engine.get_entity(id).unwrap().content_hash.clone();
909        engine.retype_entity(
910            RetypeEntityArgs {
911                id: id.clone(),
912                expected_hash: Some(hash),
913                target_type: target.to_string(),
914                section_map: map
915                    .iter()
916                    .map(|(a, b)| (a.to_string(), b.to_string()))
917                    .collect(),
918                drop_metadata: Vec::new(),
919                dry_run: false,
920            },
921            actor,
922            Some(&client),
923            Some("test retype"),
924        )
925    }
926
927    fn file_bytes(f: &Fixture, id: &crate::EntityId) -> Vec<u8> {
928        std::fs::read(f.main_dir.join(format!("{}.md", id.name()))).unwrap()
929    }
930
931    /// AC1: the type changes in place with the mapped section; id, path and
932    /// incoming edges stay; the response says checks are stale; a fresh
933    /// boot loads the result with no shape drops and the same edge count.
934    #[test]
935    fn retype_keeps_identity_and_edges_and_states_staleness() {
936        let f = fixture();
937        let mut engine = boot(&f, MountLifecycle::Eager);
938        let a = create(&mut engine, "main", "Claim A", "claim", "statement");
939        let b = create(&mut engine, "main", "Claim B", "claim", "statement");
940        // Unpinned incoming edge onto B, and an unpinned outgoing one.
941        relate(&mut engine, &a, "CITES", &b);
942        relate(&mut engine, &b, "CITES", &a);
943        let edges_before = engine.store().incoming(&b).len() + engine.store().outgoing(&b).len();
944        let hash_before = engine.get_entity(&b).unwrap().content_hash.clone();
945
946        let out = retype(&mut engine, &b, "finding", &[("statement", "conclusion")])
947            .expect("retype succeeds");
948        assert_eq!(out.id, b);
949        assert_eq!(out.file_path, "claim-b.md");
950        assert_eq!(
951            (out.old_type.as_str(), out.new_type.as_str()),
952            ("claim", "finding")
953        );
954        assert_eq!(
955            out.sections_renamed,
956            vec![("statement".to_string(), "conclusion".to_string())]
957        );
958        assert_eq!(out.edges_rechecked, 2);
959        assert!(out.checks_stale);
960        assert!(
961            out.staleness_note
962                .contains("check records and derivation baselines")
963        );
964        assert!(out.staleness_note.contains(&hash_before));
965        assert!(!out.write_id.is_empty());
966
967        let e = engine.get_entity(&b).unwrap();
968        assert_eq!(
969            e.entity_type,
970            "finding",
971            "store entity after retype: {e:?}\nfile: {}",
972            String::from_utf8(file_bytes(&f, &b)).unwrap()
973        );
974        assert_eq!(e.file_path, "claim-b.md");
975        assert_eq!(
976            e.sections.get("conclusion").map(String::as_str),
977            Some("Claim B says so.")
978        );
979        assert!(!e.sections.contains_key("statement"));
980        assert_ne!(e.content_hash, hash_before);
981        assert_eq!(engine.store().incoming(&b).len(), 1, "incoming edge stays");
982        assert_eq!(engine.store().incoming(&b)[0].from, a);
983        let text = String::from_utf8(file_bytes(&f, &b)).unwrap();
984        assert!(text.contains("type: finding"), "{text}");
985        assert!(text.contains("## Conclusion"), "{text}");
986
987        // Restart: nothing dropped, edge count unchanged.
988        let fresh = boot(&f, MountLifecycle::Eager);
989        let dropped = fresh
990            .load_warnings()
991            .iter()
992            .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
993        assert!(!dropped, "{:?}", fresh.load_warnings());
994        assert_eq!(
995            fresh.store().incoming(&b).len() + fresh.store().outgoing(&b).len(),
996            edges_before
997        );
998        assert_eq!(
999            fresh.get_entity(&b).unwrap().entity_type,
1000            "finding",
1001            "warnings: {:?}\nfile: {}",
1002            fresh.load_warnings(),
1003            String::from_utf8(file_bytes(&f, &b)).unwrap()
1004        );
1005    }
1006
1007    /// AC1 refusal complement: a map naming a key the target does not
1008    /// declare refuses UNKNOWN_SECTION with the target's declared sections
1009    /// and a proposed map, and the file is byte-identical afterwards.
1010    #[test]
1011    fn unknown_section_refuses_with_declared_sections_and_touches_nothing() {
1012        let f = fixture();
1013        let mut engine = boot(&f, MountLifecycle::Eager);
1014        let b = create(&mut engine, "main", "Claim B", "claim", "statement");
1015        let before = file_bytes(&f, &b);
1016        let err = retype(&mut engine, &b, "finding", &[("statement", "bogus")]).unwrap_err();
1017        assert_eq!(err.code(), "UNKNOWN_SECTION");
1018        let d = err.details();
1019        assert_eq!(
1020            d["target_sections"],
1021            serde_json::json!(["conclusion", "notes"])
1022        );
1023        assert_eq!(d["target_catch_all"], "notes");
1024        assert_eq!(d["proposed_section_map"]["statement"], "notes", "{d}");
1025        // Report-all: the unknown key AND the required section it leaves
1026        // empty arrive together; the envelope code is the map defect.
1027        let codes: Vec<&str> = d["problems"]
1028            .as_array()
1029            .unwrap()
1030            .iter()
1031            .map(|p| p["code"].as_str().unwrap())
1032            .collect();
1033        assert_eq!(codes, vec!["UNKNOWN_SECTION", "MISSING_REQUIRED_SECTION"]);
1034        assert_eq!(d["problems"][0]["key"], "bogus");
1035        assert_eq!(
1036            file_bytes(&f, &b),
1037            before,
1038            "refusal leaves the file untouched"
1039        );
1040        assert_eq!(engine.get_entity(&b).unwrap().entity_type, "claim");
1041
1042        // No map at all: the undeclared `statement` refuses the same way,
1043        // and the required `conclusion` is reported in the same envelope.
1044        let err = retype(&mut engine, &b, "finding", &[]).unwrap_err();
1045        assert_eq!(
1046            err.code(),
1047            "UNKNOWN_SECTION",
1048            "a section-map defect dominates the envelope code"
1049        );
1050        let codes: Vec<String> = err.details()["problems"]
1051            .as_array()
1052            .unwrap()
1053            .iter()
1054            .map(|p| p["code"].as_str().unwrap().to_string())
1055            .collect();
1056        assert_eq!(codes, vec!["UNKNOWN_SECTION", "MISSING_REQUIRED_SECTION"]);
1057        assert_eq!(file_bytes(&f, &b), before);
1058        assert!(matches!(
1059            retype(&mut engine, &b, "claim", &[]).unwrap_err(),
1060            EngineError::RetypeNoOp { .. }
1061        ));
1062    }
1063
1064    /// AC2: an incoming edge whose rel-type pins its target to the current
1065    /// type refuses; so does an outgoing one and a cross-mem one; two
1066    /// violations at once arrive in one envelope; every refusal leaves the
1067    /// file byte-identical.
1068    #[test]
1069    fn edge_shapes_are_rechecked_in_both_directions_and_across_mems() {
1070        let f = fixture();
1071        let mut engine = boot(&f, MountLifecycle::Eager);
1072        let a = create(&mut engine, "main", "Claim A", "claim", "statement");
1073        let b = create(&mut engine, "main", "Claim B", "claim", "statement");
1074        relate(&mut engine, &a, "SUPPORTS", &b); // pinned: target must stay claim
1075        let before_b = file_bytes(&f, &b);
1076
1077        // Incoming.
1078        let err = retype(&mut engine, &b, "finding", &[("statement", "conclusion")]).unwrap_err();
1079        assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1080        let EngineError::RetypeRefused { problems, .. } = &err else {
1081            panic!("{err:?}")
1082        };
1083        assert_eq!(problems.len(), 1);
1084        let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1085            panic!("{problems:?}")
1086        };
1087        assert_eq!(edge.direction, RetypeEdgeDirection::Incoming);
1088        assert_eq!(
1089            (edge.from.as_str(), edge.rel_type.as_str()),
1090            (a.as_ref(), "SUPPORTS")
1091        );
1092        assert!(!edge.cross_mem);
1093        assert_eq!(file_bytes(&f, &b), before_b);
1094
1095        // Outgoing.
1096        let before_a = file_bytes(&f, &a);
1097        let err = retype(&mut engine, &a, "finding", &[("statement", "conclusion")]).unwrap_err();
1098        assert_eq!(err.code(), "INVALID_REL_SHAPE");
1099        let EngineError::RetypeRefused { problems, .. } = &err else {
1100            panic!("{err:?}")
1101        };
1102        let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1103            panic!("{problems:?}")
1104        };
1105        assert_eq!(edge.direction, RetypeEdgeDirection::Outgoing);
1106        assert_eq!(edge.to, b.to_string());
1107        assert_eq!(file_bytes(&f, &a), before_a);
1108
1109        // Report-all: B also supports A now — retyping B violates an
1110        // incoming AND an outgoing pin, both in one envelope.
1111        relate(&mut engine, &b, "SUPPORTS", &a);
1112        let before_b = file_bytes(&f, &b);
1113        let err = retype(&mut engine, &b, "finding", &[("statement", "conclusion")]).unwrap_err();
1114        assert_eq!(err.code(), "INVALID_REL_SHAPE");
1115        let d = err.details();
1116        let dirs: Vec<String> = d["problems"]
1117            .as_array()
1118            .unwrap()
1119            .iter()
1120            .map(|p| p["direction"].as_str().unwrap().to_string())
1121            .collect();
1122        assert_eq!(dirs, vec!["outgoing", "incoming"]);
1123        assert_eq!(file_bytes(&f, &b), before_b);
1124
1125        // Cross-mem: a remark in the peer mem comments on a claim; the
1126        // cross-mem entry pins the target to claim.
1127        let c = create(&mut engine, "main", "Claim C", "claim", "statement");
1128        let r = create(&mut engine, "peer", "Remark R", "remark", "body");
1129        relate(&mut engine, &r, "COMMENTS_ON", &c);
1130        let before_c = file_bytes(&f, &c);
1131        let err = retype(&mut engine, &c, "finding", &[("statement", "conclusion")]).unwrap_err();
1132        assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1133        let EngineError::RetypeRefused { problems, .. } = &err else {
1134            panic!("{err:?}")
1135        };
1136        let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1137            panic!("{problems:?}")
1138        };
1139        assert!(edge.cross_mem);
1140        assert_eq!(edge.from, r.to_string());
1141        assert_eq!(file_bytes(&f, &c), before_c);
1142    }
1143
1144    /// Two mems pinning the SAME schema: a cross-mem edge is judged by that
1145    /// schema's own pins, exactly as relate and the loader judge it, so a
1146    /// retype that would strand the referrer's edge refuses — the
1147    /// same-schema case the grader of 2026-09-02 found waved through.
1148    #[test]
1149    fn same_schema_cross_mem_edges_are_rechecked() {
1150        let f = fixture();
1151        let tmp_twin = f._tmp.path().join("mem-twin");
1152        std::fs::create_dir_all(&tmp_twin).unwrap();
1153        let main_pin = SchemaRef::new("rt", semver::Version::new(0, 1, 0));
1154        let mut engine = Engine::from_mounts_with_schemas_dir(
1155            vec![
1156                (
1157                    mount(
1158                        "main",
1159                        f.main_dir.clone(),
1160                        main_pin.clone(),
1161                        MountLifecycle::Eager,
1162                    ),
1163                    Box::new(FilesystemMemWriter::new(f.main_dir.clone())) as Box<dyn MemBackend>,
1164                ),
1165                (
1166                    mount("twin", tmp_twin.clone(), main_pin, MountLifecycle::Eager),
1167                    Box::new(FilesystemMemWriter::new(tmp_twin.clone())) as Box<dyn MemBackend>,
1168                ),
1169            ],
1170            Some(&f.schemas_dir),
1171        )
1172        .unwrap();
1173        let mut settings = WorkspaceSettings::default();
1174        let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
1175        links.insert("twin".to_string(), CrossLinkValue::Wildcard);
1176        links.insert("main".to_string(), CrossLinkValue::Wildcard);
1177        settings.cross_mem_links = links;
1178        engine.set_settings(settings);
1179
1180        let v = create(&mut engine, "main", "Claim V", "claim", "statement");
1181        let w = create(&mut engine, "twin", "Claim W", "claim", "statement");
1182        relate(&mut engine, &w, "SUPPORTS", &v); // pinned target: claim
1183        let before = file_bytes(&f, &v);
1184        let err = retype(&mut engine, &v, "finding", &[("statement", "conclusion")]).unwrap_err();
1185        assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1186        let EngineError::RetypeRefused { problems, .. } = &err else {
1187            panic!("{err:?}")
1188        };
1189        let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1190            panic!("{problems:?}")
1191        };
1192        assert!(edge.cross_mem);
1193        assert_eq!(edge.from, w.to_string());
1194        assert_eq!(file_bytes(&f, &v), before);
1195
1196        // Outgoing across the twin: W supports V, retyping W refuses too.
1197        let err = retype(&mut engine, &w, "finding", &[("statement", "conclusion")]).unwrap_err();
1198        assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1199    }
1200
1201    /// The referrer lives in a LAZY mem: its edge is not in the store, so
1202    /// the retype probes the mem's storage and finds it; the same graph
1203    /// with the peer loaded eagerly refuses identically, and the deferred
1204    /// probe counts the edge it examined.
1205    #[test]
1206    fn deferred_referrers_are_probed_through_storage() {
1207        let f = fixture();
1208        {
1209            let mut engine = boot(&f, MountLifecycle::Eager);
1210            let c = create(&mut engine, "main", "Claim C", "claim", "statement");
1211            let r = create(&mut engine, "peer", "Remark R", "remark", "body");
1212            relate(&mut engine, &r, "COMMENTS_ON", &c);
1213        }
1214        let mut lazy = boot(&f, MountLifecycle::Lazy);
1215        let c = crate::EntityId::canonical("main--claim-c");
1216        assert!(
1217            lazy.mem_is_deferred("peer"),
1218            "peer stays unloaded until touched"
1219        );
1220        assert!(
1221            lazy.store().incoming(&c).is_empty(),
1222            "the lazy referrer's edge is not in the store"
1223        );
1224        let err = retype(&mut lazy, &c, "finding", &[("statement", "conclusion")]).unwrap_err();
1225        assert_eq!(err.code(), "INVALID_REL_SHAPE", "{err}");
1226        let EngineError::RetypeRefused { problems, .. } = &err else {
1227            panic!("{err:?}")
1228        };
1229        let RetypeProblem::EdgeShape(edge) = &problems[0] else {
1230            panic!("{problems:?}")
1231        };
1232        assert!(edge.cross_mem);
1233        assert_eq!(edge.from, "peer--remark-r");
1234        assert_eq!(lazy.get_entity(&c).unwrap().entity_type, "claim");
1235
1236        // Without the pinned referrer, a lazily mounted peer does not
1237        // block, and the probe reports the edges it examined (none).
1238        let d = create(&mut lazy, "main", "Claim D", "claim", "statement");
1239        let out = retype(&mut lazy, &d, "finding", &[("statement", "conclusion")]).unwrap();
1240        assert_eq!(out.edges_rechecked, 0);
1241    }
1242
1243    /// Dry run: the same validation, the prospective hash, no write.
1244    #[test]
1245    fn dry_run_validates_and_writes_nothing() {
1246        let f = fixture();
1247        let mut engine = boot(&f, MountLifecycle::Eager);
1248        let b = create(&mut engine, "main", "Claim B", "claim", "statement");
1249        let before = file_bytes(&f, &b);
1250        let (actor, client) = cli_actor();
1251        let out = engine
1252            .retype_entity(
1253                RetypeEntityArgs {
1254                    id: b.clone(),
1255                    expected_hash: None,
1256                    target_type: "finding".to_string(),
1257                    section_map: IndexMap::from_iter([(
1258                        "statement".to_string(),
1259                        "conclusion".to_string(),
1260                    )]),
1261                    drop_metadata: Vec::new(),
1262                    dry_run: true,
1263                },
1264                actor,
1265                Some(&client),
1266                None,
1267            )
1268            .unwrap();
1269        assert!(out.write_id.is_empty());
1270        assert!(out.prospective_hash.is_some());
1271        assert_ne!(
1272            out.prospective_hash.as_deref(),
1273            Some(out.content_hash.as_str())
1274        );
1275        assert_eq!(file_bytes(&f, &b), before);
1276        assert_eq!(engine.get_entity(&b).unwrap().entity_type, "claim");
1277    }
1278}