1use std::collections::{BTreeMap, HashMap};
5use std::path::Path;
6
7use indexmap::IndexMap;
8
9use memstead_schema::TypeDefinition;
10
11fn build_type_guidance(
19 warnings: &[WarningHint],
20 type_def: &TypeDefinition,
21) -> BTreeMap<String, Vec<String>> {
22 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
23 for w in warnings {
24 let entity_type = match w {
25 WarningHint::MissingRequiredSection { entity_type, .. }
26 | WarningHint::MissingRequiredField { entity_type, .. } => entity_type.as_str(),
27 _ => continue,
28 };
29 if !out.contains_key(entity_type) && entity_type == type_def.name {
30 out.insert(entity_type.to_string(), type_def.write_rules.clone());
31 }
32 }
33 out
34}
35
36use crate::engine_fallback_type;
37use crate::entity::generator::generate_markdown;
38use crate::entity::id::validate_and_derive_slug;
39use crate::entity::parser::parse_markdown;
40use crate::entity::store_builder::push_entities_into_store;
41use crate::entity::{Entity, EntityId, MetadataValue, Relationship, normalise_description};
42use crate::ops::{WarningHint, project_incoming};
43use crate::provenance::{Provenance, ProvenanceKind};
44use crate::runtime_validator::{
45 missing_required_fields, missing_required_sections, parse_metadata_value,
46 validate_section_content, validate_section_keys,
47};
48use crate::vcs::{Actor, ClientId, CommitContext};
49use crate::workspace::MountCapability;
50
51use super::super::{CreateEntityArgs, CreateEntityOutcome, Engine, EngineError};
52use super::{
53 EdgeRouteOutcome, make_stub, route_edge_validation, unknown_type_error,
54 validate_relation_target_grammar,
55};
56
57struct PreparedCreate {
62 mount_idx: usize,
63 id: EntityId,
64 title: String,
65 mem: String,
66 file_path: String,
67 markdown: String,
68 anchors: Vec<crate::anchor::Anchor>,
69 warnings: Vec<WarningHint>,
70 type_guidance: std::collections::BTreeMap<String, Vec<String>>,
71 relations_declared: Vec<crate::engine::outcomes::RelationDeclared>,
72 relation_targets: Vec<EntityId>,
75 type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
76}
77
78enum CreatePrepareOutcome {
81 Done(CreateEntityOutcome),
82 Prepared(PreparedCreate),
83}
84
85impl Engine {
86 pub fn create_entity(
109 &mut self,
110 args: CreateEntityArgs,
111 actor: Actor,
112 client: Option<&ClientId>,
113 note: Option<&str>,
114 ) -> Result<CreateEntityOutcome, EngineError> {
115 let drift_warnings = self.reload_if_stale(Some(&args.mem));
116 if args.relations.iter().any(|r| {
126 self.schemas.get(&args.mem).is_some_and(|s| {
127 s.relationship_acyclic(&r.rel_type)
128 || s.acyclic_set_containing(&r.rel_type).is_some()
129 }) || self
130 .schemas
131 .get(r.to.mem())
132 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
133 }) || self
134 .schemas
135 .get(&args.mem)
136 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
137 {
138 self.ensure_mems_loaded(None);
139 }
140 match self.prepare_create(args, None, drift_warnings)? {
141 CreatePrepareOutcome::Done(outcome) => Ok(outcome),
142 CreatePrepareOutcome::Prepared(prepared) => {
143 self.commit_prepared_create(prepared, actor, client, note)
144 }
145 }
146 }
147
148 fn prepare_create(
169 &mut self,
170 args: CreateEntityArgs,
171 batch_skeleton_ids: Option<&std::collections::HashSet<EntityId>>,
172 mut drift_warnings: Vec<WarningHint>,
173 ) -> Result<CreatePrepareOutcome, EngineError> {
174 let mut args = args;
175 for rel in &mut args.relations {
180 if let Ok(canonical) = crate::entity::id::validate_rel_type(&rel.rel_type) {
181 rel.rel_type = canonical;
182 }
183 }
184
185 let mut title_trimmed_warning: Option<crate::ops::WarningHint> = None;
194 let trimmed_title = args.title.trim();
195 if trimmed_title.len() != args.title.len() {
196 title_trimmed_warning = Some(crate::ops::WarningHint::TitleTrimmed {
197 original: args.title.clone(),
198 trimmed: trimmed_title.to_string(),
199 });
200 args.title = trimmed_title.to_string();
201 }
202
203 let mount_idx = self
205 .mounts
206 .iter()
207 .position(|m| m.mount.mem == args.mem)
208 .ok_or_else(|| self.unknown_mem_error(&args.mem))?;
209 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
210 return Err(EngineError::ReadOnlyMount(args.mem));
211 }
212
213 let schema = self
224 .schemas
225 .get(&args.mem)
226 .expect("schema present for every registered mount");
227 let type_def = schema
228 .get_type(&args.entity_type)
229 .ok_or_else(|| unknown_type_error(schema, &args.entity_type))?;
230
231 validate_section_keys(args.sections.keys().map(String::as_str), type_def.as_ref())?;
233 for key in args.metadata.keys() {
242 crate::runtime_validator::validate_reserved_metadata_key(key.as_str())?;
243 }
244 let validated_anchors = self.validate_anchor_inputs(&args.mem, &args.anchors)?;
254 validate_section_content(args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
259
260 let derivation = validate_and_derive_slug(&args.title)?;
267 let slug = derivation.slug.clone();
268 let id = EntityId::new(&args.mem, &slug);
269 crate::entity::id::enforce_id_length(id.as_ref())?;
270 if let Some(existing) = self.store.get(&id)
271 && !existing.stub
272 && !batch_skeleton_ids.is_some_and(|set| set.contains(&id))
273 {
274 return Err(EngineError::AlreadyExists {
275 id: id.to_string(),
276 existing_title: existing.title.clone(),
277 existing_is_stub: false,
278 });
279 }
280 let file_path = format!("{slug}.md");
281
282 let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
286 metadata.insert(
287 "type".to_string(),
288 MetadataValue::String(args.entity_type.clone()),
289 );
290 for (k, v) in &args.metadata {
291 let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
292 metadata.insert(k.clone(), parsed);
293 }
294
295 let today = self.now_iso();
303 let mut ignored_readonly: Vec<WarningHint> = Vec::new();
311 for field_def in &type_def.metadata_fields {
312 if field_def.init_timestamp || field_def.auto_timestamp {
313 if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
314 ignored_readonly.push(WarningHint::IgnoredReadonlyField {
315 field: field_def.key.clone(),
316 supplied: supplied.clone(),
317 });
318 }
319 metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
320 }
321 }
322
323 let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
337 if !missing_sections.is_empty() {
338 let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
339 if missing_sections
340 .iter()
341 .any(|m| m.entity_type == type_def.name)
342 {
343 type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
344 }
345 return Err(EngineError::MissingRequiredSection {
346 entity_type: type_def.name.clone(),
347 missing_count: missing_sections.len(),
348 sections: missing_sections,
349 type_guidance,
350 pre_announced_missing_fields: missing_required_fields(
359 type_def.as_ref(),
360 &args.metadata,
361 ),
362 });
363 }
364
365 let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
375 if !missing_fields.is_empty() {
376 let first = missing_fields[0].clone();
383 return Err(EngineError::RequiredFieldUnset {
384 field: first.key,
385 entity_type: first.entity_type,
386 field_description: Some(first.description),
387 enum_values: first.enum_values,
388 type_write_rules: type_def.write_rules.clone(),
389 on_create: true,
395 missing: missing_fields,
396 });
397 }
398
399 let mut warnings: Vec<WarningHint> = Vec::new();
400
401 warnings.append(&mut drift_warnings);
405
406 warnings.append(&mut ignored_readonly);
409
410 if !derivation.dropped_chars.is_empty() {
413 warnings.push(WarningHint::TitleCharsDroppedFromSlug {
414 title: args.title.trim().to_string(),
415 dropped_chars: derivation.dropped_chars.clone(),
416 slug: slug.clone(),
417 });
418 }
419
420 if let Some(w) = title_trimmed_warning.take() {
423 warnings.push(w);
424 }
425
426 let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
434
435 for rel in &args.relations {
450 validate_relation_target_grammar(&rel.to)?;
451 let target_mem = rel.to.mem().to_string();
452 super::validate_cross_mem_add_policy(self, &args.mem, &rel.to)?;
457 let target_type = self
463 .store
464 .get(&rel.to)
465 .map(|e| e.entity_type.clone())
466 .filter(|t| !t.is_empty());
467 let target_type = match target_type {
472 Some(t) => Some(t),
473 None => super::peek_deferred_target_type(self, &rel.to)?,
474 };
475 match route_edge_validation(
476 self,
477 &rel.rel_type,
478 args.entity_type.as_str(),
479 target_type.as_deref(),
480 &args.mem,
481 &target_mem,
482 &id,
483 &rel.to,
484 true,
485 )? {
486 EdgeRouteOutcome::Ok => {}
487 EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
488 }
489 let normalised = normalise_description(rel.description.as_deref());
492 super::validate_description_posture(
493 self,
494 &rel.rel_type,
495 normalised.as_deref(),
496 &args.mem,
497 &target_mem,
498 &id,
499 &rel.to,
500 )?;
501 super::validate_manual_authoring_posture(self, &rel.rel_type, &args.mem, &id, &rel.to)?;
505 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
516 .unwrap_or_else(|_| rel.rel_type.clone());
517 super::validate_edge_acyclicity(
518 &self.store,
519 schema.as_ref(),
520 &id,
521 args.entity_type.as_str(),
522 &rel.to,
523 &canonical,
524 )?;
525 }
526
527 let relationships: Vec<Relationship> = args
534 .relations
535 .iter()
536 .map(|r| Relationship {
537 rel_type: r.rel_type.clone(),
538 target: r.to.clone(),
539 description: normalise_description(r.description.as_deref()),
540 })
541 .collect();
542 let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
549 .relations
550 .iter()
551 .map(|r| crate::engine::outcomes::RelationDeclared {
552 rel_type: r.rel_type.clone(),
553 target: r.to.clone(),
554 target_was_stubbed: !self.store.contains(&r.to),
555 })
556 .collect();
557 let mut entity_for_render = Entity {
558 id: id.clone(),
559 title: args.title.clone(),
560 entity_type: args.entity_type.clone(),
561 mem: args.mem.clone(),
562 file_path: file_path.clone(),
563 metadata,
564 sections: args.sections,
565 relationships,
566 content_hash: String::new(),
567 stub: false,
568 stub_kind: None,
569 heading_spans: HashMap::new(),
570 raw_section_headings: Vec::new(),
571 };
572 let empty_prev_targets = std::collections::HashSet::new();
587 let (synthesised_relations, self_link_ignored) =
588 super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
589 if self_link_ignored {
590 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
591 }
592
593 let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
598 if !missing.is_empty() {
599 return Err(EngineError::WikiLinkWithoutRelation {
600 from_id: id.to_string(),
601 missing: missing
602 .into_iter()
603 .map(|(section_key, target)| crate::engine::MissingWikiLink {
604 section_key,
605 target_id: target.to_string(),
606 })
607 .collect(),
608 });
609 }
610
611 let markdown = generate_markdown(&entity_for_render, type_def.as_ref());
612
613 let auto_stubbed: Vec<EntityId> = synthesised_relations
629 .iter()
630 .filter_map(|rel| {
631 if !self.store.contains(&rel.target) {
632 Some(rel.target.clone())
633 } else {
634 None
635 }
636 })
637 .collect();
638 if !auto_stubbed.is_empty() {
639 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
640 from: id.clone(),
641 stubs: auto_stubbed,
642 });
643 }
644
645 for def in &type_def.sections {
658 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
659 continue;
660 }
661 let body = entity_for_render
670 .sections
671 .get(def.key.as_str())
672 .map(String::as_str)
673 .unwrap_or("");
674 if let Some(first) = crate::section_format::check_section_format(def, body)
675 .into_iter()
676 .next()
677 {
678 return Err(EngineError::SectionFormatRefused {
679 entity_type: entity_for_render.entity_type.clone(),
680 entity_id: id.to_string(),
681 violation: first,
682 });
683 }
684 }
685
686 let unsatisfied = crate::ops::health::unsatisfied_required_outgoing(
687 &entity_for_render,
688 type_def.as_ref(),
689 );
690 if !unsatisfied.is_empty() {
691 let blocked: Vec<_> = unsatisfied
695 .iter()
696 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
697 .cloned()
698 .collect();
699 if !blocked.is_empty() {
700 return Err(EngineError::RequiredOutgoingUnsatisfied {
701 entity_type: entity_for_render.entity_type.clone(),
702 entity_id: id.to_string(),
703 missing: blocked,
704 });
705 }
706 warnings.push(WarningHint::MissingRequiredOutgoing {
707 entity_type: entity_for_render.entity_type.clone(),
708 entity_id: id.clone(),
709 missing: unsatisfied,
710 });
711 }
712
713 let violated = crate::ops::health::unsatisfied_constraints(
718 &self.store,
719 &entity_for_render,
720 type_def.as_ref(),
721 None,
722 );
723 if !violated.is_empty() {
724 let blocked: Vec<_> = violated
725 .iter()
726 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
727 .cloned()
728 .collect();
729 if !blocked.is_empty() {
730 return Err(EngineError::ConstraintUnsatisfied {
731 entity_type: entity_for_render.entity_type.clone(),
732 entity_id: id.to_string(),
733 violations: blocked,
734 });
735 }
736 warnings.push(WarningHint::ConstraintUnsatisfied {
737 entity_type: entity_for_render.entity_type.clone(),
738 entity_id: id.clone(),
739 violations: violated,
740 });
741 }
742
743 if args.dry_run {
751 let prospective_hash = crate::entity::parser::compute_hash(&markdown);
752 let created_date = entity_for_render
756 .metadata
757 .get("created_date")
758 .map(|v| v.to_frontmatter_string())
759 .unwrap_or_default();
760 let incoming = project_incoming(self.store.incoming(&id));
764 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
765 return Ok(CreatePrepareOutcome::Done(CreateEntityOutcome {
766 id,
767 title: args.title,
768 mem: args.mem,
769 file_path,
770 content_hash: prospective_hash,
771 commit_sha: String::new(),
772 created_date,
773 warnings,
774 type_guidance,
775 incoming_count,
776 incoming,
777 relations_declared: relations_declared.clone(),
778 }));
779 }
780
781 Ok(CreatePrepareOutcome::Prepared(PreparedCreate {
782 mount_idx,
783 id,
784 title: args.title,
785 mem: args.mem,
786 file_path,
787 markdown,
788 anchors: validated_anchors,
789 warnings,
790 type_guidance,
791 relations_declared,
792 relation_targets: args.relations.iter().map(|r| r.to.clone()).collect(),
793 type_def,
794 }))
795 }
796
797 fn commit_prepared_create(
802 &mut self,
803 prepared: PreparedCreate,
804 actor: Actor,
805 client: Option<&ClientId>,
806 note: Option<&str>,
807 ) -> Result<CreateEntityOutcome, EngineError> {
808 let PreparedCreate {
809 mount_idx,
810 id,
811 title,
812 mem,
813 file_path,
814 markdown,
815 anchors: validated_anchors,
816 mut warnings,
817 type_guidance,
818 relations_declared,
819 relation_targets,
820 type_def,
821 } = prepared;
822
823 let signal_snapshot = {
828 let mut candidates: Vec<&EntityId> = vec![&id];
829 candidates.extend(relation_targets.iter());
830 crate::ops::signals::snapshot_levels(&self.store, &self.schemas, candidates)
831 };
832
833 let backend = self.mounts[mount_idx].backend.as_ref();
839 backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
840 if !validated_anchors.is_empty() {
845 super::stage_anchors_sidecar(backend, &id, &[], validated_anchors)?;
846 }
847 if let Some(schema) = self.schemas.get(&mem) {
852 for r in relations_declared
853 .iter()
854 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
855 {
856 let hash = self
857 .store
858 .get(&r.target)
859 .map(|e| e.content_hash.clone())
860 .unwrap_or_default();
861 let (from, rel, to) = (id.to_string(), r.rel_type.clone(), r.target.to_string());
862 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
863 }
864 }
865 let commit_subject = format!("memstead: create {id}");
866 let ctx = CommitContext {
867 actor,
868 client: client.cloned(),
869 tool: Some("create_entity"),
870 note: note.map(String::from),
871 role: self.current_role,
872 logical_operation_id: None,
873 entity_ids: None,
874 };
875 let commit_sha = backend.commit(&commit_subject, &ctx)?;
876
877 backend.append_provenance(
880 &Provenance::new(
881 std::time::SystemTime::now(),
882 ProvenanceKind::Create,
883 Some(id.to_string()),
884 actor,
885 client.cloned(),
886 note.map(String::from),
887 )
888 .with_role(self.current_role),
889 )?;
890
891 self.record_self_write(mount_idx, &commit_sha);
895 self.stamp_mutation_versions(mount_idx);
896
897 let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &mem)
900 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
901 let content_hash = parse_result.entity.content_hash.clone();
902
903 let created_date = parse_result
910 .entity
911 .metadata
912 .get("created_date")
913 .map(|v| v.to_frontmatter_string())
914 .unwrap_or_default();
915
916 let fallback = engine_fallback_type();
917 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
918 crate::entity::store_builder::remap_alias_target_edge_sources(
919 &mut self.store,
920 &self.schemas,
921 );
922
923 for target in &relation_targets {
931 if !self.store.contains(target) {
932 let kind = super::deferred_verified_stub_kind(self, target)?;
933 self.store.upsert(target.clone(), make_stub(target, kind));
934 }
935 }
936
937 self.invalidate_communities();
938 self.maintain_search_indexes(std::slice::from_ref(&id));
941
942 let incoming = project_incoming(self.store.incoming(&id));
946 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
947
948 warnings.extend(crate::ops::signals::crossing_warnings(
951 &self.store,
952 &self.schemas,
953 &signal_snapshot,
954 ));
955
956 if let Some(w) = self.note_missing_warning("create_entity", note) {
961 warnings.push(w);
962 }
963
964 Ok(CreateEntityOutcome {
965 id,
966 title,
967 mem,
968 file_path,
969 content_hash,
970 commit_sha,
971 created_date,
972 warnings,
973 type_guidance,
974 incoming_count,
975 incoming,
976 relations_declared,
977 })
978 }
979
980 pub fn batch_create(
1011 &mut self,
1012 creates: Vec<(CreateEntityArgs, Option<String>)>,
1013 actor: Actor,
1014 client: Option<&ClientId>,
1015 dry_run: bool,
1016 ) -> Result<crate::ops::BatchResult, EngineError> {
1017 use std::collections::HashSet;
1018
1019 if creates.is_empty() {
1020 return Ok(crate::ops::BatchResult {
1021 orphan_stubs_removed: Vec::new(),
1022 errors_suppressed: 0,
1023 applied: true,
1024 results: Vec::new(),
1025 succeeded: 0,
1026 failed: 0,
1027 commit_sha: String::new(),
1028 });
1029 }
1030
1031 let mut touched_mems: Vec<String> = creates.iter().map(|(a, _)| a.mem.clone()).collect();
1033 touched_mems.sort();
1034 touched_mems.dedup();
1035 for m in &touched_mems {
1036 self.reload_if_stale(Some(m));
1037 }
1038 if creates.iter().any(|(a, _)| {
1045 a.relations.iter().any(|r| {
1046 self.schemas.get(&a.mem).is_some_and(|s| {
1047 s.relationship_acyclic(&r.rel_type)
1048 || s.acyclic_set_containing(&r.rel_type).is_some()
1049 }) || self
1050 .schemas
1051 .get(r.to.mem())
1052 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1053 }) || self
1054 .schemas
1055 .get(&a.mem)
1056 .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1057 }) {
1058 self.ensure_mems_loaded(None);
1059 }
1060
1061 let store_snapshot = self.store.clone();
1062
1063 struct IdentityRow {
1067 id: Option<EntityId>,
1068 error: Option<EngineError>,
1069 }
1070 let mut rows: Vec<IdentityRow> = Vec::with_capacity(creates.len());
1071 let mut batch_ids: HashMap<EntityId, String> = HashMap::new();
1074 for (args, _) in &creates {
1075 let identity = (|| -> Result<EntityId, EngineError> {
1076 let title = args.title.trim();
1077 let slug = validate_and_derive_slug(title)?.slug;
1080 let id = EntityId::new(&args.mem, &slug);
1081 crate::entity::id::enforce_id_length(id.as_ref())?;
1082 if let Some(existing) = self.store.get(&id)
1083 && !existing.stub
1084 {
1085 return Err(EngineError::AlreadyExists {
1086 id: id.to_string(),
1087 existing_title: existing.title.clone(),
1088 existing_is_stub: false,
1089 });
1090 }
1091 if let Some(prior_title) = batch_ids.get(&id) {
1092 return Err(EngineError::AlreadyExists {
1096 id: id.to_string(),
1097 existing_title: prior_title.clone(),
1098 existing_is_stub: false,
1099 });
1100 }
1101 Ok(id)
1102 })();
1103 match identity {
1104 Ok(id) => {
1105 batch_ids.insert(id.clone(), args.title.trim().to_string());
1106 rows.push(IdentityRow {
1107 id: Some(id),
1108 error: None,
1109 });
1110 }
1111 Err(e) => rows.push(IdentityRow {
1112 id: None,
1113 error: Some(e),
1114 }),
1115 }
1116 }
1117
1118 for ((args, _), row) in creates.iter().zip(rows.iter()) {
1124 if let Some(id) = &row.id {
1125 let mut skeleton = make_stub(id, crate::entity::StubKind::ForwardReference);
1126 skeleton.stub = false;
1127 skeleton.stub_kind = None;
1128 skeleton.entity_type = args.entity_type.clone();
1129 skeleton.title = args.title.trim().to_string();
1130 self.store.upsert(id.clone(), skeleton);
1131 }
1132 }
1133
1134 let mut prepared: Vec<PreparedCreate> = Vec::new();
1138 let mut notes: Vec<Option<String>> = Vec::new();
1139 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1140 let mut ids_in_order: Vec<EntityId> = Vec::new();
1141 let skeleton_ids: HashSet<EntityId> = batch_ids.keys().cloned().collect();
1142 for (i, ((args, note), row)) in creates.into_iter().zip(rows).enumerate() {
1143 let fallback_id = row
1144 .id
1145 .clone()
1146 .unwrap_or_else(|| EntityId::new(&args.mem, "invalid-entry"));
1147 ids_in_order.push(fallback_id);
1148 if let Some(e) = row.error {
1149 errors.push((i, e));
1150 continue;
1151 }
1152 let mut args = args;
1156 args.dry_run = false;
1157 match self.prepare_create(args, Some(&skeleton_ids), Vec::new()) {
1158 Ok(CreatePrepareOutcome::Prepared(p)) => {
1159 for r in &p.relations_declared {
1167 self.store.add_edge(
1168 p.id.clone(),
1169 crate::store::Edge {
1170 rel_type: r.rel_type.clone(),
1171 target: r.target.clone(),
1172 source: crate::store::EdgeSource::Explicit,
1173 },
1174 );
1175 }
1176 ids_in_order[i] = p.id.clone();
1177 prepared.push(p);
1178 notes.push(note);
1179 }
1180 Ok(CreatePrepareOutcome::Done(_)) => unreachable!("dry_run forced off"),
1181 Err(e) => errors.push((i, e)),
1182 }
1183 }
1184
1185 if !errors.is_empty() {
1186 self.store = store_snapshot;
1189 self.discard_all_pending();
1190 let failed = errors.len();
1191 let mut error_map: std::collections::HashMap<usize, EngineError> =
1192 errors.into_iter().collect();
1193 let mut reported = 0usize;
1194 let mut suppressed = 0usize;
1195 let results: Vec<crate::ops::BatchEntry> = ids_in_order
1196 .into_iter()
1197 .enumerate()
1198 .map(|(i, id)| match error_map.remove(&i) {
1199 Some(e) => {
1200 if reported < Self::BATCH_ERROR_REPORT_CAP {
1201 reported += 1;
1202 crate::ops::BatchEntry {
1203 id,
1204 action: "error".to_string(),
1205 error: Some(super::update::batch_error_envelope(&e)),
1206 }
1207 } else {
1208 suppressed += 1;
1209 crate::ops::BatchEntry {
1210 id,
1211 action: "error".to_string(),
1212 error: None,
1213 }
1214 }
1215 }
1216 None => crate::ops::BatchEntry {
1217 id,
1218 action: "not_applied".to_string(),
1219 error: None,
1220 },
1221 })
1222 .collect();
1223 return Ok(crate::ops::BatchResult {
1224 orphan_stubs_removed: Vec::new(),
1225 errors_suppressed: suppressed,
1226 applied: false,
1227 results,
1228 succeeded: 0,
1229 failed,
1230 commit_sha: String::new(),
1231 });
1232 }
1233
1234 if dry_run {
1240 self.store = store_snapshot;
1241 self.discard_all_pending();
1242 let succeeded = prepared.len();
1243 let results: Vec<crate::ops::BatchEntry> = prepared
1244 .into_iter()
1245 .map(|p| crate::ops::BatchEntry {
1246 id: p.id,
1247 action: "created".to_string(),
1248 error: None,
1249 })
1250 .collect();
1251 return Ok(crate::ops::BatchResult {
1252 orphan_stubs_removed: Vec::new(),
1253 errors_suppressed: 0,
1254 applied: true,
1255 results,
1256 succeeded,
1257 failed: 0,
1258 commit_sha: String::new(),
1259 });
1260 }
1261
1262 for p in &prepared {
1264 if let Err(e) = self.mounts[p.mount_idx]
1265 .backend
1266 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1267 {
1268 self.store = store_snapshot;
1269 self.discard_all_pending();
1270 return Err(e.into());
1271 }
1272 if !p.anchors.is_empty()
1273 && let Err(e) = super::stage_anchors_sidecar(
1274 self.mounts[p.mount_idx].backend.as_ref(),
1275 &p.id,
1276 &[],
1277 p.anchors.clone(),
1278 )
1279 {
1280 self.store = store_snapshot;
1281 self.discard_all_pending();
1282 return Err(e);
1283 }
1284 if let Some(schema) = self.schemas.get(&p.mem) {
1287 for r in p
1288 .relations_declared
1289 .iter()
1290 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1291 {
1292 let hash = self
1293 .store
1294 .get(&r.target)
1295 .map(|e| e.content_hash.clone())
1296 .unwrap_or_default();
1297 let (from, rel, to) =
1298 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1299 if let Err(e) = super::stage_derivation_sidecar(
1300 self.mounts[p.mount_idx].backend.as_ref(),
1301 |s| s.set(&from, &rel, &to, &hash),
1302 ) {
1303 self.store = store_snapshot;
1304 self.discard_all_pending();
1305 return Err(e);
1306 }
1307 }
1308 }
1309 }
1310 let mut distinct_mounts: Vec<usize> = Vec::new();
1311 for p in &prepared {
1312 if !distinct_mounts.contains(&p.mount_idx) {
1313 distinct_mounts.push(p.mount_idx);
1314 }
1315 }
1316 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1317 for &m in &distinct_mounts {
1318 let entity_ids: Vec<String> = prepared
1319 .iter()
1320 .filter(|p| p.mount_idx == m)
1321 .map(|p| p.id.to_string())
1322 .collect();
1323 let count = entity_ids.len();
1324 let subject = format!("memstead: batch-create ({count} entities)");
1325 let note_lines: Vec<String> = prepared
1332 .iter()
1333 .zip(notes.iter())
1334 .filter(|(p, _)| p.mount_idx == m)
1335 .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.id)))
1336 .collect();
1337 let ctx = CommitContext {
1338 actor,
1339 client: client.cloned(),
1340 tool: Some("batch_create"),
1341 note: if note_lines.is_empty() {
1342 None
1343 } else {
1344 Some(note_lines.join("\n"))
1345 },
1346 role: self.current_role,
1347 logical_operation_id: None,
1348 entity_ids: Some(entity_ids),
1349 };
1350 match self.mounts[m].backend.commit(&subject, &ctx) {
1351 Ok(sha) => mount_commits.push((m, sha)),
1352 Err(e) => {
1353 self.store = store_snapshot;
1354 self.discard_all_pending();
1355 return Err(e.into());
1356 }
1357 }
1358 }
1359
1360 let fallback = engine_fallback_type();
1363 for (p, note) in prepared.iter().zip(notes.iter()) {
1364 let commit_sha = mount_commits
1365 .iter()
1366 .find(|(m, _)| *m == p.mount_idx)
1367 .map(|(_, s)| s.clone())
1368 .unwrap_or_default();
1369 self.mounts[p.mount_idx].backend.append_provenance(
1370 &Provenance::new(
1371 std::time::SystemTime::now(),
1372 ProvenanceKind::Create,
1373 Some(p.id.to_string()),
1374 actor,
1375 client.cloned(),
1376 note.clone(),
1377 )
1378 .with_role(self.current_role),
1379 )?;
1380 self.record_self_write(p.mount_idx, &commit_sha);
1381 self.stamp_mutation_versions(p.mount_idx);
1382 let parse_result =
1383 parse_markdown(&p.markdown, &p.file_path, p.type_def.as_ref(), &p.mem)
1384 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1385 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1386 }
1387 crate::entity::store_builder::remap_alias_target_edge_sources(
1388 &mut self.store,
1389 &self.schemas,
1390 );
1391 let mut out_of_batch_stubs: Vec<(EntityId, crate::entity::StubKind)> = Vec::new();
1394 for p in &prepared {
1395 for target in &p.relation_targets {
1396 if !self.store.contains(target) {
1397 let kind = super::deferred_verified_stub_kind(self, target)?;
1398 out_of_batch_stubs.push((target.clone(), kind));
1399 }
1400 }
1401 }
1402 for (target, kind) in out_of_batch_stubs {
1403 self.store.upsert(target.clone(), make_stub(&target, kind));
1404 }
1405 self.invalidate_communities();
1406 self.invalidate_search_indexes();
1407
1408 let commit_sha = mount_commits
1409 .last()
1410 .map(|(_, s)| s.clone())
1411 .unwrap_or_default();
1412 let succeeded = prepared.len();
1413 let results: Vec<crate::ops::BatchEntry> = prepared
1414 .into_iter()
1415 .map(|p| crate::ops::BatchEntry {
1416 id: p.id,
1417 action: "created".to_string(),
1418 error: None,
1419 })
1420 .collect();
1421 Ok(crate::ops::BatchResult {
1422 orphan_stubs_removed: Vec::new(),
1423 errors_suppressed: 0,
1424 applied: true,
1425 results,
1426 succeeded,
1427 failed: 0,
1428 commit_sha,
1429 })
1430 }
1431
1432 pub const BATCH_ERROR_REPORT_CAP: usize = 50;
1438
1439 pub fn create_entity_with_ctx(
1443 &mut self,
1444 args: CreateEntityArgs,
1445 ctx: &CommitContext<'_>,
1446 ) -> Result<CreateEntityOutcome, EngineError> {
1447 self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1448 }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453
1454 use indexmap::IndexMap;
1455 use tempfile::TempDir;
1456
1457 use crate::backend::MemBackend;
1458 use crate::engine::test_helpers::*;
1459 use crate::engine::{
1460 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
1461 };
1462 use crate::ops::WarningHint;
1463 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1464
1465 fn engine_with_required_outgoing_schema(tmp: &TempDir) -> Engine {
1470 let schemas_dir = tmp.path().join("schemas");
1471 let pkg = schemas_dir.join("reqout");
1472 std::fs::create_dir_all(pkg.join("types")).unwrap();
1473 std::fs::write(
1474 pkg.join("schema.yaml"),
1475 r#"name: reqout
1476version: 0.1.0
1477description: required-outgoing fixture
1478when_to_use: tests
1479types:
1480 - task
1481relationships:
1482 mode: strict
1483 definitions:
1484 - name: PART_OF
1485 description: hier
1486 default_weight: 3.0
1487 - name: _default
1488 description: fallback
1489 default_weight: 1.0
1490community:
1491 resolution: 1.0
1492 seed: 42
1493"#,
1494 )
1495 .unwrap();
1496 std::fs::write(
1497 pkg.join("types").join("task.yaml"),
1498 r#"name: task
1499description: t
1500when_to_use: tests
1501sections:
1502 - key: body
1503 heading: Body
1504 required: true
1505 search_weight: 10.0
1506 catch_all: true
1507 write_rules: []
1508metadata_fields: []
1509title_weight: 100.0
1510text_fields:
1511 - body
1512hierarchy_relationship: PART_OF
1513no_self_loop_relationships: []
1514updatable_fields:
1515 - title
1516 - body
1517health_required_fields:
1518 - body
1519staleness_threshold_days: 90
1520required_outgoing:
1521 - relationships: [PART_OF]
1522 cardinality: at_least_one
1523write_rules: []
1524"#,
1525 )
1526 .unwrap();
1527 let mem_dir = tmp.path().join("mem");
1528 std::fs::create_dir_all(&mem_dir).unwrap();
1529 let writer = FilesystemMemWriter::new(mem_dir.clone());
1530 let mount = crate::workspace::Mount {
1531 mem: "tasks".to_string(),
1532 schema: Some(memstead_schema::SchemaRef::new(
1533 "reqout",
1534 semver::Version::new(0, 1, 0),
1535 )),
1536 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1537 capability: crate::workspace::MountCapability::Write,
1538 lifecycle: crate::workspace::MountLifecycle::Eager,
1539 cross_linkable: true,
1540 migration_target: None,
1541 };
1542 Engine::from_mounts_with_schemas_dir(
1543 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1544 Some(&schemas_dir),
1545 )
1546 .unwrap()
1547 }
1548
1549 fn task_create_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1550 let mut sections = IndexMap::new();
1551 sections.insert("body".to_string(), "a task body.".to_string());
1552 CreateEntityArgs {
1553 anchors: Vec::new(),
1554 mem: "tasks".to_string(),
1555 title: title.to_string(),
1556 entity_type: "task".to_string(),
1557 sections,
1558 metadata: IndexMap::new(),
1559 relations,
1560 dry_run: false,
1561 }
1562 }
1563
1564 fn missing_outgoing_of(warnings: &[WarningHint]) -> Vec<(Vec<String>, String)> {
1565 warnings
1566 .iter()
1567 .filter_map(|w| match w {
1568 WarningHint::MissingRequiredOutgoing { missing, .. } => Some(
1569 missing
1570 .iter()
1571 .map(|b| (b.relationships.clone(), b.cardinality.clone()))
1572 .collect::<Vec<_>>(),
1573 ),
1574 _ => None,
1575 })
1576 .flatten()
1577 .collect()
1578 }
1579
1580 fn engine_with_constraints_schema(
1586 tmp: &TempDir,
1587 requires_when_severity: &str,
1588 required_outgoing_severity: &str,
1589 ) -> Engine {
1590 let schemas_dir = tmp.path().join("schemas");
1591 let pkg = schemas_dir.join("constr");
1592 std::fs::create_dir_all(pkg.join("types")).unwrap();
1593 std::fs::write(
1594 pkg.join("schema.yaml"),
1595 r#"name: constr
1596version: 0.1.0
1597description: constraint fixture
1598when_to_use: tests
1599types:
1600 - task
1601relationships:
1602 mode: strict
1603 definitions:
1604 - name: PART_OF
1605 description: hier
1606 default_weight: 3.0
1607 - name: _default
1608 description: fallback
1609 default_weight: 1.0
1610community:
1611 resolution: 1.0
1612 seed: 42
1613"#,
1614 )
1615 .unwrap();
1616 std::fs::write(
1617 pkg.join("types").join("task.yaml"),
1618 format!(
1619 r#"name: task
1620description: t
1621when_to_use: tests
1622sections:
1623 - key: body
1624 heading: Body
1625 required: true
1626 search_weight: 10.0
1627 catch_all: true
1628 write_rules: []
1629metadata_fields:
1630 - key: status
1631 description: workflow state
1632 field_type: string
1633 enum_values: [open, checked]
1634 - key: checked_by
1635 description: who checked
1636 field_type: string
1637title_weight: 100.0
1638text_fields:
1639 - body
1640hierarchy_relationship: PART_OF
1641no_self_loop_relationships: [PART_OF]
1642updatable_fields:
1643 - title
1644 - body
1645 - status
1646 - checked_by
1647health_required_fields:
1648 - body
1649staleness_threshold_days: 90
1650required_outgoing:
1651 - relationships: [PART_OF]
1652 cardinality: at_least_one
1653 severity: {required_outgoing_severity}
1654constraints:
1655 - kind: requires_when
1656 field: checked_by
1657 when_field: status
1658 when_value: checked
1659 severity: {requires_when_severity}
1660write_rules: []
1661"#
1662 ),
1663 )
1664 .unwrap();
1665 let mem_dir = tmp.path().join("mem");
1666 std::fs::create_dir_all(&mem_dir).unwrap();
1667 let writer = FilesystemMemWriter::new(mem_dir.clone());
1668 let mount = crate::workspace::Mount {
1669 mem: "tasks".to_string(),
1670 schema: Some(memstead_schema::SchemaRef::new(
1671 "constr",
1672 semver::Version::new(0, 1, 0),
1673 )),
1674 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1675 capability: crate::workspace::MountCapability::Write,
1676 lifecycle: crate::workspace::MountLifecycle::Eager,
1677 cross_linkable: true,
1678 migration_target: None,
1679 };
1680 Engine::from_mounts_with_schemas_dir(
1681 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1682 Some(&schemas_dir),
1683 )
1684 .unwrap()
1685 }
1686
1687 fn checked_task_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1688 let mut args = task_create_args(title, relations);
1689 args.metadata
1690 .insert("status".to_string(), "checked".to_string());
1691 args
1692 }
1693
1694 #[test]
1699 fn create_warns_requires_when_and_still_commits() {
1700 let tmp = TempDir::new().unwrap();
1701 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1702 let (actor, client) = cli_actor();
1703
1704 let outcome = engine
1705 .create_entity(
1706 checked_task_args("Unbacked Judgment", vec![]),
1707 actor,
1708 Some(&client),
1709 None,
1710 )
1711 .unwrap();
1712 assert!(!outcome.commit_sha.is_empty(), "warn tier never blocks");
1713 let violation = outcome
1714 .warnings
1715 .iter()
1716 .find_map(|w| match w {
1717 WarningHint::ConstraintUnsatisfied { violations, .. } => Some(violations.clone()),
1718 _ => None,
1719 })
1720 .expect("CONSTRAINT_UNSATISFIED warning present");
1721 assert_eq!(violation.len(), 1);
1722 let crate::ops::health::UnsatisfiedConstraint::RequiresWhen {
1723 field,
1724 when_field,
1725 when_value,
1726 ..
1727 } = &violation[0]
1728 else {
1729 panic!("expected requires_when violation");
1730 };
1731 assert_eq!(field, "checked_by");
1732 assert_eq!(when_field, "status");
1733 assert_eq!(when_value, "checked");
1734
1735 let reports =
1737 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
1738 assert_eq!(reports.len(), 1);
1739 assert_eq!(reports[0].id, outcome.id);
1740 assert_eq!(reports[0].violations.len(), 1);
1741 let crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. } =
1742 &reports[0].violations[0]
1743 else {
1744 panic!("expected requires_when finding");
1745 };
1746 assert_eq!(field, "checked_by");
1747
1748 let mut satisfied_args = checked_task_args("Backed Judgment", vec![]);
1751 satisfied_args
1752 .metadata
1753 .insert("checked_by".to_string(), "reviewer-a".to_string());
1754 let satisfied = engine
1755 .create_entity(satisfied_args, actor, Some(&client), None)
1756 .unwrap();
1757 assert!(
1758 !satisfied
1759 .warnings
1760 .iter()
1761 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1762 "satisfied constraint emits no warning: {:?}",
1763 satisfied.warnings
1764 );
1765
1766 let untriggered = engine
1769 .create_entity(
1770 task_create_args("Open Task", vec![]),
1771 actor,
1772 Some(&client),
1773 None,
1774 )
1775 .unwrap();
1776 assert!(
1777 !untriggered
1778 .warnings
1779 .iter()
1780 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1781 "untriggered constraint emits no warning"
1782 );
1783 }
1784
1785 #[test]
1789 fn create_refuses_block_tier_requires_when() {
1790 let tmp = TempDir::new().unwrap();
1791 let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1792 let (actor, client) = cli_actor();
1793
1794 let err = engine
1795 .create_entity(
1796 checked_task_args("Unbacked Judgment", vec![]),
1797 actor,
1798 Some(&client),
1799 None,
1800 )
1801 .unwrap_err();
1802 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1803 let details = err.details();
1804 assert_eq!(details["violations"][0]["field"], "checked_by");
1805 assert_eq!(details["violations"][0]["severity"], "block");
1806 assert_eq!(
1807 engine.store().all_entities().count(),
1808 0,
1809 "refused create leaves nothing behind"
1810 );
1811
1812 let mut ok_args = checked_task_args("Backed Judgment", vec![]);
1814 ok_args
1815 .metadata
1816 .insert("checked_by".to_string(), "reviewer-a".to_string());
1817 engine
1818 .create_entity(ok_args, actor, Some(&client), None)
1819 .unwrap();
1820 }
1821
1822 #[test]
1828 fn create_refuses_block_tier_required_outgoing() {
1829 let tmp = TempDir::new().unwrap();
1830 let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1831 let (actor, client) = cli_actor();
1832
1833 let err = engine
1834 .create_entity(
1835 task_create_args("Orphan Task", vec![]),
1836 actor,
1837 Some(&client),
1838 None,
1839 )
1840 .unwrap_err();
1841 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1842 let details = err.details();
1843 assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
1844 assert_eq!(details["missing"][0]["severity"], "block");
1845 assert_eq!(engine.store().all_entities().count(), 0);
1846
1847 let outcome = engine.create_entity(
1852 task_create_args(
1853 "Child Task",
1854 vec![crate::ops::RelateArg {
1855 to: crate::entity::EntityId("tasks--parent".to_string()),
1856 rel_type: "PART_OF".to_string(),
1857 description: None,
1858 }],
1859 ),
1860 actor,
1861 Some(&client),
1862 None,
1863 );
1864 assert!(
1865 outcome.is_ok(),
1866 "satisfied block-tier create passes: {:?}",
1867 outcome.err()
1868 );
1869 }
1870
1871 #[test]
1875 fn update_enforces_requires_when_by_severity() {
1876 let (actor, client) = cli_actor();
1877 let set_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
1878 let current = engine.get_entity(id).unwrap().content_hash.clone();
1879 let mut metadata = IndexMap::new();
1880 metadata.insert("status".to_string(), "checked".to_string());
1881 engine.update_entity(
1882 crate::engine::UpdateEntityArgs {
1883 anchors: Vec::new(),
1884 id: id.clone(),
1885 expected_hash: Some(current),
1886 sections: IndexMap::new(),
1887 append_sections: IndexMap::new(),
1888 patch_sections: IndexMap::new(),
1889 metadata,
1890 metadata_unset: Vec::new(),
1891 declare_relations: vec![],
1892 dry_run: false,
1893 relations_unset: Vec::new(),
1894 anchors_unset: Vec::new(),
1895 },
1896 actor,
1897 Some(&client),
1898 None,
1899 )
1900 };
1901
1902 let tmp = TempDir::new().unwrap();
1904 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1905 let a = engine
1906 .create_entity(
1907 task_create_args("Task A", vec![]),
1908 actor,
1909 Some(&client),
1910 None,
1911 )
1912 .unwrap();
1913 let outcome = set_checked(&mut engine, &a.id).unwrap();
1914 assert!(!outcome.commit_sha.is_empty());
1915 assert!(
1916 outcome
1917 .warnings
1918 .iter()
1919 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1920 "warn-tier update carries the warning: {:?}",
1921 outcome.warnings
1922 );
1923
1924 let tmp = TempDir::new().unwrap();
1927 let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1928 let b = engine
1929 .create_entity(
1930 task_create_args("Task B", vec![]),
1931 actor,
1932 Some(&client),
1933 None,
1934 )
1935 .unwrap();
1936 let err = set_checked(&mut engine, &b.id).unwrap_err();
1937 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1938 assert!(
1939 !engine
1940 .get_entity(&b.id)
1941 .unwrap()
1942 .metadata
1943 .contains_key("status"),
1944 "refused update leaves the entity unchanged"
1945 );
1946 }
1947
1948 #[test]
1952 fn relate_remove_refuses_block_tier_required_outgoing() {
1953 let tmp = TempDir::new().unwrap();
1954 let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1955 let (actor, client) = cli_actor();
1956 let parent_id = crate::entity::EntityId("tasks--parent".to_string());
1957 let child = engine
1958 .create_entity(
1959 task_create_args(
1960 "Child Task",
1961 vec![crate::ops::RelateArg {
1962 to: parent_id.clone(),
1963 rel_type: "PART_OF".to_string(),
1964 description: None,
1965 }],
1966 ),
1967 actor,
1968 Some(&client),
1969 None,
1970 )
1971 .unwrap();
1972
1973 let err = engine
1974 .relate_entity(
1975 crate::engine::RelateEntityArgs {
1976 source: child.id.clone(),
1977 target: parent_id.clone(),
1978 rel_type: "PART_OF".to_string(),
1979 description: None,
1980 remove: true,
1981 expected_hash: None,
1982 dry_run: false,
1983 },
1984 actor,
1985 Some(&client),
1986 None,
1987 )
1988 .unwrap_err();
1989 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1990 assert!(
1991 engine
1992 .get_entity(&child.id)
1993 .unwrap()
1994 .relationships
1995 .iter()
1996 .any(|r| r.rel_type == "PART_OF" && r.target == parent_id),
1997 "refused remove leaves the edge in place"
1998 );
1999 }
2000
2001 fn engine_with_conditional_ro_schema(tmp: &TempDir, severity: &str) -> Engine {
2006 let schemas_dir = tmp.path().join("schemas");
2007 let pkg = schemas_dir.join("condro");
2008 std::fs::create_dir_all(pkg.join("types")).unwrap();
2009 std::fs::write(
2010 pkg.join("schema.yaml"),
2011 r#"name: condro
2012version: 0.1.0
2013description: conditional required_outgoing fixture
2014when_to_use: tests
2015types:
2016 - task
2017relationships:
2018 mode: strict
2019 definitions:
2020 - name: PART_OF
2021 description: hier
2022 default_weight: 3.0
2023 - name: _default
2024 description: fallback
2025 default_weight: 1.0
2026community:
2027 resolution: 1.0
2028 seed: 42
2029"#,
2030 )
2031 .unwrap();
2032 std::fs::write(
2033 pkg.join("types").join("task.yaml"),
2034 format!(
2035 r#"name: task
2036description: t
2037when_to_use: tests
2038sections:
2039 - key: body
2040 heading: Body
2041 required: true
2042 search_weight: 10.0
2043 catch_all: true
2044 write_rules: []
2045metadata_fields:
2046 - key: status
2047 description: workflow state
2048 field_type: string
2049 enum_values: [open, checked]
2050title_weight: 100.0
2051text_fields:
2052 - body
2053hierarchy_relationship: PART_OF
2054no_self_loop_relationships: []
2055updatable_fields:
2056 - title
2057 - body
2058 - status
2059health_required_fields:
2060 - body
2061staleness_threshold_days: 90
2062required_outgoing:
2063 - relationships: [PART_OF]
2064 cardinality: at_least_one
2065 severity: {severity}
2066 when_field: status
2067 when_value: checked
2068write_rules: []
2069"#
2070 ),
2071 )
2072 .unwrap();
2073 let mem_dir = tmp.path().join("mem");
2074 std::fs::create_dir_all(&mem_dir).unwrap();
2075 let writer = FilesystemMemWriter::new(mem_dir.clone());
2076 let mount = crate::workspace::Mount {
2077 mem: "tasks".to_string(),
2078 schema: Some(memstead_schema::SchemaRef::new(
2079 "condro",
2080 semver::Version::new(0, 1, 0),
2081 )),
2082 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2083 capability: crate::workspace::MountCapability::Write,
2084 lifecycle: crate::workspace::MountLifecycle::Eager,
2085 cross_linkable: true,
2086 migration_target: None,
2087 };
2088 Engine::from_mounts_with_schemas_dir(
2089 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
2090 Some(&schemas_dir),
2091 )
2092 .unwrap()
2093 }
2094
2095 #[test]
2100 fn create_ignores_conditional_required_outgoing_when_unarmed() {
2101 let tmp = TempDir::new().unwrap();
2102 let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2103 let (actor, client) = cli_actor();
2104
2105 let unset = engine
2106 .create_entity(
2107 task_create_args("Unset Status", vec![]),
2108 actor,
2109 Some(&client),
2110 None,
2111 )
2112 .expect("unset trigger field must create");
2113 let mut open_args = task_create_args("Open Task", vec![]);
2114 open_args
2115 .metadata
2116 .insert("status".to_string(), "open".to_string());
2117 let open = engine
2118 .create_entity(open_args, actor, Some(&client), None)
2119 .expect("non-trigger value must create");
2120 for outcome in [&unset, &open] {
2121 assert!(
2122 !outcome
2123 .warnings
2124 .iter()
2125 .any(|w| matches!(w, WarningHint::MissingRequiredOutgoing { .. })),
2126 "unarmed block emits no warning: {:?}",
2127 outcome.warnings
2128 );
2129 }
2130 }
2131
2132 #[test]
2138 fn create_refuses_block_tier_conditional_required_outgoing() {
2139 let tmp = TempDir::new().unwrap();
2140 let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2141 let (actor, client) = cli_actor();
2142
2143 let err = engine
2144 .create_entity(
2145 checked_task_args("Checked Orphan", vec![]),
2146 actor,
2147 Some(&client),
2148 None,
2149 )
2150 .unwrap_err();
2151 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2152 let details = err.details();
2153 assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
2154 assert_eq!(details["missing"][0]["when_field"], "status");
2155 assert_eq!(details["missing"][0]["when_value"], "checked");
2156 assert_eq!(engine.store().all_entities().count(), 0);
2157
2158 let outcome = engine.create_entity(
2159 checked_task_args(
2160 "Checked Child",
2161 vec![crate::ops::RelateArg {
2162 to: crate::entity::EntityId("tasks--parent".to_string()),
2163 rel_type: "PART_OF".to_string(),
2164 description: None,
2165 }],
2166 ),
2167 actor,
2168 Some(&client),
2169 None,
2170 );
2171 assert!(
2172 outcome.is_ok(),
2173 "satisfied armed block passes: {:?}",
2174 outcome.err()
2175 );
2176 }
2177
2178 #[test]
2182 fn create_warns_conditional_required_outgoing_at_warn_tier() {
2183 let tmp = TempDir::new().unwrap();
2184 let mut engine = engine_with_conditional_ro_schema(&tmp, "warn");
2185 let (actor, client) = cli_actor();
2186
2187 let outcome = engine
2188 .create_entity(
2189 checked_task_args("Checked Orphan", vec![]),
2190 actor,
2191 Some(&client),
2192 None,
2193 )
2194 .expect("warn tier lands the write");
2195 assert!(!outcome.commit_sha.is_empty());
2196 let block = outcome
2197 .warnings
2198 .iter()
2199 .find_map(|w| match w {
2200 WarningHint::MissingRequiredOutgoing { missing, .. } => missing.first(),
2201 _ => None,
2202 })
2203 .expect("warning carries the unsatisfied block");
2204 assert_eq!(block.relationships, vec!["PART_OF".to_string()]);
2205 assert_eq!(block.when_field.as_deref(), Some("status"));
2206 assert_eq!(block.when_value.as_deref(), Some("checked"));
2207 }
2208
2209 #[test]
2213 fn update_flip_to_trigger_value_enforces_conditional_block() {
2214 let (actor, client) = cli_actor();
2215 let flip_to_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
2216 let current = engine.get_entity(id).unwrap().content_hash.clone();
2217 let mut metadata = IndexMap::new();
2218 metadata.insert("status".to_string(), "checked".to_string());
2219 engine.update_entity(
2220 crate::engine::UpdateEntityArgs {
2221 anchors: Vec::new(),
2222 id: id.clone(),
2223 expected_hash: Some(current),
2224 sections: IndexMap::new(),
2225 append_sections: IndexMap::new(),
2226 patch_sections: IndexMap::new(),
2227 metadata,
2228 metadata_unset: Vec::new(),
2229 declare_relations: vec![],
2230 dry_run: false,
2231 relations_unset: Vec::new(),
2232 anchors_unset: Vec::new(),
2233 },
2234 actor,
2235 Some(&client),
2236 None,
2237 )
2238 };
2239
2240 let tmp = TempDir::new().unwrap();
2242 let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2243 let mut open_args = task_create_args("Task A", vec![]);
2244 open_args
2245 .metadata
2246 .insert("status".to_string(), "open".to_string());
2247 let a = engine
2248 .create_entity(open_args, actor, Some(&client), None)
2249 .unwrap();
2250 let err = flip_to_checked(&mut engine, &a.id).unwrap_err();
2251 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2252 assert_eq!(
2253 engine.get_entity(&a.id).unwrap().metadata["status"].to_frontmatter_string(),
2254 "open",
2255 "refused update leaves the entity unchanged"
2256 );
2257
2258 let tmp = TempDir::new().unwrap();
2260 let mut engine = engine_with_conditional_ro_schema(&tmp, "warn");
2261 let b = engine
2262 .create_entity(
2263 task_create_args("Task B", vec![]),
2264 actor,
2265 Some(&client),
2266 None,
2267 )
2268 .unwrap();
2269 let outcome = flip_to_checked(&mut engine, &b.id).unwrap();
2270 assert!(!outcome.commit_sha.is_empty());
2271 assert!(
2272 outcome
2273 .warnings
2274 .iter()
2275 .any(|w| matches!(w, WarningHint::MissingRequiredOutgoing { .. })),
2276 "warn-tier flip carries the warning: {:?}",
2277 outcome.warnings
2278 );
2279 }
2280
2281 fn engine_with_acyclic_set_schema(tmp: &TempDir, with_set: bool) -> Engine {
2286 let schemas_dir = tmp.path().join("schemas");
2287 let pkg = schemas_dir.join("argchain");
2288 std::fs::create_dir_all(pkg.join("types")).unwrap();
2289 let sets = if with_set {
2290 " acyclic_sets:\n - [GROUNDS, CONCLUDES]\n"
2291 } else {
2292 ""
2293 };
2294 std::fs::write(
2295 pkg.join("schema.yaml"),
2296 format!(
2297 r#"name: argchain
2298version: 0.1.0
2299description: acyclicity-set fixture
2300when_to_use: tests
2301types:
2302 - claim
2303relationships:
2304 mode: strict
2305{sets} definitions:
2306 - name: GROUNDS
2307 description: g
2308 default_weight: 3.0
2309 - name: CONCLUDES
2310 description: c
2311 default_weight: 3.0
2312 - name: PART_OF
2313 description: hier
2314 default_weight: 1.0
2315 - name: _default
2316 description: fallback
2317 default_weight: 1.0
2318community:
2319 resolution: 1.0
2320 seed: 42
2321"#
2322 ),
2323 )
2324 .unwrap();
2325 std::fs::write(
2326 pkg.join("types").join("claim.yaml"),
2327 r#"name: claim
2328description: t
2329when_to_use: tests
2330sections:
2331 - key: body
2332 heading: Body
2333 required: true
2334 search_weight: 10.0
2335 catch_all: true
2336 write_rules: []
2337metadata_fields: []
2338title_weight: 100.0
2339text_fields:
2340 - body
2341hierarchy_relationship: PART_OF
2342no_self_loop_relationships: []
2343updatable_fields:
2344 - title
2345 - body
2346health_required_fields:
2347 - body
2348staleness_threshold_days: 90
2349write_rules: []
2350"#,
2351 )
2352 .unwrap();
2353 let mem_dir = tmp.path().join("mem");
2354 std::fs::create_dir_all(&mem_dir).unwrap();
2355 let writer = FilesystemMemWriter::new(mem_dir.clone());
2356 let mount = crate::workspace::Mount {
2357 mem: "arg".to_string(),
2358 schema: Some(memstead_schema::SchemaRef::new(
2359 "argchain",
2360 semver::Version::new(0, 1, 0),
2361 )),
2362 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2363 capability: crate::workspace::MountCapability::Write,
2364 lifecycle: crate::workspace::MountLifecycle::Eager,
2365 cross_linkable: true,
2366 migration_target: None,
2367 };
2368 Engine::from_mounts_with_schemas_dir(
2369 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
2370 Some(&schemas_dir),
2371 )
2372 .unwrap()
2373 }
2374
2375 fn claim_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
2376 let mut sections = IndexMap::new();
2377 sections.insert("body".to_string(), "a claim body.".to_string());
2378 CreateEntityArgs {
2379 anchors: Vec::new(),
2380 mem: "arg".to_string(),
2381 title: title.to_string(),
2382 entity_type: "claim".to_string(),
2383 sections,
2384 metadata: IndexMap::new(),
2385 relations,
2386 dry_run: false,
2387 }
2388 }
2389
2390 fn relate(
2391 engine: &mut Engine,
2392 from: &str,
2393 rel: &str,
2394 to: &str,
2395 ) -> Result<crate::engine::RelateEntityOutcome, crate::engine::EngineError> {
2396 let (actor, client) = cli_actor();
2397 engine.relate_entity(
2398 crate::engine::RelateEntityArgs {
2399 source: crate::entity::EntityId(from.to_string()),
2400 target: crate::entity::EntityId(to.to_string()),
2401 rel_type: rel.to_string(),
2402 description: None,
2403 remove: false,
2404 expected_hash: None,
2405 dry_run: false,
2406 },
2407 actor,
2408 Some(&client),
2409 None,
2410 )
2411 }
2412
2413 #[test]
2420 fn relate_refuses_mixed_type_cycle_in_declared_set() {
2421 let tmp = TempDir::new().unwrap();
2422 let mut engine = engine_with_acyclic_set_schema(&tmp, true);
2423 let (actor, client) = cli_actor();
2424 for t in ["A", "B", "C"] {
2425 engine
2426 .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2427 .unwrap();
2428 }
2429 relate(&mut engine, "arg--a", "GROUNDS", "arg--b").unwrap();
2430 relate(&mut engine, "arg--b", "CONCLUDES", "arg--c").unwrap();
2431
2432 let err = relate(&mut engine, "arg--c", "GROUNDS", "arg--a").unwrap_err();
2433 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2434 let details = err.details();
2435 assert_eq!(
2436 details["acyclic_set"],
2437 serde_json::json!(["GROUNDS", "CONCLUDES"])
2438 );
2439 assert_eq!(
2440 details["existing_path"],
2441 serde_json::json!(["arg--a", "arg--b", "arg--c"])
2442 );
2443 assert_eq!(
2444 details["existing_path_rel_types"],
2445 serde_json::json!(["GROUNDS", "CONCLUDES"]),
2446 "one rel-type per hop, mixing both members"
2447 );
2448
2449 let tmp = TempDir::new().unwrap();
2452 let mut engine = engine_with_acyclic_set_schema(&tmp, false);
2453 for t in ["A", "B", "C"] {
2454 engine
2455 .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2456 .unwrap();
2457 }
2458 relate(&mut engine, "arg--a", "GROUNDS", "arg--b").unwrap();
2459 relate(&mut engine, "arg--b", "CONCLUDES", "arg--c").unwrap();
2460 relate(&mut engine, "arg--c", "GROUNDS", "arg--a")
2461 .expect("without the set declaration the cycle is legal");
2462 }
2463
2464 #[test]
2468 fn create_inline_and_update_declared_refuse_set_cycle() {
2469 let tmp = TempDir::new().unwrap();
2470 let mut engine = engine_with_acyclic_set_schema(&tmp, true);
2471 let (actor, client) = cli_actor();
2472
2473 engine
2477 .create_entity(
2478 claim_args(
2479 "Alpha",
2480 vec![crate::ops::RelateArg {
2481 to: crate::entity::EntityId("arg--ghost".to_string()),
2482 rel_type: "GROUNDS".to_string(),
2483 description: None,
2484 }],
2485 ),
2486 actor,
2487 Some(&client),
2488 None,
2489 )
2490 .unwrap();
2491 let err = engine
2492 .create_entity(
2493 claim_args(
2494 "Ghost",
2495 vec![crate::ops::RelateArg {
2496 to: crate::entity::EntityId("arg--alpha".to_string()),
2497 rel_type: "CONCLUDES".to_string(),
2498 description: None,
2499 }],
2500 ),
2501 actor,
2502 Some(&client),
2503 None,
2504 )
2505 .expect_err("cycle-closing create.relations[] must refuse");
2506 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2507 assert_eq!(
2508 err.details()["acyclic_set"],
2509 serde_json::json!(["GROUNDS", "CONCLUDES"])
2510 );
2511
2512 for t in ["D", "E"] {
2515 engine
2516 .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2517 .unwrap();
2518 }
2519 relate(&mut engine, "arg--d", "GROUNDS", "arg--e").unwrap();
2520 let e_id = crate::entity::EntityId("arg--e".to_string());
2521 let current = engine.get_entity(&e_id).unwrap().content_hash.clone();
2522 let err = engine
2523 .update_entity(
2524 crate::engine::UpdateEntityArgs {
2525 anchors: Vec::new(),
2526 id: e_id,
2527 expected_hash: Some(current),
2528 sections: IndexMap::new(),
2529 append_sections: IndexMap::new(),
2530 patch_sections: IndexMap::new(),
2531 metadata: IndexMap::new(),
2532 metadata_unset: Vec::new(),
2533 declare_relations: vec![crate::ops::RelateArg {
2534 to: crate::entity::EntityId("arg--d".to_string()),
2535 rel_type: "CONCLUDES".to_string(),
2536 description: None,
2537 }],
2538 dry_run: false,
2539 relations_unset: Vec::new(),
2540 anchors_unset: Vec::new(),
2541 },
2542 actor,
2543 Some(&client),
2544 None,
2545 )
2546 .expect_err("cycle-closing declare_relations must refuse");
2547 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2548 assert_eq!(
2549 err.details()["acyclic_set"],
2550 serde_json::json!(["GROUNDS", "CONCLUDES"])
2551 );
2552 }
2553
2554 #[test]
2558 fn boot_drops_cycle_closing_edge_in_declared_acyclic_set() {
2559 let tmp = TempDir::new().unwrap();
2560 let mem_dir = tmp.path().join("mem");
2564 std::fs::create_dir_all(&mem_dir).unwrap();
2565 std::fs::write(
2566 mem_dir.join("a.md"),
2567 "---\ntype: claim\n---\n# A\n\n## Body\n\nfirst.\n\n## Relationships\n\n- **GROUNDS**: [[arg--b]]\n",
2568 )
2569 .unwrap();
2570 std::fs::write(
2571 mem_dir.join("b.md"),
2572 "---\ntype: claim\n---\n# B\n\n## Body\n\nsecond.\n\n## Relationships\n\n- **CONCLUDES**: [[arg--a]]\n",
2573 )
2574 .unwrap();
2575 let engine = engine_with_acyclic_set_schema(&tmp, true);
2576
2577 let surviving: usize = engine
2578 .store()
2579 .all_entities()
2580 .map(|e| {
2581 engine
2582 .store()
2583 .outgoing(&e.id)
2584 .iter()
2585 .filter(|edge| edge.rel_type == "GROUNDS" || edge.rel_type == "CONCLUDES")
2586 .count()
2587 })
2588 .sum();
2589 assert_eq!(surviving, 1, "exactly one edge survives the cycle break");
2590 let cycle_warnings: Vec<_> = engine
2591 .load_warnings()
2592 .iter()
2593 .filter(|w| {
2594 matches!(
2595 w,
2596 WarningHint::ParsedRelationInvalid { reason, .. } if reason == "cycle"
2597 )
2598 })
2599 .cloned()
2600 .collect();
2601 assert_eq!(
2602 cycle_warnings.len(),
2603 1,
2604 "exactly one cycle warning fires: {cycle_warnings:?}"
2605 );
2606 }
2607
2608 fn engine_with_labelling_schema(tmp: &TempDir, with_other_mem: bool) -> Engine {
2617 engine_with_labelling_schema_support(tmp, with_other_mem, true)
2618 }
2619
2620 fn engine_with_labelling_schema_support(
2621 tmp: &TempDir,
2622 with_other_mem: bool,
2623 with_support: bool,
2624 ) -> Engine {
2625 let schemas_dir = tmp.path().join("schemas");
2626 let pkg = schemas_dir.join("arglab");
2627 std::fs::create_dir_all(pkg.join("types")).unwrap();
2628 std::fs::write(
2629 pkg.join("schema.yaml"),
2630 format!(
2631 r#"name: arglab
2632version: 0.1.0
2633description: grounded-labelling fixture
2634when_to_use: tests
2635types:
2636 - claim
2637 - evidence
2638relationships:
2639 mode: strict
2640 labelling:
2641 attack: [REBUTS]
2642{support} definitions:
2643 - name: REBUTS
2644 description: attack
2645 default_weight: 3.0
2646 - name: GROUNDS
2647 description: support
2648 default_weight: 3.0
2649 - name: PART_OF
2650 description: hier
2651 default_weight: 1.0
2652 - name: _default
2653 description: fallback
2654 default_weight: 1.0
2655community:
2656 resolution: 1.0
2657 seed: 42
2658cross_mem_relationships:
2659 - to_schema: arglab
2660 definitions:
2661 - name: REBUTS
2662 description: cross-mem attack
2663 default_weight: 3.0
2664"#,
2665 support = if with_support {
2666 " support:\n relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n"
2667 } else {
2668 ""
2669 },
2670 ),
2671 )
2672 .unwrap();
2673 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
2674 for t in ["claim", "evidence"] {
2675 std::fs::write(
2676 pkg.join("types").join(format!("{t}.yaml")),
2677 format!("name: {t}\ndescription: t\nwhen_to_use: tests\n{body}"),
2678 )
2679 .unwrap();
2680 }
2681 let mut mounts: Vec<(crate::workspace::Mount, Box<dyn MemBackend>)> = Vec::new();
2682 for mem in std::iter::once("arg").chain(with_other_mem.then_some("other")) {
2683 let mem_dir = tmp.path().join(format!("mem-{mem}"));
2684 std::fs::create_dir_all(&mem_dir).unwrap();
2685 let writer = FilesystemMemWriter::new(mem_dir.clone());
2686 mounts.push((
2687 crate::workspace::Mount {
2688 mem: mem.to_string(),
2689 schema: Some(memstead_schema::SchemaRef::new(
2690 "arglab",
2691 semver::Version::new(0, 1, 0),
2692 )),
2693 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2694 capability: crate::workspace::MountCapability::Write,
2695 lifecycle: crate::workspace::MountLifecycle::Eager,
2696 cross_linkable: true,
2697 migration_target: None,
2698 },
2699 Box::new(writer) as Box<dyn MemBackend>,
2700 ));
2701 }
2702 Engine::from_mounts_with_schemas_dir(mounts, Some(&schemas_dir)).unwrap()
2703 }
2704
2705 fn lab_create(engine: &mut Engine, mem: &str, title: &str, entity_type: &str) {
2706 let (actor, client) = cli_actor();
2707 let mut sections = IndexMap::new();
2708 sections.insert("body".to_string(), "a body.".to_string());
2709 engine
2710 .create_entity(
2711 CreateEntityArgs {
2712 anchors: Vec::new(),
2713 mem: mem.to_string(),
2714 title: title.to_string(),
2715 entity_type: entity_type.to_string(),
2716 sections,
2717 metadata: IndexMap::new(),
2718 relations: vec![],
2719 dry_run: false,
2720 },
2721 actor,
2722 Some(&client),
2723 None,
2724 )
2725 .unwrap();
2726 }
2727
2728 fn lab_relate(engine: &mut Engine, from: &str, rel: &str, to: &str) {
2729 let (actor, client) = cli_actor();
2730 engine
2731 .relate_entity(
2732 crate::engine::RelateEntityArgs {
2733 source: crate::entity::EntityId(from.to_string()),
2734 target: crate::entity::EntityId(to.to_string()),
2735 rel_type: rel.to_string(),
2736 description: None,
2737 remove: false,
2738 expected_hash: None,
2739 dry_run: false,
2740 },
2741 actor,
2742 Some(&client),
2743 None,
2744 )
2745 .unwrap();
2746 }
2747
2748 fn label_of(engine: &Engine, id: &str) -> (String, Vec<String>, Vec<String>) {
2749 let entity = engine
2750 .get_entity(&crate::entity::EntityId(id.to_string()))
2751 .unwrap()
2752 .clone();
2753 let view = engine.computed_labelling(&entity).unwrap();
2754 (
2755 view.label.wire().to_string(),
2756 view.defeated_by.clone(),
2757 view.undecided_by.clone(),
2758 )
2759 }
2760
2761 #[test]
2769 fn grounded_labelling_extension_evidence_and_invalidation() {
2770 let tmp = TempDir::new().unwrap();
2771 let mut engine = engine_with_labelling_schema(&tmp, false);
2772 for t in ["A", "B", "C", "D", "E", "F"] {
2773 lab_create(&mut engine, "arg", t, "claim");
2774 }
2775 lab_relate(&mut engine, "arg--a", "REBUTS", "arg--b");
2776 lab_relate(&mut engine, "arg--b", "REBUTS", "arg--c");
2777 lab_relate(&mut engine, "arg--d", "REBUTS", "arg--e");
2778 lab_relate(&mut engine, "arg--e", "REBUTS", "arg--d");
2779 lab_relate(&mut engine, "arg--d", "REBUTS", "arg--f");
2780
2781 assert_eq!(label_of(&engine, "arg--a").0, "accepted", "unattacked");
2782 let (label, defeated_by, _) = label_of(&engine, "arg--b");
2783 assert_eq!(label, "defeated");
2784 assert_eq!(defeated_by, vec!["arg--a".to_string()], "evidence ships");
2785 assert_eq!(
2786 label_of(&engine, "arg--c").0,
2787 "accepted",
2788 "reinstatement: the only attacker is itself defeated"
2789 );
2790 let (label, _, undecided_by) = label_of(&engine, "arg--d");
2791 assert_eq!(label, "undecided", "cycle member");
2792 assert_eq!(undecided_by, vec!["arg--e".to_string()]);
2793 let (label, _, undecided_by) = label_of(&engine, "arg--f");
2794 assert_eq!(label, "undecided", "victim of an undecided attacker");
2795 assert_eq!(undecided_by, vec!["arg--d".to_string()]);
2796
2797 let engine_b = engine_with_labelling_schema(&tmp, false);
2799 for id in ["arg--a", "arg--b", "arg--c", "arg--d", "arg--e", "arg--f"] {
2800 assert_eq!(label_of(&engine, id), label_of(&engine_b, id));
2801 }
2802
2803 let axis = engine.health_labelling_axis(None);
2805 assert_eq!(axis["arg"]["counts"]["accepted"], 2);
2806 assert_eq!(axis["arg"]["counts"]["defeated"], 1);
2807 assert_eq!(axis["arg"]["counts"]["undecided"], 3);
2808 assert_eq!(axis["arg"]["defeated"][0]["id"], "arg--b");
2809 assert_eq!(axis["arg"]["defeated"][0]["defeated_by"][0], "arg--a");
2810
2811 let (actor, client) = cli_actor();
2814 let b_id = crate::entity::EntityId("arg--b".to_string());
2815 let current = engine.get_entity(&b_id).unwrap().content_hash.clone();
2816 let mut sections = IndexMap::new();
2817 sections.insert("body".to_string(), "updated body.".to_string());
2818 let outcome = engine
2819 .update_entity(
2820 crate::engine::UpdateEntityArgs {
2821 anchors: Vec::new(),
2822 id: b_id,
2823 expected_hash: Some(current),
2824 sections,
2825 append_sections: IndexMap::new(),
2826 patch_sections: IndexMap::new(),
2827 metadata: IndexMap::new(),
2828 metadata_unset: Vec::new(),
2829 declare_relations: vec![],
2830 dry_run: false,
2831 relations_unset: Vec::new(),
2832 anchors_unset: Vec::new(),
2833 },
2834 actor,
2835 Some(&client),
2836 None,
2837 )
2838 .expect("updating a defeated entity succeeds");
2839 assert!(!outcome.commit_sha.is_empty());
2840 lab_relate(&mut engine, "arg--f", "REBUTS", "arg--b");
2841
2842 lab_create(&mut engine, "arg", "H", "claim");
2845 lab_relate(&mut engine, "arg--h", "REBUTS", "arg--a");
2846 let (label, defeated_by, _) = label_of(&engine, "arg--a");
2847 assert_eq!(
2848 label, "defeated",
2849 "memo invalidated by the in-process mutation"
2850 );
2851 assert_eq!(defeated_by, vec!["arg--h".to_string()]);
2852
2853 let g_path = tmp.path().join("mem-arg").join("g.md");
2856 std::fs::write(
2857 &g_path,
2858 "---\ntype: claim\n---\n# G\n\n## Body\n\nout of band.\n\n## Relationships\n\n- **REBUTS**: [[arg--c]]\n",
2859 )
2860 .unwrap();
2861 engine.reload_one_mem("arg").expect("reload succeeds");
2862 let (label, defeated_by, _) = label_of(&engine, "arg--c");
2863 assert_eq!(label, "defeated", "reload invalidated the memo");
2864 assert!(defeated_by.contains(&"arg--g".to_string()));
2865 }
2866
2867 #[test]
2870 fn labelling_support_blindness_shape_and_cross_mem() {
2871 let tmp = TempDir::new().unwrap();
2872 let mut engine = engine_with_labelling_schema(&tmp, true);
2873 for (t, ty) in [
2876 ("Conclusion", "claim"),
2877 ("Inference", "claim"),
2878 ("Undercutter", "claim"),
2879 ] {
2880 lab_create(&mut engine, "arg", t, ty);
2881 }
2882 lab_create(&mut engine, "arg", "Ev One", "evidence");
2883 lab_relate(&mut engine, "arg--conclusion", "GROUNDS", "arg--inference");
2884 lab_relate(&mut engine, "arg--inference", "GROUNDS", "arg--ev-one");
2885 lab_relate(&mut engine, "arg--undercutter", "REBUTS", "arg--inference");
2886
2887 let conclusion = engine
2890 .get_entity(&crate::entity::EntityId("arg--conclusion".to_string()))
2891 .unwrap()
2892 .clone();
2893 let view = engine.computed_labelling(&conclusion).unwrap();
2894 assert_eq!(view.label.wire(), "accepted", "support-blind by design");
2895 let shape = view.shape.expect("support declared, shape served");
2896 assert_eq!(shape.depth, 2);
2897 assert!((shape.branching - 1.0).abs() < 1e-9);
2898 assert_eq!(shape.terminal_share, Some(1.0));
2899 assert_eq!(shape.defeated_in_support, 1, "the defeated inference");
2900 assert_eq!(shape.undecided_in_support, 0);
2901
2902 lab_create(&mut engine, "arg", "Loner", "claim");
2904 let loner = engine
2905 .get_entity(&crate::entity::EntityId("arg--loner".to_string()))
2906 .unwrap()
2907 .clone();
2908 let view = engine.computed_labelling(&loner).unwrap();
2909 let shape = view.shape.unwrap();
2910 assert_eq!(
2911 (shape.depth, shape.branching, shape.terminal_share),
2912 (0, 0.0, None)
2913 );
2914 assert_eq!(
2915 (shape.defeated_in_support, shape.undecided_in_support),
2916 (0, 0)
2917 );
2918
2919 let mut settings = engine.settings().clone();
2923 settings.cross_mem_links.insert(
2924 "other".to_string(),
2925 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
2926 );
2927 engine.set_settings(settings);
2928 lab_create(&mut engine, "other", "Foreign", "claim");
2929 lab_relate(&mut engine, "other--foreign", "REBUTS", "arg--conclusion");
2930 let conclusion = engine
2931 .get_entity(&crate::entity::EntityId("arg--conclusion".to_string()))
2932 .unwrap()
2933 .clone();
2934 let view = engine.computed_labelling(&conclusion).unwrap();
2935 assert_eq!(
2936 view.label.wire(),
2937 "accepted",
2938 "the cross-mem attack is excluded, never guessed"
2939 );
2940 let axis = engine.health_labelling_axis(Some("arg"));
2941 assert_eq!(axis["arg"]["cross_mem_edges_excluded"], 1);
2942 assert!(axis.get("other").is_none(), "mem filter narrows");
2943
2944 let inference = engine
2948 .get_entity(&crate::entity::EntityId("arg--inference".to_string()))
2949 .unwrap()
2950 .clone();
2951 let view = engine.computed_labelling(&inference).unwrap();
2952 let md =
2953 crate::render::render_entity_markdown_with_signals(&inference, None, None, Some(&view));
2954 assert!(md.contains("_label: defeated"), "{md}");
2955 assert!(md.contains("## Labelling"), "{md}");
2956 assert!(md.contains("defeated_by: arg--undercutter"), "{md}");
2957 let env = crate::render::build_entity_envelope(
2958 &inference,
2959 0,
2960 None,
2961 None,
2962 None,
2963 crate::render::OriginClass::FirstParty,
2964 engine
2965 .store()
2966 .outgoing(&crate::entity::EntityId("arg--inference".to_string())),
2967 None,
2968 None,
2969 Some(&view),
2970 );
2971 assert_eq!(env["_labelling"]["label"], "defeated");
2972 assert_eq!(env["_labelling"]["defeated_by"][0], "arg--undercutter");
2973 assert!(env["_labelling"]["shape"].is_object());
2974 let canonical = crate::render::render_entity_markdown(&inference, None);
2975 assert!(
2976 !canonical.contains("_label") && !canonical.contains("## Labelling"),
2977 "canonical form is projection-free"
2978 );
2979
2980 let tmp2 = TempDir::new().unwrap();
2983 let mut plain = engine_with_signals_schema(&tmp2);
2984 let (actor, client) = cli_actor();
2985 let mut sections = IndexMap::new();
2986 sections.insert("body".to_string(), "a claim body.".to_string());
2987 plain
2988 .create_entity(
2989 CreateEntityArgs {
2990 anchors: Vec::new(),
2991 mem: "arg".to_string(),
2992 title: "Plain".to_string(),
2993 entity_type: "claim".to_string(),
2994 sections,
2995 metadata: IndexMap::new(),
2996 relations: vec![],
2997 dry_run: false,
2998 },
2999 actor,
3000 Some(&client),
3001 None,
3002 )
3003 .unwrap();
3004 let plain_entity = plain
3005 .get_entity(&crate::entity::EntityId("arg--plain".to_string()))
3006 .unwrap()
3007 .clone();
3008 assert!(plain.computed_labelling(&plain_entity).is_none());
3009 }
3010
3011 #[test]
3014 fn labelling_without_support_serves_no_shape() {
3015 let tmp = TempDir::new().unwrap();
3016 let mut engine = engine_with_labelling_schema_support(&tmp, false, false);
3017 lab_create(&mut engine, "arg", "Solo", "claim");
3018 let entity = engine
3019 .get_entity(&crate::entity::EntityId("arg--solo".to_string()))
3020 .unwrap()
3021 .clone();
3022 let view = engine.computed_labelling(&entity).expect("labels served");
3023 assert_eq!(view.label.wire(), "accepted");
3024 assert!(view.shape.is_none(), "no support declaration, no shape");
3025 assert!(view.to_json().get("shape").is_none());
3026 let md =
3027 crate::render::render_entity_markdown_with_signals(&entity, None, None, Some(&view));
3028 assert!(!md.contains("- shape:"), "{md}");
3029 }
3030
3031 fn engine_with_signals_schema(tmp: &TempDir) -> Engine {
3032 let schemas_dir = tmp.path().join("schemas");
3033 let pkg = schemas_dir.join("argsig");
3034 std::fs::create_dir_all(pkg.join("types")).unwrap();
3035 std::fs::write(
3036 pkg.join("schema.yaml"),
3037 r#"name: argsig
3038version: 0.1.0
3039description: aggregate-signal fixture
3040when_to_use: tests
3041types:
3042 - claim
3043 - objection
3044relationships:
3045 mode: strict
3046 definitions:
3047 - name: REBUTS
3048 description: r
3049 default_weight: 3.0
3050 - name: PART_OF
3051 description: hier
3052 default_weight: 1.0
3053 - name: _default
3054 description: fallback
3055 default_weight: 1.0
3056community:
3057 resolution: 1.0
3058 seed: 42
3059"#,
3060 )
3061 .unwrap();
3062 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
3063 std::fs::write(
3064 pkg.join("types").join("claim.yaml"),
3065 format!(
3066 "name: claim\ndescription: t\nwhen_to_use: tests\nmetadata_fields: []\nupdatable_fields:\n - title\n - body\n{body}signals:\n - name: attack_load\n kind: edge_load\n relationships: [REBUTS]\n direction: in\n thresholds:\n - at_least: 1\n level: notice\n - at_least: 3\n level: warn\n - name: open_objections\n kind: edge_load\n relationships: [REBUTS]\n direction: in\n neighbour_field: state\n neighbour_value: open\n thresholds:\n - at_least: 1\n level: notice\n"
3067 ),
3068 )
3069 .unwrap();
3070 std::fs::write(
3071 pkg.join("types").join("objection.yaml"),
3072 format!(
3073 "name: objection\ndescription: t\nwhen_to_use: tests\nmetadata_fields:\n - key: state\n description: objection lifecycle\n field_type: string\n enum_values: [open, closed]\nupdatable_fields:\n - title\n - body\n - state\n{body}"
3074 ),
3075 )
3076 .unwrap();
3077 let mem_dir = tmp.path().join("mem");
3078 std::fs::create_dir_all(&mem_dir).unwrap();
3079 let writer = FilesystemMemWriter::new(mem_dir.clone());
3080 let mount = crate::workspace::Mount {
3081 mem: "arg".to_string(),
3082 schema: Some(memstead_schema::SchemaRef::new(
3083 "argsig",
3084 semver::Version::new(0, 1, 0),
3085 )),
3086 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
3087 capability: crate::workspace::MountCapability::Write,
3088 lifecycle: crate::workspace::MountLifecycle::Eager,
3089 cross_linkable: true,
3090 migration_target: None,
3091 };
3092 Engine::from_mounts_with_schemas_dir(
3093 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3094 Some(&schemas_dir),
3095 )
3096 .unwrap()
3097 }
3098
3099 fn objection_args(title: &str, state: Option<&str>, rebuts: Option<&str>) -> CreateEntityArgs {
3100 let mut sections = IndexMap::new();
3101 sections.insert("body".to_string(), "an objection body.".to_string());
3102 let mut metadata = IndexMap::new();
3103 if let Some(s) = state {
3104 metadata.insert("state".to_string(), s.to_string());
3105 }
3106 let relations = rebuts
3107 .map(|to| {
3108 vec![crate::ops::RelateArg {
3109 to: crate::entity::EntityId(to.to_string()),
3110 rel_type: "REBUTS".to_string(),
3111 description: None,
3112 }]
3113 })
3114 .unwrap_or_default();
3115 CreateEntityArgs {
3116 anchors: Vec::new(),
3117 mem: "arg".to_string(),
3118 title: title.to_string(),
3119 entity_type: "objection".to_string(),
3120 sections,
3121 metadata,
3122 relations,
3123 dry_run: false,
3124 }
3125 }
3126
3127 fn crossing_warnings_of(
3128 warnings: &[WarningHint],
3129 ) -> Vec<(String, String, u64, String, String)> {
3130 warnings
3131 .iter()
3132 .filter_map(|w| match w {
3133 WarningHint::SignalThresholdCrossed {
3134 entity_id,
3135 signal,
3136 value,
3137 old_level,
3138 new_level,
3139 } => Some((
3140 entity_id.to_string(),
3141 signal.clone(),
3142 *value,
3143 old_level.clone(),
3144 new_level.clone(),
3145 )),
3146 _ => None,
3147 })
3148 .collect()
3149 }
3150
3151 #[test]
3158 fn signals_reads_thresholds_neighbour_filter_and_determinism() {
3159 let tmp = TempDir::new().unwrap();
3160 let mut engine = engine_with_signals_schema(&tmp);
3161 let (actor, client) = cli_actor();
3162
3163 let mut claim_sections = IndexMap::new();
3164 claim_sections.insert("body".to_string(), "a claim body.".to_string());
3165 engine
3166 .create_entity(
3167 CreateEntityArgs {
3168 anchors: Vec::new(),
3169 mem: "arg".to_string(),
3170 title: "Claim".to_string(),
3171 entity_type: "claim".to_string(),
3172 sections: claim_sections,
3173 metadata: IndexMap::new(),
3174 relations: vec![],
3175 dry_run: false,
3176 },
3177 actor,
3178 Some(&client),
3179 None,
3180 )
3181 .unwrap();
3182 let claim_id = crate::entity::EntityId("arg--claim".to_string());
3183 let sig_of = |engine: &Engine, name: &str| {
3184 let entity = engine.get_entity(&claim_id).unwrap().clone();
3185 engine
3186 .computed_signals(&entity)
3187 .unwrap()
3188 .into_iter()
3189 .find(|s| s.name == name)
3190 .unwrap()
3191 };
3192
3193 let s = sig_of(&engine, "attack_load");
3195 assert_eq!((s.value, s.level_wire()), (0, "none"));
3196
3197 let outcome = engine
3200 .create_entity(
3201 objection_args("Obj One", Some("open"), Some("arg--claim")),
3202 actor,
3203 Some(&client),
3204 None,
3205 )
3206 .unwrap();
3207 let crossings = crossing_warnings_of(&outcome.warnings);
3208 assert!(
3209 crossings.contains(&(
3210 "arg--claim".to_string(),
3211 "attack_load".to_string(),
3212 1,
3213 "none".to_string(),
3214 "notice".to_string()
3215 )),
3216 "create with inline relation crosses attack_load: {crossings:?}"
3217 );
3218 assert!(
3219 crossings.iter().any(|c| c.1 == "open_objections"),
3220 "open counterpart crosses open_objections too: {crossings:?}"
3221 );
3222
3223 engine
3226 .create_entity(
3227 objection_args("Obj Two", Some("closed"), Some("arg--claim")),
3228 actor,
3229 Some(&client),
3230 None,
3231 )
3232 .unwrap();
3233 engine
3234 .create_entity(
3235 objection_args("Obj Three", None, Some("arg--claim")),
3236 actor,
3237 Some(&client),
3238 None,
3239 )
3240 .unwrap();
3241 let attack = sig_of(&engine, "attack_load");
3242 assert_eq!((attack.value, attack.level_wire()), (3, "warn"));
3243 assert_eq!(attack.contributors.len(), 3);
3244 let open = sig_of(&engine, "open_objections");
3245 assert_eq!((open.value, open.level_wire()), (1, "notice"));
3246 assert_eq!(open.contributors[0].0, "arg--obj-one");
3247
3248 let entity = engine.get_entity(&claim_id).unwrap().clone();
3251 let signals = engine.computed_signals(&entity).unwrap();
3252 let md =
3253 crate::render::render_entity_markdown_with_signals(&entity, None, Some(&signals), None);
3254 assert!(
3255 md.contains("_signals: [attack_load: 3 (warn), open_objections: 1 (notice)]"),
3256 "frontmatter headline: {md}"
3257 );
3258 assert!(md.contains("## Signals"), "contributors section: {md}");
3259 assert!(md.contains("arg--obj-one"), "evidence ships: {md}");
3260 let env = crate::render::build_entity_envelope(
3261 &entity,
3262 0,
3263 None,
3264 None,
3265 None,
3266 crate::render::OriginClass::FirstParty,
3267 engine.store().outgoing(&claim_id),
3268 None,
3269 Some(&signals),
3270 None,
3271 );
3272 assert_eq!(env["_signals"][0]["name"], "attack_load");
3273 assert_eq!(env["_signals"][0]["value"], 3);
3274 assert_eq!(env["_signals"][0]["level"], "warn");
3275 assert_eq!(
3276 env["_signals"][0]["contributors"].as_array().unwrap().len(),
3277 3
3278 );
3279 let canonical = crate::render::render_entity_markdown(&entity, None);
3281 assert!(
3282 !canonical.contains("_signals"),
3283 "canonical form is signal-free"
3284 );
3285
3286 let obj_one = crate::entity::EntityId("arg--obj-one".to_string());
3289 let current = engine.get_entity(&obj_one).unwrap().content_hash.clone();
3290 let mut metadata = IndexMap::new();
3291 metadata.insert("state".to_string(), "closed".to_string());
3292 let outcome = engine
3293 .update_entity(
3294 crate::engine::UpdateEntityArgs {
3295 anchors: Vec::new(),
3296 id: obj_one,
3297 expected_hash: Some(current),
3298 sections: IndexMap::new(),
3299 append_sections: IndexMap::new(),
3300 patch_sections: IndexMap::new(),
3301 metadata,
3302 metadata_unset: Vec::new(),
3303 declare_relations: vec![],
3304 dry_run: false,
3305 relations_unset: Vec::new(),
3306 anchors_unset: Vec::new(),
3307 },
3308 actor,
3309 Some(&client),
3310 None,
3311 )
3312 .unwrap();
3313 assert!(!outcome.commit_sha.is_empty(), "success shape kept");
3314 let crossings = crossing_warnings_of(&outcome.warnings);
3315 assert!(
3316 crossings.contains(&(
3317 "arg--claim".to_string(),
3318 "open_objections".to_string(),
3319 0,
3320 "notice".to_string(),
3321 "none".to_string()
3322 )),
3323 "the neighbour flip crosses the claim's filtered signal: {crossings:?}"
3324 );
3325 let open = sig_of(&engine, "open_objections");
3326 assert_eq!((open.value, open.level_wire()), (0, "none"));
3327
3328 let engine_b = engine_with_signals_schema(&tmp);
3331 let entity_b = engine_b.get_entity(&claim_id).unwrap().clone();
3332 let signals_b = engine_b.computed_signals(&entity_b).unwrap();
3333 let entity_a = engine.get_entity(&claim_id).unwrap().clone();
3334 let signals_a = engine.computed_signals(&entity_a).unwrap();
3335 assert_eq!(
3336 crate::ops::signals::signals_json(&signals_a),
3337 crate::ops::signals::signals_json(&signals_b),
3338 "two instances over the same mem state serve identical signals"
3339 );
3340 }
3341
3342 #[test]
3348 fn signal_crossings_on_relate_and_health_axis() {
3349 let tmp = TempDir::new().unwrap();
3350 let mut engine = engine_with_signals_schema(&tmp);
3351 let (actor, client) = cli_actor();
3352
3353 let mut claim_sections = IndexMap::new();
3354 claim_sections.insert("body".to_string(), "a claim body.".to_string());
3355 engine
3356 .create_entity(
3357 CreateEntityArgs {
3358 anchors: Vec::new(),
3359 mem: "arg".to_string(),
3360 title: "Claim".to_string(),
3361 entity_type: "claim".to_string(),
3362 sections: claim_sections,
3363 metadata: IndexMap::new(),
3364 relations: vec![],
3365 dry_run: false,
3366 },
3367 actor,
3368 Some(&client),
3369 None,
3370 )
3371 .unwrap();
3372 for (t, s) in [("Obj A", "open"), ("Obj B", "closed"), ("Obj C", "closed")] {
3373 engine
3374 .create_entity(objection_args(t, Some(s), None), actor, Some(&client), None)
3375 .unwrap();
3376 }
3377 let relate = |engine: &mut Engine, from: &str, remove: bool| {
3378 engine
3379 .relate_entity(
3380 crate::engine::RelateEntityArgs {
3381 source: crate::entity::EntityId(from.to_string()),
3382 target: crate::entity::EntityId("arg--claim".to_string()),
3383 rel_type: "REBUTS".to_string(),
3384 description: None,
3385 remove,
3386 expected_hash: None,
3387 dry_run: false,
3388 },
3389 actor,
3390 Some(&client),
3391 None,
3392 )
3393 .unwrap()
3394 };
3395
3396 let outcome = relate(&mut engine, "arg--obj-a", false);
3398 assert!(!outcome.commit_sha.is_empty());
3399 let crossings = crossing_warnings_of(&outcome.warnings);
3400 assert!(
3401 crossings.contains(&(
3402 "arg--claim".to_string(),
3403 "attack_load".to_string(),
3404 1,
3405 "none".to_string(),
3406 "notice".to_string()
3407 )),
3408 "{crossings:?}"
3409 );
3410
3411 let outcome = relate(&mut engine, "arg--obj-b", false);
3413 assert!(
3414 crossing_warnings_of(&outcome.warnings).is_empty(),
3415 "no threshold crossed, no warning: {:?}",
3416 outcome.warnings
3417 );
3418
3419 let outcome = relate(&mut engine, "arg--obj-c", false);
3421 let crossings = crossing_warnings_of(&outcome.warnings);
3422 assert!(
3423 crossings.contains(&(
3424 "arg--claim".to_string(),
3425 "attack_load".to_string(),
3426 3,
3427 "notice".to_string(),
3428 "warn".to_string()
3429 )),
3430 "{crossings:?}"
3431 );
3432
3433 let axis = engine.health_signals_axis(None);
3436 assert_eq!(axis["entities"].as_array().unwrap().len(), 1);
3437 assert_eq!(axis["entities"][0]["id"], "arg--claim");
3438 assert_eq!(axis["counts"]["warn"], 1, "attack_load at warn: {axis}");
3439 assert_eq!(axis["counts"]["notice"], 1, "open_objections at notice");
3440 let filtered = engine.health_signals_axis(Some("other"));
3441 assert!(filtered["entities"].as_array().unwrap().is_empty());
3442
3443 let outcome = relate(&mut engine, "arg--obj-c", true);
3445 let crossings = crossing_warnings_of(&outcome.warnings);
3446 assert!(
3447 crossings.contains(&(
3448 "arg--claim".to_string(),
3449 "attack_load".to_string(),
3450 2,
3451 "warn".to_string(),
3452 "notice".to_string()
3453 )),
3454 "{crossings:?}"
3455 );
3456 }
3457
3458 #[test]
3465 fn no_self_loop_rel_type_self_loop_refusal_is_pinned() {
3466 let tmp = TempDir::new().unwrap();
3467 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
3470 let (actor, client) = cli_actor();
3471 let a = engine
3472 .create_entity(
3473 task_create_args("Task A", vec![]),
3474 actor,
3475 Some(&client),
3476 None,
3477 )
3478 .unwrap();
3479 let err = engine
3480 .relate_entity(
3481 crate::engine::RelateEntityArgs {
3482 source: a.id.clone(),
3483 target: a.id.clone(),
3484 rel_type: "PART_OF".to_string(),
3485 description: None,
3486 remove: false,
3487 expected_hash: None,
3488 dry_run: false,
3489 },
3490 actor,
3491 Some(&client),
3492 None,
3493 )
3494 .unwrap_err();
3495 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
3496 }
3497
3498 fn engine_with_proof_schema(
3502 tmp: &TempDir,
3503 schema_name: &str,
3504 manifest_yaml: &str,
3505 types: &[(&str, &str)],
3506 ) -> Engine {
3507 let schemas_dir = tmp.path().join("schemas");
3508 let pkg = schemas_dir.join(schema_name);
3509 std::fs::create_dir_all(pkg.join("types")).unwrap();
3510 std::fs::write(pkg.join("schema.yaml"), manifest_yaml).unwrap();
3511 for (name, yaml) in types {
3512 std::fs::write(pkg.join("types").join(format!("{name}.yaml")), yaml).unwrap();
3513 }
3514 let mem_dir = tmp.path().join("mem");
3515 std::fs::create_dir_all(&mem_dir).unwrap();
3516 let writer = FilesystemMemWriter::new(mem_dir.clone());
3517 let mount = crate::workspace::Mount {
3518 mem: "proof".to_string(),
3519 schema: Some(memstead_schema::SchemaRef::new(
3520 schema_name,
3521 semver::Version::new(0, 1, 0),
3522 )),
3523 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
3524 capability: crate::workspace::MountCapability::Write,
3525 lifecycle: crate::workspace::MountLifecycle::Eager,
3526 cross_linkable: true,
3527 migration_target: None,
3528 };
3529 Engine::from_mounts_with_schemas_dir(
3530 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3531 Some(&schemas_dir),
3532 )
3533 .unwrap()
3534 }
3535
3536 fn proof_create(
3537 engine: &mut Engine,
3538 entity_type: &str,
3539 title: &str,
3540 metadata: &[(&str, &str)],
3541 relations: Vec<crate::ops::RelateArg>,
3542 ) -> Result<CreateEntityOutcome, EngineError> {
3543 let (actor, client) = cli_actor();
3544 let mut sections = IndexMap::new();
3545 sections.insert("body".to_string(), format!("{title} body."));
3546 let mut md = IndexMap::new();
3547 for (k, v) in metadata {
3548 md.insert(k.to_string(), v.to_string());
3549 }
3550 engine.create_entity(
3551 CreateEntityArgs {
3552 anchors: Vec::new(),
3553 mem: "proof".to_string(),
3554 title: title.to_string(),
3555 entity_type: entity_type.to_string(),
3556 sections,
3557 metadata: md,
3558 relations,
3559 dry_run: false,
3560 },
3561 actor,
3562 Some(&client),
3563 None,
3564 )
3565 }
3566
3567 fn rel(to: &str, rel_type: &str) -> crate::ops::RelateArg {
3568 crate::ops::RelateArg {
3569 to: crate::entity::EntityId(to.to_string()),
3570 rel_type: rel_type.to_string(),
3571 description: None,
3572 }
3573 }
3574
3575 const GROUNDING_MANIFEST: &str = r#"name: grounding
3576version: 0.1.0
3577description: anker-shaped grounding proof schema
3578when_to_use: constraint-proof tests
3579types:
3580 - anchor
3581 - tradeoff
3582relationships:
3583 mode: strict
3584 definitions:
3585 - name: FOLLOWS_FROM
3586 description: stands on
3587 default_weight: 3.0
3588 - name: SUPPORTS
3589 description: pro
3590 default_weight: 1.0
3591 - name: OPPOSES
3592 description: contra
3593 default_weight: 1.0
3594 - name: PART_OF
3595 description: hier
3596 default_weight: 1.0
3597 - name: _default
3598 description: fallback
3599 default_weight: 1.0
3600community:
3601 resolution: 1.0
3602 seed: 42
3603"#;
3604
3605 const GROUNDING_ANCHOR: &str = r#"name: anchor
3606description: a judgment standing on others
3607when_to_use: tests
3608sections:
3609 - key: body
3610 heading: Body
3611 required: true
3612 search_weight: 10.0
3613 catch_all: true
3614 write_rules: []
3615metadata_fields:
3616 - key: status
3617 description: lifecycle
3618 field_type: string
3619 enum_values: [open, checked, fallen]
3620 - key: checked_by
3621 description: who checked
3622 field_type: string
3623title_weight: 100.0
3624text_fields:
3625 - body
3626hierarchy_relationship: PART_OF
3627no_self_loop_relationships: []
3628updatable_fields:
3629 - title
3630 - body
3631 - status
3632 - checked_by
3633health_required_fields:
3634 - body
3635staleness_threshold_days: 90
3636constraints:
3637 - kind: requires_when
3638 field: checked_by
3639 when_field: status
3640 when_value: checked
3641 - kind: status_propagation
3642 field: status
3643 value: fallen
3644 rel_type: FOLLOWS_FROM
3645 direction: incoming
3646write_rules: []
3647"#;
3648
3649 const GROUNDING_TRADEOFF: &str = r#"name: tradeoff
3650description: a claim with two sides
3651when_to_use: tests
3652sections:
3653 - key: body
3654 heading: Body
3655 required: true
3656 search_weight: 10.0
3657 catch_all: true
3658 write_rules: []
3659metadata_fields: []
3660title_weight: 100.0
3661text_fields:
3662 - body
3663hierarchy_relationship: PART_OF
3664no_self_loop_relationships: []
3665updatable_fields:
3666 - title
3667 - body
3668health_required_fields:
3669 - body
3670staleness_threshold_days: 90
3671required_outgoing:
3672 - relationships: [SUPPORTS]
3673 cardinality: at_least_one
3674 - relationships: [OPPOSES]
3675 cardinality: at_least_one
3676write_rules: []
3677"#;
3678
3679 #[test]
3683 fn anker_proof_grounding_schema_answers_check_questions_from_health() {
3684 let tmp = TempDir::new().unwrap();
3685 let mut engine = engine_with_proof_schema(
3686 &tmp,
3687 "grounding",
3688 GROUNDING_MANIFEST,
3689 &[
3690 ("anchor", GROUNDING_ANCHOR),
3691 ("tradeoff", GROUNDING_TRADEOFF),
3692 ],
3693 );
3694
3695 let root = proof_create(
3698 &mut engine,
3699 "anchor",
3700 "Root",
3701 &[("status", "fallen")],
3702 vec![],
3703 )
3704 .unwrap();
3705 let child = proof_create(
3706 &mut engine,
3707 "anchor",
3708 "Child",
3709 &[],
3710 vec![rel(&root.id.0, "FOLLOWS_FROM")],
3711 )
3712 .unwrap();
3713 let grandchild = proof_create(
3714 &mut engine,
3715 "anchor",
3716 "Grandchild",
3717 &[],
3718 vec![rel(&child.id.0, "FOLLOWS_FROM")],
3719 )
3720 .unwrap();
3721 let standing = proof_create(
3722 &mut engine,
3723 "anchor",
3724 "Standing Root",
3725 &[("status", "open")],
3726 vec![],
3727 )
3728 .unwrap();
3729 let standing_child = proof_create(
3730 &mut engine,
3731 "anchor",
3732 "Standing Child",
3733 &[],
3734 vec![rel(&standing.id.0, "FOLLOWS_FROM")],
3735 )
3736 .unwrap();
3737 let unchecked = proof_create(
3739 &mut engine,
3740 "anchor",
3741 "Checked No Checker",
3742 &[("status", "checked")],
3743 vec![],
3744 )
3745 .unwrap();
3746 let onesided = proof_create(
3748 &mut engine,
3749 "tradeoff",
3750 "One Sided",
3751 &[],
3752 vec![rel(&standing.id.0, "SUPPORTS")],
3753 )
3754 .unwrap();
3755
3756 let findings =
3759 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
3760 let tainted_of = |id: &crate::entity::EntityId| -> Vec<String> {
3761 findings
3762 .iter()
3763 .filter(|r| &r.id == id)
3764 .flat_map(|r| &r.violations)
3765 .filter_map(|v| match v {
3766 crate::ops::health::UnsatisfiedConstraint::StatusPropagation {
3767 tainted_by,
3768 ..
3769 } => Some(tainted_by.clone()),
3770 _ => None,
3771 })
3772 .collect()
3773 };
3774 assert_eq!(tainted_of(&child.id), vec![root.id.to_string()]);
3775 assert_eq!(
3776 tainted_of(&grandchild.id),
3777 vec![root.id.to_string()],
3778 "the taint is transitive and names the terminal ancestor"
3779 );
3780 assert!(tainted_of(&standing_child.id).is_empty());
3781 assert!(
3782 tainted_of(&root.id).is_empty(),
3783 "the source is not its own finding"
3784 );
3785
3786 assert!(
3788 findings.iter().any(|r| r.id == unchecked.id
3789 && r.violations.iter().any(|v| matches!(
3790 v,
3791 crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. }
3792 if field == "checked_by"
3793 ))),
3794 "checked-without-checker must be a health finding"
3795 );
3796
3797 let missing = crate::ops::health::collect_missing_required_outgoing(
3800 engine.store(),
3801 None,
3802 engine.schemas(),
3803 );
3804 let onesided_report = missing
3805 .iter()
3806 .find(|r| r.id == onesided.id)
3807 .expect("one-sided trade-off flagged");
3808 assert_eq!(onesided_report.missing.len(), 1);
3809 assert_eq!(onesided_report.missing[0].relationships, vec!["OPPOSES"]);
3810 }
3811
3812 const PLENUM_MANIFEST: &str = r#"name: plenum-proof
3813version: 0.1.0
3814description: plenum-shaped uniqueness and vocabulary proof schema
3815when_to_use: constraint-proof tests
3816types:
3817 - rede
3818 - vocabulary
3819relationships:
3820 mode: strict
3821 definitions:
3822 - name: REFERENCES
3823 description: soft ref
3824 default_weight: 0.5
3825 - name: PART_OF
3826 description: hier
3827 default_weight: 1.0
3828 - name: _default
3829 description: fallback
3830 default_weight: 1.0
3831community:
3832 resolution: 1.0
3833 seed: 42
3834"#;
3835
3836 fn plenum_rede_type(unique_severity: &str) -> String {
3837 format!(
3838 r#"name: rede
3839description: one speech
3840when_to_use: tests
3841sections:
3842 - key: body
3843 heading: Body
3844 required: true
3845 search_weight: 10.0
3846 catch_all: true
3847 write_rules: []
3848metadata_fields:
3849 - key: rede_id
3850 description: source id
3851 field_type: string
3852 - key: rede_sha256
3853 description: content hash
3854 field_type: string
3855 - key: kategorie
3856 description: category from the shared vocabulary
3857 field_type: string
3858title_weight: 100.0
3859text_fields:
3860 - body
3861hierarchy_relationship: PART_OF
3862no_self_loop_relationships: []
3863updatable_fields:
3864 - title
3865 - body
3866 - rede_id
3867 - rede_sha256
3868 - kategorie
3869health_required_fields:
3870 - body
3871staleness_threshold_days: 90
3872constraints:
3873 - kind: unique
3874 fields: [rede_id, rede_sha256]
3875 severity: {unique_severity}
3876 - kind: enum_from_neighbour
3877 field: kategorie
3878 rel_type: REFERENCES
3879 section: terms
3880write_rules: []
3881"#
3882 )
3883 }
3884
3885 const PLENUM_VOCABULARY: &str = r#"name: vocabulary
3886description: the shared term list
3887when_to_use: tests
3888sections:
3889 - key: terms
3890 heading: Terms
3891 required: false
3892 search_weight: 5.0
3893 catch_all: false
3894 write_rules: []
3895 - key: body
3896 heading: Body
3897 required: true
3898 search_weight: 10.0
3899 catch_all: true
3900 write_rules: []
3901metadata_fields: []
3902title_weight: 100.0
3903text_fields:
3904 - body
3905hierarchy_relationship: PART_OF
3906no_self_loop_relationships: []
3907updatable_fields:
3908 - title
3909 - body
3910 - terms
3911health_required_fields:
3912 - body
3913staleness_threshold_days: 90
3914write_rules: []
3915"#;
3916
3917 #[test]
3923 fn plenum_proof_uniqueness_refuses_duplicates_and_health_reports_planted_ones() {
3924 let tmp = TempDir::new().unwrap();
3926 let rede = plenum_rede_type("block");
3927 let mut engine = engine_with_proof_schema(
3928 &tmp,
3929 "plenum-proof",
3930 PLENUM_MANIFEST,
3931 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
3932 );
3933 let first = proof_create(
3934 &mut engine,
3935 "rede",
3936 "Speech One",
3937 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3938 vec![],
3939 )
3940 .unwrap();
3941 let err = proof_create(
3942 &mut engine,
3943 "rede",
3944 "Speech One Duplicate",
3945 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3946 vec![],
3947 )
3948 .unwrap_err();
3949 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
3950 assert_eq!(
3951 err.details()["violations"][0]["colliding"],
3952 first.id.to_string(),
3953 "the refusal names the colliding entity"
3954 );
3955 proof_create(
3957 &mut engine,
3958 "rede",
3959 "Speech Two",
3960 &[("rede_id", "19-43"), ("rede_sha256", "def456")],
3961 vec![],
3962 )
3963 .unwrap();
3964
3965 let tmp = TempDir::new().unwrap();
3967 let rede = plenum_rede_type("warn");
3968 let mut engine = engine_with_proof_schema(
3969 &tmp,
3970 "plenum-proof",
3971 PLENUM_MANIFEST,
3972 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
3973 );
3974 proof_create(
3975 &mut engine,
3976 "rede",
3977 "Planted A",
3978 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3979 vec![],
3980 )
3981 .unwrap();
3982 let planted = proof_create(
3983 &mut engine,
3984 "rede",
3985 "Planted B",
3986 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3987 vec![],
3988 )
3989 .unwrap();
3990 assert!(
3991 planted
3992 .warnings
3993 .iter()
3994 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
3995 "warn tier surfaces the duplicate as a warning and commits"
3996 );
3997 let findings =
3998 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
3999 assert_eq!(
4000 findings.len(),
4001 2,
4002 "both sides of the planted duplicate are findings: {findings:?}"
4003 );
4004 }
4005
4006 #[test]
4010 fn plenum_proof_enum_from_neighbour_flags_stale_holders_after_rename() {
4011 let tmp = TempDir::new().unwrap();
4012 let rede = plenum_rede_type("warn");
4013 let mut engine = engine_with_proof_schema(
4014 &tmp,
4015 "plenum-proof",
4016 PLENUM_MANIFEST,
4017 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
4018 );
4019 let (actor, client) = cli_actor();
4020
4021 let mut sections = IndexMap::new();
4023 sections.insert("body".to_string(), "the term list.".to_string());
4024 sections.insert("terms".to_string(), "- haushalt\n- verkehr\n".to_string());
4025 let vocab = engine
4026 .create_entity(
4027 CreateEntityArgs {
4028 anchors: Vec::new(),
4029 mem: "proof".to_string(),
4030 title: "Kategorien".to_string(),
4031 entity_type: "vocabulary".to_string(),
4032 sections,
4033 metadata: IndexMap::new(),
4034 relations: vec![],
4035 dry_run: false,
4036 },
4037 actor,
4038 Some(&client),
4039 None,
4040 )
4041 .unwrap();
4042
4043 let holder = proof_create(
4045 &mut engine,
4046 "rede",
4047 "Holder",
4048 &[("kategorie", "haushalt")],
4049 vec![rel(&vocab.id.0, "REFERENCES")],
4050 )
4051 .unwrap();
4052 let findings =
4053 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
4054 assert!(
4055 findings.iter().all(|r| r.id != holder.id),
4056 "backed value produces no finding: {findings:?}"
4057 );
4058
4059 let current = engine.get_entity(&vocab.id).unwrap().content_hash.clone();
4062 let mut sections = IndexMap::new();
4063 sections.insert("terms".to_string(), "- finanzen\n- verkehr\n".to_string());
4064 engine
4065 .update_entity(
4066 crate::engine::UpdateEntityArgs {
4067 anchors: Vec::new(),
4068 id: vocab.id.clone(),
4069 expected_hash: Some(current),
4070 sections,
4071 append_sections: IndexMap::new(),
4072 patch_sections: IndexMap::new(),
4073 metadata: IndexMap::new(),
4074 metadata_unset: Vec::new(),
4075 declare_relations: vec![],
4076 dry_run: false,
4077 relations_unset: Vec::new(),
4078 anchors_unset: Vec::new(),
4079 },
4080 actor,
4081 Some(&client),
4082 None,
4083 )
4084 .unwrap();
4085 let findings =
4086 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
4087 let stale = findings
4088 .iter()
4089 .find(|r| r.id == holder.id)
4090 .expect("stale holder is flagged after the rename");
4091 assert!(stale.violations.iter().any(|v| matches!(
4092 v,
4093 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { value, .. }
4094 if value == "haushalt"
4095 )));
4096 }
4097
4098 #[test]
4105 fn create_warns_missing_required_outgoing_and_still_commits() {
4106 let tmp = TempDir::new().unwrap();
4107 let mut engine = engine_with_required_outgoing_schema(&tmp);
4108 let (actor, client) = cli_actor();
4109
4110 let outcome = engine
4111 .create_entity(
4112 task_create_args("Orphan Task", vec![]),
4113 actor,
4114 Some(&client),
4115 None,
4116 )
4117 .unwrap();
4118 assert!(
4119 !outcome.commit_sha.is_empty(),
4120 "the warning never blocks the mutation"
4121 );
4122 let blocks = missing_outgoing_of(&outcome.warnings);
4123 assert_eq!(
4124 blocks,
4125 vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())],
4126 "warning names the unsatisfied block with cardinality; warnings = {:?}",
4127 outcome.warnings
4128 );
4129
4130 let reports = crate::ops::health::collect_missing_required_outgoing(
4133 engine.store(),
4134 None,
4135 engine.schemas(),
4136 );
4137 assert_eq!(reports.len(), 1);
4138 assert_eq!(reports[0].id, outcome.id);
4139 assert_eq!(reports[0].missing.len(), 1);
4140 assert_eq!(reports[0].missing[0].relationships, vec!["PART_OF"]);
4141 assert_eq!(reports[0].missing[0].cardinality, "at_least_one");
4142
4143 let satisfied = engine
4146 .create_entity(
4147 task_create_args(
4148 "Child Task",
4149 vec![crate::ops::RelateArg {
4150 to: outcome.id.clone(),
4151 rel_type: "PART_OF".to_string(),
4152 description: None,
4153 }],
4154 ),
4155 actor,
4156 Some(&client),
4157 None,
4158 )
4159 .unwrap();
4160 assert!(
4161 missing_outgoing_of(&satisfied.warnings).is_empty(),
4162 "satisfied block emits no warning: {:?}",
4163 satisfied.warnings
4164 );
4165 }
4166
4167 #[test]
4171 fn update_warns_missing_required_outgoing_until_satisfied() {
4172 let tmp = TempDir::new().unwrap();
4173 let mut engine = engine_with_required_outgoing_schema(&tmp);
4174 let (actor, client) = cli_actor();
4175 let a = engine
4176 .create_entity(
4177 task_create_args("Task A", vec![]),
4178 actor,
4179 Some(&client),
4180 None,
4181 )
4182 .unwrap();
4183 let b = engine
4184 .create_entity(
4185 task_create_args("Task B", vec![]),
4186 actor,
4187 Some(&client),
4188 None,
4189 )
4190 .unwrap();
4191
4192 let update = |engine: &mut Engine,
4193 id: &crate::entity::EntityId,
4194 declare: Vec<crate::ops::RelateArg>| {
4195 let current = engine.get_entity(id).unwrap().content_hash.clone();
4196 let mut sections = IndexMap::new();
4197 sections.insert("body".to_string(), format!("edited at {:?}", declare.len()));
4198 engine
4199 .update_entity(
4200 crate::engine::UpdateEntityArgs {
4201 anchors: Vec::new(),
4202 id: id.clone(),
4203 expected_hash: Some(current),
4204 sections,
4205 append_sections: IndexMap::new(),
4206 patch_sections: IndexMap::new(),
4207 metadata: IndexMap::new(),
4208 metadata_unset: Vec::new(),
4209 declare_relations: declare,
4210 dry_run: false,
4211 relations_unset: Vec::new(),
4212 anchors_unset: Vec::new(),
4213 },
4214 actor,
4215 Some(&client),
4216 None,
4217 )
4218 .unwrap()
4219 };
4220
4221 let outcome = update(&mut engine, &a.id, vec![]);
4224 assert!(!outcome.commit_sha.is_empty());
4225 assert_eq!(
4226 missing_outgoing_of(&outcome.warnings),
4227 vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())]
4228 );
4229
4230 let outcome = update(
4232 &mut engine,
4233 &a.id,
4234 vec![crate::ops::RelateArg {
4235 to: b.id.clone(),
4236 rel_type: "PART_OF".to_string(),
4237 description: None,
4238 }],
4239 );
4240 assert!(
4241 missing_outgoing_of(&outcome.warnings).is_empty(),
4242 "satisfied block emits no warning: {:?}",
4243 outcome.warnings
4244 );
4245 }
4246
4247 #[test]
4254 fn anchor_source_validated_against_resolvable_binding() {
4255 use crate::binding::{
4256 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, hash_binding,
4257 };
4258 use crate::pipeline::{IngestTrigger, PatternEntry, PatternMode, Source};
4259
4260 let tmp = TempDir::new().unwrap();
4261 let (mut engine, _seed) = engine_with_seed(&tmp, "Seed");
4262 let (actor, client) = cli_actor();
4263
4264 let ws = TempDir::new().unwrap();
4266 let binding = Binding {
4267 version: BINDING_VERSION,
4268 intent: None,
4269 sources: ["api-docs", "guides"]
4270 .into_iter()
4271 .map(|n| Source {
4272 name: n.to_string(),
4273 medium_type: crate::pipeline::MediumType::Codebase,
4274 pointer: "../src".to_string(),
4275 change_detection: None,
4276 scope: vec![PatternEntry {
4277 path: "**/*".to_string(),
4278 mode: PatternMode::Allow,
4279 }],
4280 engagement: None,
4281 preparation: None,
4282 })
4283 .collect(),
4284 reference_mems: Vec::new(),
4285 destination_mem: "specs".to_string(),
4286 deny_paths: Vec::new(),
4287 coverage_semantics: None,
4288 rules: None,
4289 prune: None,
4290 operations: Operations {
4291 build: Some(BuildOperation {
4292 mode: BuildMode::Discovery,
4293 trigger: IngestTrigger::Loop,
4294 batch_size: 20,
4295 post_actions: None,
4296 }),
4297 sync: None,
4298 verify: None,
4299 },
4300 };
4301 let dir = ws
4302 .path()
4303 .join(".memstead")
4304 .join("projections")
4305 .join("specs");
4306 std::fs::create_dir_all(&dir).unwrap();
4307 std::fs::write(
4308 dir.join("docs.json"),
4309 serde_json::to_string_pretty(&binding).unwrap(),
4310 )
4311 .unwrap();
4312 engine.set_workspace_root(ws.path().to_path_buf());
4313 let binding_hash = hash_binding(&binding);
4314
4315 std::fs::create_dir_all(ws.path().join("src")).unwrap();
4319 std::fs::write(ws.path().join("src").join("x.rs"), "fn x() {}").unwrap();
4320
4321 let anchor = |source: &str, binding: &str| crate::anchor::AnchorInput {
4322 artifact: Some("src/x.rs".into()),
4323 grain: Some("file".into()),
4324 class: Some("anchored".into()),
4325 binding: Some(binding.into()),
4326 source: Some(source.into()),
4327 ..Default::default()
4328 };
4329 let make_args = |title: &str, a: crate::anchor::AnchorInput| {
4330 let mut args = empty_create_args("specs", title);
4331 args.anchors = vec![a];
4332 args
4333 };
4334
4335 let err = engine
4338 .create_entity(
4339 make_args("Bad Source", anchor("front-page", &binding_hash)),
4340 actor,
4341 Some(&client),
4342 None,
4343 )
4344 .unwrap_err();
4345 assert_eq!(err.code(), "INVALID_ANCHOR", "got {err:?}");
4346 let details = err.details();
4347 assert_eq!(details["field"], "source");
4348 assert_eq!(details["got"], "front-page");
4349 assert_eq!(
4350 details["declared"],
4351 serde_json::json!(["api-docs", "guides"])
4352 );
4353
4354 let ok = engine
4356 .create_entity(
4357 make_args("Good Source", anchor("api-docs", &binding_hash)),
4358 actor,
4359 Some(&client),
4360 None,
4361 )
4362 .expect("declared source name accepted");
4363 let anchors = engine.mem_anchors_resolved("specs");
4364 let stored = anchors
4365 .iter()
4366 .find(|(id, _)| id == &ok.id)
4367 .map(|(_, a)| &a.anchor)
4368 .expect("anchor stored for the new entity");
4369 assert_eq!(stored.source.as_deref(), Some("api-docs"));
4370
4371 engine
4373 .create_entity(
4374 make_args("Orphaned Binding", anchor("whatever", "deadbeef")),
4375 actor,
4376 Some(&client),
4377 None,
4378 )
4379 .expect("unresolvable binding accepts any non-empty name");
4380 }
4381
4382 #[test]
4387 fn batch_create_intra_batch_references_resolve_real() {
4388 let tmp = TempDir::new().unwrap();
4389 let mem_dir = tmp.path().to_path_buf();
4390 let writer = FilesystemMemWriter::new(mem_dir.clone());
4391 let mut engine = Engine::from_mounts(vec![(
4392 folder_mount("specs", mem_dir),
4393 Box::new(writer) as Box<dyn MemBackend>,
4394 )])
4395 .unwrap();
4396 let (actor, client) = cli_actor();
4397
4398 let with_rel = |title: &str, to: &str| {
4399 let mut args = empty_create_args("specs", title);
4400 args.relations = vec![crate::ops::RelateArg {
4401 to: crate::entity::EntityId::new("specs", to),
4402 rel_type: "USES".to_string(),
4403 description: None,
4404 }];
4405 (args, Some(format!("note for {title}")))
4406 };
4407 let result = engine
4409 .batch_create(
4410 vec![
4411 with_rel("Alpha", "beta"),
4412 with_rel("Beta", "gamma"),
4413 with_rel("Gamma", "alpha"),
4414 ],
4415 actor,
4416 Some(&client),
4417 false,
4418 )
4419 .unwrap();
4420 assert!(result.applied, "{result:?}");
4421 assert_eq!(result.succeeded, 3);
4422 assert!(!result.commit_sha.is_empty(), "one real commit");
4423 assert!(
4424 result.results.iter().all(|r| r.action == "created"),
4425 "{result:?}"
4426 );
4427
4428 for name in ["alpha", "beta", "gamma"] {
4430 let e = engine
4431 .get_entity(&crate::entity::EntityId::new("specs", name))
4432 .unwrap();
4433 assert!(!e.stub, "{name} must be real, not a stub");
4434 assert_eq!(e.entity_type, "spec");
4435 assert_eq!(e.relationships.len(), 1, "{name} carries its edge");
4436 }
4437 }
4442
4443 #[test]
4449 fn batch_create_dry_run_reports_receipt_and_writes_nothing() {
4450 let tmp = TempDir::new().unwrap();
4451 let mem_dir = tmp.path().to_path_buf();
4452 let writer = FilesystemMemWriter::new(mem_dir.clone());
4453 let mut engine = Engine::from_mounts(vec![(
4454 folder_mount("specs", mem_dir),
4455 Box::new(writer) as Box<dyn MemBackend>,
4456 )])
4457 .unwrap();
4458 let (actor, client) = cli_actor();
4459
4460 let with_rel = |title: &str, to: &str| {
4461 let mut args = empty_create_args("specs", title);
4462 args.relations = vec![crate::ops::RelateArg {
4463 to: crate::entity::EntityId::new("specs", to),
4464 rel_type: "USES".to_string(),
4465 description: None,
4466 }];
4467 (args, None)
4468 };
4469 let batch = || {
4470 vec![
4471 with_rel("Alpha", "beta"),
4472 with_rel("Beta", "gamma"),
4473 with_rel("Gamma", "alpha"),
4474 ]
4475 };
4476
4477 let rehearsed = engine
4478 .batch_create(batch(), actor, Some(&client), true)
4479 .unwrap();
4480 assert!(rehearsed.applied, "{rehearsed:?}");
4481 assert_eq!(rehearsed.succeeded, 3);
4482 assert!(
4483 rehearsed.commit_sha.is_empty(),
4484 "marker form: empty commit_sha"
4485 );
4486 assert!(rehearsed.results.iter().all(|r| r.action == "created"));
4487 for name in ["alpha", "beta", "gamma"] {
4489 let id = crate::entity::EntityId::new("specs", name);
4490 assert!(
4491 rehearsed.results.iter().any(|r| r.id == id),
4492 "receipt must name {id}: {rehearsed:?}"
4493 );
4494 assert!(
4495 !engine.store().contains(&id),
4496 "rehearsal must create nothing"
4497 );
4498 }
4499 assert_eq!(engine.store().all_entities().count(), 0);
4500
4501 let real = engine
4503 .batch_create(batch(), actor, Some(&client), false)
4504 .unwrap();
4505 assert!(real.applied, "{real:?}");
4506 assert!(!real.commit_sha.is_empty(), "the real batch commits");
4507 assert_eq!(real.succeeded, 3);
4508 }
4509
4510 #[test]
4515 fn batch_create_dry_run_refuses_identically_to_real() {
4516 let tmp = TempDir::new().unwrap();
4517 let (mut engine, _seeded) = engine_with_seed(&tmp, "Existing");
4518 let (actor, client) = cli_actor();
4519 let plain = |title: &str| (empty_create_args("specs", title), None);
4520 let batch = || {
4521 vec![
4522 plain("Fine One"),
4523 plain("Existing"), plain("Bad/Title"), ]
4526 };
4527
4528 let rehearsed = engine
4529 .batch_create(batch(), actor, Some(&client), true)
4530 .unwrap();
4531 let real = engine
4532 .batch_create(batch(), actor, Some(&client), false)
4533 .unwrap();
4534 assert!(!rehearsed.applied && !real.applied);
4535 assert_eq!(rehearsed.failed, real.failed);
4536 assert_eq!(rehearsed.errors_suppressed, real.errors_suppressed);
4537 let envelope = |r: &crate::ops::BatchResult| {
4538 r.results
4539 .iter()
4540 .map(|e| {
4541 (
4542 e.id.to_string(),
4543 e.action.clone(),
4544 e.error.as_ref().map(|err| {
4545 (err.code.clone(), err.message.clone(), err.details.clone())
4546 }),
4547 )
4548 })
4549 .collect::<Vec<_>>()
4550 };
4551 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4552 assert!(
4553 !engine
4554 .store()
4555 .contains(&crate::entity::EntityId::new("specs", "fine-one"))
4556 );
4557 }
4558
4559 #[test]
4563 fn batch_create_refuses_whole_batch_reporting_every_failure() {
4564 let tmp = TempDir::new().unwrap();
4565 let (mut engine, seeded) = engine_with_seed(&tmp, "Existing");
4566 let (actor, client) = cli_actor();
4567 let head_before = engine
4568 .mem_head_sha("specs")
4569 .ok()
4570 .flatten()
4571 .unwrap_or_default();
4572 let count_before = engine.store().all_entities().count();
4573
4574 let plain = |title: &str| (empty_create_args("specs", title), None);
4575 let result = engine
4576 .batch_create(
4577 vec![
4578 plain("Fine One"),
4579 plain("Existing"), plain("Bad\nTitle"), plain("Fine Two"),
4582 plain("Fine Two"), ],
4584 actor,
4585 Some(&client),
4586 false,
4587 )
4588 .unwrap();
4589 assert!(!result.applied);
4590 assert_eq!(result.failed, 3, "{result:?}");
4591 assert!(result.commit_sha.is_empty());
4592 let codes: Vec<(usize, &str)> = result
4593 .results
4594 .iter()
4595 .enumerate()
4596 .filter(|(_, r)| r.action == "error")
4597 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4598 .collect();
4599 assert_eq!(
4600 codes,
4601 vec![
4602 (1, "ENTITY_ALREADY_EXISTS"),
4603 (2, "INVALID_TITLE"),
4604 (4, "ENTITY_ALREADY_EXISTS"),
4605 ],
4606 "every failing entry named with index + typed code: {result:?}"
4607 );
4608 assert_eq!(result.results[0].action, "not_applied");
4610 assert_eq!(result.results[3].action, "not_applied");
4611 let head_after = engine
4612 .mem_head_sha("specs")
4613 .ok()
4614 .flatten()
4615 .unwrap_or_default();
4616 assert_eq!(head_before, head_after, "mem head unmoved");
4617 assert_eq!(
4618 engine.store().all_entities().count(),
4619 count_before,
4620 "no entity created, no skeleton left behind"
4621 );
4622 let _ = seeded;
4623 }
4624
4625 #[test]
4629 fn batch_create_bounds_the_failure_report() {
4630 let tmp = TempDir::new().unwrap();
4631 let mem_dir = tmp.path().to_path_buf();
4632 let writer = FilesystemMemWriter::new(mem_dir.clone());
4633 let mut engine = Engine::from_mounts(vec![(
4634 folder_mount("specs", mem_dir),
4635 Box::new(writer) as Box<dyn MemBackend>,
4636 )])
4637 .unwrap();
4638 let (actor, client) = cli_actor();
4639 let n = Engine::BATCH_ERROR_REPORT_CAP + 10;
4640 let batch: Vec<_> = (0..n)
4641 .map(|i| (empty_create_args("specs", &format!("Bad\nTitle {i}")), None))
4642 .collect();
4643 let result = engine
4644 .batch_create(batch, actor, Some(&client), false)
4645 .unwrap();
4646 assert!(!result.applied);
4647 assert_eq!(result.failed, n);
4648 let detailed = result
4649 .results
4650 .iter()
4651 .filter(|r| r.action == "error" && r.error.is_some())
4652 .count();
4653 let bare = result
4654 .results
4655 .iter()
4656 .filter(|r| r.action == "error" && r.error.is_none())
4657 .count();
4658 assert_eq!(detailed, Engine::BATCH_ERROR_REPORT_CAP);
4659 assert_eq!(bare, 10);
4660 assert_eq!(
4661 result.errors_suppressed, 10,
4662 "suppression is counted, never silent"
4663 );
4664 }
4665
4666 #[test]
4667 fn create_entity_writes_through_folder_backend_and_updates_store() {
4668 let tmp = TempDir::new().unwrap();
4669 let mem_dir = tmp.path().to_path_buf();
4670 let writer = FilesystemMemWriter::new(mem_dir.clone());
4671 let mut engine = Engine::from_mounts(vec![(
4672 folder_mount("specs", mem_dir.clone()),
4673 Box::new(writer) as Box<dyn MemBackend>,
4674 )])
4675 .unwrap();
4676 let (actor, client) = cli_actor();
4677
4678 let outcome = engine
4679 .create_entity(
4680 empty_create_args("specs", "Hello World"),
4681 actor,
4682 Some(&client),
4683 Some("first draft"),
4684 )
4685 .unwrap();
4686
4687 assert_eq!(outcome.id.to_string(), "specs--hello-world");
4689 assert_eq!(outcome.file_path, "hello-world.md");
4690 assert!(!outcome.content_hash.is_empty());
4691
4692 let entity = engine
4694 .get_entity(&crate::EntityId::new("specs", "hello-world"))
4695 .expect("entity must be in the store after create");
4696 assert_eq!(entity.title, "Hello World");
4697 assert_eq!(entity.entity_type, "spec");
4698 assert_eq!(entity.content_hash, outcome.content_hash);
4699
4700 let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
4702 assert!(on_disk.contains("# Hello World"));
4703 assert!(on_disk.contains("type: spec"));
4704
4705 let log_path = mem_dir.join(".memstead").join("changes.jsonl");
4707 let log = std::fs::read_to_string(&log_path).unwrap();
4708 assert!(log.contains("\"kind\":\"create\""));
4709 assert!(log.contains("\"entity\":\"specs--hello-world\""));
4710 assert!(log.contains("\"actor\":\"cli\""));
4711 assert!(log.contains("\"note\":\"first draft\""));
4712 }
4713
4714 #[test]
4720 fn create_entity_warns_on_supplied_auto_managed_field() {
4721 let tmp = TempDir::new().unwrap();
4722 let mem_dir = tmp.path().to_path_buf();
4723 let writer = FilesystemMemWriter::new(mem_dir.clone());
4724 let mut engine = Engine::from_mounts(vec![(
4725 folder_mount("specs", mem_dir),
4726 Box::new(writer) as Box<dyn MemBackend>,
4727 )])
4728 .unwrap();
4729 let (actor, client) = cli_actor();
4730
4731 let mut args = empty_create_args("specs", "Dated Entity");
4732 args.metadata
4733 .insert("created_date".to_string(), "2020-01-01".to_string());
4734
4735 let outcome = engine
4736 .create_entity(args, actor, Some(&client), None)
4737 .unwrap();
4738
4739 let warned = outcome.warnings.iter().any(|w| {
4740 w.code() == "IGNORED_READONLY_FIELD"
4741 && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
4742 if field == "created_date" && supplied == "2020-01-01")
4743 });
4744 assert!(
4745 warned,
4746 "expected IGNORED_READONLY_FIELD; got {:?}",
4747 outcome.warnings
4748 );
4749
4750 assert_ne!(outcome.created_date, "2020-01-01");
4752 }
4753
4754 #[test]
4757 fn create_entity_no_warning_when_auto_managed_field_absent() {
4758 let tmp = TempDir::new().unwrap();
4759 let mem_dir = tmp.path().to_path_buf();
4760 let writer = FilesystemMemWriter::new(mem_dir.clone());
4761 let mut engine = Engine::from_mounts(vec![(
4762 folder_mount("specs", mem_dir),
4763 Box::new(writer) as Box<dyn MemBackend>,
4764 )])
4765 .unwrap();
4766 let (actor, client) = cli_actor();
4767
4768 let outcome = engine
4769 .create_entity(
4770 empty_create_args("specs", "Plain Entity"),
4771 actor,
4772 Some(&client),
4773 None,
4774 )
4775 .unwrap();
4776 assert!(
4777 !outcome
4778 .warnings
4779 .iter()
4780 .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
4781 "no auto-managed field supplied — no warning expected; got {:?}",
4782 outcome.warnings
4783 );
4784 }
4785
4786 #[test]
4787 fn create_entity_returns_commit_sha_title_mem_on_real_write() {
4788 let tmp = TempDir::new().unwrap();
4789 let mem_dir = tmp.path().to_path_buf();
4790 let writer = FilesystemMemWriter::new(mem_dir.clone());
4791 let mut engine = Engine::from_mounts(vec![(
4792 folder_mount("specs", mem_dir),
4793 Box::new(writer) as Box<dyn MemBackend>,
4794 )])
4795 .unwrap();
4796 let (actor, client) = cli_actor();
4797
4798 let outcome = engine
4799 .create_entity(
4800 empty_create_args("specs", "Rich Shape"),
4801 actor,
4802 Some(&client),
4803 None,
4804 )
4805 .unwrap();
4806
4807 assert!(
4810 !outcome.commit_sha.is_empty(),
4811 "commit_sha must be populated on a real create"
4812 );
4813 assert_eq!(outcome.title, "Rich Shape");
4815 assert_eq!(outcome.mem, "specs");
4816 assert!(
4822 !outcome
4823 .warnings
4824 .iter()
4825 .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
4826 "success path must not carry MissingRequiredSection warnings — those refuse on create now",
4827 );
4828 }
4829
4830 #[test]
4836 fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
4837 let tmp = TempDir::new().unwrap();
4838 let mem_dir = tmp.path().to_path_buf();
4839 let writer = FilesystemMemWriter::new(mem_dir.clone());
4840 let mut engine = Engine::from_mounts(vec![(
4841 folder_mount("specs", mem_dir),
4842 Box::new(writer) as Box<dyn MemBackend>,
4843 )])
4844 .unwrap();
4845 let (actor, client) = cli_actor();
4846
4847 let args = CreateEntityArgs {
4849 anchors: Vec::new(),
4850 mem: "specs".to_string(),
4851 title: "Half Done".to_string(),
4852 entity_type: "spec".to_string(),
4853 sections: IndexMap::new(),
4854 metadata: IndexMap::new(),
4855 relations: Vec::new(),
4856 dry_run: false,
4857 };
4858 let err = engine
4859 .create_entity(args, actor, Some(&client), None)
4860 .unwrap_err();
4861 match err {
4862 EngineError::MissingRequiredSection {
4863 entity_type,
4864 missing_count,
4865 sections,
4866 type_guidance,
4867 pre_announced_missing_fields: _,
4868 } => {
4869 assert_eq!(entity_type, "spec");
4870 assert_eq!(missing_count, sections.len());
4871 assert!(
4872 missing_count >= 2,
4873 "expected ≥2 missing sections, got {missing_count}"
4874 );
4875 let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
4876 assert!(
4877 keys.contains(&"identity".to_string()),
4878 "missing keys: {keys:?}"
4879 );
4880 assert!(
4881 keys.contains(&"purpose".to_string()),
4882 "missing keys: {keys:?}"
4883 );
4884 assert!(
4885 type_guidance.contains_key("spec"),
4886 "type_guidance must include `spec` entry, got: {type_guidance:?}",
4887 );
4888 }
4889 other => panic!("expected MissingRequiredSection, got {other:?}"),
4890 }
4891
4892 let id = crate::EntityId::new("specs", "half-done");
4894 assert!(
4895 engine.store().get(&id).is_none(),
4896 "refused create must not persist any entity"
4897 );
4898 }
4899
4900 #[test]
4908 fn create_refusing_sections_pre_announces_metadata_gate_and_fixed_retry_succeeds() {
4909 let tmp = TempDir::new().unwrap();
4910 let mut engine = engine_with_planning_schema(&tmp);
4911 let (actor, client) = cli_actor();
4912
4913 let bare = CreateEntityArgs {
4918 anchors: Vec::new(),
4919 mem: "planning".to_string(),
4920 title: "Cold Write".to_string(),
4921 entity_type: "decision".to_string(),
4922 sections: IndexMap::new(),
4923 metadata: IndexMap::new(),
4924 relations: Vec::new(),
4925 dry_run: false,
4926 };
4927 let err = engine
4928 .create_entity(bare, actor, Some(&client), None)
4929 .unwrap_err();
4930 let (sections, announced) = match err {
4931 EngineError::MissingRequiredSection {
4932 sections,
4933 pre_announced_missing_fields,
4934 ..
4935 } => (sections, pre_announced_missing_fields),
4936 other => panic!("expected MissingRequiredSection, got {other:?}"),
4937 };
4938 let section_keys: Vec<&str> = sections.iter().map(|s| s.key.as_str()).collect();
4939 for key in ["decision", "context", "consequences"] {
4940 assert!(section_keys.contains(&key), "sections: {section_keys:?}");
4941 }
4942 let announced_keys: Vec<&str> = announced.iter().map(|m| m.key.as_str()).collect();
4945 assert_eq!(
4946 announced_keys,
4947 vec!["decided_on", "deciders"],
4948 "pre-announced set must equal the metadata gate's demand in declaration order"
4949 );
4950 let rebuilt = EngineError::MissingRequiredSection {
4953 entity_type: "decision".to_string(),
4954 missing_count: sections.len(),
4955 sections,
4956 type_guidance: Default::default(),
4957 pre_announced_missing_fields: announced,
4958 };
4959 let details = rebuilt.details();
4960 let wire_missing = details["pre_announced"]["required_field_unset"]["missing"]
4961 .as_array()
4962 .expect("pre_announced.required_field_unset.missing[] present");
4963 assert_eq!(wire_missing[0]["field"], "decided_on");
4964 assert!(wire_missing[0].get("description").is_some());
4965 assert!(wire_missing[0].get("enum_values").is_some());
4966
4967 let mut metadata = IndexMap::new();
4970 metadata.insert("decided_on".to_string(), "2026-08-19".to_string());
4971 metadata.insert("deciders".to_string(), "alice".to_string());
4972 engine
4973 .create_entity(
4974 CreateEntityArgs {
4975 anchors: Vec::new(),
4976 mem: "planning".to_string(),
4977 title: "Cold Write".to_string(),
4978 entity_type: "decision".to_string(),
4979 sections: IndexMap::from_iter([
4980 ("decision".to_string(), "x".to_string()),
4981 ("context".to_string(), "y".to_string()),
4982 ("consequences".to_string(), "z".to_string()),
4983 ]),
4984 metadata,
4985 relations: Vec::new(),
4986 dry_run: false,
4987 },
4988 actor,
4989 Some(&client),
4990 None,
4991 )
4992 .expect("fixing everything announced must succeed in the second round-trip");
4993 }
4994
4995 #[test]
5001 fn section_only_refusal_omits_the_pre_announced_block() {
5002 let tmp = TempDir::new().unwrap();
5003 let mut engine = engine_with_planning_schema(&tmp);
5004 let (actor, client) = cli_actor();
5005
5006 let mut metadata = IndexMap::new();
5007 metadata.insert("decided_on".to_string(), "2026-08-19".to_string());
5008 metadata.insert("deciders".to_string(), "alice".to_string());
5009 let err = engine
5010 .create_entity(
5011 CreateEntityArgs {
5012 anchors: Vec::new(),
5013 mem: "planning".to_string(),
5014 title: "Sections Only".to_string(),
5015 entity_type: "decision".to_string(),
5016 sections: IndexMap::new(),
5017 metadata,
5018 relations: Vec::new(),
5019 dry_run: false,
5020 },
5021 actor,
5022 Some(&client),
5023 None,
5024 )
5025 .unwrap_err();
5026 match &err {
5027 EngineError::MissingRequiredSection {
5028 pre_announced_missing_fields,
5029 ..
5030 } => assert!(
5031 pre_announced_missing_fields.is_empty(),
5032 "metadata gate is satisfied — nothing to pre-announce"
5033 ),
5034 other => panic!("expected MissingRequiredSection, got {other:?}"),
5035 }
5036 assert!(
5037 err.details().get("pre_announced").is_none(),
5038 "single-gate refusal must stay byte-compatible: no pre_announced key"
5039 );
5040 }
5041
5042 #[test]
5046 fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
5047 let tmp = TempDir::new().unwrap();
5048 let mem_dir = tmp.path().to_path_buf();
5049 let writer = FilesystemMemWriter::new(mem_dir.clone());
5050 let mut engine = Engine::from_mounts(vec![(
5051 folder_mount("specs", mem_dir),
5052 Box::new(writer) as Box<dyn MemBackend>,
5053 )])
5054 .unwrap();
5055 let (actor, client) = cli_actor();
5056
5057 let args = CreateEntityArgs {
5058 anchors: Vec::new(),
5059 mem: "specs".to_string(),
5060 title: "Half Done Dry".to_string(),
5061 entity_type: "spec".to_string(),
5062 sections: IndexMap::new(),
5063 metadata: IndexMap::new(),
5064 relations: Vec::new(),
5065 dry_run: true,
5066 };
5067 let err = engine
5068 .create_entity(args, actor, Some(&client), None)
5069 .unwrap_err();
5070 assert!(
5071 matches!(err, EngineError::MissingRequiredSection { .. }),
5072 "dry_run must surface the same refusal envelope, got {err:?}"
5073 );
5074 }
5075
5076 #[test]
5080 fn create_entity_succeeds_after_filling_in_required_sections() {
5081 let tmp = TempDir::new().unwrap();
5082 let mem_dir = tmp.path().to_path_buf();
5083 let writer = FilesystemMemWriter::new(mem_dir.clone());
5084 let mut engine = Engine::from_mounts(vec![(
5085 folder_mount("specs", mem_dir),
5086 Box::new(writer) as Box<dyn MemBackend>,
5087 )])
5088 .unwrap();
5089 let (actor, client) = cli_actor();
5090
5091 let mut sections = IndexMap::new();
5092 sections.insert("identity".to_string(), "the identity body".to_string());
5093 sections.insert("purpose".to_string(), "the purpose body".to_string());
5094 let args = CreateEntityArgs {
5095 anchors: Vec::new(),
5096 mem: "specs".to_string(),
5097 title: "Complete".to_string(),
5098 entity_type: "spec".to_string(),
5099 sections,
5100 metadata: IndexMap::new(),
5101 relations: Vec::new(),
5102 dry_run: false,
5103 };
5104 let outcome = engine
5105 .create_entity(args, actor, Some(&client), None)
5106 .expect("complete create succeeds");
5107 assert_eq!(outcome.title, "Complete");
5108 }
5109
5110 #[test]
5111 fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
5112 let tmp = TempDir::new().unwrap();
5113 let (mut engine, source) = engine_with_seed(&tmp, "Source");
5114 let (actor, client) = cli_actor();
5115
5116 let stub_target = crate::EntityId::new("specs", "ghost-target");
5119 engine
5120 .relate_entity(
5121 RelateEntityArgs {
5122 source: source.id.clone(),
5123 expected_hash: Some(source.content_hash.clone()),
5124 rel_type: "USES".to_string(),
5125 target: stub_target.clone(),
5126 remove: false,
5127 description: None,
5128 dry_run: false,
5129 },
5130 actor,
5131 Some(&client),
5132 None,
5133 )
5134 .unwrap();
5135 let stub = engine
5136 .store()
5137 .get(&stub_target)
5138 .expect("stub must be in store");
5139 assert!(stub.stub);
5140 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
5141
5142 let outcome = engine
5145 .create_entity(
5146 empty_create_args("specs", "Ghost Target"),
5147 actor,
5148 Some(&client),
5149 None,
5150 )
5151 .unwrap();
5152
5153 assert_eq!(outcome.id, stub_target);
5155 let real = engine
5157 .store()
5158 .get(&stub_target)
5159 .expect("entity must still be in store");
5160 assert!(!real.stub);
5161 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
5163 assert_eq!(outcome.incoming_count, Some(1));
5165 assert_eq!(outcome.incoming.len(), 1);
5166 assert_eq!(outcome.incoming[0].from, source.id);
5167 assert_eq!(outcome.incoming[0].rel_type, "USES");
5168 }
5169
5170 #[test]
5171 fn create_entity_reports_no_incoming_on_greenfield_create() {
5172 let tmp = TempDir::new().unwrap();
5173 let mem_dir = tmp.path().to_path_buf();
5174 let writer = FilesystemMemWriter::new(mem_dir.clone());
5175 let mut engine = Engine::from_mounts(vec![(
5176 folder_mount("specs", mem_dir),
5177 Box::new(writer) as Box<dyn MemBackend>,
5178 )])
5179 .unwrap();
5180 let (actor, client) = cli_actor();
5181
5182 let outcome = engine
5183 .create_entity(
5184 empty_create_args("specs", "Greenfield"),
5185 actor,
5186 Some(&client),
5187 None,
5188 )
5189 .unwrap();
5190 assert!(outcome.incoming_count.is_none());
5193 assert!(outcome.incoming.is_empty());
5194 }
5195
5196 #[test]
5197 fn create_entity_populates_created_date_from_schema_auto_stamp() {
5198 let tmp = TempDir::new().unwrap();
5199 let mem_dir = tmp.path().to_path_buf();
5200 let writer = FilesystemMemWriter::new(mem_dir.clone());
5201 let mut engine = Engine::from_mounts(vec![(
5202 folder_mount("specs", mem_dir),
5203 Box::new(writer) as Box<dyn MemBackend>,
5204 )])
5205 .unwrap();
5206 let (actor, client) = cli_actor();
5207
5208 let outcome = engine
5209 .create_entity(
5210 empty_create_args("specs", "Has Date"),
5211 actor,
5212 Some(&client),
5213 None,
5214 )
5215 .unwrap();
5216 assert!(
5221 !outcome.created_date.is_empty(),
5222 "created_date must be populated when the schema auto-stamps it"
5223 );
5224 }
5225
5226 #[test]
5227 fn create_overrides_user_supplied_timestamps_update_rejects_them() {
5228 let tmp = TempDir::new().unwrap();
5239 let mem_dir = tmp.path().to_path_buf();
5240 let writer = FilesystemMemWriter::new(mem_dir.clone());
5241 let mut engine = Engine::from_mounts(vec![(
5242 folder_mount("specs", mem_dir),
5243 Box::new(writer) as Box<dyn MemBackend>,
5244 )])
5245 .unwrap();
5246 const FROZEN_SECS: u64 = 1_754_000_000;
5255 let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(FROZEN_SECS);
5256 engine.set_mutation_clock(std::sync::Arc::new(move || frozen));
5257 let (actor, client) = cli_actor();
5258
5259 let mut args = empty_create_args("specs", "Stamped Today");
5263 args.metadata
5264 .insert("created_date".to_string(), "2020-01-01".to_string());
5265 args.metadata
5266 .insert("last_modified".to_string(), "2020-01-01".to_string());
5267
5268 let outcome = engine
5269 .create_entity(args, actor, Some(&client), None)
5270 .unwrap();
5271
5272 let today = crate::engine::mutation::iso_from_system_time(frozen);
5275 assert_eq!(outcome.created_date, today);
5276 let entity = engine
5277 .get_entity(&outcome.id)
5278 .expect("entity must be in store after create");
5279 assert_eq!(
5280 entity
5281 .metadata
5282 .get("created_date")
5283 .and_then(|v| v.as_str())
5284 .unwrap_or_default(),
5285 today,
5286 "init_timestamp field must be engine-determined on create, not user-supplied"
5287 );
5288 assert_eq!(
5289 entity
5290 .metadata
5291 .get("last_modified")
5292 .and_then(|v| v.as_str())
5293 .unwrap_or_default(),
5294 today,
5295 "auto_timestamp field must be engine-determined on create, not user-supplied"
5296 );
5297
5298 let attempt_update = |key: &str, value: &str| {
5302 let mut metadata = IndexMap::new();
5303 metadata.insert(key.to_string(), value.to_string());
5304 crate::engine::UpdateEntityArgs {
5305 anchors: Vec::new(),
5306 id: outcome.id.clone(),
5307 metadata,
5308 metadata_unset: Vec::new(),
5309 sections: IndexMap::new(),
5310 append_sections: IndexMap::new(),
5311 patch_sections: IndexMap::new(),
5312 expected_hash: Some(outcome.content_hash.clone()),
5313 dry_run: false,
5314 declare_relations: Vec::new(),
5315 relations_unset: Vec::new(),
5316 anchors_unset: Vec::new(),
5317 }
5318 };
5319 for key in ["created_date", "last_modified"] {
5320 let err = engine
5321 .update_entity(
5322 attempt_update(key, "2019-12-31"),
5323 actor,
5324 Some(&client),
5325 None,
5326 )
5327 .expect_err("schema-managed timestamp must be rejected on update");
5328 assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
5329 }
5330 let entity = engine
5332 .get_entity(&outcome.id)
5333 .expect("entity must remain in store after rejected update");
5334 assert_eq!(
5335 entity
5336 .metadata
5337 .get("last_modified")
5338 .and_then(|v| v.as_str())
5339 .unwrap_or_default(),
5340 today,
5341 "rejected update must not mutate the auto_timestamp field"
5342 );
5343 }
5344
5345 #[test]
5346 fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
5347 let tmp = TempDir::new().unwrap();
5348 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
5349 let (actor, client) = cli_actor();
5350 let absent = crate::EntityId::new("specs", "future-target");
5351 assert!(!engine.store().contains(&absent));
5352
5353 let mut args = empty_create_args("specs", "Source With Relations");
5354 args.relations = vec![
5355 crate::ops::RelateArg {
5356 to: existing.id.clone(),
5357 rel_type: "USES".to_string(),
5358 description: None,
5359 },
5360 crate::ops::RelateArg {
5361 to: absent.clone(),
5362 rel_type: "USES".to_string(),
5363 description: None,
5364 },
5365 ];
5366
5367 let outcome = engine
5368 .create_entity(args, actor, Some(&client), None)
5369 .unwrap();
5370
5371 let source = engine
5373 .store()
5374 .get(&outcome.id)
5375 .expect("source must be in store");
5376 assert_eq!(source.relationships.len(), 2);
5377 assert!(
5378 source
5379 .relationships
5380 .iter()
5381 .any(|r| r.target == existing.id && r.rel_type == "USES")
5382 );
5383 assert!(
5384 source
5385 .relationships
5386 .iter()
5387 .any(|r| r.target == absent && r.rel_type == "USES")
5388 );
5389
5390 let stub = engine
5393 .store()
5394 .get(&absent)
5395 .expect("absent relation target must be auto-stubbed");
5396 assert!(stub.stub);
5397 let existing_after = engine.store().get(&existing.id).unwrap();
5399 assert!(!existing_after.stub);
5400 }
5401
5402 fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
5407 use crate::workspace::Mount;
5408 use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
5409 let mem_dir = tmp.path().to_path_buf();
5410 let writer = FilesystemMemWriter::new(mem_dir.clone());
5411 let mount = Mount {
5412 mem: "planning".to_string(),
5413 schema: Some(memstead_schema::SchemaRef::new(
5414 "planning",
5415 semver::Version::new(0, 1, 0),
5416 )),
5417 storage: MountStorage::Folder { path: mem_dir },
5418 capability: MountCapability::Write,
5419 lifecycle: MountLifecycle::Eager,
5420 cross_linkable: true,
5421 migration_target: None,
5422 };
5423 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
5424 }
5425
5426 #[test]
5435 fn create_entity_refuses_unsupplied_no_default_required_field() {
5436 let tmp = TempDir::new().unwrap();
5440 let mut engine = engine_with_planning_schema(&tmp);
5441 let (actor, client) = cli_actor();
5442
5443 let mut args = CreateEntityArgs {
5444 anchors: Vec::new(),
5445 mem: "planning".to_string(),
5446 title: "Skip Postgres".to_string(),
5447 entity_type: "decision".to_string(),
5448 sections: IndexMap::from_iter([
5449 ("decision".to_string(), "Use SQLite locally.".to_string()),
5450 ("context".to_string(), "Single-user dev.".to_string()),
5451 ("consequences".to_string(), "Lose multi-writer.".to_string()),
5452 ]),
5453 metadata: IndexMap::new(),
5454 relations: Vec::new(),
5455 dry_run: false,
5456 };
5457
5458 let err = engine
5461 .create_entity(args.clone(), actor, Some(&client), None)
5462 .unwrap_err();
5463 match err {
5464 EngineError::RequiredFieldUnset {
5465 field, entity_type, ..
5466 } => {
5467 assert!(
5468 field == "decided_on" || field == "deciders",
5469 "expected first missing field, got {field:?}"
5470 );
5471 assert_eq!(entity_type, "decision");
5472 }
5473 other => panic!("expected RequiredFieldUnset, got {other:?}"),
5474 }
5475
5476 args.title = "Different Title".to_string();
5480 args.dry_run = true;
5481 let dry_err = engine
5482 .create_entity(args, actor, Some(&client), None)
5483 .unwrap_err();
5484 assert!(
5485 matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
5486 "dry_run must surface the same refusal envelope, got {dry_err:?}"
5487 );
5488 }
5489
5490 #[test]
5494 fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
5495 let tmp = TempDir::new().unwrap();
5496 let mut engine = engine_with_planning_schema(&tmp);
5497 let (actor, client) = cli_actor();
5498
5499 let mut metadata = IndexMap::new();
5500 metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
5501 metadata.insert("deciders".to_string(), "alice, bob".to_string());
5502
5503 let outcome = engine
5504 .create_entity(
5505 CreateEntityArgs {
5506 anchors: Vec::new(),
5507 mem: "planning".to_string(),
5508 title: "Complete Decision".to_string(),
5509 entity_type: "decision".to_string(),
5510 sections: IndexMap::from_iter([
5511 ("decision".to_string(), "x".to_string()),
5512 ("context".to_string(), "y".to_string()),
5513 ("consequences".to_string(), "z".to_string()),
5514 ]),
5515 metadata,
5516 relations: Vec::new(),
5517 dry_run: false,
5518 },
5519 actor,
5520 Some(&client),
5521 None,
5522 )
5523 .expect("complete decision create succeeds");
5524 let missing_field_warnings: Vec<&WarningHint> = outcome
5527 .warnings
5528 .iter()
5529 .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
5530 .collect();
5531 assert!(
5532 missing_field_warnings.is_empty(),
5533 "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
5534 );
5535 }
5536
5537 #[test]
5542 fn create_entity_rejects_inline_relation_with_malformed_target_id() {
5543 let tmp = TempDir::new().unwrap();
5544 let mem_dir = tmp.path().to_path_buf();
5545 let writer = FilesystemMemWriter::new(mem_dir.clone());
5546 let mut engine = Engine::from_mounts(vec![(
5547 folder_mount("specs", mem_dir),
5548 Box::new(writer) as Box<dyn MemBackend>,
5549 )])
5550 .unwrap();
5551 let (actor, client) = cli_actor();
5552
5553 let mut args = empty_create_args("specs", "Source");
5554 args.relations = vec![crate::ops::RelateArg {
5555 to: crate::EntityId("specs--bad target with spaces!!".to_string()),
5556 rel_type: "USES".to_string(),
5557 description: None,
5558 }];
5559 let err = engine
5560 .create_entity(args, actor, Some(&client), None)
5561 .unwrap_err();
5562 assert!(
5563 matches!(err, EngineError::InvalidEntityId { .. }),
5564 "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
5565 );
5566 }
5567
5568 #[test]
5577 fn create_entity_rejects_inline_relation_with_shape_violation() {
5578 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5579 let tmp = TempDir::new().unwrap();
5580 let mem_dir = tmp.path().to_path_buf();
5581 let writer = FilesystemMemWriter::new(mem_dir.clone());
5582 let mount = Mount {
5583 mem: "code".to_string(),
5584 schema: Some(memstead_schema::SchemaRef::new(
5585 "software",
5586 semver::Version::new(0, 1, 0),
5587 )),
5588 storage: MountStorage::Folder { path: mem_dir },
5589 capability: MountCapability::Write,
5590 lifecycle: MountLifecycle::Eager,
5591 cross_linkable: true,
5592 migration_target: None,
5593 };
5594 let mut engine =
5595 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
5596 let (actor, client) = cli_actor();
5597
5598 let target = engine
5606 .create_entity(
5607 CreateEntityArgs {
5608 anchors: Vec::new(),
5609 mem: "code".to_string(),
5610 title: "Target Requirement".to_string(),
5611 entity_type: "requirement".to_string(),
5612 sections: IndexMap::from_iter([
5613 ("statement".to_string(), "MUST hold.".to_string()),
5614 ("rationale".to_string(), "Because tests.".to_string()),
5615 ]),
5616 metadata: IndexMap::from_iter([
5617 ("verified_on".to_string(), "2026-05-19".to_string()),
5618 ("source".to_string(), "test fixture".to_string()),
5619 ]),
5620 relations: Vec::new(),
5621 dry_run: false,
5622 },
5623 actor,
5624 Some(&client),
5625 None,
5626 )
5627 .unwrap();
5628
5629 let args = CreateEntityArgs {
5635 anchors: Vec::new(),
5636 mem: "code".to_string(),
5637 title: "Misshape Source".to_string(),
5638 entity_type: "spec".to_string(),
5639 sections: IndexMap::from_iter([
5640 ("identity".to_string(), "this spec".to_string()),
5641 (
5642 "purpose".to_string(),
5643 "exercising the shape gate".to_string(),
5644 ),
5645 ]),
5646 metadata: IndexMap::new(),
5647 relations: vec![crate::ops::RelateArg {
5648 to: target.id.clone(),
5649 rel_type: "VIOLATES".to_string(),
5650 description: None,
5651 }],
5652 dry_run: false,
5653 };
5654 let err = engine
5655 .create_entity(args, actor, Some(&client), None)
5656 .unwrap_err();
5657 assert!(
5658 matches!(err, EngineError::Validation(_)),
5659 "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
5660 );
5661 }
5662
5663 #[test]
5664 fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
5665 let tmp = TempDir::new().unwrap();
5669 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
5670 let (actor, client) = cli_actor();
5671
5672 let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
5673 args.relations = vec![crate::ops::RelateArg {
5674 to: existing.id.clone(),
5675 rel_type: "uses".to_string(),
5676 description: None,
5677 }];
5678
5679 let outcome = engine
5680 .create_entity(args, actor, Some(&client), None)
5681 .unwrap();
5682
5683 let source = engine
5684 .store()
5685 .get(&outcome.id)
5686 .expect("source must be in store");
5687 assert_eq!(source.relationships.len(), 1);
5688 assert_eq!(
5689 source.relationships[0].rel_type, "USES",
5690 "inline relation rel_type must be stored UPPER_SNAKE_CASE",
5691 );
5692 }
5693
5694 #[test]
5695 fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
5696 let tmp = TempDir::new().unwrap();
5697 let mem_dir = tmp.path().to_path_buf();
5698 let writer = FilesystemMemWriter::new(mem_dir.clone());
5699 let mut engine = Engine::from_mounts(vec![(
5700 folder_mount("specs", mem_dir.clone()),
5701 Box::new(writer) as Box<dyn MemBackend>,
5702 )])
5703 .unwrap();
5704 let (actor, client) = cli_actor();
5705
5706 let mut args = empty_create_args("specs", "Preview Only");
5707 args.dry_run = true;
5708
5709 let outcome = engine
5710 .create_entity(args, actor, Some(&client), None)
5711 .unwrap();
5712
5713 assert_eq!(outcome.id.to_string(), "specs--preview-only");
5715 assert!(
5716 !outcome.content_hash.is_empty(),
5717 "prospective hash populated"
5718 );
5719 assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
5720 assert!(
5722 engine.store().get(&outcome.id).is_none(),
5723 "dry_run must not mutate the store",
5724 );
5725 assert!(
5727 !mem_dir.join("preview-only.md").exists(),
5728 "dry_run must not touch disk",
5729 );
5730 let log = mem_dir.join(".memstead").join("changes.jsonl");
5732 assert!(
5733 !log.exists()
5734 || !std::fs::read_to_string(&log)
5735 .unwrap()
5736 .contains("preview-only"),
5737 "dry_run must not append provenance",
5738 );
5739 }
5740
5741 #[test]
5742 fn create_entity_rejects_read_only_mount_before_backend() {
5743 let tmp = TempDir::new().unwrap();
5744 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
5745 let mut engine = Engine::from_mounts(vec![(
5746 archive_mount("external", archive_path.clone()),
5747 Box::new(ArchiveBackend::new(archive_path)),
5748 )])
5749 .unwrap();
5750 let (actor, client) = cli_actor();
5751
5752 let err = engine
5753 .create_entity(
5754 empty_create_args("external", "Should Fail"),
5755 actor,
5756 Some(&client),
5757 None,
5758 )
5759 .unwrap_err();
5760 match err {
5761 EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
5762 other => panic!("expected ReadOnlyMount, got {other:?}"),
5763 }
5764 }
5768
5769 #[test]
5770 fn create_entity_rejects_unknown_mem() {
5771 let tmp = TempDir::new().unwrap();
5772 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
5773 let mut engine = Engine::from_mounts(vec![(
5774 folder_mount("specs", tmp.path().to_path_buf()),
5775 Box::new(writer) as Box<dyn MemBackend>,
5776 )])
5777 .unwrap();
5778 let (actor, client) = cli_actor();
5779
5780 let err = engine
5781 .create_entity(
5782 empty_create_args("does-not-exist", "Anything"),
5783 actor,
5784 Some(&client),
5785 None,
5786 )
5787 .unwrap_err();
5788 assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
5789 }
5790
5791 #[test]
5792 fn create_entity_rejects_unknown_type_against_pinned_schema() {
5793 let tmp = TempDir::new().unwrap();
5794 let mem_dir = tmp.path().to_path_buf();
5795 let writer = FilesystemMemWriter::new(mem_dir.clone());
5796 let mut engine = Engine::from_mounts(vec![(
5797 folder_mount("specs", mem_dir),
5798 Box::new(writer) as Box<dyn MemBackend>,
5799 )])
5800 .unwrap();
5801 let (actor, client) = cli_actor();
5802
5803 let mut args = empty_create_args("specs", "Anything");
5804 args.entity_type = "definitely-not-a-real-type".to_string();
5805 let err = engine
5806 .create_entity(args, actor, Some(&client), None)
5807 .unwrap_err();
5808 match err {
5809 EngineError::UnknownType { name, declared, .. } => {
5810 assert_eq!(name, "definitely-not-a-real-type");
5811 assert!(!declared.is_empty(), "declared types must be listed");
5812 }
5813 other => panic!("expected UnknownType, got {other:?}"),
5814 }
5815 }
5816
5817 #[test]
5818 fn create_entity_rejects_duplicate_id() {
5819 let tmp = TempDir::new().unwrap();
5820 let mem_dir = tmp.path().to_path_buf();
5821 let writer = FilesystemMemWriter::new(mem_dir.clone());
5822 let mut engine = Engine::from_mounts(vec![(
5823 folder_mount("specs", mem_dir),
5824 Box::new(writer) as Box<dyn MemBackend>,
5825 )])
5826 .unwrap();
5827 let (actor, client) = cli_actor();
5828
5829 engine
5830 .create_entity(
5831 empty_create_args("specs", "Same Slug"),
5832 actor,
5833 Some(&client),
5834 None,
5835 )
5836 .unwrap();
5837 let err = engine
5838 .create_entity(
5839 empty_create_args("specs", "Same Slug"),
5840 actor,
5841 Some(&client),
5842 None,
5843 )
5844 .unwrap_err();
5845 match err {
5846 EngineError::AlreadyExists {
5847 id,
5848 existing_title,
5849 existing_is_stub,
5850 } => {
5851 assert_eq!(id, "specs--same-slug");
5852 assert!(!existing_title.is_empty());
5855 assert!(!existing_is_stub);
5856 }
5857 other => panic!("expected AlreadyExists, got {other:?}"),
5858 }
5859 }
5860
5861 #[test]
5862 fn create_entity_rejects_invalid_title() {
5863 let tmp = TempDir::new().unwrap();
5864 let mem_dir = tmp.path().to_path_buf();
5865 let writer = FilesystemMemWriter::new(mem_dir.clone());
5866 let mut engine = Engine::from_mounts(vec![(
5867 folder_mount("specs", mem_dir),
5868 Box::new(writer) as Box<dyn MemBackend>,
5869 )])
5870 .unwrap();
5871 let (actor, client) = cli_actor();
5872
5873 let err = engine
5879 .create_entity(empty_create_args("specs", " "), actor, Some(&client), None)
5880 .unwrap_err();
5881 match err {
5882 EngineError::InvalidTitle(slug_err) => {
5883 assert_eq!(slug_err.reason(), "empty", "expected empty reason");
5884 }
5885 other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
5886 }
5887
5888 let outcome = engine
5892 .create_entity(
5893 empty_create_args("specs", "Hello, World!"),
5894 actor,
5895 Some(&client),
5896 None,
5897 )
5898 .expect("char-drop title lands under the widened grammar");
5899 assert_eq!(outcome.id.as_ref(), "specs--hello-world");
5900 let dropped = outcome
5901 .warnings
5902 .iter()
5903 .find_map(|w| match w {
5904 WarningHint::TitleCharsDroppedFromSlug {
5905 dropped_chars,
5906 slug,
5907 ..
5908 } => Some((dropped_chars.clone(), slug.clone())),
5909 _ => None,
5910 })
5911 .expect("divergence warning rides the outcome");
5912 assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
5913 assert_eq!(dropped.1, "hello-world");
5914
5915 let outcome = engine
5919 .create_entity(
5920 empty_create_args("specs", "../etc/passwd"),
5921 actor,
5922 Some(&client),
5923 None,
5924 )
5925 .expect("traversal-shaped title lands with a sanitised slug");
5926 assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
5927 assert!(
5928 outcome
5929 .warnings
5930 .iter()
5931 .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
5932 );
5933 }
5934
5935 #[test]
5936 fn create_entity_rejects_unknown_section_key() {
5937 let tmp = TempDir::new().unwrap();
5938 let mem_dir = tmp.path().to_path_buf();
5939 let writer = FilesystemMemWriter::new(mem_dir.clone());
5940 let mut engine = Engine::from_mounts(vec![(
5941 folder_mount("specs", mem_dir),
5942 Box::new(writer) as Box<dyn MemBackend>,
5943 )])
5944 .unwrap();
5945 let (actor, client) = cli_actor();
5946
5947 let mut args = empty_create_args("specs", "Bad Sections");
5948 args.sections
5949 .insert("not-a-real-section-key".to_string(), "body".to_string());
5950 let err = engine
5951 .create_entity(args, actor, Some(&client), None)
5952 .unwrap_err();
5953 assert!(matches!(err, EngineError::Validation(_)));
5954 }
5955
5956 #[test]
5957 fn create_entity_persists_across_engine_restart() {
5958 let tmp = TempDir::new().unwrap();
5959 let mem_dir = tmp.path().to_path_buf();
5960 {
5961 let writer = FilesystemMemWriter::new(mem_dir.clone());
5962 let mut engine = Engine::from_mounts(vec![(
5963 folder_mount("specs", mem_dir.clone()),
5964 Box::new(writer) as Box<dyn MemBackend>,
5965 )])
5966 .unwrap();
5967 let (actor, client) = cli_actor();
5968 engine
5969 .create_entity(
5970 empty_create_args("specs", "Survives Restart"),
5971 actor,
5972 Some(&client),
5973 None,
5974 )
5975 .unwrap();
5976 }
5977 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
5979 let engine2 = Engine::from_mounts(vec![(
5980 folder_mount("specs", mem_dir),
5981 Box::new(writer2) as Box<dyn MemBackend>,
5982 )])
5983 .unwrap();
5984 let entity = engine2
5985 .get_entity(&crate::EntityId::new("specs", "survives-restart"))
5986 .expect("entity must persist across engine restart");
5987 assert_eq!(entity.title, "Survives Restart");
5988 }
5989
5990 fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
5997 let mem_dir = tmp.path().to_path_buf();
5998 let writer = FilesystemMemWriter::new(mem_dir.clone());
5999 let mut engine = Engine::from_mounts(vec![(
6000 folder_mount("specs", mem_dir),
6001 Box::new(writer) as Box<dyn MemBackend>,
6002 )])
6003 .unwrap();
6004 let (actor, client) = cli_actor();
6005 let outcome = engine
6006 .create_entity(
6007 empty_create_args("specs", title),
6008 actor,
6009 Some(&client),
6010 None,
6011 )
6012 .unwrap();
6013 (engine, outcome)
6014 }
6015
6016 #[test]
6024 fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
6025 let tmp = TempDir::new().unwrap();
6026 let mem_dir = tmp.path().to_path_buf();
6027 let writer = FilesystemMemWriter::new(mem_dir.clone());
6028 let mut engine = Engine::from_mounts(vec![(
6029 folder_mount("specs", mem_dir),
6030 Box::new(writer) as Box<dyn MemBackend>,
6031 )])
6032 .unwrap();
6033 let (actor, client) = cli_actor();
6034
6035 let ghost = crate::EntityId::new("specs", "ghost-target");
6036 assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
6037
6038 let mut args = empty_create_args("specs", "Source With Body Link");
6039 args.sections.insert(
6040 "identity".to_string(),
6041 "ref [[ghost-target]] for context".to_string(),
6042 );
6043
6044 let outcome = engine
6045 .create_entity(args, actor, Some(&client), None)
6046 .unwrap();
6047 let stubbed: Vec<&crate::EntityId> = outcome
6048 .warnings
6049 .iter()
6050 .filter_map(|w| match w {
6051 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
6052 _ => None,
6053 })
6054 .flatten()
6055 .collect();
6056 assert!(
6057 stubbed.contains(&&ghost),
6058 "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
6059 outcome.warnings,
6060 );
6061 assert!(
6063 engine.store().contains(&ghost),
6064 "ghost stub must materialise"
6065 );
6066 }
6067
6068 #[test]
6073 fn create_entity_drops_self_link_keeps_other_links_and_warns() {
6074 let tmp = TempDir::new().unwrap();
6075 let mem_dir = tmp.path().to_path_buf();
6076 let writer = FilesystemMemWriter::new(mem_dir.clone());
6077 let mut engine = Engine::from_mounts(vec![(
6078 folder_mount("specs", mem_dir),
6079 Box::new(writer) as Box<dyn MemBackend>,
6080 )])
6081 .unwrap();
6082 let (actor, client) = cli_actor();
6083
6084 let mut args = empty_create_args("specs", "Selfie");
6087 args.sections.insert(
6088 "identity".to_string(),
6089 "see [[selfie]] itself and also [[other-ref]]".to_string(),
6090 );
6091 let outcome = engine
6092 .create_entity(args, actor, Some(&client), None)
6093 .unwrap();
6094 let self_id = outcome.id.clone();
6095 assert_eq!(self_id.to_string(), "specs--selfie");
6096 let other_id = crate::EntityId::new("specs", "other-ref");
6097
6098 assert!(
6100 outcome.warnings.iter().any(|w| matches!(
6101 w, WarningHint::SelfLinkIgnored { id } if *id == self_id
6102 )),
6103 "self-link must emit SELF_LINK_IGNORED; got: {:?}",
6104 outcome.warnings,
6105 );
6106
6107 let ent = engine.get_entity(&self_id).unwrap();
6109 assert!(
6110 ent.relationships.iter().all(|r| r.target != self_id),
6111 "no self-relation may be synthesised; got: {:?}",
6112 ent.relationships,
6113 );
6114 assert!(
6115 engine
6116 .store()
6117 .outgoing(&self_id)
6118 .iter()
6119 .all(|e| e.target != self_id),
6120 "self must not be its own Outgoing neighbour",
6121 );
6122 assert!(
6123 engine
6124 .store()
6125 .incoming(&self_id)
6126 .iter()
6127 .all(|e| e.from != self_id),
6128 "self must not be its own Incoming neighbour",
6129 );
6130
6131 assert!(
6134 ent.relationships
6135 .iter()
6136 .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
6137 "non-self body link must still synthesise its edge; got: {:?}",
6138 ent.relationships,
6139 );
6140 }
6141
6142 #[test]
6144 fn create_entity_dry_run_emits_same_auto_stub_warning() {
6145 let tmp = TempDir::new().unwrap();
6146 let mem_dir = tmp.path().to_path_buf();
6147 let writer = FilesystemMemWriter::new(mem_dir.clone());
6148 let mut engine = Engine::from_mounts(vec![(
6149 folder_mount("specs", mem_dir),
6150 Box::new(writer) as Box<dyn MemBackend>,
6151 )])
6152 .unwrap();
6153 let (actor, client) = cli_actor();
6154
6155 let mut args = empty_create_args("specs", "Dry Run Body Link");
6156 args.dry_run = true;
6157 args.sections
6158 .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
6159
6160 let outcome = engine
6161 .create_entity(args, actor, Some(&client), None)
6162 .unwrap();
6163 let has_warning = outcome.warnings.iter().any(|w| {
6164 matches!(
6165 w,
6166 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
6167 if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
6168 )
6169 });
6170 assert!(
6171 has_warning,
6172 "dry_run must emit the same warning as real write: {:?}",
6173 outcome.warnings
6174 );
6175 }
6176
6177 #[test]
6180 fn create_entity_no_auto_stub_warning_when_target_exists() {
6181 let tmp = TempDir::new().unwrap();
6182 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
6183 let (actor, client) = cli_actor();
6184
6185 let mut args = empty_create_args("specs", "Source Linking Existing");
6186 let body = format!("ref [[{}]]", existing.id.path());
6187 args.sections.insert("identity".to_string(), body);
6188
6189 let outcome = engine
6190 .create_entity(args, actor, Some(&client), None)
6191 .unwrap();
6192 let has_warning = outcome
6193 .warnings
6194 .iter()
6195 .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
6196 assert!(
6197 !has_warning,
6198 "no auto-stub warning when target pre-exists; got: {:?}",
6199 outcome.warnings
6200 );
6201 }
6202
6203 #[test]
6208 fn obligation_wildcard_links_into_arbitrary_destination_schema() {
6209 use crate::engine::test_helpers::write_schema_files_with_default_type;
6210 use memstead_schema::workspace_config::CrossLinkValue;
6211
6212 let tmp = TempDir::new().unwrap();
6213 let dest_dir = tmp.path().join("dest");
6214 let duties_dir = tmp.path().join("duties");
6215 std::fs::create_dir_all(&dest_dir).unwrap();
6216 std::fs::create_dir_all(&duties_dir).unwrap();
6217 let schemas_dir = tmp.path().join("schemas");
6218 let user_manifest = r#"name: casefiles
6219version: 0.1.0
6220description: a user-written, non-software destination schema
6221when_to_use: tests
6222types:
6223 - doc
6224relationships:
6225 mode: strict
6226 definitions:
6227 - name: _default
6228 description: fallback
6229 default_weight: 1.0
6230community:
6231 resolution: 1.0
6232 seed: 42
6233"#;
6234 write_schema_files_with_default_type(
6235 &schemas_dir,
6236 "casefiles@0.1.0",
6237 user_manifest,
6238 &["doc"],
6239 );
6240
6241 let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
6242 mem: mem.to_string(),
6243 schema: Some(memstead_schema::SchemaRef::new(
6244 schema,
6245 semver::Version::new(0, 1, 0),
6246 )),
6247 storage: crate::workspace::MountStorage::Folder {
6248 path: dir.to_path_buf(),
6249 },
6250 capability: crate::workspace::MountCapability::Write,
6251 lifecycle: crate::workspace::MountLifecycle::Eager,
6252 cross_linkable: true,
6253 migration_target: None,
6254 };
6255 let mounts = vec![
6256 (
6257 mount("dest", &dest_dir, "casefiles"),
6258 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
6259 ),
6260 (
6261 mount("duties", &duties_dir, "obligation"),
6262 Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
6263 ),
6264 ];
6265 let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
6266 .expect("obligation + user schema boot");
6267 let mut settings = crate::workspace::WorkspaceSettings::default();
6268 settings.cross_mem_links.insert(
6269 "duties".to_string(),
6270 CrossLinkValue::List(vec!["dest".to_string()]),
6271 );
6272 engine.set_settings(settings);
6273 let (actor, client) = cli_actor();
6274
6275 let target = engine
6276 .create_entity(
6277 CreateEntityArgs {
6278 anchors: Vec::new(),
6279 mem: "dest".to_string(),
6280 title: "Case File 17".to_string(),
6281 entity_type: "doc".to_string(),
6282 sections: IndexMap::from_iter([(
6283 "body".to_string(),
6284 "destination content".to_string(),
6285 )]),
6286 metadata: IndexMap::new(),
6287 relations: Vec::new(),
6288 dry_run: false,
6289 },
6290 actor,
6291 Some(&client),
6292 None,
6293 )
6294 .unwrap();
6295
6296 let entry = engine
6297 .create_entity(
6298 CreateEntityArgs {
6299 anchors: Vec::new(),
6300 mem: "duties".to_string(),
6301 title: "File Annual Report & Notice".to_string(),
6302 entity_type: "obligation".to_string(),
6303 sections: IndexMap::from_iter([
6304 (
6305 "duty".to_string(),
6306 "File the report cited in [[dest--case-file-17]].".to_string(),
6307 ),
6308 (
6309 "consequence".to_string(),
6310 "Standing lapses at the deadline.".to_string(),
6311 ),
6312 ]),
6313 metadata: IndexMap::from_iter([
6314 ("due_date".to_string(), "2026-12-31".to_string()),
6315 ("status".to_string(), "open".to_string()),
6316 ]),
6317 relations: vec![crate::ops::RelateArg {
6318 to: crate::entity::EntityId::new("duties", "subject"),
6319 rel_type: "CONCERNS".to_string(),
6320 description: None,
6321 }],
6322 dry_run: false,
6323 },
6324 actor,
6325 Some(&client),
6326 None,
6327 )
6328 .expect("wildcard admits the alias link into the non-software destination");
6329 let stored = engine.get_entity(&entry.id).unwrap();
6330 assert!(
6331 stored
6332 .relationships
6333 .iter()
6334 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6335 "alias REFERENCES edge must emit cross-mem: {:?}",
6336 stored.relationships
6337 );
6338 }
6339
6340 #[test]
6351 fn ingest_wildcard_links_into_arbitrary_destination_schema() {
6352 use crate::engine::test_helpers::write_schema_files_with_default_type;
6353 use memstead_schema::workspace_config::CrossLinkValue;
6354
6355 let tmp = TempDir::new().unwrap();
6356 let dest_dir = tmp.path().join("dest");
6357 let proc_dir = tmp.path().join("proc");
6358 std::fs::create_dir_all(&dest_dir).unwrap();
6359 std::fs::create_dir_all(&proc_dir).unwrap();
6360
6361 let schemas_dir = tmp.path().join("schemas");
6363 let user_manifest = r#"name: debate
6364version: 0.1.0
6365description: a user-written destination schema
6366when_to_use: tests
6367types:
6368 - doc
6369relationships:
6370 mode: strict
6371 definitions:
6372 - name: _default
6373 description: fallback
6374 default_weight: 1.0
6375community:
6376 resolution: 1.0
6377 seed: 42
6378"#;
6379 write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
6380
6381 let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
6382 crate::workspace::Mount {
6383 mem: mem.to_string(),
6384 schema: Some(memstead_schema::SchemaRef::new(
6385 schema,
6386 semver::Version::new(version.0, version.1, version.2),
6387 )),
6388 storage: crate::workspace::MountStorage::Folder {
6389 path: dir.to_path_buf(),
6390 },
6391 capability: crate::workspace::MountCapability::Write,
6392 lifecycle: crate::workspace::MountLifecycle::Eager,
6393 cross_linkable: true,
6394 migration_target: None,
6395 }
6396 };
6397 let boot = |grant: bool| -> Engine {
6398 let mounts = vec![
6399 (
6400 mount("dest", &dest_dir, "debate", (0, 1, 0)),
6401 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
6402 ),
6403 (
6404 mount("proc", &proc_dir, "ingest", (0, 2, 0)),
6405 Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
6406 ),
6407 ];
6408 let mut engine =
6409 Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
6410 .expect("ingest + user schema boot");
6411 let mut settings = crate::workspace::WorkspaceSettings::default();
6412 if grant {
6413 settings.cross_mem_links.insert(
6414 "proc".to_string(),
6415 CrossLinkValue::List(vec!["dest".to_string()]),
6416 );
6417 }
6418 engine.set_settings(settings);
6419 engine
6420 };
6421 let (actor, client) = cli_actor();
6422
6423 let mut engine = boot(true);
6424 let target = engine
6426 .create_entity(
6427 CreateEntityArgs {
6428 anchors: Vec::new(),
6429 mem: "dest".to_string(),
6430 title: "Target Doc".to_string(),
6431 entity_type: "doc".to_string(),
6432 sections: IndexMap::from_iter([(
6433 "body".to_string(),
6434 "destination content".to_string(),
6435 )]),
6436 metadata: IndexMap::new(),
6437 relations: Vec::new(),
6438 dry_run: false,
6439 },
6440 actor,
6441 Some(&client),
6442 None,
6443 )
6444 .unwrap();
6445
6446 let entry = engine
6448 .create_entity(
6449 CreateEntityArgs {
6450 anchors: Vec::new(),
6451 mem: "proc".to_string(),
6452 title: "Check The Claim".to_string(),
6453 entity_type: "verification_target".to_string(),
6454 sections: IndexMap::from_iter([
6455 (
6456 "claim".to_string(),
6457 "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
6458 ),
6459 ("source_to_check".to_string(), "dest mem".to_string()),
6460 (
6461 "verifiable_when".to_string(),
6462 "the linked entity still says so".to_string(),
6463 ),
6464 ]),
6465 metadata: IndexMap::new(),
6466 relations: Vec::new(),
6467 dry_run: false,
6468 },
6469 actor,
6470 Some(&client),
6471 None,
6472 )
6473 .expect("wildcard admits the alias link into the user-schema destination");
6474 let stored = engine.get_entity(&entry.id).unwrap();
6475 assert!(
6476 stored
6477 .relationships
6478 .iter()
6479 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6480 "alias REFERENCES edge must emit: {:?}",
6481 stored.relationships
6482 );
6483
6484 let err = engine
6486 .relate_entity(
6487 RelateEntityArgs {
6488 source: entry.id.clone(),
6489 expected_hash: None,
6490 rel_type: "REFERENCES".to_string(),
6491 target: target.id.clone(),
6492 remove: false,
6493 description: None,
6494 dry_run: false,
6495 },
6496 actor,
6497 Some(&client),
6498 None,
6499 )
6500 .unwrap_err();
6501 assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
6502
6503 let err = engine
6506 .relate_entity(
6507 RelateEntityArgs {
6508 source: entry.id.clone(),
6509 expected_hash: None,
6510 rel_type: "PART_OF".to_string(),
6511 target: target.id.clone(),
6512 remove: false,
6513 description: None,
6514 dry_run: false,
6515 },
6516 actor,
6517 Some(&client),
6518 None,
6519 )
6520 .unwrap_err();
6521 assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
6522
6523 drop(engine);
6527 let rebooted = boot(true);
6528 let reloaded = rebooted.get_entity(&entry.id).unwrap();
6529 assert!(
6530 reloaded
6531 .relationships
6532 .iter()
6533 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6534 "alias edge must survive reload: {:?}",
6535 reloaded.relationships
6536 );
6537
6538 let mut denied = boot(false);
6541 let err = denied
6542 .create_entity(
6543 CreateEntityArgs {
6544 anchors: Vec::new(),
6545 mem: "proc".to_string(),
6546 title: "Denied Entry".to_string(),
6547 entity_type: "verification_target".to_string(),
6548 sections: IndexMap::from_iter([
6549 (
6550 "claim".to_string(),
6551 "points at [[dest--target-doc]]".to_string(),
6552 ),
6553 ("source_to_check".to_string(), "dest mem".to_string()),
6554 ("verifiable_when".to_string(), "never".to_string()),
6555 ]),
6556 metadata: IndexMap::new(),
6557 relations: Vec::new(),
6558 dry_run: false,
6559 },
6560 actor,
6561 Some(&client),
6562 None,
6563 )
6564 .unwrap_err();
6565 assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
6566 }
6567
6568 fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
6573 let tmp_test = TempDir::new().unwrap();
6574 let tmp_other = TempDir::new().unwrap();
6575 let test_dir = tmp_test.path().to_path_buf();
6576 let other_dir = tmp_other.path().to_path_buf();
6577 let writer_test = FilesystemMemWriter::new(test_dir.clone());
6578 let writer_other = FilesystemMemWriter::new(other_dir.clone());
6579 let engine = Engine::from_mounts(vec![
6580 (
6581 folder_mount("test", test_dir),
6582 Box::new(writer_test) as Box<dyn MemBackend>,
6583 ),
6584 (
6585 folder_mount("other", other_dir),
6586 Box::new(writer_other) as Box<dyn MemBackend>,
6587 ),
6588 ])
6589 .unwrap();
6590 (tmp_test, tmp_other, engine)
6591 }
6592
6593 #[test]
6598 fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
6599 use crate::entity::EntityId;
6600 use crate::ops::RelateArg;
6601 use memstead_schema::workspace_config::CrossLinkValue;
6602
6603 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6604 let (actor, client) = cli_actor();
6605
6606 let mut settings = crate::workspace::WorkspaceSettings::default();
6609 settings.cross_mem_links.insert(
6610 "test".to_string(),
6611 CrossLinkValue::List(vec!["other".to_string()]),
6612 );
6613 engine.set_settings(settings);
6614
6615 let target = engine
6620 .create_entity(
6621 empty_create_args("test", "Target"),
6622 actor,
6623 Some(&client),
6624 None,
6625 )
6626 .unwrap();
6627
6628 let mut args = empty_create_args("other", "Source");
6629 args.relations = vec![RelateArg {
6630 rel_type: "IMPLEMENTS".to_string(),
6631 to: target.id.clone(),
6632 description: None,
6633 }];
6634 let err = engine
6635 .create_entity(args, actor, Some(&client), None)
6636 .unwrap_err();
6637 match err {
6638 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
6639 assert_eq!(from_mem, "other");
6640 assert_eq!(to_mem, "test");
6641 }
6642 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
6643 }
6644
6645 let would_be = EntityId::new("other", "source");
6647 assert!(
6648 engine.get_entity(&would_be).is_none(),
6649 "entity must not persist when inline relation refuses"
6650 );
6651 }
6652
6653 #[test]
6656 fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
6657 use crate::ops::RelateArg;
6658 use memstead_schema::workspace_config::CrossLinkValue;
6659
6660 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6661 let (actor, client) = cli_actor();
6662
6663 let mut settings = crate::workspace::WorkspaceSettings::default();
6664 settings.cross_mem_links.insert(
6665 "other".to_string(),
6666 CrossLinkValue::List(vec!["test".to_string()]),
6667 );
6668 engine.set_settings(settings);
6669
6670 let target = engine
6671 .create_entity(
6672 empty_create_args("test", "Target"),
6673 actor,
6674 Some(&client),
6675 None,
6676 )
6677 .unwrap();
6678
6679 let mut args = empty_create_args("other", "Source");
6680 args.relations = vec![RelateArg {
6681 rel_type: "IMPLEMENTS".to_string(),
6682 to: target.id.clone(),
6683 description: None,
6684 }];
6685 let outcome = engine
6686 .create_entity(args, actor, Some(&client), None)
6687 .unwrap();
6688 let stored = engine.get_entity(&outcome.id).expect("entity persists");
6689 assert!(
6690 stored
6691 .relationships
6692 .iter()
6693 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
6694 "IMPLEMENTS edge must persist on the source's relationships",
6695 );
6696 }
6697
6698 #[test]
6702 fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
6703 use crate::ops::RelateArg;
6704
6705 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6706 let (actor, client) = cli_actor();
6707 let target = engine
6710 .create_entity(
6711 empty_create_args("test", "Target"),
6712 actor,
6713 Some(&client),
6714 None,
6715 )
6716 .unwrap();
6717 let mut args = empty_create_args("test", "Source");
6718 args.relations = vec![RelateArg {
6719 rel_type: "USES".to_string(),
6720 to: target.id.clone(),
6721 description: None,
6722 }];
6723 let outcome = engine
6724 .create_entity(args, actor, Some(&client), None)
6725 .unwrap();
6726 let stored = engine.get_entity(&outcome.id).expect("entity persists");
6727 assert!(
6728 stored
6729 .relationships
6730 .iter()
6731 .any(|r| r.rel_type == "USES" && r.target == target.id),
6732 "same-mem USES edge must persist",
6733 );
6734 }
6735
6736 #[test]
6741 fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
6742 use crate::ops::RelateArg;
6743 use memstead_schema::workspace_config::CrossLinkValue;
6744
6745 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6746 let (actor, client) = cli_actor();
6747
6748 let mut settings = crate::workspace::WorkspaceSettings::default();
6749 settings.cross_mem_links.insert(
6750 "test".to_string(),
6751 CrossLinkValue::List(vec!["other".to_string()]),
6752 );
6753 engine.set_settings(settings);
6754
6755 let target = engine
6756 .create_entity(
6757 empty_create_args("test", "Target"),
6758 actor,
6759 Some(&client),
6760 None,
6761 )
6762 .unwrap();
6763 let src = engine
6764 .create_entity(
6765 empty_create_args("other", "Source"),
6766 actor,
6767 Some(&client),
6768 None,
6769 )
6770 .unwrap();
6771
6772 let relate_err = engine
6774 .relate_entity(
6775 RelateEntityArgs {
6776 source: src.id.clone(),
6777 rel_type: "IMPLEMENTS".to_string(),
6778 target: target.id.clone(),
6779 expected_hash: Some(src.content_hash.clone()),
6780 remove: false,
6781 description: None,
6782 dry_run: false,
6783 },
6784 actor,
6785 Some(&client),
6786 None,
6787 )
6788 .unwrap_err();
6789
6790 let mut create_args = empty_create_args("other", "Source Two");
6793 create_args.relations = vec![RelateArg {
6794 rel_type: "IMPLEMENTS".to_string(),
6795 to: target.id.clone(),
6796 description: None,
6797 }];
6798 let create_err = engine
6799 .create_entity(create_args, actor, Some(&client), None)
6800 .unwrap_err();
6801
6802 match (relate_err, create_err) {
6805 (
6806 EngineError::CrossMemLinkNotAllowed {
6807 from_mem: rfv,
6808 to_mem: rtv,
6809 },
6810 EngineError::CrossMemLinkNotAllowed {
6811 from_mem: cfv,
6812 to_mem: ctv,
6813 },
6814 ) => {
6815 assert_eq!(rfv, "other");
6816 assert_eq!(rtv, "test");
6817 assert_eq!(cfv, "other");
6818 assert_eq!(ctv, "test");
6819 }
6820 (a, b) => panic!(
6821 "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
6822 ),
6823 }
6824 }
6825
6826 #[test]
6832 fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
6833 use crate::entity::EntityId;
6834 use indexmap::IndexMap;
6835 use memstead_schema::workspace_config::CrossLinkValue;
6836
6837 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6838 let (actor, client) = cli_actor();
6839
6840 let mut settings = crate::workspace::WorkspaceSettings::default();
6841 settings.cross_mem_links.insert(
6842 "test".to_string(),
6843 CrossLinkValue::List(vec!["other".to_string()]),
6844 );
6845 engine.set_settings(settings);
6846
6847 let mut sections: IndexMap<String, String> = IndexMap::new();
6848 sections.insert(
6849 "identity".to_string(),
6850 "see [[other--target]] for details".to_string(),
6851 );
6852 sections.insert("purpose".to_string(), "source purpose".to_string());
6853 let outcome = engine
6854 .create_entity(
6855 crate::engine::CreateEntityArgs {
6856 anchors: Vec::new(),
6857 mem: "test".to_string(),
6858 title: "Source".to_string(),
6859 entity_type: "spec".to_string(),
6860 sections,
6861 metadata: IndexMap::new(),
6862 relations: Vec::new(),
6863 dry_run: false,
6864 },
6865 actor,
6866 Some(&client),
6867 None,
6868 )
6869 .unwrap();
6870
6871 let canonical = EntityId::new("other", "target");
6873 assert!(
6874 engine.get_entity(&canonical).is_some(),
6875 "auto-stub must land at the canonical cross-mem id"
6876 );
6877 let phantom = EntityId::new("test", "other--target");
6878 assert!(
6879 engine.get_entity(&phantom).is_none(),
6880 "no double-prefixed phantom stub"
6881 );
6882
6883 let source = engine.get_entity(&outcome.id).unwrap();
6885 let references_count = source
6886 .relationships
6887 .iter()
6888 .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
6889 .count();
6890 assert_eq!(
6891 references_count, 1,
6892 "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
6893 );
6894 }
6895
6896 #[test]
6900 fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
6901 use indexmap::IndexMap;
6902
6903 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6904 let (actor, client) = cli_actor();
6905 let mut sections: IndexMap<String, String> = IndexMap::new();
6908 sections.insert(
6909 "identity".to_string(),
6910 "see [[other--target]] for details".to_string(),
6911 );
6912 sections.insert("purpose".to_string(), "source purpose".to_string());
6913 let err = engine
6914 .create_entity(
6915 crate::engine::CreateEntityArgs {
6916 anchors: Vec::new(),
6917 mem: "test".to_string(),
6918 title: "Source".to_string(),
6919 entity_type: "spec".to_string(),
6920 sections,
6921 metadata: IndexMap::new(),
6922 relations: Vec::new(),
6923 dry_run: false,
6924 },
6925 actor,
6926 Some(&client),
6927 None,
6928 )
6929 .unwrap_err();
6930 match err {
6931 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
6932 assert_eq!(from_mem, "test");
6933 assert_eq!(to_mem, "other");
6934 }
6935 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
6936 }
6937 }
6938
6939 #[test]
6947 fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
6948 use crate::engine::UpdateEntityArgs;
6949 use crate::workspace::{MutationsSection, WorkspaceSettings};
6950 use indexmap::IndexMap;
6951
6952 let tmp = TempDir::new().unwrap();
6953 let mem_dir = tmp.path().to_path_buf();
6954 let writer = FilesystemMemWriter::new(mem_dir.clone());
6955 let mut engine = Engine::from_mounts(vec![(
6956 folder_mount("specs", mem_dir.clone()),
6957 Box::new(writer) as Box<dyn MemBackend>,
6958 )])
6959 .unwrap();
6960 engine.set_workspace_root(mem_dir.clone());
6961 engine.set_settings(WorkspaceSettings {
6962 mutations: MutationsSection {
6963 require_notes: Some(true),
6964 },
6965 ..Default::default()
6966 });
6967 let (actor, client) = cli_actor();
6968
6969 let note_missing = |ws: &[WarningHint]| -> usize {
6970 ws.iter()
6971 .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
6972 .count()
6973 };
6974
6975 let created = engine
6977 .create_entity(
6978 empty_create_args("specs", "Noteless"),
6979 actor,
6980 Some(&client),
6981 None,
6982 )
6983 .unwrap();
6984 assert_eq!(
6985 note_missing(&created.warnings),
6986 1,
6987 "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
6988 created.warnings,
6989 );
6990 assert!(
6991 matches!(
6992 created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
6993 Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
6994 ),
6995 "the warning names the engine-level verb",
6996 );
6997 assert!(
6998 !created.commit_sha.is_empty(),
6999 "create still commits (nudge, not block)"
7000 );
7001
7002 let mut edit: IndexMap<String, String> = IndexMap::new();
7004 edit.insert("identity".to_string(), "revised".to_string());
7005 let updated = engine
7006 .update_entity(
7007 UpdateEntityArgs {
7008 anchors: Vec::new(),
7009 id: created.id.clone(),
7010 expected_hash: Some(created.content_hash.clone()),
7011 sections: edit,
7012 append_sections: IndexMap::new(),
7013 patch_sections: IndexMap::new(),
7014 metadata: IndexMap::new(),
7015 metadata_unset: Vec::new(),
7016 declare_relations: Vec::new(),
7017 dry_run: false,
7018 relations_unset: Vec::new(),
7019 anchors_unset: Vec::new(),
7020 },
7021 actor,
7022 Some(&client),
7023 None,
7024 )
7025 .unwrap();
7026 assert_eq!(
7027 note_missing(&updated.warnings),
7028 1,
7029 "update emits NOTE_MISSING"
7030 );
7031 assert!(!updated.commit_sha.is_empty(), "update still commits");
7032
7033 let target = engine
7035 .create_entity(
7036 empty_create_args("specs", "Target"),
7037 actor,
7038 Some(&client),
7039 Some("seed"),
7040 )
7041 .unwrap();
7042 let related = engine
7043 .relate_entity(
7044 RelateEntityArgs {
7045 source: updated.id.clone(),
7046 expected_hash: Some(updated.content_hash.clone()),
7047 rel_type: "USES".to_string(),
7048 target: target.id.clone(),
7049 remove: false,
7050 description: None,
7051 dry_run: false,
7052 },
7053 actor,
7054 Some(&client),
7055 None,
7056 )
7057 .unwrap();
7058 assert_eq!(
7059 note_missing(&related.warnings),
7060 1,
7061 "relate emits NOTE_MISSING"
7062 );
7063 assert!(!related.commit_sha.is_empty(), "relate still commits");
7064
7065 let with_note = engine
7067 .create_entity(
7068 empty_create_args("specs", "Documented"),
7069 actor,
7070 Some(&client),
7071 Some("a real provenance note"),
7072 )
7073 .unwrap();
7074 assert_eq!(
7075 note_missing(&with_note.warnings),
7076 0,
7077 "a supplied note suppresses the warning",
7078 );
7079
7080 engine.set_settings(WorkspaceSettings::default());
7082 let after_off = engine
7083 .create_entity(
7084 empty_create_args("specs", "Quiet"),
7085 actor,
7086 Some(&client),
7087 None,
7088 )
7089 .unwrap();
7090 assert_eq!(
7091 note_missing(&after_off.warnings),
7092 0,
7093 "no NOTE_MISSING when require_notes is unset",
7094 );
7095 }
7096
7097 fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
7100 crate::anchor::AnchorInput {
7101 artifact: Some(artifact.to_string()),
7102 grain: Some("file".to_string()),
7103 class: Some("anchored".to_string()),
7104 hash: Some(hash.to_string()),
7105 hash_stability: Some("stable".to_string()),
7106 ..Default::default()
7107 }
7108 }
7109
7110 fn folder_engine(mem: &str) -> (Engine, TempDir) {
7111 let tmp = TempDir::new().unwrap();
7112 let dir = tmp.path().to_path_buf();
7113 let writer = FilesystemMemWriter::new(dir.clone());
7114 let engine = Engine::from_mounts(vec![(
7115 folder_mount(mem, dir.clone()),
7116 Box::new(writer) as Box<dyn MemBackend>,
7117 )])
7118 .unwrap();
7119 (engine, tmp)
7120 }
7121
7122 #[test]
7123 fn create_with_anchors_persists_and_survives_reload() {
7124 let (mut engine, tmp) = folder_engine("specs");
7125 let dir = tmp.path().to_path_buf();
7126 let (actor, client) = cli_actor();
7127 let mut args = empty_create_args("specs", "Anchored Entity");
7128 args.anchors = vec![file_anchor("src/lib.rs", "h1")];
7129 engine
7130 .create_entity(args, actor, Some(&client), None)
7131 .unwrap();
7132
7133 let id = crate::EntityId::new("specs", "anchored-entity");
7134 let anchors = engine.entity_anchors(&id);
7135 assert_eq!(anchors.len(), 1);
7136 assert_eq!(anchors[0].artifact, "src/lib.rs");
7137 assert_eq!(
7138 anchors[0].class,
7139 crate::anchor::AnchorProvenanceClass::Anchored
7140 );
7141
7142 let writer = FilesystemMemWriter::new(dir.clone());
7144 let reloaded = Engine::from_mounts(vec![(
7145 folder_mount("specs", dir.clone()),
7146 Box::new(writer) as Box<dyn MemBackend>,
7147 )])
7148 .unwrap();
7149 assert_eq!(reloaded.entity_anchors(&id).len(), 1);
7150 assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
7152 }
7153
7154 #[test]
7155 fn malformed_anchor_refuses_and_entity_not_written() {
7156 let (mut engine, tmp) = folder_engine("specs");
7157 let (actor, client) = cli_actor();
7158 let mut args = empty_create_args("specs", "Bad Anchor");
7159 args.anchors = vec![crate::anchor::AnchorInput {
7160 artifact: Some("x".into()),
7161 grain: Some("paragraph".into()), class: Some("anchored".into()),
7163 ..Default::default()
7164 }];
7165 let err = engine
7166 .create_entity(args, actor, Some(&client), None)
7167 .unwrap_err();
7168 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
7169 assert!(
7171 engine
7172 .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
7173 .is_none()
7174 );
7175 assert!(!tmp.path().join("bad-anchor.md").exists());
7176 }
7177
7178 #[test]
7179 fn anchors_are_not_folded_into_content_hash() {
7180 let (mut anchored, _t1) = folder_engine("specs");
7184 let (mut plain, _t2) = folder_engine("specs");
7185 let (actor, client) = cli_actor();
7186
7187 let mut a = empty_create_args("specs", "Same Title");
7188 a.anchors = vec![file_anchor("src/lib.rs", "h1")];
7189 let with = anchored
7190 .create_entity(a, actor, Some(&client), None)
7191 .unwrap();
7192
7193 let p = empty_create_args("specs", "Same Title");
7194 let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
7195
7196 assert_eq!(
7197 with.content_hash, without.content_hash,
7198 "anchors must not change the entity content hash"
7199 );
7200 }
7201
7202 #[test]
7203 fn anchorless_create_writes_no_sidecar() {
7204 let (mut engine, _tmp) = folder_engine("specs");
7205 let (actor, client) = cli_actor();
7206 engine
7207 .create_entity(
7208 empty_create_args("specs", "No Anchors"),
7209 actor,
7210 Some(&client),
7211 None,
7212 )
7213 .unwrap();
7214 assert!(
7215 engine
7216 .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
7217 .is_empty()
7218 );
7219 }
7220
7221 #[test]
7232 fn create_refuses_reserved_metadata_keys_deliberately() {
7233 let (mut engine, _tmp) = folder_engine("specs");
7234 let (actor, client) = cli_actor();
7235 for reserved in ["type", "mem", "id"] {
7236 let mut args = empty_create_args("specs", "Smuggler");
7237 args.metadata
7238 .insert(reserved.to_string(), "bogus".to_string());
7239 let err = engine
7240 .create_entity(args, actor, Some(&client), None)
7241 .expect_err("reserved key must refuse on create");
7242 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
7243 assert!(
7244 engine
7245 .get_entity(&crate::EntityId::new("specs", "smuggler"))
7246 .is_none(),
7247 "entity must not be written after the '{reserved}' refusal"
7248 );
7249 }
7250 engine
7252 .create_entity(
7253 empty_create_args("specs", "Smuggler"),
7254 actor,
7255 Some(&client),
7256 None,
7257 )
7258 .expect("a clean create is untouched by the reserved-key gate");
7259 }
7260
7261 fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
7264 let mut args = empty_create_args(mem, title);
7265 args.relations = vec![crate::ops::RelateArg {
7266 to: crate::EntityId(to.to_string()),
7267 rel_type: rel_type.to_string(),
7268 description: None,
7269 }];
7270 args
7271 }
7272
7273 #[test]
7281 fn create_relations_refuse_cycle_and_self_loop_like_relate() {
7282 let (mut engine, _tmp) = folder_engine("specs");
7283 let (actor, client) = cli_actor();
7284
7285 engine
7287 .create_entity(
7288 create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
7289 actor,
7290 Some(&client),
7291 None,
7292 )
7293 .unwrap();
7294
7295 let err = engine
7297 .create_entity(
7298 create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
7299 actor,
7300 Some(&client),
7301 None,
7302 )
7303 .expect_err("cycle-closing create.relations[] must refuse");
7304 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
7305 let details = err.details();
7307 assert_eq!(details["rel_type"], "PART_OF");
7308 assert!(details["existing_path"].is_array());
7309 assert!(
7310 engine
7311 .get_entity(&crate::EntityId::new("specs", "ghost"))
7312 .is_none_or(|e| e.stub),
7313 "the refused entity must not be written"
7314 );
7315
7316 let err = engine
7318 .create_entity(
7319 create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
7320 actor,
7321 Some(&client),
7322 None,
7323 )
7324 .expect_err("self-loop create.relations[] must refuse");
7325 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
7326
7327 engine
7330 .create_entity(
7331 create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
7332 actor,
7333 Some(&client),
7334 None,
7335 )
7336 .expect("a non-cycle PART_OF edge must land as today");
7337 }
7338
7339 #[test]
7343 fn batch_create_refuses_intra_batch_cycle() {
7344 let (mut engine, _tmp) = folder_engine("specs");
7345 let (actor, client) = cli_actor();
7346
7347 let result = engine
7348 .batch_create(
7349 vec![
7350 (
7351 create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
7352 None,
7353 ),
7354 (
7355 create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
7356 None,
7357 ),
7358 ],
7359 actor,
7360 Some(&client),
7361 false,
7362 )
7363 .expect("batch returns a result envelope");
7364 assert!(!result.applied, "intra-batch cycle must refuse the batch");
7365 assert!(
7366 result.results.iter().any(|r| r
7367 .error
7368 .as_ref()
7369 .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
7370 "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
7371 result.results
7372 );
7373 assert!(
7374 engine
7375 .get_entity(&crate::EntityId::new("specs", "ping"))
7376 .is_none(),
7377 "nothing lands from a refused batch"
7378 );
7379
7380 let result = engine
7382 .batch_create(
7383 vec![
7384 (
7385 create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
7386 None,
7387 ),
7388 (empty_create_args("specs", "Chain Two"), None),
7389 ],
7390 actor,
7391 Some(&client),
7392 false,
7393 )
7394 .expect("acyclic batch lands");
7395 assert!(result.applied, "{:?}", result.results);
7396 assert_eq!(result.succeeded, 2);
7397 }
7398
7399 #[test]
7405 fn deep_acyclic_chain_near_path_cap_is_accepted() {
7406 let (mut engine, _tmp) = folder_engine("specs");
7407 let (actor, client) = cli_actor();
7408 let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
7409
7410 engine
7412 .create_entity(
7413 empty_create_args("specs", "Link 0"),
7414 actor,
7415 Some(&client),
7416 None,
7417 )
7418 .unwrap();
7419 for i in 1..depth {
7420 engine
7421 .create_entity(
7422 create_with_relation(
7423 "specs",
7424 &format!("Link {i}"),
7425 "PART_OF",
7426 &format!("specs--link-{}", i - 1),
7427 ),
7428 actor,
7429 Some(&client),
7430 None,
7431 )
7432 .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
7433 }
7434
7435 let last = depth - 1;
7438 let err = engine
7439 .update_entity(
7440 {
7441 let id = crate::EntityId::new("specs", "link-0");
7442 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
7443 crate::engine::UpdateEntityArgs {
7444 anchors: Vec::new(),
7445 anchors_unset: Vec::new(),
7446 id,
7447 expected_hash: Some(hash),
7448 sections: IndexMap::new(),
7449 append_sections: IndexMap::new(),
7450 patch_sections: IndexMap::new(),
7451 metadata: IndexMap::new(),
7452 metadata_unset: Vec::new(),
7453 declare_relations: vec![crate::ops::RelateArg {
7454 to: crate::EntityId::new("specs", &format!("link-{last}")),
7455 rel_type: "PART_OF".to_string(),
7456 description: None,
7457 }],
7458 dry_run: false,
7459 relations_unset: Vec::new(),
7460 }
7461 },
7462 actor,
7463 Some(&client),
7464 None,
7465 )
7466 .expect_err("closing the deep chain must refuse");
7467 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
7468 let details = err.details();
7469 assert_eq!(details["path_truncated"], true);
7470 assert_eq!(
7471 details["existing_path"].as_array().unwrap().len(),
7472 crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
7473 );
7474 }
7475
7476 const FORMAT_MANIFEST: &str = r#"name: formatproof
7477version: 0.1.0
7478description: section-format proof schema
7479when_to_use: format tests
7480types:
7481 - plan
7482relationships:
7483 mode: strict
7484 definitions:
7485 - name: PART_OF
7486 description: hier
7487 default_weight: 1.0
7488 - name: _default
7489 description: fallback
7490 default_weight: 1.0
7491community:
7492 resolution: 1.0
7493 seed: 42
7494"#;
7495
7496 const FORMAT_PLAN_TYPE: &str = r#"name: plan
7497description: a plan with formatted milestones
7498when_to_use: tests
7499sections:
7500 - key: body
7501 heading: Body
7502 required: true
7503 search_weight: 10.0
7504 catch_all: true
7505 write_rules: []
7506 - key: meilensteine
7507 heading: Meilensteine
7508 required: false
7509 search_weight: 5.0
7510 catch_all: false
7511 write_rules: []
7512 content: "(heading(3) list(bullet))+"
7513 item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
7514 example: |
7515 ### Phase 1
7516 - **Kickoff** — 2026-09-01
7517 - key: notizen
7518 heading: Notizen
7519 required: false
7520 search_weight: 5.0
7521 catch_all: false
7522 write_rules: []
7523 content: "list(bullet)"
7524 format_severity: warn
7525metadata_fields: []
7526title_weight: 100.0
7527text_fields:
7528 - body
7529hierarchy_relationship: PART_OF
7530no_self_loop_relationships: []
7531updatable_fields:
7532 - title
7533 - body
7534 - meilensteine
7535 - notizen
7536health_required_fields:
7537 - body
7538staleness_threshold_days: 90
7539write_rules: []
7540"#;
7541
7542 fn format_engine(tmp: &TempDir) -> Engine {
7543 engine_with_proof_schema(
7544 tmp,
7545 "formatproof",
7546 FORMAT_MANIFEST,
7547 &[("plan", FORMAT_PLAN_TYPE)],
7548 )
7549 }
7550
7551 fn plan_create_args(
7552 title: &str,
7553 meilensteine: Option<&str>,
7554 notizen: Option<&str>,
7555 ) -> CreateEntityArgs {
7556 let mut sections = IndexMap::new();
7557 sections.insert("body".to_string(), "a plan body.".to_string());
7558 if let Some(m) = meilensteine {
7559 sections.insert("meilensteine".to_string(), m.to_string());
7560 }
7561 if let Some(n) = notizen {
7562 sections.insert("notizen".to_string(), n.to_string());
7563 }
7564 CreateEntityArgs {
7565 anchors: Vec::new(),
7566 mem: "proof".to_string(),
7567 title: title.to_string(),
7568 entity_type: "plan".to_string(),
7569 sections,
7570 metadata: IndexMap::new(),
7571 relations: vec![],
7572 dry_run: false,
7573 }
7574 }
7575
7576 #[test]
7580 fn create_enforces_declared_section_format() {
7581 let tmp = TempDir::new().unwrap();
7582 let mut engine = format_engine(&tmp);
7583 let (actor, client) = cli_actor();
7584
7585 let err = engine
7586 .create_entity(
7587 plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
7588 actor,
7589 Some(&client),
7590 None,
7591 )
7592 .unwrap_err();
7593 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7594 let details = err.details();
7595 assert_eq!(details["section"], "meilensteine");
7596 assert!(
7597 details["example"].as_str().unwrap().contains("Kickoff"),
7598 "the conforming example is echoed: {details}"
7599 );
7600 assert_eq!(details["expected_next"][0], "list(bullet)");
7601
7602 let err = engine
7604 .create_entity(
7605 plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
7606 actor,
7607 Some(&client),
7608 None,
7609 )
7610 .unwrap_err();
7611 assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
7612
7613 engine
7615 .create_entity(
7616 plan_create_args(
7617 "Plan C",
7618 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7619 None,
7620 ),
7621 actor,
7622 Some(&client),
7623 None,
7624 )
7625 .unwrap();
7626
7627 let outcome = engine
7629 .create_entity(
7630 plan_create_args(
7631 "Plan D",
7632 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7633 Some("kein listenpunkt\n"),
7634 ),
7635 actor,
7636 Some(&client),
7637 None,
7638 )
7639 .unwrap();
7640 assert!(!outcome.commit_sha.is_empty(), "warn tier never refuses");
7641
7642 let err = engine
7648 .create_entity(
7649 plan_create_args("Plan E", None, None),
7650 actor,
7651 Some(&client),
7652 None,
7653 )
7654 .unwrap_err();
7655 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7656 }
7657
7658 #[test]
7662 fn update_judges_format_on_composed_body() {
7663 let tmp = TempDir::new().unwrap();
7664 let mut engine = format_engine(&tmp);
7665 let (actor, client) = cli_actor();
7666 let created = engine
7667 .create_entity(
7668 plan_create_args(
7669 "Plan A",
7670 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7671 None,
7672 ),
7673 actor,
7674 Some(&client),
7675 None,
7676 )
7677 .unwrap();
7678
7679 let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
7682 let mut append = IndexMap::new();
7683 append.insert(
7684 "meilensteine".to_string(),
7685 "\n\nnachtrag als absatz\n".to_string(),
7686 );
7687 let err = engine
7688 .update_entity(
7689 crate::engine::UpdateEntityArgs {
7690 anchors: Vec::new(),
7691 id: created.id.clone(),
7692 expected_hash: Some(current.clone()),
7693 sections: IndexMap::new(),
7694 append_sections: append,
7695 patch_sections: IndexMap::new(),
7696 metadata: IndexMap::new(),
7697 metadata_unset: Vec::new(),
7698 declare_relations: vec![],
7699 dry_run: false,
7700 relations_unset: Vec::new(),
7701 anchors_unset: Vec::new(),
7702 },
7703 actor,
7704 Some(&client),
7705 None,
7706 )
7707 .unwrap_err();
7708 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7709
7710 let mut append = IndexMap::new();
7712 append.insert(
7713 "meilensteine".to_string(),
7714 "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
7715 );
7716 engine
7717 .update_entity(
7718 crate::engine::UpdateEntityArgs {
7719 anchors: Vec::new(),
7720 id: created.id.clone(),
7721 expected_hash: Some(current),
7722 sections: IndexMap::new(),
7723 append_sections: append,
7724 patch_sections: IndexMap::new(),
7725 metadata: IndexMap::new(),
7726 metadata_unset: Vec::new(),
7727 declare_relations: vec![],
7728 dry_run: false,
7729 relations_unset: Vec::new(),
7730 anchors_unset: Vec::new(),
7731 },
7732 actor,
7733 Some(&client),
7734 None,
7735 )
7736 .unwrap();
7737 }
7738
7739 #[test]
7743 fn embedded_h1_refuses_in_any_section() {
7744 let tmp = TempDir::new().unwrap();
7745 let mut engine = format_engine(&tmp);
7746 let (actor, client) = cli_actor();
7747 let mut args = plan_create_args(
7748 "Plan H",
7749 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7750 None,
7751 );
7752 args.sections.insert(
7753 "body".to_string(),
7754 "intro\n# Injected Title\ntail".to_string(),
7755 );
7756 let err = engine
7757 .create_entity(args, actor, Some(&client), None)
7758 .unwrap_err();
7759 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
7760 }
7761}