1use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::generator::generate_markdown;
8use crate::entity::parser::parse_markdown;
9use crate::entity::store_builder::push_entities_into_store;
10use crate::entity::{Entity, EntityId, Relationship, normalise_description};
11use crate::ops::WarningHint;
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::runtime_validator::{
14 CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
15 validate_rel_type,
16};
17use crate::vcs::{Actor, ClientId, CommitContext};
18use crate::workspace::MountCapability;
19use memstead_schema::SchemaRef;
20
21use super::super::{Engine, EngineError, RelateAction, RelateEntityArgs, RelateEntityOutcome};
22use super::{
23 RELATIONSHIP_CYCLE_PATH_CAP, make_stub, unknown_type_error, validate_description_posture,
24 validate_relation_target_grammar,
25};
26
27impl Engine {
28 pub fn relate_entity(
47 &mut self,
48 args: RelateEntityArgs,
49 actor: Actor,
50 client: Option<&ClientId>,
51 note: Option<&str>,
52 ) -> Result<RelateEntityOutcome, EngineError> {
53 let mut args = args;
54 let source_mem = args.source.mem().to_string();
55 let target_mem = args.target.mem().to_string();
56
57 let mut drift_warnings = self.reload_if_stale(Some(&source_mem));
63 if target_mem != source_mem {
64 drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
65 }
66
67 validate_relation_target_grammar(&args.target)?;
83
84 let mut target_mem_uncreated = false;
90 if source_mem != target_mem {
91 if !args.remove {
102 super::validate_cross_mem_add_policy(self, &source_mem, &args.target)?;
103 }
104 if let Some(mount) = self.mount(&target_mem)
112 && mount.capability == MountCapability::ReadOnly
113 && !self.store.contains(&args.target)
114 {
115 return Err(EngineError::CrossMemTargetNotFound {
116 target_id: args.target.to_string(),
117 target_mem: target_mem.clone(),
118 });
119 }
120 if self.mount(&target_mem).is_none() {
128 target_mem_uncreated = true;
129 }
130 }
131
132 if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
139 args.rel_type = canonical;
140 }
141 args.description = normalise_description(args.description.as_deref());
145
146 let mount_idx = self
147 .mounts
148 .iter()
149 .position(|m| m.mount.mem == source_mem)
150 .ok_or_else(|| EngineError::UnknownMem(source_mem.clone()))?;
151 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
152 return Err(EngineError::ReadOnlyMount(source_mem));
153 }
154
155 let schema = self
156 .schemas
157 .get(&source_mem)
158 .expect("schema present for every registered mount");
159
160 let target_schema = if source_mem == target_mem {
174 None
175 } else {
176 self.schemas.get(&target_mem).cloned()
177 };
178 let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
179 let (name, version) = s.id();
180 SchemaRef::new(name, version)
181 });
182 let cross_mem_different = match (&target_schema_ref, schema.id()) {
183 (Some(target), (src_name, _)) => target.name != src_name,
184 (None, _) => false,
185 };
186
187 let mut warnings: Vec<WarningHint> = Vec::new();
188 warnings.append(&mut drift_warnings);
190 if !cross_mem_different {
195 match validate_rel_type(&args.rel_type, schema.as_ref())? {
196 RelationshipCheck::Ok => {}
197 RelationshipCheck::OpenWarning(message) => {
198 warnings.push(WarningHint::UndeclaredRelationshipOpen {
199 rel_type: args.rel_type.clone(),
200 message,
201 });
202 }
203 }
204 }
205
206 let entity: Entity = self
210 .store
211 .get(&args.source)
212 .ok_or_else(|| EngineError::NotFound {
213 id: args.source.to_string(),
214 })?
215 .clone();
216
217 if entity.stub {
225 return Err(EngineError::StubCannotRelate {
226 id: args.source.to_string(),
227 });
228 }
229
230 let target_type = self
231 .store
232 .get(&args.target)
233 .map(|e| e.entity_type.clone())
234 .filter(|t| !t.is_empty());
235 if !args.remove {
250 validate_description_posture(
251 self,
252 &args.rel_type,
253 args.description.as_deref(),
254 &source_mem,
255 &target_mem,
256 &args.source,
257 &args.target,
258 )?;
259 super::validate_manual_authoring_posture(
267 self,
268 &args.rel_type,
269 &source_mem,
270 &args.source,
271 &args.target,
272 )?;
273 }
274
275 if !args.remove {
276 if cross_mem_different {
277 let target_ref = target_schema_ref
280 .as_ref()
281 .expect("target_schema_ref is Some when cross_mem_different");
282 match validate_cross_mem_edge(
283 &args.rel_type,
284 entity.entity_type.as_str(),
285 target_type.as_deref(),
286 schema.as_ref(),
287 target_ref,
288 ) {
289 CrossMemRelCheck::Ok => {}
290 CrossMemRelCheck::EdgeNotDeclared => {
291 let (src_name, src_version) = schema.id();
292 return Err(EngineError::CrossMemEdgeNotDeclared {
293 source_schema: SchemaRef::new(src_name, src_version).as_display(),
294 target_schema: target_ref.as_display(),
295 rel_type: args.rel_type.clone(),
296 from_id: args.source.to_string(),
297 to_id: args.target.to_string(),
298 });
299 }
300 CrossMemRelCheck::Invalid(v) => {
301 return Err(EngineError::Validation(v));
302 }
303 }
304 } else {
305 validate_rel_shape(
306 &args.rel_type,
307 entity.entity_type.as_str(),
308 target_type.as_deref(),
309 schema.as_ref(),
310 )?;
311 }
312 }
313
314 if let Some(expected) = args.expected_hash.as_deref()
315 && entity.content_hash != expected
316 {
317 return Err(EngineError::HashMismatch {
318 id: args.source.to_string(),
319 current: entity.content_hash.clone(),
320 is_stub: entity.stub,
321 });
322 }
323
324 if !args.remove
334 && args.source == args.target
335 && schema.type_propagates(entity.entity_type.as_str(), &args.rel_type)
336 {
337 return Err(EngineError::RelationshipCycle {
338 rel_type: args.rel_type.clone(),
339 from: args.source.clone(),
340 to: args.target.clone(),
341 existing_path: vec![args.source.clone()],
342 path_truncated: false,
343 });
344 }
345
346 if !args.remove
353 && schema.relationship_acyclic(&args.rel_type)
354 && let Some(path) = crate::graph::query::would_cycle(
355 &self.store,
356 &args.source,
357 &args.target,
358 &args.rel_type,
359 )
360 {
361 let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
362 let mut existing_path = path;
363 if truncated {
364 existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
365 }
366 return Err(EngineError::RelationshipCycle {
367 rel_type: args.rel_type.clone(),
368 from: args.source.clone(),
369 to: args.target.clone(),
370 existing_path,
371 path_truncated: truncated,
372 });
373 }
374
375 let type_def = schema
376 .get_type(&entity.entity_type)
377 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
378
379 let mut next = entity.clone();
380 let already = next
381 .relationships
382 .iter()
383 .position(|r| r.rel_type == args.rel_type && r.target == args.target);
384
385 if args.remove && already.is_some() {
392 let other_relation_to_target_exists = entity
393 .relationships
394 .iter()
395 .any(|r| r.target == args.target && r.rel_type != args.rel_type);
396 if !other_relation_to_target_exists {
397 let mut surviving_sections: Vec<String> = Vec::new();
404 for (section_key, body) in entity.sections.iter() {
405 let inline_targets =
406 crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
407 if inline_targets.iter().any(|t| t == &args.target) {
408 surviving_sections.push(section_key.clone());
409 }
410 }
411 if !surviving_sections.is_empty() {
412 return Err(EngineError::RelationHasBodyLinks {
413 from_id: args.source.to_string(),
414 to_id: args.target.to_string(),
415 rel_type: args.rel_type.clone(),
416 body_links: surviving_sections,
417 });
418 }
419 }
420 }
421
422 let action = if args.remove {
423 match already {
424 Some(idx) => {
425 next.relationships.remove(idx);
426 RelateAction::Removed
427 }
428 None => RelateAction::NoOpAbsent,
429 }
430 } else {
431 match already {
432 Some(_) => RelateAction::NoOpAlreadyPresent,
433 None => {
434 next.relationships.push(Relationship {
435 rel_type: args.rel_type.clone(),
436 target: args.target.clone(),
437 description: normalise_description(args.description.as_deref()),
438 });
439 RelateAction::Added
440 }
441 }
442 };
443
444 if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
455 self.store.upsert(
456 args.target.clone(),
457 make_stub(&args.target, crate::entity::StubKind::ForwardReference),
458 );
459 warnings.push(WarningHint::AutoStubCreated {
460 stub_id: args.target.clone(),
461 });
462 if target_mem_uncreated {
467 warnings.push(WarningHint::CrossMemTargetMemUncreated {
468 from_mem: source_mem.clone(),
469 to_mem: target_mem.clone(),
470 target_id: args.target.clone(),
471 });
472 }
473 }
474
475 if matches!(
481 action,
482 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
483 ) {
484 match action {
485 RelateAction::NoOpAlreadyPresent => {
486 warnings.push(WarningHint::DuplicateRelationship {
487 rel_type: args.rel_type.clone(),
488 from: args.source.clone(),
489 to: args.target.clone(),
490 });
491 }
492 RelateAction::NoOpAbsent => {
493 warnings.push(WarningHint::NoSuchRelationship {
494 rel_type: args.rel_type.clone(),
495 from: args.source.clone(),
496 to: args.target.clone(),
497 });
498 }
499 _ => unreachable!(),
500 }
501 return Ok(RelateEntityOutcome {
502 from: args.source,
503 to: args.target,
504 rel_type: args.rel_type,
505 action,
506 content_hash: entity.content_hash.clone(),
507 commit_sha: String::new(),
508 source: "explicit".to_string(),
509 warnings,
510 orphan_stubs_removed: Vec::new(),
513 });
514 }
515
516 let today = super::today_iso();
524 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
525
526 let file_path = next.file_path.clone();
527 let markdown = generate_markdown(&next, type_def.as_ref());
528
529 let backend = self.mounts[mount_idx].backend.as_ref();
530 backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
531 let commit_subject = format!("memstead: relate {}", args.source);
532 let ctx = CommitContext {
533 actor,
534 client: client.cloned(),
535 tool: Some("relate_entity"),
536 note: note.map(String::from),
537 logical_operation_id: None,
538 entity_ids: None,
539 };
540 let commit_sha = backend.commit(&commit_subject, &ctx)?;
541
542 backend.append_provenance(&Provenance::new(
543 std::time::SystemTime::now(),
544 ProvenanceKind::Relate,
545 Some(args.source.to_string()),
546 actor,
547 client.cloned(),
548 note.map(String::from),
549 ))?;
550
551 self.record_self_write(mount_idx, &commit_sha);
552
553 let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &source_mem)
554 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
555 let content_hash = parse_result.entity.content_hash.clone();
556
557 let fallback = engine_fallback_type();
558 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
559 crate::entity::store_builder::remap_alias_target_edge_sources(
560 &mut self.store,
561 &self.schemas,
562 );
563
564 let orphan_stubs_removed: Vec<EntityId> = if matches!(action, RelateAction::Removed) {
578 super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&args.target))
579 } else {
580 Vec::new()
581 };
582
583 self.invalidate_communities();
584 self.invalidate_search_indexes();
585
586 if let Some(w) = self.note_missing_warning("relate_entity", note) {
592 warnings.push(w);
593 }
594
595 Ok(RelateEntityOutcome {
596 from: args.source,
597 to: args.target,
598 rel_type: args.rel_type,
599 action,
600 content_hash,
601 commit_sha,
602 source: "explicit".to_string(),
603 warnings,
604 orphan_stubs_removed,
605 })
606 }
607
608 pub fn relate(
615 &mut self,
616 from: &EntityId,
617 to: &EntityId,
618 rel_type: &str,
619 remove: bool,
620 ctx: &CommitContext<'_>,
621 ) -> Result<RelateEntityOutcome, EngineError> {
622 let args = RelateEntityArgs {
623 source: from.clone(),
624 expected_hash: None,
625 rel_type: rel_type.to_string(),
626 target: to.clone(),
627 remove,
628 description: None,
629 };
630 self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
631 }
632}
633
634#[cfg(test)]
635mod tests {
636
637 use indexmap::IndexMap;
638 use tempfile::TempDir;
639
640 use crate::backend::MemBackend;
641 use crate::engine::test_helpers::*;
642 use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
643 use crate::ops::WarningHint;
644 use crate::storage::FilesystemMemWriter;
645 use crate::vcs::{Actor, CommitContext};
646
647 #[test]
648 fn relate_alias_delegates_to_relate_entity() {
649 let tmp = TempDir::new().unwrap();
654 let mem_dir = tmp.path().to_path_buf();
655 let writer = FilesystemMemWriter::new(mem_dir.clone());
656 let mut engine = Engine::from_mounts(vec![(
657 folder_mount("specs", mem_dir),
658 Box::new(writer) as Box<dyn MemBackend>,
659 )])
660 .unwrap();
661
662 let a = engine
664 .create_entity(
665 CreateEntityArgs {
666 anchors: Vec::new(),
667 mem: "specs".to_string(),
668 title: "A".to_string(),
669 entity_type: "spec".to_string(),
670 sections: IndexMap::from_iter([
671 ("identity".to_string(), "seed identity".to_string()),
672 ("purpose".to_string(), "seed purpose".to_string()),
673 ]),
674 metadata: IndexMap::new(),
675 relations: Vec::new(),
676 dry_run: false,
677 },
678 Actor::Cli,
679 None,
680 None,
681 )
682 .unwrap();
683 let b = engine
684 .create_entity(
685 CreateEntityArgs {
686 anchors: Vec::new(),
687 mem: "specs".to_string(),
688 title: "B".to_string(),
689 entity_type: "spec".to_string(),
690 sections: IndexMap::from_iter([
691 ("identity".to_string(), "seed identity".to_string()),
692 ("purpose".to_string(), "seed purpose".to_string()),
693 ]),
694 metadata: IndexMap::new(),
695 relations: Vec::new(),
696 dry_run: false,
697 },
698 Actor::Cli,
699 None,
700 None,
701 )
702 .unwrap();
703
704 let ctx = CommitContext::internal();
706 let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
707 assert_eq!(result.from, a.id);
708 assert_eq!(result.to, b.id);
709 assert_eq!(result.rel_type, "PART_OF");
710 let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
712 assert!(
713 outgoing
714 .iter()
715 .any(|e| e.target == b.id && e.rel_type == "PART_OF")
716 );
717 }
718
719 #[test]
720 fn relate_entity_appends_relationship_and_logs_provenance() {
721 let tmp = TempDir::new().unwrap();
722 let (mut engine, source) = engine_with_seed(&tmp, "Source");
723 let (actor, client) = cli_actor();
724 let target = engine
725 .create_entity(
726 empty_create_args("specs", "Target"),
727 actor,
728 Some(&client),
729 None,
730 )
731 .unwrap();
732
733 let outcome = engine
734 .relate_entity(
735 RelateEntityArgs {
736 source: source.id.clone(),
737 expected_hash: Some(source.content_hash.clone()),
738 rel_type: "USES".to_string(),
739 target: target.id.clone(),
740 remove: false,
741 description: None,
742 },
743 actor,
744 Some(&client),
745 None,
746 )
747 .unwrap();
748 assert_eq!(outcome.action, RelateAction::Added);
749 assert_ne!(outcome.content_hash, source.content_hash);
750 let edges = engine.store().outgoing(&source.id);
752 assert!(
753 edges
754 .iter()
755 .any(|e| e.rel_type == "USES" && e.target == target.id),
756 "expected USES edge in store"
757 );
758 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
760 assert!(log.contains("\"kind\":\"relate\""));
761 }
762
763 #[test]
764 fn relate_entity_no_op_when_already_present() {
765 let tmp = TempDir::new().unwrap();
766 let (mut engine, source) = engine_with_seed(&tmp, "Already");
767 let (actor, client) = cli_actor();
768 let target = engine
769 .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
770 .unwrap();
771 let first = engine
772 .relate_entity(
773 RelateEntityArgs {
774 source: source.id.clone(),
775 expected_hash: Some(source.content_hash.clone()),
776 rel_type: "USES".to_string(),
777 target: target.id.clone(),
778 remove: false,
779 description: None,
780 },
781 actor,
782 Some(&client),
783 None,
784 )
785 .unwrap();
786 let second = engine
787 .relate_entity(
788 RelateEntityArgs {
789 source: source.id.clone(),
790 expected_hash: Some(first.content_hash.clone()),
791 rel_type: "USES".to_string(),
792 target: target.id.clone(),
793 remove: false,
794 description: None,
795 },
796 actor,
797 Some(&client),
798 None,
799 )
800 .unwrap();
801 assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
802 assert_eq!(second.content_hash, first.content_hash);
804 }
805
806 #[test]
807 fn relate_entity_returns_commit_sha_on_real_write() {
808 let tmp = TempDir::new().unwrap();
809 let (mut engine, source) = engine_with_seed(&tmp, "Source");
810 let (actor, client) = cli_actor();
811 let target = engine
812 .create_entity(
813 empty_create_args("specs", "Target"),
814 actor,
815 Some(&client),
816 None,
817 )
818 .unwrap();
819
820 let outcome = engine
821 .relate_entity(
822 RelateEntityArgs {
823 source: source.id.clone(),
824 expected_hash: Some(source.content_hash.clone()),
825 rel_type: "USES".to_string(),
826 target: target.id.clone(),
827 remove: false,
828 description: None,
829 },
830 actor,
831 Some(&client),
832 None,
833 )
834 .unwrap();
835 assert!(
839 !outcome.commit_sha.is_empty(),
840 "commit_sha must be populated on a real write"
841 );
842 }
843
844 #[test]
845 fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_commit_sha() {
846 let tmp = TempDir::new().unwrap();
847 let (mut engine, source) = engine_with_seed(&tmp, "S");
848 let (actor, client) = cli_actor();
849 let target = engine
850 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
851 .unwrap();
852
853 let first = engine
855 .relate_entity(
856 RelateEntityArgs {
857 source: source.id.clone(),
858 expected_hash: Some(source.content_hash.clone()),
859 rel_type: "USES".to_string(),
860 target: target.id.clone(),
861 remove: false,
862 description: None,
863 },
864 actor,
865 Some(&client),
866 None,
867 )
868 .unwrap();
869
870 let dup = engine
873 .relate_entity(
874 RelateEntityArgs {
875 source: source.id.clone(),
876 expected_hash: Some(first.content_hash.clone()),
877 rel_type: "USES".to_string(),
878 target: target.id.clone(),
879 remove: false,
880 description: None,
881 },
882 actor,
883 Some(&client),
884 None,
885 )
886 .unwrap();
887 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
888 assert!(dup.commit_sha.is_empty());
889 assert_eq!(dup.warnings.len(), 1);
890 assert!(matches!(
891 dup.warnings[0],
892 WarningHint::DuplicateRelationship { .. }
893 ));
894
895 let no_such = engine
898 .relate_entity(
899 RelateEntityArgs {
900 source: source.id.clone(),
901 expected_hash: Some(first.content_hash.clone()),
902 rel_type: "DEPENDS_ON".to_string(),
903 target: target.id.clone(),
904 remove: true,
905 description: None,
906 },
907 actor,
908 Some(&client),
909 None,
910 )
911 .unwrap();
912 assert_eq!(no_such.action, RelateAction::NoOpAbsent);
913 assert!(no_such.commit_sha.is_empty());
914 assert_eq!(no_such.warnings.len(), 1);
915 assert!(matches!(
916 no_such.warnings[0],
917 WarningHint::NoSuchRelationship { .. }
918 ));
919 }
920
921 #[test]
922 fn relate_entity_creates_stub_for_absent_target_on_add_path() {
923 let tmp = TempDir::new().unwrap();
924 let (mut engine, source) = engine_with_seed(&tmp, "Source");
925 let (actor, client) = cli_actor();
926 let absent_target = crate::EntityId::new("specs", "ghost-target");
927 assert!(!engine.store().contains(&absent_target));
929
930 let outcome = engine
931 .relate_entity(
932 RelateEntityArgs {
933 source: source.id.clone(),
934 expected_hash: Some(source.content_hash.clone()),
935 rel_type: "USES".to_string(),
936 target: absent_target.clone(),
937 remove: false,
938 description: None,
939 },
940 actor,
941 Some(&client),
942 None,
943 )
944 .unwrap();
945
946 assert_eq!(outcome.action, RelateAction::Added);
947 assert_eq!(outcome.source, "explicit");
948 let stub_warning = outcome
954 .warnings
955 .iter()
956 .find_map(|w| match w {
957 crate::ops::WarningHint::AutoStubCreated { stub_id } => Some(stub_id.clone()),
958 _ => None,
959 })
960 .expect("AutoStubCreated warning must surface when target was absent");
961 assert_eq!(stub_warning, absent_target);
962
963 let stub = engine.store().get(&absent_target).expect("stub upserted");
965 assert!(stub.stub);
966 assert!(stub.entity_type.is_empty());
967 assert!(stub.file_path.is_empty());
968 }
969
970 #[test]
971 fn relate_entity_skips_stub_creation_when_target_already_exists() {
972 let tmp = TempDir::new().unwrap();
973 let (mut engine, source) = engine_with_seed(&tmp, "Src");
974 let (actor, client) = cli_actor();
975 let target = engine
976 .create_entity(
977 empty_create_args("specs", "Real"),
978 actor,
979 Some(&client),
980 None,
981 )
982 .unwrap();
983
984 let outcome = engine
985 .relate_entity(
986 RelateEntityArgs {
987 source: source.id.clone(),
988 expected_hash: Some(source.content_hash.clone()),
989 rel_type: "USES".to_string(),
990 target: target.id.clone(),
991 remove: false,
992 description: None,
993 },
994 actor,
995 Some(&client),
996 None,
997 )
998 .unwrap();
999
1000 assert!(
1001 !outcome
1002 .warnings
1003 .iter()
1004 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1005 "AutoStubCreated must not surface when target was already in store"
1006 );
1007 assert_eq!(outcome.source, "explicit");
1008 let target_after = engine.store().get(&target.id).unwrap();
1010 assert!(!target_after.stub);
1011 }
1012
1013 #[test]
1014 fn relate_entity_does_not_create_stub_on_remove_path() {
1015 let tmp = TempDir::new().unwrap();
1016 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1017 let (actor, client) = cli_actor();
1018 let absent_target = crate::EntityId::new("specs", "never-existed");
1019
1020 let outcome = engine
1021 .relate_entity(
1022 RelateEntityArgs {
1023 source: source.id.clone(),
1024 expected_hash: Some(source.content_hash.clone()),
1025 rel_type: "USES".to_string(),
1026 target: absent_target.clone(),
1027 remove: true,
1028 description: None,
1029 },
1030 actor,
1031 Some(&client),
1032 None,
1033 )
1034 .unwrap();
1035
1036 assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1039 assert!(
1040 !outcome
1041 .warnings
1042 .iter()
1043 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1044 "remove path must never auto-stub the target",
1045 );
1046 assert!(!engine.store().contains(&absent_target));
1047 }
1048
1049 #[test]
1050 fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1051 use crate::engine::CreateEntityArgs;
1052 use indexmap::IndexMap;
1053
1054 let tmp = TempDir::new().unwrap();
1055 let mem_dir = tmp.path().to_path_buf();
1056 let writer = FilesystemMemWriter::new(mem_dir.clone());
1057 let mut engine = Engine::from_mounts(vec![(
1058 folder_mount("specs", mem_dir.clone()),
1059 Box::new(writer) as Box<dyn MemBackend>,
1060 )])
1061 .unwrap();
1062 let (actor, client) = cli_actor();
1063
1064 let target = engine
1065 .create_entity(
1066 empty_create_args("specs", "Target"),
1067 actor,
1068 Some(&client),
1069 None,
1070 )
1071 .unwrap();
1072
1073 let mut sections: IndexMap<String, String> = IndexMap::new();
1079 sections.insert("identity".to_string(), "source identity".to_string());
1080 sections.insert(
1081 "purpose".to_string(),
1082 "discussion stems from [[target]]".to_string(),
1083 );
1084 let source = engine
1085 .create_entity(
1086 CreateEntityArgs {
1087 anchors: Vec::new(),
1088 mem: "specs".to_string(),
1089 title: "Source".to_string(),
1090 entity_type: "spec".to_string(),
1091 sections,
1092 metadata: IndexMap::new(),
1093 relations: Vec::new(),
1094 dry_run: false,
1095 },
1096 actor,
1097 Some(&client),
1098 None,
1099 )
1100 .unwrap();
1101 let related = source.clone();
1102
1103 let err = engine
1107 .relate_entity(
1108 RelateEntityArgs {
1109 source: source.id.clone(),
1110 expected_hash: Some(related.content_hash.clone()),
1111 rel_type: "REFERENCES".to_string(),
1112 target: target.id.clone(),
1113 remove: true,
1114 description: None,
1115 },
1116 actor,
1117 Some(&client),
1118 None,
1119 )
1120 .unwrap_err();
1121 match err {
1122 EngineError::RelationHasBodyLinks {
1123 from_id,
1124 to_id,
1125 rel_type,
1126 body_links,
1127 } => {
1128 assert_eq!(from_id, source.id.to_string());
1129 assert_eq!(to_id, target.id.to_string());
1130 assert_eq!(rel_type, "REFERENCES");
1131 assert_eq!(body_links, vec!["purpose".to_string()]);
1132 }
1133 other => panic!("expected RelationHasBodyLinks, got {other:?}"),
1134 }
1135 let in_mem = engine.get_entity(&source.id).unwrap();
1138 assert!(
1139 in_mem
1140 .relationships
1141 .iter()
1142 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
1143 "relation must survive the refused remove; got {:?}",
1144 in_mem.relationships
1145 );
1146 }
1147
1148 #[test]
1149 fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
1150 let tmp = TempDir::new().unwrap();
1151 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1152 let (actor, client) = cli_actor();
1153 let target = engine
1154 .create_entity(
1155 empty_create_args("specs", "Other"),
1156 actor,
1157 Some(&client),
1158 None,
1159 )
1160 .unwrap();
1161 let related = engine
1167 .relate_entity(
1168 RelateEntityArgs {
1169 source: source.id.clone(),
1170 expected_hash: Some(source.content_hash.clone()),
1171 rel_type: "USES".to_string(),
1172 target: target.id.clone(),
1173 remove: false,
1174 description: None,
1175 },
1176 actor,
1177 Some(&client),
1178 None,
1179 )
1180 .unwrap();
1181 let removed = engine
1182 .relate_entity(
1183 RelateEntityArgs {
1184 source: source.id.clone(),
1185 expected_hash: Some(related.content_hash.clone()),
1186 rel_type: "USES".to_string(),
1187 target: target.id.clone(),
1188 remove: true,
1189 description: None,
1190 },
1191 actor,
1192 Some(&client),
1193 None,
1194 )
1195 .unwrap();
1196 assert_eq!(removed.action, RelateAction::Removed);
1197 }
1198
1199 #[test]
1200 fn relate_entity_auto_stub_is_tagged_forward_reference() {
1201 use crate::entity::StubKind;
1206
1207 let tmp = TempDir::new().unwrap();
1208 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1209 let (actor, client) = cli_actor();
1210 let absent_target = crate::EntityId::new("specs", "absent-target");
1211
1212 let _ = engine
1213 .relate_entity(
1214 RelateEntityArgs {
1215 source: source.id.clone(),
1216 expected_hash: Some(source.content_hash.clone()),
1217 rel_type: "USES".to_string(),
1218 target: absent_target.clone(),
1219 remove: false,
1220 description: None,
1221 },
1222 actor,
1223 Some(&client),
1224 None,
1225 )
1226 .unwrap();
1227
1228 let stub = engine
1229 .get_entity(&absent_target)
1230 .expect("relate auto-stubbed target must be in the store");
1231 assert!(stub.stub, "auto-stubbed target must carry stub: true");
1232 assert_eq!(
1233 stub.stub_kind,
1234 Some(StubKind::ForwardReference),
1235 "auto-stub from relate must be tagged ForwardReference; got {:?}",
1236 stub.stub_kind
1237 );
1238 }
1239
1240 #[test]
1241 fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
1242 let tmp = TempDir::new().unwrap();
1247 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1248 let (actor, client) = cli_actor();
1249 let target = engine
1250 .create_entity(
1251 empty_create_args("specs", "Target"),
1252 actor,
1253 Some(&client),
1254 None,
1255 )
1256 .unwrap();
1257
1258 let lower = engine
1260 .relate_entity(
1261 RelateEntityArgs {
1262 source: source.id.clone(),
1263 expected_hash: Some(source.content_hash.clone()),
1264 rel_type: "uses".to_string(),
1265 target: target.id.clone(),
1266 remove: false,
1267 description: None,
1268 },
1269 actor,
1270 Some(&client),
1271 None,
1272 )
1273 .unwrap();
1274 assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
1275 assert_eq!(lower.action, RelateAction::Added);
1276 let edges = engine.store().outgoing(&source.id);
1277 assert!(
1278 edges
1279 .iter()
1280 .any(|e| e.rel_type == "USES" && e.target == target.id),
1281 "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
1282 );
1283
1284 let dup = engine
1288 .relate_entity(
1289 RelateEntityArgs {
1290 source: source.id.clone(),
1291 expected_hash: Some(lower.content_hash.clone()),
1292 rel_type: "Uses".to_string(),
1293 target: target.id.clone(),
1294 remove: false,
1295 description: None,
1296 },
1297 actor,
1298 Some(&client),
1299 None,
1300 )
1301 .unwrap();
1302 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1303 assert_eq!(dup.rel_type, "USES");
1304 assert!(matches!(
1305 dup.warnings[0],
1306 WarningHint::DuplicateRelationship { .. }
1307 ));
1308 }
1309
1310 #[test]
1311 fn relate_entity_rejects_cross_mem_when_policy_denies() {
1312 let tmp = TempDir::new().unwrap();
1319 let (mut engine, source) = engine_with_seed(&tmp, "S");
1320 let (actor, client) = cli_actor();
1321 let err = engine
1322 .relate_entity(
1323 RelateEntityArgs {
1324 source: source.id.clone(),
1325 expected_hash: Some(source.content_hash.clone()),
1326 rel_type: "USES".to_string(),
1327 target: crate::EntityId::new("other-mem", "thing"),
1328 remove: false,
1329 description: None,
1330 },
1331 actor,
1332 Some(&client),
1333 None,
1334 )
1335 .unwrap_err();
1336 match err {
1337 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
1338 assert_eq!(from_mem, "specs");
1339 assert_eq!(to_mem, "other-mem");
1340 }
1341 other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
1342 }
1343 }
1344
1345 #[test]
1354 fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
1355 let tmp = TempDir::new().unwrap();
1356 let (mut engine, source) = engine_with_seed(&tmp, "S");
1357 let (actor, client) = cli_actor();
1358 let err = engine
1359 .relate_entity(
1360 RelateEntityArgs {
1361 source: source.id.clone(),
1362 expected_hash: Some(source.content_hash.clone()),
1363 rel_type: "USES".to_string(),
1364 target: crate::EntityId("bad target with spaces!!".to_string()),
1367 remove: false,
1368 description: None,
1369 },
1370 actor,
1371 Some(&client),
1372 None,
1373 )
1374 .unwrap_err();
1375 assert!(
1376 matches!(err, EngineError::InvalidEntityId { .. }),
1377 "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
1378 );
1379 }
1380
1381 #[test]
1386 fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
1387 let tmp = TempDir::new().unwrap();
1388 let (mut engine, source) = engine_with_seed(&tmp, "S");
1389 let (actor, client) = cli_actor();
1390 let source_mem = source.id.mem().to_string();
1391 let err = engine
1392 .relate_entity(
1393 RelateEntityArgs {
1394 source: source.id.clone(),
1395 expected_hash: Some(source.content_hash.clone()),
1396 rel_type: "USES".to_string(),
1397 target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
1398 remove: false,
1399 description: None,
1400 },
1401 actor,
1402 Some(&client),
1403 None,
1404 )
1405 .unwrap_err();
1406 assert!(
1407 matches!(err, EngineError::InvalidEntityId { .. }),
1408 "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
1409 );
1410 }
1411
1412 #[test]
1418 fn relate_add_bumps_last_modified_on_source_entity() {
1419 let tmp = TempDir::new().unwrap();
1420 let (mut engine, source) = engine_with_seed(&tmp, "S");
1421 let (actor, client) = cli_actor();
1422 let target = engine
1423 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1424 .unwrap();
1425
1426 let outcome = engine
1427 .relate_entity(
1428 RelateEntityArgs {
1429 source: source.id.clone(),
1430 expected_hash: Some(source.content_hash.clone()),
1431 rel_type: "USES".to_string(),
1432 target: target.id.clone(),
1433 remove: false,
1434 description: None,
1435 },
1436 actor,
1437 Some(&client),
1438 None,
1439 )
1440 .unwrap();
1441 assert_eq!(outcome.action, RelateAction::Added);
1442
1443 let post = engine.get_entity(&source.id).unwrap();
1448 let last_modified = post
1449 .metadata
1450 .get("last_modified")
1451 .map(|v| v.to_frontmatter_string())
1452 .unwrap_or_default();
1453 assert!(
1454 last_modified.starts_with("20"),
1455 "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
1456 );
1457 }
1458
1459 #[test]
1464 fn relate_add_noop_does_not_bump_last_modified() {
1465 let tmp = TempDir::new().unwrap();
1466 let (mut engine, source) = engine_with_seed(&tmp, "S");
1467 let (actor, client) = cli_actor();
1468 let target = engine
1469 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1470 .unwrap();
1471 let first = engine
1472 .relate_entity(
1473 RelateEntityArgs {
1474 source: source.id.clone(),
1475 expected_hash: Some(source.content_hash.clone()),
1476 rel_type: "USES".to_string(),
1477 target: target.id.clone(),
1478 remove: false,
1479 description: None,
1480 },
1481 actor,
1482 Some(&client),
1483 None,
1484 )
1485 .unwrap();
1486 let pre = engine.get_entity(&source.id).unwrap();
1487 let pre_stamp = pre
1488 .metadata
1489 .get("last_modified")
1490 .map(|v| v.to_frontmatter_string())
1491 .unwrap_or_default();
1492
1493 let dup = engine
1495 .relate_entity(
1496 RelateEntityArgs {
1497 source: source.id.clone(),
1498 expected_hash: Some(first.content_hash.clone()),
1499 rel_type: "USES".to_string(),
1500 target: target.id.clone(),
1501 remove: false,
1502 description: None,
1503 },
1504 actor,
1505 Some(&client),
1506 None,
1507 )
1508 .unwrap();
1509 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1510
1511 let post = engine.get_entity(&source.id).unwrap();
1512 let post_stamp = post
1513 .metadata
1514 .get("last_modified")
1515 .map(|v| v.to_frontmatter_string())
1516 .unwrap_or_default();
1517 assert_eq!(
1518 pre_stamp, post_stamp,
1519 "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
1520 );
1521 }
1522
1523 #[test]
1528 fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
1529 use memstead_schema::workspace_config::CrossLinkValue;
1530 let tmp = TempDir::new().unwrap();
1531 let (mut engine, source) = engine_with_seed(&tmp, "S");
1532 let (actor, client) = cli_actor();
1533 let mut settings = crate::workspace::WorkspaceSettings::default();
1537 settings.cross_mem_links.insert(
1538 "specs".to_string(),
1539 CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
1540 );
1541 engine.set_settings(settings);
1542
1543 let absent = crate::EntityId::new("uncreated-mem", "ghost");
1544 let outcome = engine
1545 .relate_entity(
1546 RelateEntityArgs {
1547 source: source.id.clone(),
1548 expected_hash: Some(source.content_hash.clone()),
1549 rel_type: "USES".to_string(),
1550 target: absent.clone(),
1551 remove: false,
1552 description: None,
1553 },
1554 actor,
1555 Some(&client),
1556 None,
1557 )
1558 .unwrap();
1559 assert_eq!(outcome.action, RelateAction::Added);
1560
1561 let saw_uncreated = outcome.warnings.iter().any(|w| {
1563 matches!(
1564 w,
1565 WarningHint::CrossMemTargetMemUncreated {
1566 from_mem,
1567 to_mem,
1568 target_id,
1569 } if from_mem == "specs"
1570 && to_mem == "uncreated-mem"
1571 && target_id == &absent
1572 )
1573 });
1574 assert!(
1575 saw_uncreated,
1576 "CrossMemTargetMemUncreated warning must surface; got: {:?}",
1577 outcome.warnings
1578 );
1579 assert!(engine.store().contains(&absent));
1581 }
1582
1583 #[test]
1589 fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
1590 let tmp = TempDir::new().unwrap();
1591 let (mut engine, source) = engine_with_seed(&tmp, "S");
1592 let (actor, client) = cli_actor();
1593 let absent = crate::EntityId::new("uncreated-mem", "ghost");
1595 let err = engine
1596 .relate_entity(
1597 RelateEntityArgs {
1598 source: source.id.clone(),
1599 expected_hash: Some(source.content_hash.clone()),
1600 rel_type: "USES".to_string(),
1601 target: absent.clone(),
1602 remove: false,
1603 description: None,
1604 },
1605 actor,
1606 Some(&client),
1607 None,
1608 )
1609 .unwrap_err();
1610 assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
1611 assert!(!engine.store().contains(&absent));
1613 }
1614
1615 #[test]
1621 fn relate_remove_garbage_collects_orphan_stub() {
1622 let tmp = TempDir::new().unwrap();
1623 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1624 let (actor, client) = cli_actor();
1625 let stub_id = crate::EntityId::new("specs", "ghost-target");
1626
1627 let added = engine
1629 .relate_entity(
1630 RelateEntityArgs {
1631 source: source.id.clone(),
1632 expected_hash: Some(source.content_hash.clone()),
1633 rel_type: "USES".to_string(),
1634 target: stub_id.clone(),
1635 remove: false,
1636 description: None,
1637 },
1638 actor,
1639 Some(&client),
1640 None,
1641 )
1642 .unwrap();
1643 assert!(engine.store().contains(&stub_id));
1644
1645 let removed = engine
1646 .relate_entity(
1647 RelateEntityArgs {
1648 source: source.id.clone(),
1649 expected_hash: Some(added.content_hash.clone()),
1650 rel_type: "USES".to_string(),
1651 target: stub_id.clone(),
1652 remove: true,
1653 description: None,
1654 },
1655 actor,
1656 Some(&client),
1657 None,
1658 )
1659 .unwrap();
1660 assert_eq!(removed.action, RelateAction::Removed);
1661 assert_eq!(
1662 removed.orphan_stubs_removed,
1663 vec![stub_id.clone()],
1664 "orphan stub must be GC'd in the same call"
1665 );
1666 assert!(
1667 !engine.store().contains(&stub_id),
1668 "stub must be gone from the store after GC"
1669 );
1670 }
1671
1672 #[test]
1678 fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
1679 let tmp = TempDir::new().unwrap();
1680 let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
1681 let (actor, client) = cli_actor();
1682 let source_b = engine
1683 .create_entity(
1684 empty_create_args("specs", "SrcB"),
1685 actor,
1686 Some(&client),
1687 None,
1688 )
1689 .unwrap();
1690 let stub_id = crate::EntityId::new("specs", "ghost-target");
1691
1692 let a_added = engine
1694 .relate_entity(
1695 RelateEntityArgs {
1696 source: source_a.id.clone(),
1697 expected_hash: Some(source_a.content_hash.clone()),
1698 rel_type: "USES".to_string(),
1699 target: stub_id.clone(),
1700 remove: false,
1701 description: None,
1702 },
1703 actor,
1704 Some(&client),
1705 None,
1706 )
1707 .unwrap();
1708 let _b_added = engine
1709 .relate_entity(
1710 RelateEntityArgs {
1711 source: source_b.id.clone(),
1712 expected_hash: Some(source_b.content_hash.clone()),
1713 rel_type: "USES".to_string(),
1714 target: stub_id.clone(),
1715 remove: false,
1716 description: None,
1717 },
1718 actor,
1719 Some(&client),
1720 None,
1721 )
1722 .unwrap();
1723
1724 let removed = engine
1727 .relate_entity(
1728 RelateEntityArgs {
1729 source: source_a.id.clone(),
1730 expected_hash: Some(a_added.content_hash.clone()),
1731 rel_type: "USES".to_string(),
1732 target: stub_id.clone(),
1733 remove: true,
1734 description: None,
1735 },
1736 actor,
1737 Some(&client),
1738 None,
1739 )
1740 .unwrap();
1741 assert_eq!(removed.action, RelateAction::Removed);
1742 assert!(
1743 removed.orphan_stubs_removed.is_empty(),
1744 "stub with surviving referrer must not be GC'd; got: {:?}",
1745 removed.orphan_stubs_removed
1746 );
1747 assert!(
1748 engine.store().contains(&stub_id),
1749 "stub must remain in store while another referrer holds it"
1750 );
1751 }
1752
1753 mod cross_mem {
1763 use std::collections::BTreeMap;
1764 use std::path::Path;
1765
1766 use indexmap::IndexMap;
1767 use memstead_schema::SchemaRef;
1768 use memstead_schema::workspace_config::CrossLinkValue;
1769 use tempfile::TempDir;
1770
1771 use crate::backend::MemBackend;
1772 use crate::engine::test_helpers::*;
1773 use crate::engine::{
1774 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
1775 RelateEntityArgs,
1776 };
1777 use crate::storage::FilesystemMemWriter;
1778
1779 use crate::workspace::{
1780 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
1781 };
1782
1783 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
1784 let dir = root.join(name);
1785 std::fs::create_dir_all(dir.join("types")).unwrap();
1786 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
1787 for (type_name, body) in types {
1788 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
1789 }
1790 }
1791
1792 const TYPE_BODY: &str = r#"description: t
1793when_to_use: Here
1794sections:
1795 - key: body
1796 heading: Body
1797 required: true
1798 search_weight: 10.0
1799 catch_all: true
1800 write_rules: []
1801metadata_fields: []
1802title_weight: 100.0
1803text_fields:
1804 - body
1805hierarchy_relationship: _default
1806propagating_relationships: []
1807updatable_fields:
1808 - title
1809 - body
1810health_required_fields:
1811 - body
1812staleness_threshold_days: 90
1813write_rules: []
1814"#;
1815
1816 fn make_type_yaml(name: &str) -> String {
1817 format!("name: {name}\n{TYPE_BODY}")
1818 }
1819
1820 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
1821 Mount {
1822 mem: mem.to_string(),
1823 schema: Some(pin),
1824 storage: MountStorage::Folder { path },
1825 capability: MountCapability::Write,
1826 lifecycle: MountLifecycle::Eager,
1827 cross_linkable: true,
1828 migration_target: None,
1829 }
1830 }
1831
1832 fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
1835 let tmp = TempDir::new().unwrap();
1836
1837 let src_manifest = r#"name: src-cv
1840version: 0.1.0
1841description: source schema
1842when_to_use: tests
1843types:
1844 - doc
1845relationships:
1846 mode: strict
1847 definitions:
1848 - name: IMPLEMENTS
1849 description: intra-mem only
1850 default_weight: 1.0
1851 - name: _default
1852 description: fallback
1853 default_weight: 1.0
1854cross_mem_relationships:
1855 - to_schema: tgt-cv
1856 definitions:
1857 - name: ADDRESSES
1858 description: outbound shape-pinned
1859 default_weight: 1.0
1860 source_types: [doc]
1861 target_types: [req]
1862community:
1863 resolution: 1.0
1864 seed: 42
1865"#;
1866 let tgt_manifest = r#"name: tgt-cv
1869version: 0.1.0
1870description: target schema
1871when_to_use: tests
1872types:
1873 - req
1874relationships:
1875 mode: strict
1876 definitions:
1877 - name: PART_OF
1878 description: hierarchy
1879 default_weight: 3.0
1880 acyclic: true
1881 - name: _default
1882 description: fallback
1883 default_weight: 1.0
1884community:
1885 resolution: 1.0
1886 seed: 42
1887"#;
1888 let schemas_dir = tmp.path().join("schemas");
1889 std::fs::create_dir_all(&schemas_dir).unwrap();
1890 write_schema_files(
1891 &schemas_dir,
1892 "src-cv",
1893 src_manifest,
1894 &[("doc", &make_type_yaml("doc"))],
1895 );
1896 write_schema_files(
1897 &schemas_dir,
1898 "tgt-cv",
1899 tgt_manifest,
1900 &[("req", &make_type_yaml("req"))],
1901 );
1902
1903 let src_dir = tmp.path().join("mem-src");
1904 let tgt_dir = tmp.path().join("mem-tgt");
1905 std::fs::create_dir_all(&src_dir).unwrap();
1906 std::fs::create_dir_all(&tgt_dir).unwrap();
1907
1908 let src_writer = FilesystemMemWriter::new(src_dir.clone());
1909 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
1910 let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
1911 let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
1912
1913 let mut engine = Engine::from_mounts_with_schemas_dir(
1914 vec![
1915 (
1916 folder_mount_with_pin("src", src_dir, src_pin),
1917 Box::new(src_writer) as Box<dyn MemBackend>,
1918 ),
1919 (
1920 folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
1921 Box::new(tgt_writer) as Box<dyn MemBackend>,
1922 ),
1923 ],
1924 Some(&schemas_dir),
1925 )
1926 .expect("two-mem engine constructs");
1927
1928 let mut settings = WorkspaceSettings::default();
1932 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
1933 links.insert("src".to_string(), CrossLinkValue::Wildcard);
1934 settings.cross_mem_links = links;
1935 engine.set_settings(settings);
1936
1937 let (actor, client) = cli_actor();
1938 let src_entity = engine
1939 .create_entity(
1940 CreateEntityArgs {
1941 anchors: Vec::new(),
1942 mem: "src".to_string(),
1943 title: "Doc One".to_string(),
1944 entity_type: "doc".to_string(),
1945 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
1946 metadata: IndexMap::new(),
1947 relations: Vec::new(),
1948 dry_run: false,
1949 },
1950 actor,
1951 Some(&client),
1952 None,
1953 )
1954 .expect("source entity creates");
1955 let tgt_entity = engine
1956 .create_entity(
1957 CreateEntityArgs {
1958 anchors: Vec::new(),
1959 mem: "tgt".to_string(),
1960 title: "Req One".to_string(),
1961 entity_type: "req".to_string(),
1962 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
1963 metadata: IndexMap::new(),
1964 relations: Vec::new(),
1965 dry_run: false,
1966 },
1967 actor,
1968 Some(&client),
1969 None,
1970 )
1971 .expect("target entity creates");
1972
1973 (tmp, engine, src_entity, tgt_entity)
1974 }
1975
1976 #[test]
1977 fn cross_different_schema_admits_declared_edge() {
1978 let (_tmp, mut engine, src, tgt) = two_mem_engine();
1979 let (actor, client) = cli_actor();
1980 let outcome = engine
1981 .relate_entity(
1982 RelateEntityArgs {
1983 source: src.id.clone(),
1984 expected_hash: Some(src.content_hash.clone()),
1985 rel_type: "ADDRESSES".to_string(),
1986 target: tgt.id.clone(),
1987 remove: false,
1988 description: None,
1989 },
1990 actor,
1991 Some(&client),
1992 None,
1993 )
1994 .expect("declared cross-mem edge admits");
1995 assert_eq!(outcome.rel_type, "ADDRESSES");
1996 }
1997
1998 #[test]
2004 fn same_name_different_version_uses_intra_mem_vocabulary() {
2005 let tmp = TempDir::new().unwrap();
2006
2007 let manifest_for = |version: &str| {
2008 format!(
2009 r#"name: same-dom
2010version: {version}
2011description: same-domain schema
2012when_to_use: tests
2013types:
2014 - doc
2015relationships:
2016 mode: strict
2017 definitions:
2018 - name: IMPLEMENTS
2019 description: intra-mem vocabulary
2020 default_weight: 1.0
2021 - name: _default
2022 description: fallback
2023 default_weight: 1.0
2024community:
2025 resolution: 1.0
2026 seed: 42
2027"#
2028 )
2029 };
2030 let schemas_dir = tmp.path().join("schemas");
2031 std::fs::create_dir_all(&schemas_dir).unwrap();
2032 write_schema_files(
2035 &schemas_dir,
2036 "same-dom-0.1.0",
2037 &manifest_for("0.1.0"),
2038 &[("doc", &make_type_yaml("doc"))],
2039 );
2040 write_schema_files(
2041 &schemas_dir,
2042 "same-dom-0.2.0",
2043 &manifest_for("0.2.0"),
2044 &[("doc", &make_type_yaml("doc"))],
2045 );
2046
2047 let src_dir = tmp.path().join("mem-src");
2048 let tgt_dir = tmp.path().join("mem-tgt");
2049 std::fs::create_dir_all(&src_dir).unwrap();
2050 std::fs::create_dir_all(&tgt_dir).unwrap();
2051 let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2052 let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2053 let mut engine = Engine::from_mounts_with_schemas_dir(
2054 vec![
2055 (
2056 folder_mount_with_pin("src", src_dir.clone(), src_pin),
2057 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2058 ),
2059 (
2060 folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2061 Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2062 ),
2063 ],
2064 Some(&schemas_dir),
2065 )
2066 .expect("same-domain two-version engine constructs");
2067
2068 let mut settings = WorkspaceSettings::default();
2069 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2070 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2071 settings.cross_mem_links = links;
2072 engine.set_settings(settings);
2073
2074 let (actor, client) = cli_actor();
2075 let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2076 engine
2077 .create_entity(
2078 CreateEntityArgs {
2079 anchors: Vec::new(),
2080 mem: mem.to_string(),
2081 title: title.to_string(),
2082 entity_type: "doc".to_string(),
2083 sections: IndexMap::from_iter([(
2084 "body".to_string(),
2085 "seed".to_string(),
2086 )]),
2087 metadata: IndexMap::new(),
2088 relations: Vec::new(),
2089 dry_run: false,
2090 },
2091 actor,
2092 Some(&client),
2093 None,
2094 )
2095 .expect("entity creates")
2096 };
2097 let src_entity = mk_entity(&mut engine, "src", "Doc A");
2098 let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
2099
2100 let outcome = engine
2101 .relate_entity(
2102 RelateEntityArgs {
2103 source: src_entity.id.clone(),
2104 expected_hash: Some(src_entity.content_hash.clone()),
2105 rel_type: "IMPLEMENTS".to_string(),
2106 target: tgt_entity.id.clone(),
2107 remove: false,
2108 description: None,
2109 },
2110 actor,
2111 Some(&client),
2112 None,
2113 )
2114 .expect("same-domain edge uses the intra-schema vocabulary across versions");
2115 assert_eq!(outcome.rel_type, "IMPLEMENTS");
2116 }
2117
2118 #[test]
2119 fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
2120 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2124 let (actor, client) = cli_actor();
2125 let err = engine
2126 .relate_entity(
2127 RelateEntityArgs {
2128 source: src.id.clone(),
2129 expected_hash: Some(src.content_hash.clone()),
2130 rel_type: "IMPLEMENTS".to_string(),
2131 target: tgt.id.clone(),
2132 remove: false,
2133 description: None,
2134 },
2135 actor,
2136 Some(&client),
2137 None,
2138 )
2139 .unwrap_err();
2140 match err {
2141 EngineError::Validation(
2142 crate::runtime_validator::ValidationError::InvalidRelationshipType {
2143 input,
2144 allowed,
2145 ..
2146 },
2147 ) => {
2148 assert_eq!(input, "IMPLEMENTS");
2149 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
2150 assert!(names.iter().any(|n| n == "ADDRESSES"));
2151 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
2152 }
2153 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2154 }
2155 }
2156
2157 #[test]
2158 fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
2159 }
2176
2177 #[test]
2178 fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
2179 let tmp = TempDir::new().unwrap();
2182 let src_manifest = r#"name: src-cv
2183version: 0.1.0
2184description: source schema
2185when_to_use: tests
2186types:
2187 - doc
2188relationships:
2189 mode: strict
2190 definitions:
2191 - name: IMPLEMENTS
2192 description: intra-mem
2193 default_weight: 1.0
2194 - name: _default
2195 description: fallback
2196 default_weight: 1.0
2197cross_mem_relationships:
2198 - to_schema: tgt-cv
2199 definitions:
2200 - name: ADDRESSES
2201 description: outbound
2202 default_weight: 1.0
2203 source_types: [doc]
2204 target_types: [req]
2205community:
2206 resolution: 1.0
2207 seed: 42
2208"#;
2209 let other_manifest = r#"name: other-cv
2211version: 0.1.0
2212description: foreign schema
2213when_to_use: tests
2214types:
2215 - thing
2216relationships:
2217 mode: strict
2218 definitions:
2219 - name: _default
2220 description: fallback
2221 default_weight: 1.0
2222community:
2223 resolution: 1.0
2224 seed: 42
2225"#;
2226 let schemas_dir = tmp.path().join("schemas");
2227 std::fs::create_dir_all(&schemas_dir).unwrap();
2228 write_schema_files(
2229 &schemas_dir,
2230 "src-cv",
2231 src_manifest,
2232 &[("doc", &make_type_yaml("doc"))],
2233 );
2234 write_schema_files(
2235 &schemas_dir,
2236 "other-cv",
2237 other_manifest,
2238 &[("thing", &make_type_yaml("thing"))],
2239 );
2240 let src_dir = tmp.path().join("mem-src");
2241 let other_dir = tmp.path().join("mem-other");
2242 std::fs::create_dir_all(&src_dir).unwrap();
2243 std::fs::create_dir_all(&other_dir).unwrap();
2244
2245 let mut engine = Engine::from_mounts_with_schemas_dir(
2246 vec![
2247 (
2248 folder_mount_with_pin(
2249 "src",
2250 src_dir.clone(),
2251 SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
2252 ),
2253 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2254 ),
2255 (
2256 folder_mount_with_pin(
2257 "other",
2258 other_dir.clone(),
2259 SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
2260 ),
2261 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2262 ),
2263 ],
2264 Some(&schemas_dir),
2265 )
2266 .expect("engine constructs");
2267
2268 let mut settings = WorkspaceSettings::default();
2269 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2270 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2271 settings.cross_mem_links = links;
2272 engine.set_settings(settings);
2273
2274 let (actor, client) = cli_actor();
2275 let src_entity = engine
2276 .create_entity(
2277 CreateEntityArgs {
2278 anchors: Vec::new(),
2279 mem: "src".to_string(),
2280 title: "D".to_string(),
2281 entity_type: "doc".to_string(),
2282 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2283 metadata: IndexMap::new(),
2284 relations: Vec::new(),
2285 dry_run: false,
2286 },
2287 actor,
2288 Some(&client),
2289 None,
2290 )
2291 .unwrap();
2292 let other_entity = engine
2293 .create_entity(
2294 CreateEntityArgs {
2295 anchors: Vec::new(),
2296 mem: "other".to_string(),
2297 title: "T".to_string(),
2298 entity_type: "thing".to_string(),
2299 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2300 metadata: IndexMap::new(),
2301 relations: Vec::new(),
2302 dry_run: false,
2303 },
2304 actor,
2305 Some(&client),
2306 None,
2307 )
2308 .unwrap();
2309
2310 let err = engine
2311 .relate_entity(
2312 RelateEntityArgs {
2313 source: src_entity.id.clone(),
2314 expected_hash: Some(src_entity.content_hash.clone()),
2315 rel_type: "ADDRESSES".to_string(),
2316 target: other_entity.id.clone(),
2317 remove: false,
2318 description: None,
2319 },
2320 actor,
2321 Some(&client),
2322 None,
2323 )
2324 .unwrap_err();
2325 match err {
2326 EngineError::CrossMemEdgeNotDeclared {
2327 source_schema,
2328 target_schema,
2329 rel_type,
2330 from_id,
2331 to_id,
2332 } => {
2333 assert_eq!(source_schema, "src-cv@0.1.0");
2334 assert_eq!(target_schema, "other-cv@0.1.0");
2335 assert_eq!(rel_type, "ADDRESSES");
2336 assert_eq!(from_id, src_entity.id.to_string());
2337 assert_eq!(to_id, other_entity.id.to_string());
2338 }
2339 other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
2340 }
2341 }
2342
2343 #[test]
2344 fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
2345 let (_tmp, mut engine, src, _tgt) = two_mem_engine();
2350 let (actor, client) = cli_actor();
2351 let intra_target = engine
2353 .create_entity(
2354 CreateEntityArgs {
2355 anchors: Vec::new(),
2356 mem: "src".to_string(),
2357 title: "Doc Two".to_string(),
2358 entity_type: "doc".to_string(),
2359 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2360 metadata: IndexMap::new(),
2361 relations: Vec::new(),
2362 dry_run: false,
2363 },
2364 actor,
2365 Some(&client),
2366 None,
2367 )
2368 .unwrap();
2369 let src_fresh = engine.get_entity(&src.id).unwrap();
2372 let err = engine
2373 .relate_entity(
2374 RelateEntityArgs {
2375 source: src.id.clone(),
2376 expected_hash: Some(src_fresh.content_hash.clone()),
2377 rel_type: "ADDRESSES".to_string(),
2378 target: intra_target.id.clone(),
2379 remove: false,
2380 description: None,
2381 },
2382 actor,
2383 Some(&client),
2384 None,
2385 )
2386 .unwrap_err();
2387 match err {
2388 EngineError::Validation(
2389 crate::runtime_validator::ValidationError::InvalidRelationshipType {
2390 input,
2391 ..
2392 },
2393 ) => {
2394 assert_eq!(input, "ADDRESSES");
2395 }
2396 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2397 }
2398 }
2399
2400 #[test]
2401 fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
2402 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2406 engine.set_settings(WorkspaceSettings::default());
2408 let (actor, client) = cli_actor();
2409 let err = engine
2410 .relate_entity(
2411 RelateEntityArgs {
2412 source: src.id.clone(),
2413 expected_hash: Some(src.content_hash.clone()),
2414 rel_type: "ADDRESSES".to_string(),
2415 target: tgt.id.clone(),
2416 remove: false,
2417 description: None,
2418 },
2419 actor,
2420 Some(&client),
2421 None,
2422 )
2423 .unwrap_err();
2424 assert!(
2425 matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
2426 "expected CrossMemLinkNotAllowed, got {err:?}"
2427 );
2428 }
2429
2430 #[test]
2437 fn cross_mem_remove_bypasses_policy_after_revoke() {
2438 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2439 let (actor, client) = cli_actor();
2440
2441 let added = engine
2443 .relate_entity(
2444 RelateEntityArgs {
2445 source: src.id.clone(),
2446 expected_hash: Some(src.content_hash.clone()),
2447 rel_type: "ADDRESSES".to_string(),
2448 target: tgt.id.clone(),
2449 remove: false,
2450 description: None,
2451 },
2452 actor,
2453 Some(&client),
2454 None,
2455 )
2456 .expect("declared cross-mem edge admits under grant");
2457 assert_eq!(added.action, RelateAction::Added);
2458
2459 engine.set_settings(WorkspaceSettings::default());
2461
2462 let add_err = engine
2465 .relate_entity(
2466 RelateEntityArgs {
2467 source: src.id.clone(),
2468 expected_hash: Some(added.content_hash.clone()),
2469 rel_type: "ADDRESSES".to_string(),
2470 target: tgt.id.clone(),
2471 remove: false,
2472 description: None,
2473 },
2474 actor,
2475 Some(&client),
2476 None,
2477 )
2478 .unwrap_err();
2479 assert!(
2480 matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
2481 "add path must still refuse under denial, got {add_err:?}"
2482 );
2483
2484 let removed = engine
2487 .relate_entity(
2488 RelateEntityArgs {
2489 source: src.id.clone(),
2490 expected_hash: Some(added.content_hash.clone()),
2491 rel_type: "ADDRESSES".to_string(),
2492 target: tgt.id.clone(),
2493 remove: true,
2494 description: None,
2495 },
2496 actor,
2497 Some(&client),
2498 None,
2499 )
2500 .expect("remove must bypass the policy gate post-revoke");
2501 assert_eq!(removed.action, RelateAction::Removed);
2502
2503 let outgoing = engine.store().outgoing(&src.id);
2505 assert!(
2506 !outgoing
2507 .iter()
2508 .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
2509 "ADDRESSES edge must be gone after remove"
2510 );
2511 }
2512
2513 #[test]
2517 fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
2518 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2519 engine.set_settings(WorkspaceSettings::default());
2521 let (actor, client) = cli_actor();
2522 let outcome = engine
2523 .relate_entity(
2524 RelateEntityArgs {
2525 source: src.id.clone(),
2526 expected_hash: Some(src.content_hash.clone()),
2527 rel_type: "ADDRESSES".to_string(),
2528 target: tgt.id.clone(),
2529 remove: true,
2530 description: None,
2531 },
2532 actor,
2533 Some(&client),
2534 None,
2535 )
2536 .expect("absent-edge remove must not refuse on policy");
2537 assert!(
2538 matches!(outcome.action, RelateAction::NoOpAbsent),
2539 "expected NoOpAbsent, got {:?}",
2540 outcome.action
2541 );
2542 }
2543
2544 fn engine_with_tgt_capability(
2553 capability: MountCapability,
2554 ) -> (TempDir, Engine, CreateEntityOutcome) {
2555 let tmp = TempDir::new().unwrap();
2556
2557 let src_manifest = r#"name: src-al
2558version: 0.1.0
2559description: source schema with alias pointer
2560when_to_use: tests
2561types:
2562 - doc
2563relationships:
2564 mode: strict
2565 definitions:
2566 - name: ADDRESSES
2567 description: explicit cross-mem
2568 default_weight: 1.0
2569 - name: REFERENCES
2570 description: alias pointer
2571 default_weight: 1.0
2572 - name: _default
2573 description: fallback
2574 default_weight: 1.0
2575cross_mem_relationships:
2576 - to_schema: tgt-al
2577 definitions:
2578 - name: ADDRESSES
2579 description: explicit cross-mem
2580 default_weight: 1.0
2581 - name: REFERENCES
2582 description: alias-emitted cross-mem
2583 default_weight: 1.0
2584alias_target_rel_type: REFERENCES
2585community:
2586 resolution: 1.0
2587 seed: 42
2588"#;
2589 let tgt_manifest = r#"name: tgt-al
2590version: 0.1.0
2591description: target schema
2592when_to_use: tests
2593types:
2594 - req
2595relationships:
2596 mode: strict
2597 definitions:
2598 - name: _default
2599 description: fallback
2600 default_weight: 1.0
2601community:
2602 resolution: 1.0
2603 seed: 42
2604"#;
2605 let schemas_dir = tmp.path().join("schemas");
2606 std::fs::create_dir_all(&schemas_dir).unwrap();
2607 write_schema_files(
2608 &schemas_dir,
2609 "src-al",
2610 src_manifest,
2611 &[("doc", &make_type_yaml("doc"))],
2612 );
2613 write_schema_files(
2614 &schemas_dir,
2615 "tgt-al",
2616 tgt_manifest,
2617 &[("req", &make_type_yaml("req"))],
2618 );
2619
2620 let src_dir = tmp.path().join("mem-src");
2621 let tgt_dir = tmp.path().join("mem-tgt");
2622 std::fs::create_dir_all(&src_dir).unwrap();
2623 std::fs::create_dir_all(&tgt_dir).unwrap();
2624 std::fs::write(
2627 tgt_dir.join("req-one.md"),
2628 "---\ntype: req\n---\n# Req One\n\n## Body\n\nseed.\n",
2629 )
2630 .unwrap();
2631
2632 let src_writer = FilesystemMemWriter::new(src_dir.clone());
2633 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
2634 let src_pin = SchemaRef::new("src-al", semver::Version::new(0, 1, 0));
2635 let tgt_pin = SchemaRef::new("tgt-al", semver::Version::new(0, 1, 0));
2636
2637 let tgt_mount = Mount {
2638 mem: "tgt".to_string(),
2639 schema: Some(tgt_pin),
2640 storage: MountStorage::Folder {
2641 path: tgt_dir.clone(),
2642 },
2643 capability,
2644 lifecycle: MountLifecycle::Eager,
2645 cross_linkable: true,
2646 migration_target: None,
2647 };
2648 let mut engine = Engine::from_mounts_with_schemas_dir(
2649 vec![
2650 (
2651 folder_mount_with_pin("src", src_dir, src_pin),
2652 Box::new(src_writer) as Box<dyn MemBackend>,
2653 ),
2654 (tgt_mount, Box::new(tgt_writer) as Box<dyn MemBackend>),
2655 ],
2656 Some(&schemas_dir),
2657 )
2658 .expect("two-mem engine constructs");
2659
2660 let mut settings = WorkspaceSettings::default();
2661 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2662 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2663 settings.cross_mem_links = links;
2664 engine.set_settings(settings);
2665
2666 let (actor, client) = cli_actor();
2667 let src_entity = engine
2668 .create_entity(
2669 CreateEntityArgs {
2670 anchors: Vec::new(),
2671 mem: "src".to_string(),
2672 title: "Doc One".to_string(),
2673 entity_type: "doc".to_string(),
2674 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2675 metadata: IndexMap::new(),
2676 relations: Vec::new(),
2677 dry_run: false,
2678 },
2679 actor,
2680 Some(&client),
2681 None,
2682 )
2683 .expect("source entity creates");
2684
2685 (tmp, engine, src_entity)
2686 }
2687
2688 fn assert_cross_mem_target_not_found(err: EngineError, expected_target: &str) {
2689 match err {
2690 EngineError::CrossMemTargetNotFound {
2691 target_id,
2692 target_mem,
2693 } => {
2694 assert_eq!(target_id, expected_target);
2695 assert_eq!(target_mem, "tgt");
2696 }
2697 other => panic!("expected CrossMemTargetNotFound, got {other:?}"),
2698 }
2699 }
2700
2701 #[test]
2702 fn relate_to_missing_target_in_readonly_mem_refuses() {
2703 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
2704 let (actor, client) = cli_actor();
2705 let err = engine
2706 .relate_entity(
2707 RelateEntityArgs {
2708 source: src.id.clone(),
2709 expected_hash: Some(src.content_hash.clone()),
2710 rel_type: "ADDRESSES".to_string(),
2711 target: crate::EntityId::new("tgt", "missing"),
2712 remove: false,
2713 description: None,
2714 },
2715 actor,
2716 Some(&client),
2717 None,
2718 )
2719 .unwrap_err();
2720 assert_cross_mem_target_not_found(err, "tgt--missing");
2721 }
2722
2723 #[test]
2728 fn create_inline_relation_to_missing_target_in_readonly_mem_refuses() {
2729 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
2730 let (actor, client) = cli_actor();
2731 let err = engine
2732 .create_entity(
2733 CreateEntityArgs {
2734 anchors: Vec::new(),
2735 mem: "src".to_string(),
2736 title: "Doc Two".to_string(),
2737 entity_type: "doc".to_string(),
2738 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2739 metadata: IndexMap::new(),
2740 relations: vec![crate::ops::RelateArg {
2741 to: crate::EntityId::new("tgt", "missing"),
2742 rel_type: "ADDRESSES".to_string(),
2743 description: None,
2744 }],
2745 dry_run: false,
2746 },
2747 actor,
2748 Some(&client),
2749 None,
2750 )
2751 .unwrap_err();
2752 assert_cross_mem_target_not_found(err, "tgt--missing");
2753 }
2754
2755 #[test]
2759 fn create_body_link_to_missing_target_in_readonly_mem_refuses() {
2760 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
2761 let (actor, client) = cli_actor();
2762 let err = engine
2763 .create_entity(
2764 CreateEntityArgs {
2765 anchors: Vec::new(),
2766 mem: "src".to_string(),
2767 title: "Doc Three".to_string(),
2768 entity_type: "doc".to_string(),
2769 sections: IndexMap::from_iter([(
2770 "body".to_string(),
2771 "see [[tgt--missing]].".to_string(),
2772 )]),
2773 metadata: IndexMap::new(),
2774 relations: Vec::new(),
2775 dry_run: false,
2776 },
2777 actor,
2778 Some(&client),
2779 None,
2780 )
2781 .unwrap_err();
2782 assert_cross_mem_target_not_found(err, "tgt--missing");
2783 }
2784
2785 #[test]
2786 fn update_body_link_to_missing_target_in_readonly_mem_refuses() {
2787 let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
2788 let (actor, client) = cli_actor();
2789 let err = engine
2790 .update_entity(
2791 crate::engine::UpdateEntityArgs {
2792 anchors: Vec::new(),
2793 id: src.id.clone(),
2794 expected_hash: Some(src.content_hash.clone()),
2795 sections: IndexMap::from_iter([(
2796 "body".to_string(),
2797 "now see [[tgt--missing]].".to_string(),
2798 )]),
2799 append_sections: IndexMap::new(),
2800 patch_sections: IndexMap::new(),
2801 metadata: IndexMap::new(),
2802 metadata_unset: Vec::new(),
2803 declare_relations: Vec::new(),
2804 dry_run: false,
2805 relations_unset: Vec::new(),
2806 },
2807 actor,
2808 Some(&client),
2809 None,
2810 )
2811 .unwrap_err();
2812 assert_cross_mem_target_not_found(err, "tgt--missing");
2813 }
2814
2815 #[test]
2819 fn body_link_to_existing_target_in_readonly_mem_admits_and_emits_edge() {
2820 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
2821 let (actor, client) = cli_actor();
2822 let created = engine
2823 .create_entity(
2824 CreateEntityArgs {
2825 anchors: Vec::new(),
2826 mem: "src".to_string(),
2827 title: "Doc Four".to_string(),
2828 entity_type: "doc".to_string(),
2829 sections: IndexMap::from_iter([(
2830 "body".to_string(),
2831 "see [[tgt--req-one]].".to_string(),
2832 )]),
2833 metadata: IndexMap::new(),
2834 relations: Vec::new(),
2835 dry_run: false,
2836 },
2837 actor,
2838 Some(&client),
2839 None,
2840 )
2841 .expect("body link to existing read-only target admits");
2842 let outgoing = engine.store().outgoing(&created.id);
2843 assert!(
2844 outgoing.iter().any(|e| e.rel_type == "REFERENCES"
2845 && e.target == crate::EntityId::new("tgt", "req-one")),
2846 "alias REFERENCES edge to the read-only target must materialise; got {outgoing:?}"
2847 );
2848 }
2849
2850 #[test]
2854 fn body_link_to_missing_target_in_write_mem_still_stubs() {
2855 let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::Write);
2856 let (actor, client) = cli_actor();
2857 let created = engine
2858 .create_entity(
2859 CreateEntityArgs {
2860 anchors: Vec::new(),
2861 mem: "src".to_string(),
2862 title: "Doc Five".to_string(),
2863 entity_type: "doc".to_string(),
2864 sections: IndexMap::from_iter([(
2865 "body".to_string(),
2866 "see [[tgt--missing]].".to_string(),
2867 )]),
2868 metadata: IndexMap::new(),
2869 relations: Vec::new(),
2870 dry_run: false,
2871 },
2872 actor,
2873 Some(&client),
2874 None,
2875 )
2876 .expect("forward reference into a Write sibling mem keeps stubbing");
2877 assert!(
2878 engine
2879 .store()
2880 .contains(&crate::EntityId::new("tgt", "missing")),
2881 "auto-stub must land for the Write-mem forward reference"
2882 );
2883 let outgoing = engine.store().outgoing(&created.id);
2884 assert!(
2885 outgoing.iter().any(|e| e.rel_type == "REFERENCES"),
2886 "alias edge must still emit for the stubbed target"
2887 );
2888 }
2889 }
2890
2891 }