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