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, &target_mem)?;
103 }
104 if let Some(mount) = self.mount(&target_mem)
109 && mount.capability == MountCapability::ReadOnly
110 && !self.store.contains(&args.target)
111 {
112 return Err(EngineError::CrossMemTargetNotFound {
113 target_id: args.target.to_string(),
114 target_mem: target_mem.clone(),
115 });
116 }
117 if self.mount(&target_mem).is_none() {
125 target_mem_uncreated = true;
126 }
127 }
128
129 if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
136 args.rel_type = canonical;
137 }
138 args.description = normalise_description(args.description.as_deref());
142
143 let mount_idx = self
144 .mounts
145 .iter()
146 .position(|m| m.mount.mem == source_mem)
147 .ok_or_else(|| EngineError::UnknownMem(source_mem.clone()))?;
148 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
149 return Err(EngineError::ReadOnlyMount(source_mem));
150 }
151
152 let schema = self
153 .schemas
154 .get(&source_mem)
155 .expect("schema present for every registered mount");
156
157 let target_schema = if source_mem == target_mem {
171 None
172 } else {
173 self.schemas.get(&target_mem).cloned()
174 };
175 let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
176 let (name, version) = s.id();
177 SchemaRef::new(name, version)
178 });
179 let cross_mem_different = match (&target_schema_ref, schema.id()) {
180 (Some(target), (src_name, _)) => target.name != src_name,
181 (None, _) => false,
182 };
183
184 let mut warnings: Vec<WarningHint> = Vec::new();
185 warnings.append(&mut drift_warnings);
187 if !cross_mem_different {
192 match validate_rel_type(&args.rel_type, schema.as_ref())? {
193 RelationshipCheck::Ok => {}
194 RelationshipCheck::OpenWarning(message) => {
195 warnings.push(WarningHint::UndeclaredRelationshipOpen {
196 rel_type: args.rel_type.clone(),
197 message,
198 });
199 }
200 }
201 }
202
203 let entity: Entity = self
207 .store
208 .get(&args.source)
209 .ok_or_else(|| EngineError::NotFound {
210 id: args.source.to_string(),
211 })?
212 .clone();
213
214 if entity.stub {
222 return Err(EngineError::StubCannotRelate {
223 id: args.source.to_string(),
224 });
225 }
226
227 let target_type = self
228 .store
229 .get(&args.target)
230 .map(|e| e.entity_type.clone())
231 .filter(|t| !t.is_empty());
232 if !args.remove {
247 validate_description_posture(
248 self,
249 &args.rel_type,
250 args.description.as_deref(),
251 &source_mem,
252 &target_mem,
253 &args.source,
254 &args.target,
255 )?;
256 super::validate_manual_authoring_posture(
264 self,
265 &args.rel_type,
266 &source_mem,
267 &args.source,
268 &args.target,
269 )?;
270 }
271
272 if !args.remove {
273 if cross_mem_different {
274 let target_ref = target_schema_ref
277 .as_ref()
278 .expect("target_schema_ref is Some when cross_mem_different");
279 match validate_cross_mem_edge(
280 &args.rel_type,
281 entity.entity_type.as_str(),
282 target_type.as_deref(),
283 schema.as_ref(),
284 target_ref,
285 ) {
286 CrossMemRelCheck::Ok => {}
287 CrossMemRelCheck::EdgeNotDeclared => {
288 let (src_name, src_version) = schema.id();
289 return Err(EngineError::CrossMemEdgeNotDeclared {
290 source_schema: SchemaRef::new(src_name, src_version).as_display(),
291 target_schema: target_ref.as_display(),
292 rel_type: args.rel_type.clone(),
293 from_id: args.source.to_string(),
294 to_id: args.target.to_string(),
295 });
296 }
297 CrossMemRelCheck::Invalid(v) => {
298 return Err(EngineError::Validation(v));
299 }
300 }
301 } else {
302 validate_rel_shape(
303 &args.rel_type,
304 entity.entity_type.as_str(),
305 target_type.as_deref(),
306 schema.as_ref(),
307 )?;
308 }
309 }
310
311 if let Some(expected) = args.expected_hash.as_deref()
312 && entity.content_hash != expected
313 {
314 return Err(EngineError::HashMismatch {
315 id: args.source.to_string(),
316 current: entity.content_hash.clone(),
317 is_stub: entity.stub,
318 });
319 }
320
321 if !args.remove
331 && args.source == args.target
332 && schema.type_propagates(entity.entity_type.as_str(), &args.rel_type)
333 {
334 return Err(EngineError::RelationshipCycle {
335 rel_type: args.rel_type.clone(),
336 from: args.source.clone(),
337 to: args.target.clone(),
338 existing_path: vec![args.source.clone()],
339 path_truncated: false,
340 });
341 }
342
343 if !args.remove
350 && schema.relationship_acyclic(&args.rel_type)
351 && let Some(path) = crate::graph::query::would_cycle(
352 &self.store,
353 &args.source,
354 &args.target,
355 &args.rel_type,
356 )
357 {
358 let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
359 let mut existing_path = path;
360 if truncated {
361 existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
362 }
363 return Err(EngineError::RelationshipCycle {
364 rel_type: args.rel_type.clone(),
365 from: args.source.clone(),
366 to: args.target.clone(),
367 existing_path,
368 path_truncated: truncated,
369 });
370 }
371
372 let type_def = schema
373 .get_type(&entity.entity_type)
374 .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
375
376 let mut next = entity.clone();
377 let already = next
378 .relationships
379 .iter()
380 .position(|r| r.rel_type == args.rel_type && r.target == args.target);
381
382 if args.remove && already.is_some() {
389 let other_relation_to_target_exists = entity
390 .relationships
391 .iter()
392 .any(|r| r.target == args.target && r.rel_type != args.rel_type);
393 if !other_relation_to_target_exists {
394 let mut surviving_sections: Vec<String> = Vec::new();
401 for (section_key, body) in entity.sections.iter() {
402 let inline_targets =
403 crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
404 if inline_targets.iter().any(|t| t == &args.target) {
405 surviving_sections.push(section_key.clone());
406 }
407 }
408 if !surviving_sections.is_empty() {
409 return Err(EngineError::RelationHasBodyLinks {
410 from_id: args.source.to_string(),
411 to_id: args.target.to_string(),
412 rel_type: args.rel_type.clone(),
413 body_links: surviving_sections,
414 });
415 }
416 }
417 }
418
419 let action = if args.remove {
420 match already {
421 Some(idx) => {
422 next.relationships.remove(idx);
423 RelateAction::Removed
424 }
425 None => RelateAction::NoOpAbsent,
426 }
427 } else {
428 match already {
429 Some(_) => RelateAction::NoOpAlreadyPresent,
430 None => {
431 next.relationships.push(Relationship {
432 rel_type: args.rel_type.clone(),
433 target: args.target.clone(),
434 description: normalise_description(args.description.as_deref()),
435 });
436 RelateAction::Added
437 }
438 }
439 };
440
441 if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
452 self.store.upsert(
453 args.target.clone(),
454 make_stub(&args.target, crate::entity::StubKind::ForwardReference),
455 );
456 warnings.push(WarningHint::AutoStubCreated {
457 stub_id: args.target.clone(),
458 });
459 if target_mem_uncreated {
464 warnings.push(WarningHint::CrossMemTargetMemUncreated {
465 from_mem: source_mem.clone(),
466 to_mem: target_mem.clone(),
467 target_id: args.target.clone(),
468 });
469 }
470 }
471
472 if matches!(
478 action,
479 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
480 ) {
481 match action {
482 RelateAction::NoOpAlreadyPresent => {
483 warnings.push(WarningHint::DuplicateRelationship {
484 rel_type: args.rel_type.clone(),
485 from: args.source.clone(),
486 to: args.target.clone(),
487 });
488 }
489 RelateAction::NoOpAbsent => {
490 warnings.push(WarningHint::NoSuchRelationship {
491 rel_type: args.rel_type.clone(),
492 from: args.source.clone(),
493 to: args.target.clone(),
494 });
495 }
496 _ => unreachable!(),
497 }
498 return Ok(RelateEntityOutcome {
499 from: args.source,
500 to: args.target,
501 rel_type: args.rel_type,
502 action,
503 content_hash: entity.content_hash.clone(),
504 commit_sha: String::new(),
505 source: "explicit".to_string(),
506 warnings,
507 orphan_stubs_removed: Vec::new(),
510 });
511 }
512
513 let today = super::today_iso();
521 super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
522
523 let file_path = next.file_path.clone();
524 let markdown = generate_markdown(&next, type_def.as_ref());
525
526 let backend = self.mounts[mount_idx].backend.as_ref();
527 backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
528 let commit_subject = format!("memstead: relate {}", args.source);
529 let ctx = CommitContext {
530 actor,
531 client: client.cloned(),
532 tool: Some("relate_entity"),
533 note: note.map(String::from),
534 logical_operation_id: None,
535 entity_ids: None,
536 };
537 let commit_sha = backend.commit(&commit_subject, &ctx)?;
538
539 backend.append_provenance(&Provenance::new(
540 std::time::SystemTime::now(),
541 ProvenanceKind::Relate,
542 Some(args.source.to_string()),
543 actor,
544 client.cloned(),
545 note.map(String::from),
546 ))?;
547
548 self.record_self_write(mount_idx, &commit_sha);
549
550 let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &source_mem)
551 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
552 let content_hash = parse_result.entity.content_hash.clone();
553
554 let fallback = engine_fallback_type();
555 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
556 crate::entity::store_builder::remap_alias_target_edge_sources(
557 &mut self.store,
558 &self.schemas,
559 );
560
561 let orphan_stubs_removed: Vec<EntityId> = if matches!(action, RelateAction::Removed) {
575 super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&args.target))
576 } else {
577 Vec::new()
578 };
579
580 self.invalidate_communities();
581 self.invalidate_search_indexes();
582
583 if let Some(w) = self.note_missing_warning("relate_entity", note) {
589 warnings.push(w);
590 }
591
592 Ok(RelateEntityOutcome {
593 from: args.source,
594 to: args.target,
595 rel_type: args.rel_type,
596 action,
597 content_hash,
598 commit_sha,
599 source: "explicit".to_string(),
600 warnings,
601 orphan_stubs_removed,
602 })
603 }
604
605 pub fn relate(
612 &mut self,
613 from: &EntityId,
614 to: &EntityId,
615 rel_type: &str,
616 remove: bool,
617 ctx: &CommitContext<'_>,
618 ) -> Result<RelateEntityOutcome, EngineError> {
619 let args = RelateEntityArgs {
620 source: from.clone(),
621 expected_hash: None,
622 rel_type: rel_type.to_string(),
623 target: to.clone(),
624 remove,
625 description: None,
626 };
627 self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
628 }
629}
630
631#[cfg(test)]
632mod tests {
633
634 use indexmap::IndexMap;
635 use tempfile::TempDir;
636
637 use crate::backend::MemBackend;
638 use crate::engine::test_helpers::*;
639 use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
640 use crate::ops::WarningHint;
641 use crate::storage::FilesystemMemWriter;
642 use crate::vcs::{Actor, CommitContext};
643
644 #[test]
645 fn relate_alias_delegates_to_relate_entity() {
646 let tmp = TempDir::new().unwrap();
651 let mem_dir = tmp.path().to_path_buf();
652 let writer = FilesystemMemWriter::new(mem_dir.clone());
653 let mut engine = Engine::from_mounts(vec![(
654 folder_mount("specs", mem_dir),
655 Box::new(writer) as Box<dyn MemBackend>,
656 )])
657 .unwrap();
658
659 let a = engine
661 .create_entity(
662 CreateEntityArgs {
663 mem: "specs".to_string(),
664 title: "A".to_string(),
665 entity_type: "spec".to_string(),
666 sections: IndexMap::from_iter([
667 ("identity".to_string(), "seed identity".to_string()),
668 ("purpose".to_string(), "seed purpose".to_string()),
669 ]),
670 metadata: IndexMap::new(),
671 relations: Vec::new(),
672 dry_run: false,
673 },
674 Actor::Cli,
675 None,
676 None,
677 )
678 .unwrap();
679 let b = engine
680 .create_entity(
681 CreateEntityArgs {
682 mem: "specs".to_string(),
683 title: "B".to_string(),
684 entity_type: "spec".to_string(),
685 sections: IndexMap::from_iter([
686 ("identity".to_string(), "seed identity".to_string()),
687 ("purpose".to_string(), "seed purpose".to_string()),
688 ]),
689 metadata: IndexMap::new(),
690 relations: Vec::new(),
691 dry_run: false,
692 },
693 Actor::Cli,
694 None,
695 None,
696 )
697 .unwrap();
698
699 let ctx = CommitContext::internal();
701 let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
702 assert_eq!(result.from, a.id);
703 assert_eq!(result.to, b.id);
704 assert_eq!(result.rel_type, "PART_OF");
705 let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
707 assert!(
708 outgoing
709 .iter()
710 .any(|e| e.target == b.id && e.rel_type == "PART_OF")
711 );
712 }
713
714 #[test]
715 fn relate_entity_appends_relationship_and_logs_provenance() {
716 let tmp = TempDir::new().unwrap();
717 let (mut engine, source) = engine_with_seed(&tmp, "Source");
718 let (actor, client) = cli_actor();
719 let target = engine
720 .create_entity(
721 empty_create_args("specs", "Target"),
722 actor,
723 Some(&client),
724 None,
725 )
726 .unwrap();
727
728 let outcome = engine
729 .relate_entity(
730 RelateEntityArgs {
731 source: source.id.clone(),
732 expected_hash: Some(source.content_hash.clone()),
733 rel_type: "USES".to_string(),
734 target: target.id.clone(),
735 remove: false,
736 description: None,
737 },
738 actor,
739 Some(&client),
740 None,
741 )
742 .unwrap();
743 assert_eq!(outcome.action, RelateAction::Added);
744 assert_ne!(outcome.content_hash, source.content_hash);
745 let edges = engine.store().outgoing(&source.id);
747 assert!(
748 edges
749 .iter()
750 .any(|e| e.rel_type == "USES" && e.target == target.id),
751 "expected USES edge in store"
752 );
753 let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
755 assert!(log.contains("\"kind\":\"relate\""));
756 }
757
758 #[test]
759 fn relate_entity_no_op_when_already_present() {
760 let tmp = TempDir::new().unwrap();
761 let (mut engine, source) = engine_with_seed(&tmp, "Already");
762 let (actor, client) = cli_actor();
763 let target = engine
764 .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
765 .unwrap();
766 let first = engine
767 .relate_entity(
768 RelateEntityArgs {
769 source: source.id.clone(),
770 expected_hash: Some(source.content_hash.clone()),
771 rel_type: "USES".to_string(),
772 target: target.id.clone(),
773 remove: false,
774 description: None,
775 },
776 actor,
777 Some(&client),
778 None,
779 )
780 .unwrap();
781 let second = engine
782 .relate_entity(
783 RelateEntityArgs {
784 source: source.id.clone(),
785 expected_hash: Some(first.content_hash.clone()),
786 rel_type: "USES".to_string(),
787 target: target.id.clone(),
788 remove: false,
789 description: None,
790 },
791 actor,
792 Some(&client),
793 None,
794 )
795 .unwrap();
796 assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
797 assert_eq!(second.content_hash, first.content_hash);
799 }
800
801 #[test]
802 fn relate_entity_returns_commit_sha_on_real_write() {
803 let tmp = TempDir::new().unwrap();
804 let (mut engine, source) = engine_with_seed(&tmp, "Source");
805 let (actor, client) = cli_actor();
806 let target = engine
807 .create_entity(
808 empty_create_args("specs", "Target"),
809 actor,
810 Some(&client),
811 None,
812 )
813 .unwrap();
814
815 let outcome = engine
816 .relate_entity(
817 RelateEntityArgs {
818 source: source.id.clone(),
819 expected_hash: Some(source.content_hash.clone()),
820 rel_type: "USES".to_string(),
821 target: target.id.clone(),
822 remove: false,
823 description: None,
824 },
825 actor,
826 Some(&client),
827 None,
828 )
829 .unwrap();
830 assert!(
834 !outcome.commit_sha.is_empty(),
835 "commit_sha must be populated on a real write"
836 );
837 }
838
839 #[test]
840 fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_commit_sha() {
841 let tmp = TempDir::new().unwrap();
842 let (mut engine, source) = engine_with_seed(&tmp, "S");
843 let (actor, client) = cli_actor();
844 let target = engine
845 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
846 .unwrap();
847
848 let first = engine
850 .relate_entity(
851 RelateEntityArgs {
852 source: source.id.clone(),
853 expected_hash: Some(source.content_hash.clone()),
854 rel_type: "USES".to_string(),
855 target: target.id.clone(),
856 remove: false,
857 description: None,
858 },
859 actor,
860 Some(&client),
861 None,
862 )
863 .unwrap();
864
865 let dup = engine
868 .relate_entity(
869 RelateEntityArgs {
870 source: source.id.clone(),
871 expected_hash: Some(first.content_hash.clone()),
872 rel_type: "USES".to_string(),
873 target: target.id.clone(),
874 remove: false,
875 description: None,
876 },
877 actor,
878 Some(&client),
879 None,
880 )
881 .unwrap();
882 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
883 assert!(dup.commit_sha.is_empty());
884 assert_eq!(dup.warnings.len(), 1);
885 assert!(matches!(
886 dup.warnings[0],
887 WarningHint::DuplicateRelationship { .. }
888 ));
889
890 let no_such = engine
893 .relate_entity(
894 RelateEntityArgs {
895 source: source.id.clone(),
896 expected_hash: Some(first.content_hash.clone()),
897 rel_type: "DEPENDS_ON".to_string(),
898 target: target.id.clone(),
899 remove: true,
900 description: None,
901 },
902 actor,
903 Some(&client),
904 None,
905 )
906 .unwrap();
907 assert_eq!(no_such.action, RelateAction::NoOpAbsent);
908 assert!(no_such.commit_sha.is_empty());
909 assert_eq!(no_such.warnings.len(), 1);
910 assert!(matches!(
911 no_such.warnings[0],
912 WarningHint::NoSuchRelationship { .. }
913 ));
914 }
915
916 #[test]
917 fn relate_entity_creates_stub_for_absent_target_on_add_path() {
918 let tmp = TempDir::new().unwrap();
919 let (mut engine, source) = engine_with_seed(&tmp, "Source");
920 let (actor, client) = cli_actor();
921 let absent_target = crate::EntityId::new("specs", "ghost-target");
922 assert!(!engine.store().contains(&absent_target));
924
925 let outcome = engine
926 .relate_entity(
927 RelateEntityArgs {
928 source: source.id.clone(),
929 expected_hash: Some(source.content_hash.clone()),
930 rel_type: "USES".to_string(),
931 target: absent_target.clone(),
932 remove: false,
933 description: None,
934 },
935 actor,
936 Some(&client),
937 None,
938 )
939 .unwrap();
940
941 assert_eq!(outcome.action, RelateAction::Added);
942 assert_eq!(outcome.source, "explicit");
943 let stub_warning = outcome
949 .warnings
950 .iter()
951 .find_map(|w| match w {
952 crate::ops::WarningHint::AutoStubCreated { stub_id } => Some(stub_id.clone()),
953 _ => None,
954 })
955 .expect("AutoStubCreated warning must surface when target was absent");
956 assert_eq!(stub_warning, absent_target);
957
958 let stub = engine.store().get(&absent_target).expect("stub upserted");
960 assert!(stub.stub);
961 assert!(stub.entity_type.is_empty());
962 assert!(stub.file_path.is_empty());
963 }
964
965 #[test]
966 fn relate_entity_skips_stub_creation_when_target_already_exists() {
967 let tmp = TempDir::new().unwrap();
968 let (mut engine, source) = engine_with_seed(&tmp, "Src");
969 let (actor, client) = cli_actor();
970 let target = engine
971 .create_entity(
972 empty_create_args("specs", "Real"),
973 actor,
974 Some(&client),
975 None,
976 )
977 .unwrap();
978
979 let outcome = engine
980 .relate_entity(
981 RelateEntityArgs {
982 source: source.id.clone(),
983 expected_hash: Some(source.content_hash.clone()),
984 rel_type: "USES".to_string(),
985 target: target.id.clone(),
986 remove: false,
987 description: None,
988 },
989 actor,
990 Some(&client),
991 None,
992 )
993 .unwrap();
994
995 assert!(
996 !outcome
997 .warnings
998 .iter()
999 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1000 "AutoStubCreated must not surface when target was already in store"
1001 );
1002 assert_eq!(outcome.source, "explicit");
1003 let target_after = engine.store().get(&target.id).unwrap();
1005 assert!(!target_after.stub);
1006 }
1007
1008 #[test]
1009 fn relate_entity_does_not_create_stub_on_remove_path() {
1010 let tmp = TempDir::new().unwrap();
1011 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1012 let (actor, client) = cli_actor();
1013 let absent_target = crate::EntityId::new("specs", "never-existed");
1014
1015 let outcome = engine
1016 .relate_entity(
1017 RelateEntityArgs {
1018 source: source.id.clone(),
1019 expected_hash: Some(source.content_hash.clone()),
1020 rel_type: "USES".to_string(),
1021 target: absent_target.clone(),
1022 remove: true,
1023 description: None,
1024 },
1025 actor,
1026 Some(&client),
1027 None,
1028 )
1029 .unwrap();
1030
1031 assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1034 assert!(
1035 !outcome
1036 .warnings
1037 .iter()
1038 .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1039 "remove path must never auto-stub the target",
1040 );
1041 assert!(!engine.store().contains(&absent_target));
1042 }
1043
1044 #[test]
1045 fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1046 use crate::engine::CreateEntityArgs;
1047 use indexmap::IndexMap;
1048
1049 let tmp = TempDir::new().unwrap();
1050 let mem_dir = tmp.path().to_path_buf();
1051 let writer = FilesystemMemWriter::new(mem_dir.clone());
1052 let mut engine = Engine::from_mounts(vec![(
1053 folder_mount("specs", mem_dir.clone()),
1054 Box::new(writer) as Box<dyn MemBackend>,
1055 )])
1056 .unwrap();
1057 let (actor, client) = cli_actor();
1058
1059 let target = engine
1060 .create_entity(
1061 empty_create_args("specs", "Target"),
1062 actor,
1063 Some(&client),
1064 None,
1065 )
1066 .unwrap();
1067
1068 let mut sections: IndexMap<String, String> = IndexMap::new();
1074 sections.insert("identity".to_string(), "source identity".to_string());
1075 sections.insert(
1076 "purpose".to_string(),
1077 "discussion stems from [[target]]".to_string(),
1078 );
1079 let source = engine
1080 .create_entity(
1081 CreateEntityArgs {
1082 mem: "specs".to_string(),
1083 title: "Source".to_string(),
1084 entity_type: "spec".to_string(),
1085 sections,
1086 metadata: IndexMap::new(),
1087 relations: Vec::new(),
1088 dry_run: false,
1089 },
1090 actor,
1091 Some(&client),
1092 None,
1093 )
1094 .unwrap();
1095 let related = source.clone();
1096
1097 let err = engine
1101 .relate_entity(
1102 RelateEntityArgs {
1103 source: source.id.clone(),
1104 expected_hash: Some(related.content_hash.clone()),
1105 rel_type: "REFERENCES".to_string(),
1106 target: target.id.clone(),
1107 remove: true,
1108 description: None,
1109 },
1110 actor,
1111 Some(&client),
1112 None,
1113 )
1114 .unwrap_err();
1115 match err {
1116 EngineError::RelationHasBodyLinks {
1117 from_id,
1118 to_id,
1119 rel_type,
1120 body_links,
1121 } => {
1122 assert_eq!(from_id, source.id.to_string());
1123 assert_eq!(to_id, target.id.to_string());
1124 assert_eq!(rel_type, "REFERENCES");
1125 assert_eq!(body_links, vec!["purpose".to_string()]);
1126 }
1127 other => panic!("expected RelationHasBodyLinks, got {other:?}"),
1128 }
1129 let in_mem = engine.get_entity(&source.id).unwrap();
1132 assert!(
1133 in_mem
1134 .relationships
1135 .iter()
1136 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
1137 "relation must survive the refused remove; got {:?}",
1138 in_mem.relationships
1139 );
1140 }
1141
1142 #[test]
1143 fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
1144 let tmp = TempDir::new().unwrap();
1145 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1146 let (actor, client) = cli_actor();
1147 let target = engine
1148 .create_entity(
1149 empty_create_args("specs", "Other"),
1150 actor,
1151 Some(&client),
1152 None,
1153 )
1154 .unwrap();
1155 let related = engine
1161 .relate_entity(
1162 RelateEntityArgs {
1163 source: source.id.clone(),
1164 expected_hash: Some(source.content_hash.clone()),
1165 rel_type: "USES".to_string(),
1166 target: target.id.clone(),
1167 remove: false,
1168 description: None,
1169 },
1170 actor,
1171 Some(&client),
1172 None,
1173 )
1174 .unwrap();
1175 let removed = engine
1176 .relate_entity(
1177 RelateEntityArgs {
1178 source: source.id.clone(),
1179 expected_hash: Some(related.content_hash.clone()),
1180 rel_type: "USES".to_string(),
1181 target: target.id.clone(),
1182 remove: true,
1183 description: None,
1184 },
1185 actor,
1186 Some(&client),
1187 None,
1188 )
1189 .unwrap();
1190 assert_eq!(removed.action, RelateAction::Removed);
1191 }
1192
1193 #[test]
1194 fn relate_entity_auto_stub_is_tagged_forward_reference() {
1195 use crate::entity::StubKind;
1200
1201 let tmp = TempDir::new().unwrap();
1202 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1203 let (actor, client) = cli_actor();
1204 let absent_target = crate::EntityId::new("specs", "absent-target");
1205
1206 let _ = engine
1207 .relate_entity(
1208 RelateEntityArgs {
1209 source: source.id.clone(),
1210 expected_hash: Some(source.content_hash.clone()),
1211 rel_type: "USES".to_string(),
1212 target: absent_target.clone(),
1213 remove: false,
1214 description: None,
1215 },
1216 actor,
1217 Some(&client),
1218 None,
1219 )
1220 .unwrap();
1221
1222 let stub = engine
1223 .get_entity(&absent_target)
1224 .expect("relate auto-stubbed target must be in the store");
1225 assert!(stub.stub, "auto-stubbed target must carry stub: true");
1226 assert_eq!(
1227 stub.stub_kind,
1228 Some(StubKind::ForwardReference),
1229 "auto-stub from relate must be tagged ForwardReference; got {:?}",
1230 stub.stub_kind
1231 );
1232 }
1233
1234 #[test]
1235 fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
1236 let tmp = TempDir::new().unwrap();
1241 let (mut engine, source) = engine_with_seed(&tmp, "Source");
1242 let (actor, client) = cli_actor();
1243 let target = engine
1244 .create_entity(
1245 empty_create_args("specs", "Target"),
1246 actor,
1247 Some(&client),
1248 None,
1249 )
1250 .unwrap();
1251
1252 let lower = engine
1254 .relate_entity(
1255 RelateEntityArgs {
1256 source: source.id.clone(),
1257 expected_hash: Some(source.content_hash.clone()),
1258 rel_type: "uses".to_string(),
1259 target: target.id.clone(),
1260 remove: false,
1261 description: None,
1262 },
1263 actor,
1264 Some(&client),
1265 None,
1266 )
1267 .unwrap();
1268 assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
1269 assert_eq!(lower.action, RelateAction::Added);
1270 let edges = engine.store().outgoing(&source.id);
1271 assert!(
1272 edges
1273 .iter()
1274 .any(|e| e.rel_type == "USES" && e.target == target.id),
1275 "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
1276 );
1277
1278 let dup = engine
1282 .relate_entity(
1283 RelateEntityArgs {
1284 source: source.id.clone(),
1285 expected_hash: Some(lower.content_hash.clone()),
1286 rel_type: "Uses".to_string(),
1287 target: target.id.clone(),
1288 remove: false,
1289 description: None,
1290 },
1291 actor,
1292 Some(&client),
1293 None,
1294 )
1295 .unwrap();
1296 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1297 assert_eq!(dup.rel_type, "USES");
1298 assert!(matches!(
1299 dup.warnings[0],
1300 WarningHint::DuplicateRelationship { .. }
1301 ));
1302 }
1303
1304 #[test]
1305 fn relate_entity_rejects_cross_mem_when_policy_denies() {
1306 let tmp = TempDir::new().unwrap();
1313 let (mut engine, source) = engine_with_seed(&tmp, "S");
1314 let (actor, client) = cli_actor();
1315 let err = engine
1316 .relate_entity(
1317 RelateEntityArgs {
1318 source: source.id.clone(),
1319 expected_hash: Some(source.content_hash.clone()),
1320 rel_type: "USES".to_string(),
1321 target: crate::EntityId::new("other-mem", "thing"),
1322 remove: false,
1323 description: None,
1324 },
1325 actor,
1326 Some(&client),
1327 None,
1328 )
1329 .unwrap_err();
1330 match err {
1331 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
1332 assert_eq!(from_mem, "specs");
1333 assert_eq!(to_mem, "other-mem");
1334 }
1335 other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
1336 }
1337 }
1338
1339 #[test]
1348 fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
1349 let tmp = TempDir::new().unwrap();
1350 let (mut engine, source) = engine_with_seed(&tmp, "S");
1351 let (actor, client) = cli_actor();
1352 let err = engine
1353 .relate_entity(
1354 RelateEntityArgs {
1355 source: source.id.clone(),
1356 expected_hash: Some(source.content_hash.clone()),
1357 rel_type: "USES".to_string(),
1358 target: crate::EntityId("bad target with spaces!!".to_string()),
1361 remove: false,
1362 description: None,
1363 },
1364 actor,
1365 Some(&client),
1366 None,
1367 )
1368 .unwrap_err();
1369 assert!(
1370 matches!(err, EngineError::InvalidEntityId { .. }),
1371 "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
1372 );
1373 }
1374
1375 #[test]
1380 fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
1381 let tmp = TempDir::new().unwrap();
1382 let (mut engine, source) = engine_with_seed(&tmp, "S");
1383 let (actor, client) = cli_actor();
1384 let source_mem = source.id.mem().to_string();
1385 let err = engine
1386 .relate_entity(
1387 RelateEntityArgs {
1388 source: source.id.clone(),
1389 expected_hash: Some(source.content_hash.clone()),
1390 rel_type: "USES".to_string(),
1391 target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
1392 remove: false,
1393 description: None,
1394 },
1395 actor,
1396 Some(&client),
1397 None,
1398 )
1399 .unwrap_err();
1400 assert!(
1401 matches!(err, EngineError::InvalidEntityId { .. }),
1402 "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
1403 );
1404 }
1405
1406 #[test]
1412 fn relate_add_bumps_last_modified_on_source_entity() {
1413 let tmp = TempDir::new().unwrap();
1414 let (mut engine, source) = engine_with_seed(&tmp, "S");
1415 let (actor, client) = cli_actor();
1416 let target = engine
1417 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1418 .unwrap();
1419
1420 let outcome = engine
1421 .relate_entity(
1422 RelateEntityArgs {
1423 source: source.id.clone(),
1424 expected_hash: Some(source.content_hash.clone()),
1425 rel_type: "USES".to_string(),
1426 target: target.id.clone(),
1427 remove: false,
1428 description: None,
1429 },
1430 actor,
1431 Some(&client),
1432 None,
1433 )
1434 .unwrap();
1435 assert_eq!(outcome.action, RelateAction::Added);
1436
1437 let post = engine.get_entity(&source.id).unwrap();
1442 let last_modified = post
1443 .metadata
1444 .get("last_modified")
1445 .map(|v| v.to_frontmatter_string())
1446 .unwrap_or_default();
1447 assert!(
1448 last_modified.starts_with("20"),
1449 "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
1450 );
1451 }
1452
1453 #[test]
1458 fn relate_add_noop_does_not_bump_last_modified() {
1459 let tmp = TempDir::new().unwrap();
1460 let (mut engine, source) = engine_with_seed(&tmp, "S");
1461 let (actor, client) = cli_actor();
1462 let target = engine
1463 .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1464 .unwrap();
1465 let first = engine
1466 .relate_entity(
1467 RelateEntityArgs {
1468 source: source.id.clone(),
1469 expected_hash: Some(source.content_hash.clone()),
1470 rel_type: "USES".to_string(),
1471 target: target.id.clone(),
1472 remove: false,
1473 description: None,
1474 },
1475 actor,
1476 Some(&client),
1477 None,
1478 )
1479 .unwrap();
1480 let pre = engine.get_entity(&source.id).unwrap();
1481 let pre_stamp = pre
1482 .metadata
1483 .get("last_modified")
1484 .map(|v| v.to_frontmatter_string())
1485 .unwrap_or_default();
1486
1487 let dup = engine
1489 .relate_entity(
1490 RelateEntityArgs {
1491 source: source.id.clone(),
1492 expected_hash: Some(first.content_hash.clone()),
1493 rel_type: "USES".to_string(),
1494 target: target.id.clone(),
1495 remove: false,
1496 description: None,
1497 },
1498 actor,
1499 Some(&client),
1500 None,
1501 )
1502 .unwrap();
1503 assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1504
1505 let post = engine.get_entity(&source.id).unwrap();
1506 let post_stamp = post
1507 .metadata
1508 .get("last_modified")
1509 .map(|v| v.to_frontmatter_string())
1510 .unwrap_or_default();
1511 assert_eq!(
1512 pre_stamp, post_stamp,
1513 "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
1514 );
1515 }
1516
1517 #[test]
1522 fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
1523 use memstead_schema::workspace_config::CrossLinkValue;
1524 let tmp = TempDir::new().unwrap();
1525 let (mut engine, source) = engine_with_seed(&tmp, "S");
1526 let (actor, client) = cli_actor();
1527 let mut settings = crate::workspace::WorkspaceSettings::default();
1531 settings.cross_mem_links.insert(
1532 "specs".to_string(),
1533 CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
1534 );
1535 engine.set_settings(settings);
1536
1537 let absent = crate::EntityId::new("uncreated-mem", "ghost");
1538 let outcome = engine
1539 .relate_entity(
1540 RelateEntityArgs {
1541 source: source.id.clone(),
1542 expected_hash: Some(source.content_hash.clone()),
1543 rel_type: "USES".to_string(),
1544 target: absent.clone(),
1545 remove: false,
1546 description: None,
1547 },
1548 actor,
1549 Some(&client),
1550 None,
1551 )
1552 .unwrap();
1553 assert_eq!(outcome.action, RelateAction::Added);
1554
1555 let saw_uncreated = outcome.warnings.iter().any(|w| {
1557 matches!(
1558 w,
1559 WarningHint::CrossMemTargetMemUncreated {
1560 from_mem,
1561 to_mem,
1562 target_id,
1563 } if from_mem == "specs"
1564 && to_mem == "uncreated-mem"
1565 && target_id == &absent
1566 )
1567 });
1568 assert!(
1569 saw_uncreated,
1570 "CrossMemTargetMemUncreated warning must surface; got: {:?}",
1571 outcome.warnings
1572 );
1573 assert!(engine.store().contains(&absent));
1575 }
1576
1577 #[test]
1583 fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
1584 let tmp = TempDir::new().unwrap();
1585 let (mut engine, source) = engine_with_seed(&tmp, "S");
1586 let (actor, client) = cli_actor();
1587 let absent = crate::EntityId::new("uncreated-mem", "ghost");
1589 let err = engine
1590 .relate_entity(
1591 RelateEntityArgs {
1592 source: source.id.clone(),
1593 expected_hash: Some(source.content_hash.clone()),
1594 rel_type: "USES".to_string(),
1595 target: absent.clone(),
1596 remove: false,
1597 description: None,
1598 },
1599 actor,
1600 Some(&client),
1601 None,
1602 )
1603 .unwrap_err();
1604 assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
1605 assert!(!engine.store().contains(&absent));
1607 }
1608
1609 #[test]
1615 fn relate_remove_garbage_collects_orphan_stub() {
1616 let tmp = TempDir::new().unwrap();
1617 let (mut engine, source) = engine_with_seed(&tmp, "Src");
1618 let (actor, client) = cli_actor();
1619 let stub_id = crate::EntityId::new("specs", "ghost-target");
1620
1621 let added = engine
1623 .relate_entity(
1624 RelateEntityArgs {
1625 source: source.id.clone(),
1626 expected_hash: Some(source.content_hash.clone()),
1627 rel_type: "USES".to_string(),
1628 target: stub_id.clone(),
1629 remove: false,
1630 description: None,
1631 },
1632 actor,
1633 Some(&client),
1634 None,
1635 )
1636 .unwrap();
1637 assert!(engine.store().contains(&stub_id));
1638
1639 let removed = engine
1640 .relate_entity(
1641 RelateEntityArgs {
1642 source: source.id.clone(),
1643 expected_hash: Some(added.content_hash.clone()),
1644 rel_type: "USES".to_string(),
1645 target: stub_id.clone(),
1646 remove: true,
1647 description: None,
1648 },
1649 actor,
1650 Some(&client),
1651 None,
1652 )
1653 .unwrap();
1654 assert_eq!(removed.action, RelateAction::Removed);
1655 assert_eq!(
1656 removed.orphan_stubs_removed,
1657 vec![stub_id.clone()],
1658 "orphan stub must be GC'd in the same call"
1659 );
1660 assert!(
1661 !engine.store().contains(&stub_id),
1662 "stub must be gone from the store after GC"
1663 );
1664 }
1665
1666 #[test]
1672 fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
1673 let tmp = TempDir::new().unwrap();
1674 let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
1675 let (actor, client) = cli_actor();
1676 let source_b = engine
1677 .create_entity(
1678 empty_create_args("specs", "SrcB"),
1679 actor,
1680 Some(&client),
1681 None,
1682 )
1683 .unwrap();
1684 let stub_id = crate::EntityId::new("specs", "ghost-target");
1685
1686 let a_added = engine
1688 .relate_entity(
1689 RelateEntityArgs {
1690 source: source_a.id.clone(),
1691 expected_hash: Some(source_a.content_hash.clone()),
1692 rel_type: "USES".to_string(),
1693 target: stub_id.clone(),
1694 remove: false,
1695 description: None,
1696 },
1697 actor,
1698 Some(&client),
1699 None,
1700 )
1701 .unwrap();
1702 let _b_added = engine
1703 .relate_entity(
1704 RelateEntityArgs {
1705 source: source_b.id.clone(),
1706 expected_hash: Some(source_b.content_hash.clone()),
1707 rel_type: "USES".to_string(),
1708 target: stub_id.clone(),
1709 remove: false,
1710 description: None,
1711 },
1712 actor,
1713 Some(&client),
1714 None,
1715 )
1716 .unwrap();
1717
1718 let removed = engine
1721 .relate_entity(
1722 RelateEntityArgs {
1723 source: source_a.id.clone(),
1724 expected_hash: Some(a_added.content_hash.clone()),
1725 rel_type: "USES".to_string(),
1726 target: stub_id.clone(),
1727 remove: true,
1728 description: None,
1729 },
1730 actor,
1731 Some(&client),
1732 None,
1733 )
1734 .unwrap();
1735 assert_eq!(removed.action, RelateAction::Removed);
1736 assert!(
1737 removed.orphan_stubs_removed.is_empty(),
1738 "stub with surviving referrer must not be GC'd; got: {:?}",
1739 removed.orphan_stubs_removed
1740 );
1741 assert!(
1742 engine.store().contains(&stub_id),
1743 "stub must remain in store while another referrer holds it"
1744 );
1745 }
1746
1747 mod cross_mem {
1757 use std::collections::BTreeMap;
1758 use std::path::Path;
1759
1760 use indexmap::IndexMap;
1761 use memstead_schema::SchemaRef;
1762 use memstead_schema::workspace_config::CrossLinkValue;
1763 use tempfile::TempDir;
1764
1765 use crate::backend::MemBackend;
1766 use crate::engine::test_helpers::*;
1767 use crate::engine::{
1768 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
1769 RelateEntityArgs,
1770 };
1771 use crate::storage::FilesystemMemWriter;
1772
1773 use crate::workspace::{
1774 Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
1775 };
1776
1777 fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
1778 let dir = root.join(name);
1779 std::fs::create_dir_all(dir.join("types")).unwrap();
1780 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
1781 for (type_name, body) in types {
1782 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
1783 }
1784 }
1785
1786 const TYPE_BODY: &str = r#"description: t
1787when_to_use: Here
1788sections:
1789 - key: body
1790 heading: Body
1791 required: true
1792 search_weight: 10.0
1793 catch_all: true
1794 write_rules: []
1795metadata_fields: []
1796title_weight: 100.0
1797text_fields:
1798 - body
1799hierarchy_relationship: _default
1800propagating_relationships: []
1801updatable_fields:
1802 - title
1803 - body
1804health_required_fields:
1805 - body
1806staleness_threshold_days: 90
1807write_rules: []
1808"#;
1809
1810 fn make_type_yaml(name: &str) -> String {
1811 format!("name: {name}\n{TYPE_BODY}")
1812 }
1813
1814 fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
1815 Mount {
1816 mem: mem.to_string(),
1817 schema: Some(pin),
1818 storage: MountStorage::Folder { path },
1819 capability: MountCapability::Write,
1820 lifecycle: MountLifecycle::Eager,
1821 cross_linkable: true,
1822 migration_target: None,
1823 }
1824 }
1825
1826 fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
1829 let tmp = TempDir::new().unwrap();
1830
1831 let src_manifest = r#"name: src-cv
1834version: 0.1.0
1835description: source schema
1836when_to_use: tests
1837types:
1838 - doc
1839relationships:
1840 mode: strict
1841 definitions:
1842 - name: IMPLEMENTS
1843 description: intra-mem only
1844 default_weight: 1.0
1845 - name: _default
1846 description: fallback
1847 default_weight: 1.0
1848cross_mem_relationships:
1849 - to_schema: tgt-cv
1850 definitions:
1851 - name: ADDRESSES
1852 description: outbound shape-pinned
1853 default_weight: 1.0
1854 source_types: [doc]
1855 target_types: [req]
1856community:
1857 resolution: 1.0
1858 seed: 42
1859"#;
1860 let tgt_manifest = r#"name: tgt-cv
1863version: 0.1.0
1864description: target schema
1865when_to_use: tests
1866types:
1867 - req
1868relationships:
1869 mode: strict
1870 definitions:
1871 - name: PART_OF
1872 description: hierarchy
1873 default_weight: 3.0
1874 acyclic: true
1875 - name: _default
1876 description: fallback
1877 default_weight: 1.0
1878community:
1879 resolution: 1.0
1880 seed: 42
1881"#;
1882 let schemas_dir = tmp.path().join("schemas");
1883 std::fs::create_dir_all(&schemas_dir).unwrap();
1884 write_schema_files(
1885 &schemas_dir,
1886 "src-cv",
1887 src_manifest,
1888 &[("doc", &make_type_yaml("doc"))],
1889 );
1890 write_schema_files(
1891 &schemas_dir,
1892 "tgt-cv",
1893 tgt_manifest,
1894 &[("req", &make_type_yaml("req"))],
1895 );
1896
1897 let src_dir = tmp.path().join("mem-src");
1898 let tgt_dir = tmp.path().join("mem-tgt");
1899 std::fs::create_dir_all(&src_dir).unwrap();
1900 std::fs::create_dir_all(&tgt_dir).unwrap();
1901
1902 let src_writer = FilesystemMemWriter::new(src_dir.clone());
1903 let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
1904 let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
1905 let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
1906
1907 let mut engine = Engine::from_mounts_with_schemas_dir(
1908 vec![
1909 (
1910 folder_mount_with_pin("src", src_dir, src_pin),
1911 Box::new(src_writer) as Box<dyn MemBackend>,
1912 ),
1913 (
1914 folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
1915 Box::new(tgt_writer) as Box<dyn MemBackend>,
1916 ),
1917 ],
1918 Some(&schemas_dir),
1919 )
1920 .expect("two-mem engine constructs");
1921
1922 let mut settings = WorkspaceSettings::default();
1926 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
1927 links.insert("src".to_string(), CrossLinkValue::Wildcard);
1928 settings.cross_mem_links = links;
1929 engine.set_settings(settings);
1930
1931 let (actor, client) = cli_actor();
1932 let src_entity = engine
1933 .create_entity(
1934 CreateEntityArgs {
1935 mem: "src".to_string(),
1936 title: "Doc One".to_string(),
1937 entity_type: "doc".to_string(),
1938 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
1939 metadata: IndexMap::new(),
1940 relations: Vec::new(),
1941 dry_run: false,
1942 },
1943 actor,
1944 Some(&client),
1945 None,
1946 )
1947 .expect("source entity creates");
1948 let tgt_entity = engine
1949 .create_entity(
1950 CreateEntityArgs {
1951 mem: "tgt".to_string(),
1952 title: "Req One".to_string(),
1953 entity_type: "req".to_string(),
1954 sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
1955 metadata: IndexMap::new(),
1956 relations: Vec::new(),
1957 dry_run: false,
1958 },
1959 actor,
1960 Some(&client),
1961 None,
1962 )
1963 .expect("target entity creates");
1964
1965 (tmp, engine, src_entity, tgt_entity)
1966 }
1967
1968 #[test]
1969 fn cross_different_schema_admits_declared_edge() {
1970 let (_tmp, mut engine, src, tgt) = two_mem_engine();
1971 let (actor, client) = cli_actor();
1972 let outcome = engine
1973 .relate_entity(
1974 RelateEntityArgs {
1975 source: src.id.clone(),
1976 expected_hash: Some(src.content_hash.clone()),
1977 rel_type: "ADDRESSES".to_string(),
1978 target: tgt.id.clone(),
1979 remove: false,
1980 description: None,
1981 },
1982 actor,
1983 Some(&client),
1984 None,
1985 )
1986 .expect("declared cross-mem edge admits");
1987 assert_eq!(outcome.rel_type, "ADDRESSES");
1988 }
1989
1990 #[test]
1996 fn same_name_different_version_uses_intra_mem_vocabulary() {
1997 let tmp = TempDir::new().unwrap();
1998
1999 let manifest_for = |version: &str| {
2000 format!(
2001 r#"name: same-dom
2002version: {version}
2003description: same-domain schema
2004when_to_use: tests
2005types:
2006 - doc
2007relationships:
2008 mode: strict
2009 definitions:
2010 - name: IMPLEMENTS
2011 description: intra-mem vocabulary
2012 default_weight: 1.0
2013 - name: _default
2014 description: fallback
2015 default_weight: 1.0
2016community:
2017 resolution: 1.0
2018 seed: 42
2019"#
2020 )
2021 };
2022 let schemas_dir = tmp.path().join("schemas");
2023 std::fs::create_dir_all(&schemas_dir).unwrap();
2024 write_schema_files(
2027 &schemas_dir,
2028 "same-dom-0.1.0",
2029 &manifest_for("0.1.0"),
2030 &[("doc", &make_type_yaml("doc"))],
2031 );
2032 write_schema_files(
2033 &schemas_dir,
2034 "same-dom-0.2.0",
2035 &manifest_for("0.2.0"),
2036 &[("doc", &make_type_yaml("doc"))],
2037 );
2038
2039 let src_dir = tmp.path().join("mem-src");
2040 let tgt_dir = tmp.path().join("mem-tgt");
2041 std::fs::create_dir_all(&src_dir).unwrap();
2042 std::fs::create_dir_all(&tgt_dir).unwrap();
2043 let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2044 let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2045 let mut engine = Engine::from_mounts_with_schemas_dir(
2046 vec![
2047 (
2048 folder_mount_with_pin("src", src_dir.clone(), src_pin),
2049 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2050 ),
2051 (
2052 folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2053 Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2054 ),
2055 ],
2056 Some(&schemas_dir),
2057 )
2058 .expect("same-domain two-version engine constructs");
2059
2060 let mut settings = WorkspaceSettings::default();
2061 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2062 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2063 settings.cross_mem_links = links;
2064 engine.set_settings(settings);
2065
2066 let (actor, client) = cli_actor();
2067 let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2068 engine
2069 .create_entity(
2070 CreateEntityArgs {
2071 mem: mem.to_string(),
2072 title: title.to_string(),
2073 entity_type: "doc".to_string(),
2074 sections: IndexMap::from_iter([(
2075 "body".to_string(),
2076 "seed".to_string(),
2077 )]),
2078 metadata: IndexMap::new(),
2079 relations: Vec::new(),
2080 dry_run: false,
2081 },
2082 actor,
2083 Some(&client),
2084 None,
2085 )
2086 .expect("entity creates")
2087 };
2088 let src_entity = mk_entity(&mut engine, "src", "Doc A");
2089 let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
2090
2091 let outcome = engine
2092 .relate_entity(
2093 RelateEntityArgs {
2094 source: src_entity.id.clone(),
2095 expected_hash: Some(src_entity.content_hash.clone()),
2096 rel_type: "IMPLEMENTS".to_string(),
2097 target: tgt_entity.id.clone(),
2098 remove: false,
2099 description: None,
2100 },
2101 actor,
2102 Some(&client),
2103 None,
2104 )
2105 .expect("same-domain edge uses the intra-schema vocabulary across versions");
2106 assert_eq!(outcome.rel_type, "IMPLEMENTS");
2107 }
2108
2109 #[test]
2110 fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
2111 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2115 let (actor, client) = cli_actor();
2116 let err = engine
2117 .relate_entity(
2118 RelateEntityArgs {
2119 source: src.id.clone(),
2120 expected_hash: Some(src.content_hash.clone()),
2121 rel_type: "IMPLEMENTS".to_string(),
2122 target: tgt.id.clone(),
2123 remove: false,
2124 description: None,
2125 },
2126 actor,
2127 Some(&client),
2128 None,
2129 )
2130 .unwrap_err();
2131 match err {
2132 EngineError::Validation(
2133 crate::runtime_validator::ValidationError::InvalidRelationshipType {
2134 input,
2135 allowed,
2136 ..
2137 },
2138 ) => {
2139 assert_eq!(input, "IMPLEMENTS");
2140 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
2141 assert!(names.iter().any(|n| n == "ADDRESSES"));
2142 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
2143 }
2144 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2145 }
2146 }
2147
2148 #[test]
2149 fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
2150 }
2167
2168 #[test]
2169 fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
2170 let tmp = TempDir::new().unwrap();
2173 let src_manifest = r#"name: src-cv
2174version: 0.1.0
2175description: source schema
2176when_to_use: tests
2177types:
2178 - doc
2179relationships:
2180 mode: strict
2181 definitions:
2182 - name: IMPLEMENTS
2183 description: intra-mem
2184 default_weight: 1.0
2185 - name: _default
2186 description: fallback
2187 default_weight: 1.0
2188cross_mem_relationships:
2189 - to_schema: tgt-cv
2190 definitions:
2191 - name: ADDRESSES
2192 description: outbound
2193 default_weight: 1.0
2194 source_types: [doc]
2195 target_types: [req]
2196community:
2197 resolution: 1.0
2198 seed: 42
2199"#;
2200 let other_manifest = r#"name: other-cv
2202version: 0.1.0
2203description: foreign schema
2204when_to_use: tests
2205types:
2206 - thing
2207relationships:
2208 mode: strict
2209 definitions:
2210 - name: _default
2211 description: fallback
2212 default_weight: 1.0
2213community:
2214 resolution: 1.0
2215 seed: 42
2216"#;
2217 let schemas_dir = tmp.path().join("schemas");
2218 std::fs::create_dir_all(&schemas_dir).unwrap();
2219 write_schema_files(
2220 &schemas_dir,
2221 "src-cv",
2222 src_manifest,
2223 &[("doc", &make_type_yaml("doc"))],
2224 );
2225 write_schema_files(
2226 &schemas_dir,
2227 "other-cv",
2228 other_manifest,
2229 &[("thing", &make_type_yaml("thing"))],
2230 );
2231 let src_dir = tmp.path().join("mem-src");
2232 let other_dir = tmp.path().join("mem-other");
2233 std::fs::create_dir_all(&src_dir).unwrap();
2234 std::fs::create_dir_all(&other_dir).unwrap();
2235
2236 let mut engine = Engine::from_mounts_with_schemas_dir(
2237 vec![
2238 (
2239 folder_mount_with_pin(
2240 "src",
2241 src_dir.clone(),
2242 SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
2243 ),
2244 Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2245 ),
2246 (
2247 folder_mount_with_pin(
2248 "other",
2249 other_dir.clone(),
2250 SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
2251 ),
2252 Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2253 ),
2254 ],
2255 Some(&schemas_dir),
2256 )
2257 .expect("engine constructs");
2258
2259 let mut settings = WorkspaceSettings::default();
2260 let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2261 links.insert("src".to_string(), CrossLinkValue::Wildcard);
2262 settings.cross_mem_links = links;
2263 engine.set_settings(settings);
2264
2265 let (actor, client) = cli_actor();
2266 let src_entity = engine
2267 .create_entity(
2268 CreateEntityArgs {
2269 mem: "src".to_string(),
2270 title: "D".to_string(),
2271 entity_type: "doc".to_string(),
2272 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2273 metadata: IndexMap::new(),
2274 relations: Vec::new(),
2275 dry_run: false,
2276 },
2277 actor,
2278 Some(&client),
2279 None,
2280 )
2281 .unwrap();
2282 let other_entity = engine
2283 .create_entity(
2284 CreateEntityArgs {
2285 mem: "other".to_string(),
2286 title: "T".to_string(),
2287 entity_type: "thing".to_string(),
2288 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2289 metadata: IndexMap::new(),
2290 relations: Vec::new(),
2291 dry_run: false,
2292 },
2293 actor,
2294 Some(&client),
2295 None,
2296 )
2297 .unwrap();
2298
2299 let err = engine
2300 .relate_entity(
2301 RelateEntityArgs {
2302 source: src_entity.id.clone(),
2303 expected_hash: Some(src_entity.content_hash.clone()),
2304 rel_type: "ADDRESSES".to_string(),
2305 target: other_entity.id.clone(),
2306 remove: false,
2307 description: None,
2308 },
2309 actor,
2310 Some(&client),
2311 None,
2312 )
2313 .unwrap_err();
2314 match err {
2315 EngineError::CrossMemEdgeNotDeclared {
2316 source_schema,
2317 target_schema,
2318 rel_type,
2319 from_id,
2320 to_id,
2321 } => {
2322 assert_eq!(source_schema, "src-cv@0.1.0");
2323 assert_eq!(target_schema, "other-cv@0.1.0");
2324 assert_eq!(rel_type, "ADDRESSES");
2325 assert_eq!(from_id, src_entity.id.to_string());
2326 assert_eq!(to_id, other_entity.id.to_string());
2327 }
2328 other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
2329 }
2330 }
2331
2332 #[test]
2333 fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
2334 let (_tmp, mut engine, src, _tgt) = two_mem_engine();
2339 let (actor, client) = cli_actor();
2340 let intra_target = engine
2342 .create_entity(
2343 CreateEntityArgs {
2344 mem: "src".to_string(),
2345 title: "Doc Two".to_string(),
2346 entity_type: "doc".to_string(),
2347 sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2348 metadata: IndexMap::new(),
2349 relations: Vec::new(),
2350 dry_run: false,
2351 },
2352 actor,
2353 Some(&client),
2354 None,
2355 )
2356 .unwrap();
2357 let src_fresh = engine.get_entity(&src.id).unwrap();
2360 let err = engine
2361 .relate_entity(
2362 RelateEntityArgs {
2363 source: src.id.clone(),
2364 expected_hash: Some(src_fresh.content_hash.clone()),
2365 rel_type: "ADDRESSES".to_string(),
2366 target: intra_target.id.clone(),
2367 remove: false,
2368 description: None,
2369 },
2370 actor,
2371 Some(&client),
2372 None,
2373 )
2374 .unwrap_err();
2375 match err {
2376 EngineError::Validation(
2377 crate::runtime_validator::ValidationError::InvalidRelationshipType {
2378 input,
2379 ..
2380 },
2381 ) => {
2382 assert_eq!(input, "ADDRESSES");
2383 }
2384 other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2385 }
2386 }
2387
2388 #[test]
2389 fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
2390 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2394 engine.set_settings(WorkspaceSettings::default());
2396 let (actor, client) = cli_actor();
2397 let err = engine
2398 .relate_entity(
2399 RelateEntityArgs {
2400 source: src.id.clone(),
2401 expected_hash: Some(src.content_hash.clone()),
2402 rel_type: "ADDRESSES".to_string(),
2403 target: tgt.id.clone(),
2404 remove: false,
2405 description: None,
2406 },
2407 actor,
2408 Some(&client),
2409 None,
2410 )
2411 .unwrap_err();
2412 assert!(
2413 matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
2414 "expected CrossMemLinkNotAllowed, got {err:?}"
2415 );
2416 }
2417
2418 #[test]
2425 fn cross_mem_remove_bypasses_policy_after_revoke() {
2426 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2427 let (actor, client) = cli_actor();
2428
2429 let added = engine
2431 .relate_entity(
2432 RelateEntityArgs {
2433 source: src.id.clone(),
2434 expected_hash: Some(src.content_hash.clone()),
2435 rel_type: "ADDRESSES".to_string(),
2436 target: tgt.id.clone(),
2437 remove: false,
2438 description: None,
2439 },
2440 actor,
2441 Some(&client),
2442 None,
2443 )
2444 .expect("declared cross-mem edge admits under grant");
2445 assert_eq!(added.action, RelateAction::Added);
2446
2447 engine.set_settings(WorkspaceSettings::default());
2449
2450 let add_err = engine
2453 .relate_entity(
2454 RelateEntityArgs {
2455 source: src.id.clone(),
2456 expected_hash: Some(added.content_hash.clone()),
2457 rel_type: "ADDRESSES".to_string(),
2458 target: tgt.id.clone(),
2459 remove: false,
2460 description: None,
2461 },
2462 actor,
2463 Some(&client),
2464 None,
2465 )
2466 .unwrap_err();
2467 assert!(
2468 matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
2469 "add path must still refuse under denial, got {add_err:?}"
2470 );
2471
2472 let removed = engine
2475 .relate_entity(
2476 RelateEntityArgs {
2477 source: src.id.clone(),
2478 expected_hash: Some(added.content_hash.clone()),
2479 rel_type: "ADDRESSES".to_string(),
2480 target: tgt.id.clone(),
2481 remove: true,
2482 description: None,
2483 },
2484 actor,
2485 Some(&client),
2486 None,
2487 )
2488 .expect("remove must bypass the policy gate post-revoke");
2489 assert_eq!(removed.action, RelateAction::Removed);
2490
2491 let outgoing = engine.store().outgoing(&src.id);
2493 assert!(
2494 !outgoing
2495 .iter()
2496 .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
2497 "ADDRESSES edge must be gone after remove"
2498 );
2499 }
2500
2501 #[test]
2505 fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
2506 let (_tmp, mut engine, src, tgt) = two_mem_engine();
2507 engine.set_settings(WorkspaceSettings::default());
2509 let (actor, client) = cli_actor();
2510 let outcome = engine
2511 .relate_entity(
2512 RelateEntityArgs {
2513 source: src.id.clone(),
2514 expected_hash: Some(src.content_hash.clone()),
2515 rel_type: "ADDRESSES".to_string(),
2516 target: tgt.id.clone(),
2517 remove: true,
2518 description: None,
2519 },
2520 actor,
2521 Some(&client),
2522 None,
2523 )
2524 .expect("absent-edge remove must not refuse on policy");
2525 assert!(
2526 matches!(outcome.action, RelateAction::NoOpAbsent),
2527 "expected NoOpAbsent, got {:?}",
2528 outcome.action
2529 );
2530 }
2531 }
2532
2533 }