1use std::collections::{BTreeMap, HashMap};
5use std::path::Path;
6
7use indexmap::IndexMap;
8
9use memstead_schema::TypeDefinition;
10
11fn build_type_guidance(
19 warnings: &[WarningHint],
20 type_def: &TypeDefinition,
21) -> BTreeMap<String, Vec<String>> {
22 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
23 for w in warnings {
24 let entity_type = match w {
25 WarningHint::MissingRequiredSection { entity_type, .. }
26 | WarningHint::MissingRequiredField { entity_type, .. } => entity_type.as_str(),
27 _ => continue,
28 };
29 if !out.contains_key(entity_type) && entity_type == type_def.name {
30 out.insert(entity_type.to_string(), type_def.write_rules.clone());
31 }
32 }
33 out
34}
35
36use crate::engine_fallback_type;
37use crate::entity::generator::generate_markdown;
38use crate::entity::id::validate_and_derive_slug;
39use crate::entity::parser::parse_markdown;
40use crate::entity::store_builder::push_entities_into_store;
41use crate::entity::{Entity, EntityId, MetadataValue, Relationship, normalise_description};
42use crate::ops::{WarningHint, project_incoming};
43use crate::provenance::{Provenance, ProvenanceKind};
44use crate::runtime_validator::{
45 missing_required_fields, missing_required_sections, parse_metadata_value,
46 validate_section_content, validate_section_keys,
47};
48use crate::vcs::{Actor, ClientId, CommitContext};
49use crate::workspace::MountCapability;
50
51use super::super::{CreateEntityArgs, CreateEntityOutcome, Engine, EngineError};
52use super::{
53 EdgeRouteOutcome, make_stub, route_edge_validation, unknown_type_error,
54 validate_relation_target_grammar,
55};
56
57struct PreparedCreate {
62 mount_idx: usize,
63 id: EntityId,
64 title: String,
65 mem: String,
66 file_path: String,
67 markdown: String,
68 anchors: Vec<crate::anchor::Anchor>,
69 warnings: Vec<WarningHint>,
70 type_guidance: std::collections::BTreeMap<String, Vec<String>>,
71 relations_declared: Vec<crate::engine::outcomes::RelationDeclared>,
72 relation_targets: Vec<EntityId>,
75 type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
76}
77
78enum CreatePrepareOutcome {
81 Done(CreateEntityOutcome),
82 Prepared(PreparedCreate),
83}
84
85impl Engine {
86 pub fn create_entity(
109 &mut self,
110 args: CreateEntityArgs,
111 actor: Actor,
112 client: Option<&ClientId>,
113 note: Option<&str>,
114 ) -> Result<CreateEntityOutcome, EngineError> {
115 let drift_warnings = self.reload_if_stale(Some(&args.mem));
116 match self.prepare_create(args, None, drift_warnings)? {
117 CreatePrepareOutcome::Done(outcome) => Ok(outcome),
118 CreatePrepareOutcome::Prepared(prepared) => {
119 self.commit_prepared_create(prepared, actor, client, note)
120 }
121 }
122 }
123
124 fn prepare_create(
145 &mut self,
146 args: CreateEntityArgs,
147 batch_skeleton_ids: Option<&std::collections::HashSet<EntityId>>,
148 mut drift_warnings: Vec<WarningHint>,
149 ) -> Result<CreatePrepareOutcome, EngineError> {
150 let mut args = args;
151 for rel in &mut args.relations {
156 if let Ok(canonical) = crate::entity::id::validate_rel_type(&rel.rel_type) {
157 rel.rel_type = canonical;
158 }
159 }
160
161 let mut title_trimmed_warning: Option<crate::ops::WarningHint> = None;
170 let trimmed_title = args.title.trim();
171 if trimmed_title.len() != args.title.len() {
172 title_trimmed_warning = Some(crate::ops::WarningHint::TitleTrimmed {
173 original: args.title.clone(),
174 trimmed: trimmed_title.to_string(),
175 });
176 args.title = trimmed_title.to_string();
177 }
178
179 let mount_idx = self
181 .mounts
182 .iter()
183 .position(|m| m.mount.mem == args.mem)
184 .ok_or_else(|| self.unknown_mem_error(&args.mem))?;
185 if self.mounts[mount_idx].mount.capability != MountCapability::Write {
186 return Err(EngineError::ReadOnlyMount(args.mem));
187 }
188
189 let schema = self
200 .schemas
201 .get(&args.mem)
202 .expect("schema present for every registered mount");
203 let type_def = schema
204 .get_type(&args.entity_type)
205 .ok_or_else(|| unknown_type_error(schema, &args.entity_type))?;
206
207 validate_section_keys(args.sections.keys().map(String::as_str), type_def.as_ref())?;
209 for key in args.metadata.keys() {
218 crate::runtime_validator::validate_reserved_metadata_key(key.as_str())?;
219 }
220 let validated_anchors = self.validate_anchor_inputs(&args.mem, &args.anchors)?;
230 validate_section_content(args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
235
236 let derivation = validate_and_derive_slug(&args.title)?;
243 let slug = derivation.slug.clone();
244 let id = EntityId::new(&args.mem, &slug);
245 crate::entity::id::enforce_id_length(id.as_ref())?;
246 if let Some(existing) = self.store.get(&id)
247 && !existing.stub
248 && !batch_skeleton_ids.is_some_and(|set| set.contains(&id))
249 {
250 return Err(EngineError::AlreadyExists {
251 id: id.to_string(),
252 existing_title: existing.title.clone(),
253 existing_is_stub: false,
254 });
255 }
256 let file_path = format!("{slug}.md");
257
258 let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
262 metadata.insert(
263 "type".to_string(),
264 MetadataValue::String(args.entity_type.clone()),
265 );
266 for (k, v) in &args.metadata {
267 let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
268 metadata.insert(k.clone(), parsed);
269 }
270
271 let today = self.now_iso();
279 let mut ignored_readonly: Vec<WarningHint> = Vec::new();
287 for field_def in &type_def.metadata_fields {
288 if field_def.init_timestamp || field_def.auto_timestamp {
289 if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
290 ignored_readonly.push(WarningHint::IgnoredReadonlyField {
291 field: field_def.key.clone(),
292 supplied: supplied.clone(),
293 });
294 }
295 metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
296 }
297 }
298
299 let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
313 if !missing_sections.is_empty() {
314 let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
315 if missing_sections
316 .iter()
317 .any(|m| m.entity_type == type_def.name)
318 {
319 type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
320 }
321 return Err(EngineError::MissingRequiredSection {
322 entity_type: type_def.name.clone(),
323 missing_count: missing_sections.len(),
324 sections: missing_sections,
325 type_guidance,
326 });
327 }
328
329 let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
339 if !missing_fields.is_empty() {
340 let first = missing_fields[0].clone();
347 return Err(EngineError::RequiredFieldUnset {
348 field: first.key,
349 entity_type: first.entity_type,
350 field_description: Some(first.description),
351 enum_values: first.enum_values,
352 type_write_rules: type_def.write_rules.clone(),
353 on_create: true,
359 missing: missing_fields,
360 });
361 }
362
363 let mut warnings: Vec<WarningHint> = Vec::new();
364
365 warnings.append(&mut drift_warnings);
369
370 warnings.append(&mut ignored_readonly);
373
374 if !derivation.dropped_chars.is_empty() {
377 warnings.push(WarningHint::TitleCharsDroppedFromSlug {
378 title: args.title.trim().to_string(),
379 dropped_chars: derivation.dropped_chars.clone(),
380 slug: slug.clone(),
381 });
382 }
383
384 if let Some(w) = title_trimmed_warning.take() {
387 warnings.push(w);
388 }
389
390 let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
398
399 for rel in &args.relations {
414 validate_relation_target_grammar(&rel.to)?;
415 let target_mem = rel.to.mem().to_string();
416 super::validate_cross_mem_add_policy(self, &args.mem, &rel.to)?;
421 let target_type = self
427 .store
428 .get(&rel.to)
429 .map(|e| e.entity_type.clone())
430 .filter(|t| !t.is_empty());
431 match route_edge_validation(
432 self,
433 &rel.rel_type,
434 args.entity_type.as_str(),
435 target_type.as_deref(),
436 &args.mem,
437 &target_mem,
438 &id,
439 &rel.to,
440 true,
441 )? {
442 EdgeRouteOutcome::Ok => {}
443 EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
444 }
445 let normalised = normalise_description(rel.description.as_deref());
448 super::validate_description_posture(
449 self,
450 &rel.rel_type,
451 normalised.as_deref(),
452 &args.mem,
453 &target_mem,
454 &id,
455 &rel.to,
456 )?;
457 super::validate_manual_authoring_posture(self, &rel.rel_type, &args.mem, &id, &rel.to)?;
461 let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
472 .unwrap_or_else(|_| rel.rel_type.clone());
473 super::validate_edge_acyclicity(
474 &self.store,
475 schema.as_ref(),
476 &id,
477 args.entity_type.as_str(),
478 &rel.to,
479 &canonical,
480 )?;
481 }
482
483 let relationships: Vec<Relationship> = args
490 .relations
491 .iter()
492 .map(|r| Relationship {
493 rel_type: r.rel_type.clone(),
494 target: r.to.clone(),
495 description: normalise_description(r.description.as_deref()),
496 })
497 .collect();
498 let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
505 .relations
506 .iter()
507 .map(|r| crate::engine::outcomes::RelationDeclared {
508 rel_type: r.rel_type.clone(),
509 target: r.to.clone(),
510 target_was_stubbed: !self.store.contains(&r.to),
511 })
512 .collect();
513 let mut entity_for_render = Entity {
514 id: id.clone(),
515 title: args.title.clone(),
516 entity_type: args.entity_type.clone(),
517 mem: args.mem.clone(),
518 file_path: file_path.clone(),
519 metadata,
520 sections: args.sections,
521 relationships,
522 content_hash: String::new(),
523 stub: false,
524 stub_kind: None,
525 heading_spans: HashMap::new(),
526 raw_section_headings: Vec::new(),
527 };
528 let empty_prev_targets = std::collections::HashSet::new();
543 let (synthesised_relations, self_link_ignored) =
544 super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
545 if self_link_ignored {
546 warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
547 }
548
549 let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
554 if !missing.is_empty() {
555 return Err(EngineError::WikiLinkWithoutRelation {
556 from_id: id.to_string(),
557 missing: missing
558 .into_iter()
559 .map(|(section_key, target)| crate::engine::MissingWikiLink {
560 section_key,
561 target_id: target.to_string(),
562 })
563 .collect(),
564 });
565 }
566
567 let markdown = generate_markdown(&entity_for_render, type_def.as_ref());
568
569 let auto_stubbed: Vec<EntityId> = synthesised_relations
585 .iter()
586 .filter_map(|rel| {
587 if !self.store.contains(&rel.target) {
588 Some(rel.target.clone())
589 } else {
590 None
591 }
592 })
593 .collect();
594 if !auto_stubbed.is_empty() {
595 warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
596 from: id.clone(),
597 stubs: auto_stubbed,
598 });
599 }
600
601 for def in &type_def.sections {
614 if def.format_severity != memstead_schema::ConstraintSeverity::Block {
615 continue;
616 }
617 let body = entity_for_render
626 .sections
627 .get(def.key.as_str())
628 .map(String::as_str)
629 .unwrap_or("");
630 if let Some(first) = crate::section_format::check_section_format(def, body)
631 .into_iter()
632 .next()
633 {
634 return Err(EngineError::SectionFormatRefused {
635 entity_type: entity_for_render.entity_type.clone(),
636 entity_id: id.to_string(),
637 violation: first,
638 });
639 }
640 }
641
642 let unsatisfied = crate::ops::health::unsatisfied_required_outgoing(
643 &entity_for_render,
644 type_def.as_ref(),
645 );
646 if !unsatisfied.is_empty() {
647 let blocked: Vec<_> = unsatisfied
651 .iter()
652 .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
653 .cloned()
654 .collect();
655 if !blocked.is_empty() {
656 return Err(EngineError::RequiredOutgoingUnsatisfied {
657 entity_type: entity_for_render.entity_type.clone(),
658 entity_id: id.to_string(),
659 missing: blocked,
660 });
661 }
662 warnings.push(WarningHint::MissingRequiredOutgoing {
663 entity_type: entity_for_render.entity_type.clone(),
664 entity_id: id.clone(),
665 missing: unsatisfied,
666 });
667 }
668
669 let violated = crate::ops::health::unsatisfied_constraints(
674 &self.store,
675 &entity_for_render,
676 type_def.as_ref(),
677 None,
678 );
679 if !violated.is_empty() {
680 let blocked: Vec<_> = violated
681 .iter()
682 .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
683 .cloned()
684 .collect();
685 if !blocked.is_empty() {
686 return Err(EngineError::ConstraintUnsatisfied {
687 entity_type: entity_for_render.entity_type.clone(),
688 entity_id: id.to_string(),
689 violations: blocked,
690 });
691 }
692 warnings.push(WarningHint::ConstraintUnsatisfied {
693 entity_type: entity_for_render.entity_type.clone(),
694 entity_id: id.clone(),
695 violations: violated,
696 });
697 }
698
699 if args.dry_run {
707 let prospective_hash = crate::entity::parser::compute_hash(&markdown);
708 let created_date = entity_for_render
712 .metadata
713 .get("created_date")
714 .map(|v| v.to_frontmatter_string())
715 .unwrap_or_default();
716 let incoming = project_incoming(self.store.incoming(&id));
720 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
721 return Ok(CreatePrepareOutcome::Done(CreateEntityOutcome {
722 id,
723 title: args.title,
724 mem: args.mem,
725 file_path,
726 content_hash: prospective_hash,
727 commit_sha: String::new(),
728 created_date,
729 warnings,
730 type_guidance,
731 incoming_count,
732 incoming,
733 relations_declared: relations_declared.clone(),
734 }));
735 }
736
737 Ok(CreatePrepareOutcome::Prepared(PreparedCreate {
738 mount_idx,
739 id,
740 title: args.title,
741 mem: args.mem,
742 file_path,
743 markdown,
744 anchors: validated_anchors,
745 warnings,
746 type_guidance,
747 relations_declared,
748 relation_targets: args.relations.iter().map(|r| r.to.clone()).collect(),
749 type_def,
750 }))
751 }
752
753 fn commit_prepared_create(
758 &mut self,
759 prepared: PreparedCreate,
760 actor: Actor,
761 client: Option<&ClientId>,
762 note: Option<&str>,
763 ) -> Result<CreateEntityOutcome, EngineError> {
764 let PreparedCreate {
765 mount_idx,
766 id,
767 title,
768 mem,
769 file_path,
770 markdown,
771 anchors: validated_anchors,
772 mut warnings,
773 type_guidance,
774 relations_declared,
775 relation_targets,
776 type_def,
777 } = prepared;
778
779 let backend = self.mounts[mount_idx].backend.as_ref();
785 backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
786 if !validated_anchors.is_empty() {
791 super::stage_anchors_sidecar(backend, &id, &[], validated_anchors)?;
792 }
793 if let Some(schema) = self.schemas.get(&mem) {
798 for r in relations_declared
799 .iter()
800 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
801 {
802 let hash = self
803 .store
804 .get(&r.target)
805 .map(|e| e.content_hash.clone())
806 .unwrap_or_default();
807 let (from, rel, to) = (id.to_string(), r.rel_type.clone(), r.target.to_string());
808 super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
809 }
810 }
811 let commit_subject = format!("memstead: create {id}");
812 let ctx = CommitContext {
813 actor,
814 client: client.cloned(),
815 tool: Some("create_entity"),
816 note: note.map(String::from),
817 role: self.current_role,
818 logical_operation_id: None,
819 entity_ids: None,
820 };
821 let commit_sha = backend.commit(&commit_subject, &ctx)?;
822
823 backend.append_provenance(
826 &Provenance::new(
827 std::time::SystemTime::now(),
828 ProvenanceKind::Create,
829 Some(id.to_string()),
830 actor,
831 client.cloned(),
832 note.map(String::from),
833 )
834 .with_role(self.current_role),
835 )?;
836
837 self.record_self_write(mount_idx, &commit_sha);
841 self.stamp_mutation_versions(mount_idx);
842
843 let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &mem)
846 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
847 let content_hash = parse_result.entity.content_hash.clone();
848
849 let created_date = parse_result
856 .entity
857 .metadata
858 .get("created_date")
859 .map(|v| v.to_frontmatter_string())
860 .unwrap_or_default();
861
862 let fallback = engine_fallback_type();
863 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
864 crate::entity::store_builder::remap_alias_target_edge_sources(
865 &mut self.store,
866 &self.schemas,
867 );
868
869 for target in &relation_targets {
877 if !self.store.contains(target) {
878 self.store.upsert(
879 target.clone(),
880 make_stub(target, crate::entity::StubKind::ForwardReference),
881 );
882 }
883 }
884
885 self.invalidate_communities();
886 self.invalidate_search_indexes();
887
888 let incoming = project_incoming(self.store.incoming(&id));
892 let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
893
894 if let Some(w) = self.note_missing_warning("create_entity", note) {
899 warnings.push(w);
900 }
901
902 Ok(CreateEntityOutcome {
903 id,
904 title,
905 mem,
906 file_path,
907 content_hash,
908 commit_sha,
909 created_date,
910 warnings,
911 type_guidance,
912 incoming_count,
913 incoming,
914 relations_declared,
915 })
916 }
917
918 pub fn batch_create(
949 &mut self,
950 creates: Vec<(CreateEntityArgs, Option<String>)>,
951 actor: Actor,
952 client: Option<&ClientId>,
953 dry_run: bool,
954 ) -> Result<crate::ops::BatchResult, EngineError> {
955 use std::collections::HashSet;
956
957 if creates.is_empty() {
958 return Ok(crate::ops::BatchResult {
959 orphan_stubs_removed: Vec::new(),
960 errors_suppressed: 0,
961 applied: true,
962 results: Vec::new(),
963 succeeded: 0,
964 failed: 0,
965 commit_sha: String::new(),
966 });
967 }
968
969 let mut touched_mems: Vec<String> = creates.iter().map(|(a, _)| a.mem.clone()).collect();
971 touched_mems.sort();
972 touched_mems.dedup();
973 for m in &touched_mems {
974 self.reload_if_stale(Some(m));
975 }
976
977 let store_snapshot = self.store.clone();
978
979 struct IdentityRow {
983 id: Option<EntityId>,
984 error: Option<EngineError>,
985 }
986 let mut rows: Vec<IdentityRow> = Vec::with_capacity(creates.len());
987 let mut batch_ids: HashMap<EntityId, String> = HashMap::new();
990 for (args, _) in &creates {
991 let identity = (|| -> Result<EntityId, EngineError> {
992 let title = args.title.trim();
993 let slug = validate_and_derive_slug(title)?.slug;
996 let id = EntityId::new(&args.mem, &slug);
997 crate::entity::id::enforce_id_length(id.as_ref())?;
998 if let Some(existing) = self.store.get(&id)
999 && !existing.stub
1000 {
1001 return Err(EngineError::AlreadyExists {
1002 id: id.to_string(),
1003 existing_title: existing.title.clone(),
1004 existing_is_stub: false,
1005 });
1006 }
1007 if let Some(prior_title) = batch_ids.get(&id) {
1008 return Err(EngineError::AlreadyExists {
1012 id: id.to_string(),
1013 existing_title: prior_title.clone(),
1014 existing_is_stub: false,
1015 });
1016 }
1017 Ok(id)
1018 })();
1019 match identity {
1020 Ok(id) => {
1021 batch_ids.insert(id.clone(), args.title.trim().to_string());
1022 rows.push(IdentityRow {
1023 id: Some(id),
1024 error: None,
1025 });
1026 }
1027 Err(e) => rows.push(IdentityRow {
1028 id: None,
1029 error: Some(e),
1030 }),
1031 }
1032 }
1033
1034 for ((args, _), row) in creates.iter().zip(rows.iter()) {
1040 if let Some(id) = &row.id {
1041 let mut skeleton = make_stub(id, crate::entity::StubKind::ForwardReference);
1042 skeleton.stub = false;
1043 skeleton.stub_kind = None;
1044 skeleton.entity_type = args.entity_type.clone();
1045 skeleton.title = args.title.trim().to_string();
1046 self.store.upsert(id.clone(), skeleton);
1047 }
1048 }
1049
1050 let mut prepared: Vec<PreparedCreate> = Vec::new();
1054 let mut notes: Vec<Option<String>> = Vec::new();
1055 let mut errors: Vec<(usize, EngineError)> = Vec::new();
1056 let mut ids_in_order: Vec<EntityId> = Vec::new();
1057 let skeleton_ids: HashSet<EntityId> = batch_ids.keys().cloned().collect();
1058 for (i, ((args, note), row)) in creates.into_iter().zip(rows).enumerate() {
1059 let fallback_id = row
1060 .id
1061 .clone()
1062 .unwrap_or_else(|| EntityId::new(&args.mem, "invalid-entry"));
1063 ids_in_order.push(fallback_id);
1064 if let Some(e) = row.error {
1065 errors.push((i, e));
1066 continue;
1067 }
1068 let mut args = args;
1072 args.dry_run = false;
1073 match self.prepare_create(args, Some(&skeleton_ids), Vec::new()) {
1074 Ok(CreatePrepareOutcome::Prepared(p)) => {
1075 for r in &p.relations_declared {
1083 self.store.add_edge(
1084 p.id.clone(),
1085 crate::store::Edge {
1086 rel_type: r.rel_type.clone(),
1087 target: r.target.clone(),
1088 source: crate::store::EdgeSource::Explicit,
1089 },
1090 );
1091 }
1092 ids_in_order[i] = p.id.clone();
1093 prepared.push(p);
1094 notes.push(note);
1095 }
1096 Ok(CreatePrepareOutcome::Done(_)) => unreachable!("dry_run forced off"),
1097 Err(e) => errors.push((i, e)),
1098 }
1099 }
1100
1101 if !errors.is_empty() {
1102 self.store = store_snapshot;
1105 self.discard_all_pending();
1106 let failed = errors.len();
1107 let mut error_map: std::collections::HashMap<usize, EngineError> =
1108 errors.into_iter().collect();
1109 let mut reported = 0usize;
1110 let mut suppressed = 0usize;
1111 let results: Vec<crate::ops::BatchEntry> = ids_in_order
1112 .into_iter()
1113 .enumerate()
1114 .map(|(i, id)| match error_map.remove(&i) {
1115 Some(e) => {
1116 if reported < Self::BATCH_ERROR_REPORT_CAP {
1117 reported += 1;
1118 crate::ops::BatchEntry {
1119 id,
1120 action: "error".to_string(),
1121 error: Some(super::update::batch_error_envelope(&e)),
1122 }
1123 } else {
1124 suppressed += 1;
1125 crate::ops::BatchEntry {
1126 id,
1127 action: "error".to_string(),
1128 error: None,
1129 }
1130 }
1131 }
1132 None => crate::ops::BatchEntry {
1133 id,
1134 action: "not_applied".to_string(),
1135 error: None,
1136 },
1137 })
1138 .collect();
1139 return Ok(crate::ops::BatchResult {
1140 orphan_stubs_removed: Vec::new(),
1141 errors_suppressed: suppressed,
1142 applied: false,
1143 results,
1144 succeeded: 0,
1145 failed,
1146 commit_sha: String::new(),
1147 });
1148 }
1149
1150 if dry_run {
1156 self.store = store_snapshot;
1157 self.discard_all_pending();
1158 let succeeded = prepared.len();
1159 let results: Vec<crate::ops::BatchEntry> = prepared
1160 .into_iter()
1161 .map(|p| crate::ops::BatchEntry {
1162 id: p.id,
1163 action: "created".to_string(),
1164 error: None,
1165 })
1166 .collect();
1167 return Ok(crate::ops::BatchResult {
1168 orphan_stubs_removed: Vec::new(),
1169 errors_suppressed: 0,
1170 applied: true,
1171 results,
1172 succeeded,
1173 failed: 0,
1174 commit_sha: String::new(),
1175 });
1176 }
1177
1178 for p in &prepared {
1180 if let Err(e) = self.mounts[p.mount_idx]
1181 .backend
1182 .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1183 {
1184 self.store = store_snapshot;
1185 self.discard_all_pending();
1186 return Err(e.into());
1187 }
1188 if !p.anchors.is_empty()
1189 && let Err(e) = super::stage_anchors_sidecar(
1190 self.mounts[p.mount_idx].backend.as_ref(),
1191 &p.id,
1192 &[],
1193 p.anchors.clone(),
1194 )
1195 {
1196 self.store = store_snapshot;
1197 self.discard_all_pending();
1198 return Err(e);
1199 }
1200 if let Some(schema) = self.schemas.get(&p.mem) {
1203 for r in p
1204 .relations_declared
1205 .iter()
1206 .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1207 {
1208 let hash = self
1209 .store
1210 .get(&r.target)
1211 .map(|e| e.content_hash.clone())
1212 .unwrap_or_default();
1213 let (from, rel, to) =
1214 (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1215 if let Err(e) = super::stage_derivation_sidecar(
1216 self.mounts[p.mount_idx].backend.as_ref(),
1217 |s| s.set(&from, &rel, &to, &hash),
1218 ) {
1219 self.store = store_snapshot;
1220 self.discard_all_pending();
1221 return Err(e);
1222 }
1223 }
1224 }
1225 }
1226 let mut distinct_mounts: Vec<usize> = Vec::new();
1227 for p in &prepared {
1228 if !distinct_mounts.contains(&p.mount_idx) {
1229 distinct_mounts.push(p.mount_idx);
1230 }
1231 }
1232 let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1233 for &m in &distinct_mounts {
1234 let entity_ids: Vec<String> = prepared
1235 .iter()
1236 .filter(|p| p.mount_idx == m)
1237 .map(|p| p.id.to_string())
1238 .collect();
1239 let count = entity_ids.len();
1240 let subject = format!("memstead: batch-create ({count} entities)");
1241 let ctx = CommitContext {
1242 actor,
1243 client: client.cloned(),
1244 tool: Some("batch_create"),
1245 note: None,
1246 role: self.current_role,
1247 logical_operation_id: None,
1248 entity_ids: Some(entity_ids),
1249 };
1250 match self.mounts[m].backend.commit(&subject, &ctx) {
1251 Ok(sha) => mount_commits.push((m, sha)),
1252 Err(e) => {
1253 self.store = store_snapshot;
1254 self.discard_all_pending();
1255 return Err(e.into());
1256 }
1257 }
1258 }
1259
1260 let fallback = engine_fallback_type();
1263 for (p, note) in prepared.iter().zip(notes.iter()) {
1264 let commit_sha = mount_commits
1265 .iter()
1266 .find(|(m, _)| *m == p.mount_idx)
1267 .map(|(_, s)| s.clone())
1268 .unwrap_or_default();
1269 self.mounts[p.mount_idx].backend.append_provenance(
1270 &Provenance::new(
1271 std::time::SystemTime::now(),
1272 ProvenanceKind::Create,
1273 Some(p.id.to_string()),
1274 actor,
1275 client.cloned(),
1276 note.clone(),
1277 )
1278 .with_role(self.current_role),
1279 )?;
1280 self.record_self_write(p.mount_idx, &commit_sha);
1281 self.stamp_mutation_versions(p.mount_idx);
1282 let parse_result =
1283 parse_markdown(&p.markdown, &p.file_path, p.type_def.as_ref(), &p.mem)
1284 .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1285 push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1286 }
1287 crate::entity::store_builder::remap_alias_target_edge_sources(
1288 &mut self.store,
1289 &self.schemas,
1290 );
1291 for p in &prepared {
1294 for target in &p.relation_targets {
1295 if !self.store.contains(target) {
1296 self.store.upsert(
1297 target.clone(),
1298 make_stub(target, crate::entity::StubKind::ForwardReference),
1299 );
1300 }
1301 }
1302 }
1303 self.invalidate_communities();
1304 self.invalidate_search_indexes();
1305
1306 let commit_sha = mount_commits
1307 .last()
1308 .map(|(_, s)| s.clone())
1309 .unwrap_or_default();
1310 let succeeded = prepared.len();
1311 let results: Vec<crate::ops::BatchEntry> = prepared
1312 .into_iter()
1313 .map(|p| crate::ops::BatchEntry {
1314 id: p.id,
1315 action: "created".to_string(),
1316 error: None,
1317 })
1318 .collect();
1319 Ok(crate::ops::BatchResult {
1320 orphan_stubs_removed: Vec::new(),
1321 errors_suppressed: 0,
1322 applied: true,
1323 results,
1324 succeeded,
1325 failed: 0,
1326 commit_sha,
1327 })
1328 }
1329
1330 pub const BATCH_ERROR_REPORT_CAP: usize = 50;
1336
1337 pub fn create_entity_with_ctx(
1341 &mut self,
1342 args: CreateEntityArgs,
1343 ctx: &CommitContext<'_>,
1344 ) -> Result<CreateEntityOutcome, EngineError> {
1345 self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1346 }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351
1352 use indexmap::IndexMap;
1353 use tempfile::TempDir;
1354
1355 use crate::backend::MemBackend;
1356 use crate::engine::test_helpers::*;
1357 use crate::engine::{
1358 CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
1359 };
1360 use crate::ops::WarningHint;
1361 use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1362
1363 fn engine_with_required_outgoing_schema(tmp: &TempDir) -> Engine {
1368 let schemas_dir = tmp.path().join("schemas");
1369 let pkg = schemas_dir.join("reqout");
1370 std::fs::create_dir_all(pkg.join("types")).unwrap();
1371 std::fs::write(
1372 pkg.join("schema.yaml"),
1373 r#"name: reqout
1374version: 0.1.0
1375description: required-outgoing fixture
1376when_to_use: tests
1377types:
1378 - task
1379relationships:
1380 mode: strict
1381 definitions:
1382 - name: PART_OF
1383 description: hier
1384 default_weight: 3.0
1385 - name: _default
1386 description: fallback
1387 default_weight: 1.0
1388community:
1389 resolution: 1.0
1390 seed: 42
1391"#,
1392 )
1393 .unwrap();
1394 std::fs::write(
1395 pkg.join("types").join("task.yaml"),
1396 r#"name: task
1397description: t
1398when_to_use: tests
1399sections:
1400 - key: body
1401 heading: Body
1402 required: true
1403 search_weight: 10.0
1404 catch_all: true
1405 write_rules: []
1406metadata_fields: []
1407title_weight: 100.0
1408text_fields:
1409 - body
1410hierarchy_relationship: PART_OF
1411no_self_loop_relationships: []
1412updatable_fields:
1413 - title
1414 - body
1415health_required_fields:
1416 - body
1417staleness_threshold_days: 90
1418required_outgoing:
1419 - relationships: [PART_OF]
1420 cardinality: at_least_one
1421write_rules: []
1422"#,
1423 )
1424 .unwrap();
1425 let mem_dir = tmp.path().join("mem");
1426 std::fs::create_dir_all(&mem_dir).unwrap();
1427 let writer = FilesystemMemWriter::new(mem_dir.clone());
1428 let mount = crate::workspace::Mount {
1429 mem: "tasks".to_string(),
1430 schema: Some(memstead_schema::SchemaRef::new(
1431 "reqout",
1432 semver::Version::new(0, 1, 0),
1433 )),
1434 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1435 capability: crate::workspace::MountCapability::Write,
1436 lifecycle: crate::workspace::MountLifecycle::Eager,
1437 cross_linkable: true,
1438 migration_target: None,
1439 };
1440 Engine::from_mounts_with_schemas_dir(
1441 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1442 Some(&schemas_dir),
1443 )
1444 .unwrap()
1445 }
1446
1447 fn task_create_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1448 let mut sections = IndexMap::new();
1449 sections.insert("body".to_string(), "a task body.".to_string());
1450 CreateEntityArgs {
1451 anchors: Vec::new(),
1452 mem: "tasks".to_string(),
1453 title: title.to_string(),
1454 entity_type: "task".to_string(),
1455 sections,
1456 metadata: IndexMap::new(),
1457 relations,
1458 dry_run: false,
1459 }
1460 }
1461
1462 fn missing_outgoing_of(warnings: &[WarningHint]) -> Vec<(Vec<String>, String)> {
1463 warnings
1464 .iter()
1465 .filter_map(|w| match w {
1466 WarningHint::MissingRequiredOutgoing { missing, .. } => Some(
1467 missing
1468 .iter()
1469 .map(|b| (b.relationships.clone(), b.cardinality.clone()))
1470 .collect::<Vec<_>>(),
1471 ),
1472 _ => None,
1473 })
1474 .flatten()
1475 .collect()
1476 }
1477
1478 fn engine_with_constraints_schema(
1484 tmp: &TempDir,
1485 requires_when_severity: &str,
1486 required_outgoing_severity: &str,
1487 ) -> Engine {
1488 let schemas_dir = tmp.path().join("schemas");
1489 let pkg = schemas_dir.join("constr");
1490 std::fs::create_dir_all(pkg.join("types")).unwrap();
1491 std::fs::write(
1492 pkg.join("schema.yaml"),
1493 r#"name: constr
1494version: 0.1.0
1495description: constraint fixture
1496when_to_use: tests
1497types:
1498 - task
1499relationships:
1500 mode: strict
1501 definitions:
1502 - name: PART_OF
1503 description: hier
1504 default_weight: 3.0
1505 - name: _default
1506 description: fallback
1507 default_weight: 1.0
1508community:
1509 resolution: 1.0
1510 seed: 42
1511"#,
1512 )
1513 .unwrap();
1514 std::fs::write(
1515 pkg.join("types").join("task.yaml"),
1516 format!(
1517 r#"name: task
1518description: t
1519when_to_use: tests
1520sections:
1521 - key: body
1522 heading: Body
1523 required: true
1524 search_weight: 10.0
1525 catch_all: true
1526 write_rules: []
1527metadata_fields:
1528 - key: status
1529 description: workflow state
1530 field_type: string
1531 enum_values: [open, checked]
1532 - key: checked_by
1533 description: who checked
1534 field_type: string
1535title_weight: 100.0
1536text_fields:
1537 - body
1538hierarchy_relationship: PART_OF
1539no_self_loop_relationships: [PART_OF]
1540updatable_fields:
1541 - title
1542 - body
1543 - status
1544 - checked_by
1545health_required_fields:
1546 - body
1547staleness_threshold_days: 90
1548required_outgoing:
1549 - relationships: [PART_OF]
1550 cardinality: at_least_one
1551 severity: {required_outgoing_severity}
1552constraints:
1553 - kind: requires_when
1554 field: checked_by
1555 when_field: status
1556 when_value: checked
1557 severity: {requires_when_severity}
1558write_rules: []
1559"#
1560 ),
1561 )
1562 .unwrap();
1563 let mem_dir = tmp.path().join("mem");
1564 std::fs::create_dir_all(&mem_dir).unwrap();
1565 let writer = FilesystemMemWriter::new(mem_dir.clone());
1566 let mount = crate::workspace::Mount {
1567 mem: "tasks".to_string(),
1568 schema: Some(memstead_schema::SchemaRef::new(
1569 "constr",
1570 semver::Version::new(0, 1, 0),
1571 )),
1572 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1573 capability: crate::workspace::MountCapability::Write,
1574 lifecycle: crate::workspace::MountLifecycle::Eager,
1575 cross_linkable: true,
1576 migration_target: None,
1577 };
1578 Engine::from_mounts_with_schemas_dir(
1579 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1580 Some(&schemas_dir),
1581 )
1582 .unwrap()
1583 }
1584
1585 fn checked_task_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1586 let mut args = task_create_args(title, relations);
1587 args.metadata
1588 .insert("status".to_string(), "checked".to_string());
1589 args
1590 }
1591
1592 #[test]
1597 fn create_warns_requires_when_and_still_commits() {
1598 let tmp = TempDir::new().unwrap();
1599 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1600 let (actor, client) = cli_actor();
1601
1602 let outcome = engine
1603 .create_entity(
1604 checked_task_args("Unbacked Judgment", vec![]),
1605 actor,
1606 Some(&client),
1607 None,
1608 )
1609 .unwrap();
1610 assert!(!outcome.commit_sha.is_empty(), "warn tier never blocks");
1611 let violation = outcome
1612 .warnings
1613 .iter()
1614 .find_map(|w| match w {
1615 WarningHint::ConstraintUnsatisfied { violations, .. } => Some(violations.clone()),
1616 _ => None,
1617 })
1618 .expect("CONSTRAINT_UNSATISFIED warning present");
1619 assert_eq!(violation.len(), 1);
1620 let crate::ops::health::UnsatisfiedConstraint::RequiresWhen {
1621 field,
1622 when_field,
1623 when_value,
1624 ..
1625 } = &violation[0]
1626 else {
1627 panic!("expected requires_when violation");
1628 };
1629 assert_eq!(field, "checked_by");
1630 assert_eq!(when_field, "status");
1631 assert_eq!(when_value, "checked");
1632
1633 let reports =
1635 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
1636 assert_eq!(reports.len(), 1);
1637 assert_eq!(reports[0].id, outcome.id);
1638 assert_eq!(reports[0].violations.len(), 1);
1639 let crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. } =
1640 &reports[0].violations[0]
1641 else {
1642 panic!("expected requires_when finding");
1643 };
1644 assert_eq!(field, "checked_by");
1645
1646 let mut satisfied_args = checked_task_args("Backed Judgment", vec![]);
1649 satisfied_args
1650 .metadata
1651 .insert("checked_by".to_string(), "reviewer-a".to_string());
1652 let satisfied = engine
1653 .create_entity(satisfied_args, actor, Some(&client), None)
1654 .unwrap();
1655 assert!(
1656 !satisfied
1657 .warnings
1658 .iter()
1659 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1660 "satisfied constraint emits no warning: {:?}",
1661 satisfied.warnings
1662 );
1663
1664 let untriggered = engine
1667 .create_entity(
1668 task_create_args("Open Task", vec![]),
1669 actor,
1670 Some(&client),
1671 None,
1672 )
1673 .unwrap();
1674 assert!(
1675 !untriggered
1676 .warnings
1677 .iter()
1678 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1679 "untriggered constraint emits no warning"
1680 );
1681 }
1682
1683 #[test]
1687 fn create_refuses_block_tier_requires_when() {
1688 let tmp = TempDir::new().unwrap();
1689 let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1690 let (actor, client) = cli_actor();
1691
1692 let err = engine
1693 .create_entity(
1694 checked_task_args("Unbacked Judgment", vec![]),
1695 actor,
1696 Some(&client),
1697 None,
1698 )
1699 .unwrap_err();
1700 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1701 let details = err.details();
1702 assert_eq!(details["violations"][0]["field"], "checked_by");
1703 assert_eq!(details["violations"][0]["severity"], "block");
1704 assert_eq!(
1705 engine.store().all_entities().count(),
1706 0,
1707 "refused create leaves nothing behind"
1708 );
1709
1710 let mut ok_args = checked_task_args("Backed Judgment", vec![]);
1712 ok_args
1713 .metadata
1714 .insert("checked_by".to_string(), "reviewer-a".to_string());
1715 engine
1716 .create_entity(ok_args, actor, Some(&client), None)
1717 .unwrap();
1718 }
1719
1720 #[test]
1726 fn create_refuses_block_tier_required_outgoing() {
1727 let tmp = TempDir::new().unwrap();
1728 let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1729 let (actor, client) = cli_actor();
1730
1731 let err = engine
1732 .create_entity(
1733 task_create_args("Orphan Task", vec![]),
1734 actor,
1735 Some(&client),
1736 None,
1737 )
1738 .unwrap_err();
1739 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1740 let details = err.details();
1741 assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
1742 assert_eq!(details["missing"][0]["severity"], "block");
1743 assert_eq!(engine.store().all_entities().count(), 0);
1744
1745 let outcome = engine.create_entity(
1750 task_create_args(
1751 "Child Task",
1752 vec![crate::ops::RelateArg {
1753 to: crate::entity::EntityId("tasks--parent".to_string()),
1754 rel_type: "PART_OF".to_string(),
1755 description: None,
1756 }],
1757 ),
1758 actor,
1759 Some(&client),
1760 None,
1761 );
1762 assert!(
1763 outcome.is_ok(),
1764 "satisfied block-tier create passes: {:?}",
1765 outcome.err()
1766 );
1767 }
1768
1769 #[test]
1773 fn update_enforces_requires_when_by_severity() {
1774 let (actor, client) = cli_actor();
1775 let set_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
1776 let current = engine.get_entity(id).unwrap().content_hash.clone();
1777 let mut metadata = IndexMap::new();
1778 metadata.insert("status".to_string(), "checked".to_string());
1779 engine.update_entity(
1780 crate::engine::UpdateEntityArgs {
1781 anchors: Vec::new(),
1782 id: id.clone(),
1783 expected_hash: Some(current),
1784 sections: IndexMap::new(),
1785 append_sections: IndexMap::new(),
1786 patch_sections: IndexMap::new(),
1787 metadata,
1788 metadata_unset: Vec::new(),
1789 declare_relations: vec![],
1790 dry_run: false,
1791 relations_unset: Vec::new(),
1792 anchors_unset: Vec::new(),
1793 },
1794 actor,
1795 Some(&client),
1796 None,
1797 )
1798 };
1799
1800 let tmp = TempDir::new().unwrap();
1802 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1803 let a = engine
1804 .create_entity(
1805 task_create_args("Task A", vec![]),
1806 actor,
1807 Some(&client),
1808 None,
1809 )
1810 .unwrap();
1811 let outcome = set_checked(&mut engine, &a.id).unwrap();
1812 assert!(!outcome.commit_sha.is_empty());
1813 assert!(
1814 outcome
1815 .warnings
1816 .iter()
1817 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1818 "warn-tier update carries the warning: {:?}",
1819 outcome.warnings
1820 );
1821
1822 let tmp = TempDir::new().unwrap();
1825 let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1826 let b = engine
1827 .create_entity(
1828 task_create_args("Task B", vec![]),
1829 actor,
1830 Some(&client),
1831 None,
1832 )
1833 .unwrap();
1834 let err = set_checked(&mut engine, &b.id).unwrap_err();
1835 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1836 assert!(
1837 !engine
1838 .get_entity(&b.id)
1839 .unwrap()
1840 .metadata
1841 .contains_key("status"),
1842 "refused update leaves the entity unchanged"
1843 );
1844 }
1845
1846 #[test]
1850 fn relate_remove_refuses_block_tier_required_outgoing() {
1851 let tmp = TempDir::new().unwrap();
1852 let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1853 let (actor, client) = cli_actor();
1854 let parent_id = crate::entity::EntityId("tasks--parent".to_string());
1855 let child = engine
1856 .create_entity(
1857 task_create_args(
1858 "Child Task",
1859 vec![crate::ops::RelateArg {
1860 to: parent_id.clone(),
1861 rel_type: "PART_OF".to_string(),
1862 description: None,
1863 }],
1864 ),
1865 actor,
1866 Some(&client),
1867 None,
1868 )
1869 .unwrap();
1870
1871 let err = engine
1872 .relate_entity(
1873 crate::engine::RelateEntityArgs {
1874 source: child.id.clone(),
1875 target: parent_id.clone(),
1876 rel_type: "PART_OF".to_string(),
1877 description: None,
1878 remove: true,
1879 expected_hash: None,
1880 dry_run: false,
1881 },
1882 actor,
1883 Some(&client),
1884 None,
1885 )
1886 .unwrap_err();
1887 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1888 assert!(
1889 engine
1890 .get_entity(&child.id)
1891 .unwrap()
1892 .relationships
1893 .iter()
1894 .any(|r| r.rel_type == "PART_OF" && r.target == parent_id),
1895 "refused remove leaves the edge in place"
1896 );
1897 }
1898
1899 #[test]
1906 fn no_self_loop_rel_type_self_loop_refusal_is_pinned() {
1907 let tmp = TempDir::new().unwrap();
1908 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1911 let (actor, client) = cli_actor();
1912 let a = engine
1913 .create_entity(
1914 task_create_args("Task A", vec![]),
1915 actor,
1916 Some(&client),
1917 None,
1918 )
1919 .unwrap();
1920 let err = engine
1921 .relate_entity(
1922 crate::engine::RelateEntityArgs {
1923 source: a.id.clone(),
1924 target: a.id.clone(),
1925 rel_type: "PART_OF".to_string(),
1926 description: None,
1927 remove: false,
1928 expected_hash: None,
1929 dry_run: false,
1930 },
1931 actor,
1932 Some(&client),
1933 None,
1934 )
1935 .unwrap_err();
1936 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
1937 }
1938
1939 fn engine_with_proof_schema(
1943 tmp: &TempDir,
1944 schema_name: &str,
1945 manifest_yaml: &str,
1946 types: &[(&str, &str)],
1947 ) -> Engine {
1948 let schemas_dir = tmp.path().join("schemas");
1949 let pkg = schemas_dir.join(schema_name);
1950 std::fs::create_dir_all(pkg.join("types")).unwrap();
1951 std::fs::write(pkg.join("schema.yaml"), manifest_yaml).unwrap();
1952 for (name, yaml) in types {
1953 std::fs::write(pkg.join("types").join(format!("{name}.yaml")), yaml).unwrap();
1954 }
1955 let mem_dir = tmp.path().join("mem");
1956 std::fs::create_dir_all(&mem_dir).unwrap();
1957 let writer = FilesystemMemWriter::new(mem_dir.clone());
1958 let mount = crate::workspace::Mount {
1959 mem: "proof".to_string(),
1960 schema: Some(memstead_schema::SchemaRef::new(
1961 schema_name,
1962 semver::Version::new(0, 1, 0),
1963 )),
1964 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1965 capability: crate::workspace::MountCapability::Write,
1966 lifecycle: crate::workspace::MountLifecycle::Eager,
1967 cross_linkable: true,
1968 migration_target: None,
1969 };
1970 Engine::from_mounts_with_schemas_dir(
1971 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1972 Some(&schemas_dir),
1973 )
1974 .unwrap()
1975 }
1976
1977 fn proof_create(
1978 engine: &mut Engine,
1979 entity_type: &str,
1980 title: &str,
1981 metadata: &[(&str, &str)],
1982 relations: Vec<crate::ops::RelateArg>,
1983 ) -> Result<CreateEntityOutcome, EngineError> {
1984 let (actor, client) = cli_actor();
1985 let mut sections = IndexMap::new();
1986 sections.insert("body".to_string(), format!("{title} body."));
1987 let mut md = IndexMap::new();
1988 for (k, v) in metadata {
1989 md.insert(k.to_string(), v.to_string());
1990 }
1991 engine.create_entity(
1992 CreateEntityArgs {
1993 anchors: Vec::new(),
1994 mem: "proof".to_string(),
1995 title: title.to_string(),
1996 entity_type: entity_type.to_string(),
1997 sections,
1998 metadata: md,
1999 relations,
2000 dry_run: false,
2001 },
2002 actor,
2003 Some(&client),
2004 None,
2005 )
2006 }
2007
2008 fn rel(to: &str, rel_type: &str) -> crate::ops::RelateArg {
2009 crate::ops::RelateArg {
2010 to: crate::entity::EntityId(to.to_string()),
2011 rel_type: rel_type.to_string(),
2012 description: None,
2013 }
2014 }
2015
2016 const GROUNDING_MANIFEST: &str = r#"name: grounding
2017version: 0.1.0
2018description: anker-shaped grounding proof schema
2019when_to_use: constraint-proof tests
2020types:
2021 - anchor
2022 - tradeoff
2023relationships:
2024 mode: strict
2025 definitions:
2026 - name: FOLLOWS_FROM
2027 description: stands on
2028 default_weight: 3.0
2029 - name: SUPPORTS
2030 description: pro
2031 default_weight: 1.0
2032 - name: OPPOSES
2033 description: contra
2034 default_weight: 1.0
2035 - name: PART_OF
2036 description: hier
2037 default_weight: 1.0
2038 - name: _default
2039 description: fallback
2040 default_weight: 1.0
2041community:
2042 resolution: 1.0
2043 seed: 42
2044"#;
2045
2046 const GROUNDING_ANCHOR: &str = r#"name: anchor
2047description: a judgment standing on others
2048when_to_use: tests
2049sections:
2050 - key: body
2051 heading: Body
2052 required: true
2053 search_weight: 10.0
2054 catch_all: true
2055 write_rules: []
2056metadata_fields:
2057 - key: status
2058 description: lifecycle
2059 field_type: string
2060 enum_values: [open, checked, fallen]
2061 - key: checked_by
2062 description: who checked
2063 field_type: string
2064title_weight: 100.0
2065text_fields:
2066 - body
2067hierarchy_relationship: PART_OF
2068no_self_loop_relationships: []
2069updatable_fields:
2070 - title
2071 - body
2072 - status
2073 - checked_by
2074health_required_fields:
2075 - body
2076staleness_threshold_days: 90
2077constraints:
2078 - kind: requires_when
2079 field: checked_by
2080 when_field: status
2081 when_value: checked
2082 - kind: status_propagation
2083 field: status
2084 value: fallen
2085 rel_type: FOLLOWS_FROM
2086 direction: incoming
2087write_rules: []
2088"#;
2089
2090 const GROUNDING_TRADEOFF: &str = r#"name: tradeoff
2091description: a claim with two sides
2092when_to_use: tests
2093sections:
2094 - key: body
2095 heading: Body
2096 required: true
2097 search_weight: 10.0
2098 catch_all: true
2099 write_rules: []
2100metadata_fields: []
2101title_weight: 100.0
2102text_fields:
2103 - body
2104hierarchy_relationship: PART_OF
2105no_self_loop_relationships: []
2106updatable_fields:
2107 - title
2108 - body
2109health_required_fields:
2110 - body
2111staleness_threshold_days: 90
2112required_outgoing:
2113 - relationships: [SUPPORTS]
2114 cardinality: at_least_one
2115 - relationships: [OPPOSES]
2116 cardinality: at_least_one
2117write_rules: []
2118"#;
2119
2120 #[test]
2124 fn anker_proof_grounding_schema_answers_check_questions_from_health() {
2125 let tmp = TempDir::new().unwrap();
2126 let mut engine = engine_with_proof_schema(
2127 &tmp,
2128 "grounding",
2129 GROUNDING_MANIFEST,
2130 &[
2131 ("anchor", GROUNDING_ANCHOR),
2132 ("tradeoff", GROUNDING_TRADEOFF),
2133 ],
2134 );
2135
2136 let root = proof_create(
2139 &mut engine,
2140 "anchor",
2141 "Root",
2142 &[("status", "fallen")],
2143 vec![],
2144 )
2145 .unwrap();
2146 let child = proof_create(
2147 &mut engine,
2148 "anchor",
2149 "Child",
2150 &[],
2151 vec![rel(&root.id.0, "FOLLOWS_FROM")],
2152 )
2153 .unwrap();
2154 let grandchild = proof_create(
2155 &mut engine,
2156 "anchor",
2157 "Grandchild",
2158 &[],
2159 vec![rel(&child.id.0, "FOLLOWS_FROM")],
2160 )
2161 .unwrap();
2162 let standing = proof_create(
2163 &mut engine,
2164 "anchor",
2165 "Standing Root",
2166 &[("status", "open")],
2167 vec![],
2168 )
2169 .unwrap();
2170 let standing_child = proof_create(
2171 &mut engine,
2172 "anchor",
2173 "Standing Child",
2174 &[],
2175 vec![rel(&standing.id.0, "FOLLOWS_FROM")],
2176 )
2177 .unwrap();
2178 let unchecked = proof_create(
2180 &mut engine,
2181 "anchor",
2182 "Checked No Checker",
2183 &[("status", "checked")],
2184 vec![],
2185 )
2186 .unwrap();
2187 let onesided = proof_create(
2189 &mut engine,
2190 "tradeoff",
2191 "One Sided",
2192 &[],
2193 vec![rel(&standing.id.0, "SUPPORTS")],
2194 )
2195 .unwrap();
2196
2197 let findings =
2200 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2201 let tainted_of = |id: &crate::entity::EntityId| -> Vec<String> {
2202 findings
2203 .iter()
2204 .filter(|r| &r.id == id)
2205 .flat_map(|r| &r.violations)
2206 .filter_map(|v| match v {
2207 crate::ops::health::UnsatisfiedConstraint::StatusPropagation {
2208 tainted_by,
2209 ..
2210 } => Some(tainted_by.clone()),
2211 _ => None,
2212 })
2213 .collect()
2214 };
2215 assert_eq!(tainted_of(&child.id), vec![root.id.to_string()]);
2216 assert_eq!(
2217 tainted_of(&grandchild.id),
2218 vec![root.id.to_string()],
2219 "the taint is transitive and names the terminal ancestor"
2220 );
2221 assert!(tainted_of(&standing_child.id).is_empty());
2222 assert!(
2223 tainted_of(&root.id).is_empty(),
2224 "the source is not its own finding"
2225 );
2226
2227 assert!(
2229 findings.iter().any(|r| r.id == unchecked.id
2230 && r.violations.iter().any(|v| matches!(
2231 v,
2232 crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. }
2233 if field == "checked_by"
2234 ))),
2235 "checked-without-checker must be a health finding"
2236 );
2237
2238 let missing = crate::ops::health::collect_missing_required_outgoing(
2241 engine.store(),
2242 None,
2243 engine.schemas(),
2244 );
2245 let onesided_report = missing
2246 .iter()
2247 .find(|r| r.id == onesided.id)
2248 .expect("one-sided trade-off flagged");
2249 assert_eq!(onesided_report.missing.len(), 1);
2250 assert_eq!(onesided_report.missing[0].relationships, vec!["OPPOSES"]);
2251 }
2252
2253 const PLENUM_MANIFEST: &str = r#"name: plenum-proof
2254version: 0.1.0
2255description: plenum-shaped uniqueness and vocabulary proof schema
2256when_to_use: constraint-proof tests
2257types:
2258 - rede
2259 - vocabulary
2260relationships:
2261 mode: strict
2262 definitions:
2263 - name: REFERENCES
2264 description: soft ref
2265 default_weight: 0.5
2266 - name: PART_OF
2267 description: hier
2268 default_weight: 1.0
2269 - name: _default
2270 description: fallback
2271 default_weight: 1.0
2272community:
2273 resolution: 1.0
2274 seed: 42
2275"#;
2276
2277 fn plenum_rede_type(unique_severity: &str) -> String {
2278 format!(
2279 r#"name: rede
2280description: one speech
2281when_to_use: tests
2282sections:
2283 - key: body
2284 heading: Body
2285 required: true
2286 search_weight: 10.0
2287 catch_all: true
2288 write_rules: []
2289metadata_fields:
2290 - key: rede_id
2291 description: source id
2292 field_type: string
2293 - key: rede_sha256
2294 description: content hash
2295 field_type: string
2296 - key: kategorie
2297 description: category from the shared vocabulary
2298 field_type: string
2299title_weight: 100.0
2300text_fields:
2301 - body
2302hierarchy_relationship: PART_OF
2303no_self_loop_relationships: []
2304updatable_fields:
2305 - title
2306 - body
2307 - rede_id
2308 - rede_sha256
2309 - kategorie
2310health_required_fields:
2311 - body
2312staleness_threshold_days: 90
2313constraints:
2314 - kind: unique
2315 fields: [rede_id, rede_sha256]
2316 severity: {unique_severity}
2317 - kind: enum_from_neighbour
2318 field: kategorie
2319 rel_type: REFERENCES
2320 section: terms
2321write_rules: []
2322"#
2323 )
2324 }
2325
2326 const PLENUM_VOCABULARY: &str = r#"name: vocabulary
2327description: the shared term list
2328when_to_use: tests
2329sections:
2330 - key: terms
2331 heading: Terms
2332 required: false
2333 search_weight: 5.0
2334 catch_all: false
2335 write_rules: []
2336 - key: body
2337 heading: Body
2338 required: true
2339 search_weight: 10.0
2340 catch_all: true
2341 write_rules: []
2342metadata_fields: []
2343title_weight: 100.0
2344text_fields:
2345 - body
2346hierarchy_relationship: PART_OF
2347no_self_loop_relationships: []
2348updatable_fields:
2349 - title
2350 - body
2351 - terms
2352health_required_fields:
2353 - body
2354staleness_threshold_days: 90
2355write_rules: []
2356"#;
2357
2358 #[test]
2364 fn plenum_proof_uniqueness_refuses_duplicates_and_health_reports_planted_ones() {
2365 let tmp = TempDir::new().unwrap();
2367 let rede = plenum_rede_type("block");
2368 let mut engine = engine_with_proof_schema(
2369 &tmp,
2370 "plenum-proof",
2371 PLENUM_MANIFEST,
2372 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
2373 );
2374 let first = proof_create(
2375 &mut engine,
2376 "rede",
2377 "Speech One",
2378 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2379 vec![],
2380 )
2381 .unwrap();
2382 let err = proof_create(
2383 &mut engine,
2384 "rede",
2385 "Speech One Duplicate",
2386 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2387 vec![],
2388 )
2389 .unwrap_err();
2390 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
2391 assert_eq!(
2392 err.details()["violations"][0]["colliding"],
2393 first.id.to_string(),
2394 "the refusal names the colliding entity"
2395 );
2396 proof_create(
2398 &mut engine,
2399 "rede",
2400 "Speech Two",
2401 &[("rede_id", "19-43"), ("rede_sha256", "def456")],
2402 vec![],
2403 )
2404 .unwrap();
2405
2406 let tmp = TempDir::new().unwrap();
2408 let rede = plenum_rede_type("warn");
2409 let mut engine = engine_with_proof_schema(
2410 &tmp,
2411 "plenum-proof",
2412 PLENUM_MANIFEST,
2413 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
2414 );
2415 proof_create(
2416 &mut engine,
2417 "rede",
2418 "Planted A",
2419 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2420 vec![],
2421 )
2422 .unwrap();
2423 let planted = proof_create(
2424 &mut engine,
2425 "rede",
2426 "Planted B",
2427 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2428 vec![],
2429 )
2430 .unwrap();
2431 assert!(
2432 planted
2433 .warnings
2434 .iter()
2435 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
2436 "warn tier surfaces the duplicate as a warning and commits"
2437 );
2438 let findings =
2439 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2440 assert_eq!(
2441 findings.len(),
2442 2,
2443 "both sides of the planted duplicate are findings: {findings:?}"
2444 );
2445 }
2446
2447 #[test]
2451 fn plenum_proof_enum_from_neighbour_flags_stale_holders_after_rename() {
2452 let tmp = TempDir::new().unwrap();
2453 let rede = plenum_rede_type("warn");
2454 let mut engine = engine_with_proof_schema(
2455 &tmp,
2456 "plenum-proof",
2457 PLENUM_MANIFEST,
2458 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
2459 );
2460 let (actor, client) = cli_actor();
2461
2462 let mut sections = IndexMap::new();
2464 sections.insert("body".to_string(), "the term list.".to_string());
2465 sections.insert("terms".to_string(), "- haushalt\n- verkehr\n".to_string());
2466 let vocab = engine
2467 .create_entity(
2468 CreateEntityArgs {
2469 anchors: Vec::new(),
2470 mem: "proof".to_string(),
2471 title: "Kategorien".to_string(),
2472 entity_type: "vocabulary".to_string(),
2473 sections,
2474 metadata: IndexMap::new(),
2475 relations: vec![],
2476 dry_run: false,
2477 },
2478 actor,
2479 Some(&client),
2480 None,
2481 )
2482 .unwrap();
2483
2484 let holder = proof_create(
2486 &mut engine,
2487 "rede",
2488 "Holder",
2489 &[("kategorie", "haushalt")],
2490 vec![rel(&vocab.id.0, "REFERENCES")],
2491 )
2492 .unwrap();
2493 let findings =
2494 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2495 assert!(
2496 findings.iter().all(|r| r.id != holder.id),
2497 "backed value produces no finding: {findings:?}"
2498 );
2499
2500 let current = engine.get_entity(&vocab.id).unwrap().content_hash.clone();
2503 let mut sections = IndexMap::new();
2504 sections.insert("terms".to_string(), "- finanzen\n- verkehr\n".to_string());
2505 engine
2506 .update_entity(
2507 crate::engine::UpdateEntityArgs {
2508 anchors: Vec::new(),
2509 id: vocab.id.clone(),
2510 expected_hash: Some(current),
2511 sections,
2512 append_sections: IndexMap::new(),
2513 patch_sections: IndexMap::new(),
2514 metadata: IndexMap::new(),
2515 metadata_unset: Vec::new(),
2516 declare_relations: vec![],
2517 dry_run: false,
2518 relations_unset: Vec::new(),
2519 anchors_unset: Vec::new(),
2520 },
2521 actor,
2522 Some(&client),
2523 None,
2524 )
2525 .unwrap();
2526 let findings =
2527 crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2528 let stale = findings
2529 .iter()
2530 .find(|r| r.id == holder.id)
2531 .expect("stale holder is flagged after the rename");
2532 assert!(stale.violations.iter().any(|v| matches!(
2533 v,
2534 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { value, .. }
2535 if value == "haushalt"
2536 )));
2537 }
2538
2539 #[test]
2546 fn create_warns_missing_required_outgoing_and_still_commits() {
2547 let tmp = TempDir::new().unwrap();
2548 let mut engine = engine_with_required_outgoing_schema(&tmp);
2549 let (actor, client) = cli_actor();
2550
2551 let outcome = engine
2552 .create_entity(
2553 task_create_args("Orphan Task", vec![]),
2554 actor,
2555 Some(&client),
2556 None,
2557 )
2558 .unwrap();
2559 assert!(
2560 !outcome.commit_sha.is_empty(),
2561 "the warning never blocks the mutation"
2562 );
2563 let blocks = missing_outgoing_of(&outcome.warnings);
2564 assert_eq!(
2565 blocks,
2566 vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())],
2567 "warning names the unsatisfied block with cardinality; warnings = {:?}",
2568 outcome.warnings
2569 );
2570
2571 let reports = crate::ops::health::collect_missing_required_outgoing(
2574 engine.store(),
2575 None,
2576 engine.schemas(),
2577 );
2578 assert_eq!(reports.len(), 1);
2579 assert_eq!(reports[0].id, outcome.id);
2580 assert_eq!(reports[0].missing.len(), 1);
2581 assert_eq!(reports[0].missing[0].relationships, vec!["PART_OF"]);
2582 assert_eq!(reports[0].missing[0].cardinality, "at_least_one");
2583
2584 let satisfied = engine
2587 .create_entity(
2588 task_create_args(
2589 "Child Task",
2590 vec![crate::ops::RelateArg {
2591 to: outcome.id.clone(),
2592 rel_type: "PART_OF".to_string(),
2593 description: None,
2594 }],
2595 ),
2596 actor,
2597 Some(&client),
2598 None,
2599 )
2600 .unwrap();
2601 assert!(
2602 missing_outgoing_of(&satisfied.warnings).is_empty(),
2603 "satisfied block emits no warning: {:?}",
2604 satisfied.warnings
2605 );
2606 }
2607
2608 #[test]
2612 fn update_warns_missing_required_outgoing_until_satisfied() {
2613 let tmp = TempDir::new().unwrap();
2614 let mut engine = engine_with_required_outgoing_schema(&tmp);
2615 let (actor, client) = cli_actor();
2616 let a = engine
2617 .create_entity(
2618 task_create_args("Task A", vec![]),
2619 actor,
2620 Some(&client),
2621 None,
2622 )
2623 .unwrap();
2624 let b = engine
2625 .create_entity(
2626 task_create_args("Task B", vec![]),
2627 actor,
2628 Some(&client),
2629 None,
2630 )
2631 .unwrap();
2632
2633 let update = |engine: &mut Engine,
2634 id: &crate::entity::EntityId,
2635 declare: Vec<crate::ops::RelateArg>| {
2636 let current = engine.get_entity(id).unwrap().content_hash.clone();
2637 let mut sections = IndexMap::new();
2638 sections.insert("body".to_string(), format!("edited at {:?}", declare.len()));
2639 engine
2640 .update_entity(
2641 crate::engine::UpdateEntityArgs {
2642 anchors: Vec::new(),
2643 id: id.clone(),
2644 expected_hash: Some(current),
2645 sections,
2646 append_sections: IndexMap::new(),
2647 patch_sections: IndexMap::new(),
2648 metadata: IndexMap::new(),
2649 metadata_unset: Vec::new(),
2650 declare_relations: declare,
2651 dry_run: false,
2652 relations_unset: Vec::new(),
2653 anchors_unset: Vec::new(),
2654 },
2655 actor,
2656 Some(&client),
2657 None,
2658 )
2659 .unwrap()
2660 };
2661
2662 let outcome = update(&mut engine, &a.id, vec![]);
2665 assert!(!outcome.commit_sha.is_empty());
2666 assert_eq!(
2667 missing_outgoing_of(&outcome.warnings),
2668 vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())]
2669 );
2670
2671 let outcome = update(
2673 &mut engine,
2674 &a.id,
2675 vec![crate::ops::RelateArg {
2676 to: b.id.clone(),
2677 rel_type: "PART_OF".to_string(),
2678 description: None,
2679 }],
2680 );
2681 assert!(
2682 missing_outgoing_of(&outcome.warnings).is_empty(),
2683 "satisfied block emits no warning: {:?}",
2684 outcome.warnings
2685 );
2686 }
2687
2688 #[test]
2695 fn anchor_source_validated_against_resolvable_binding() {
2696 use crate::binding::{
2697 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, hash_binding,
2698 };
2699 use crate::pipeline::{IngestTrigger, PatternEntry, PatternMode, Source};
2700
2701 let tmp = TempDir::new().unwrap();
2702 let (mut engine, _seed) = engine_with_seed(&tmp, "Seed");
2703 let (actor, client) = cli_actor();
2704
2705 let ws = TempDir::new().unwrap();
2707 let binding = Binding {
2708 version: BINDING_VERSION,
2709 intent: None,
2710 sources: ["api-docs", "guides"]
2711 .into_iter()
2712 .map(|n| Source {
2713 name: n.to_string(),
2714 medium_type: crate::pipeline::MediumType::Codebase,
2715 pointer: "../src".to_string(),
2716 change_detection: None,
2717 scope: vec![PatternEntry {
2718 path: "**/*".to_string(),
2719 mode: PatternMode::Allow,
2720 }],
2721 engagement: None,
2722 preparation: None,
2723 })
2724 .collect(),
2725 reference_mems: Vec::new(),
2726 destination_mem: "specs".to_string(),
2727 deny_paths: Vec::new(),
2728 coverage_semantics: None,
2729 rules: None,
2730 prune: None,
2731 operations: Operations {
2732 build: Some(BuildOperation {
2733 mode: BuildMode::Discovery,
2734 trigger: IngestTrigger::Loop,
2735 batch_size: 20,
2736 post_actions: None,
2737 }),
2738 sync: None,
2739 verify: None,
2740 },
2741 };
2742 let dir = ws
2743 .path()
2744 .join(".memstead")
2745 .join("projections")
2746 .join("specs");
2747 std::fs::create_dir_all(&dir).unwrap();
2748 std::fs::write(
2749 dir.join("docs.json"),
2750 serde_json::to_string_pretty(&binding).unwrap(),
2751 )
2752 .unwrap();
2753 engine.set_workspace_root(ws.path().to_path_buf());
2754 let binding_hash = hash_binding(&binding);
2755
2756 let anchor = |source: &str, binding: &str| crate::anchor::AnchorInput {
2757 artifact: Some("src/x.rs".into()),
2758 grain: Some("file".into()),
2759 class: Some("anchored".into()),
2760 binding: Some(binding.into()),
2761 source: Some(source.into()),
2762 ..Default::default()
2763 };
2764 let make_args = |title: &str, a: crate::anchor::AnchorInput| {
2765 let mut args = empty_create_args("specs", title);
2766 args.anchors = vec![a];
2767 args
2768 };
2769
2770 let err = engine
2773 .create_entity(
2774 make_args("Bad Source", anchor("front-page", &binding_hash)),
2775 actor,
2776 Some(&client),
2777 None,
2778 )
2779 .unwrap_err();
2780 assert_eq!(err.code(), "INVALID_ANCHOR", "got {err:?}");
2781 let details = err.details();
2782 assert_eq!(details["field"], "source");
2783 assert_eq!(details["got"], "front-page");
2784 assert_eq!(
2785 details["declared"],
2786 serde_json::json!(["api-docs", "guides"])
2787 );
2788
2789 let ok = engine
2791 .create_entity(
2792 make_args("Good Source", anchor("api-docs", &binding_hash)),
2793 actor,
2794 Some(&client),
2795 None,
2796 )
2797 .expect("declared source name accepted");
2798 let anchors = engine.mem_anchors_resolved("specs");
2799 let stored = anchors
2800 .iter()
2801 .find(|(id, _)| id == &ok.id)
2802 .map(|(_, a)| &a.anchor)
2803 .expect("anchor stored for the new entity");
2804 assert_eq!(stored.source.as_deref(), Some("api-docs"));
2805
2806 engine
2808 .create_entity(
2809 make_args("Orphaned Binding", anchor("whatever", "deadbeef")),
2810 actor,
2811 Some(&client),
2812 None,
2813 )
2814 .expect("unresolvable binding accepts any non-empty name");
2815 }
2816
2817 #[test]
2822 fn batch_create_intra_batch_references_resolve_real() {
2823 let tmp = TempDir::new().unwrap();
2824 let mem_dir = tmp.path().to_path_buf();
2825 let writer = FilesystemMemWriter::new(mem_dir.clone());
2826 let mut engine = Engine::from_mounts(vec![(
2827 folder_mount("specs", mem_dir),
2828 Box::new(writer) as Box<dyn MemBackend>,
2829 )])
2830 .unwrap();
2831 let (actor, client) = cli_actor();
2832
2833 let with_rel = |title: &str, to: &str| {
2834 let mut args = empty_create_args("specs", title);
2835 args.relations = vec![crate::ops::RelateArg {
2836 to: crate::entity::EntityId::new("specs", to),
2837 rel_type: "USES".to_string(),
2838 description: None,
2839 }];
2840 (args, Some(format!("note for {title}")))
2841 };
2842 let result = engine
2844 .batch_create(
2845 vec![
2846 with_rel("Alpha", "beta"),
2847 with_rel("Beta", "gamma"),
2848 with_rel("Gamma", "alpha"),
2849 ],
2850 actor,
2851 Some(&client),
2852 false,
2853 )
2854 .unwrap();
2855 assert!(result.applied, "{result:?}");
2856 assert_eq!(result.succeeded, 3);
2857 assert!(!result.commit_sha.is_empty(), "one real commit");
2858 assert!(
2859 result.results.iter().all(|r| r.action == "created"),
2860 "{result:?}"
2861 );
2862
2863 for name in ["alpha", "beta", "gamma"] {
2865 let e = engine
2866 .get_entity(&crate::entity::EntityId::new("specs", name))
2867 .unwrap();
2868 assert!(!e.stub, "{name} must be real, not a stub");
2869 assert_eq!(e.entity_type, "spec");
2870 assert_eq!(e.relationships.len(), 1, "{name} carries its edge");
2871 }
2872 }
2877
2878 #[test]
2884 fn batch_create_dry_run_reports_receipt_and_writes_nothing() {
2885 let tmp = TempDir::new().unwrap();
2886 let mem_dir = tmp.path().to_path_buf();
2887 let writer = FilesystemMemWriter::new(mem_dir.clone());
2888 let mut engine = Engine::from_mounts(vec![(
2889 folder_mount("specs", mem_dir),
2890 Box::new(writer) as Box<dyn MemBackend>,
2891 )])
2892 .unwrap();
2893 let (actor, client) = cli_actor();
2894
2895 let with_rel = |title: &str, to: &str| {
2896 let mut args = empty_create_args("specs", title);
2897 args.relations = vec![crate::ops::RelateArg {
2898 to: crate::entity::EntityId::new("specs", to),
2899 rel_type: "USES".to_string(),
2900 description: None,
2901 }];
2902 (args, None)
2903 };
2904 let batch = || {
2905 vec![
2906 with_rel("Alpha", "beta"),
2907 with_rel("Beta", "gamma"),
2908 with_rel("Gamma", "alpha"),
2909 ]
2910 };
2911
2912 let rehearsed = engine
2913 .batch_create(batch(), actor, Some(&client), true)
2914 .unwrap();
2915 assert!(rehearsed.applied, "{rehearsed:?}");
2916 assert_eq!(rehearsed.succeeded, 3);
2917 assert!(
2918 rehearsed.commit_sha.is_empty(),
2919 "marker form: empty commit_sha"
2920 );
2921 assert!(rehearsed.results.iter().all(|r| r.action == "created"));
2922 for name in ["alpha", "beta", "gamma"] {
2924 let id = crate::entity::EntityId::new("specs", name);
2925 assert!(
2926 rehearsed.results.iter().any(|r| r.id == id),
2927 "receipt must name {id}: {rehearsed:?}"
2928 );
2929 assert!(
2930 !engine.store().contains(&id),
2931 "rehearsal must create nothing"
2932 );
2933 }
2934 assert_eq!(engine.store().all_entities().count(), 0);
2935
2936 let real = engine
2938 .batch_create(batch(), actor, Some(&client), false)
2939 .unwrap();
2940 assert!(real.applied, "{real:?}");
2941 assert!(!real.commit_sha.is_empty(), "the real batch commits");
2942 assert_eq!(real.succeeded, 3);
2943 }
2944
2945 #[test]
2950 fn batch_create_dry_run_refuses_identically_to_real() {
2951 let tmp = TempDir::new().unwrap();
2952 let (mut engine, _seeded) = engine_with_seed(&tmp, "Existing");
2953 let (actor, client) = cli_actor();
2954 let plain = |title: &str| (empty_create_args("specs", title), None);
2955 let batch = || {
2956 vec![
2957 plain("Fine One"),
2958 plain("Existing"), plain("Bad/Title"), ]
2961 };
2962
2963 let rehearsed = engine
2964 .batch_create(batch(), actor, Some(&client), true)
2965 .unwrap();
2966 let real = engine
2967 .batch_create(batch(), actor, Some(&client), false)
2968 .unwrap();
2969 assert!(!rehearsed.applied && !real.applied);
2970 assert_eq!(rehearsed.failed, real.failed);
2971 assert_eq!(rehearsed.errors_suppressed, real.errors_suppressed);
2972 let envelope = |r: &crate::ops::BatchResult| {
2973 r.results
2974 .iter()
2975 .map(|e| {
2976 (
2977 e.id.to_string(),
2978 e.action.clone(),
2979 e.error.as_ref().map(|err| {
2980 (err.code.clone(), err.message.clone(), err.details.clone())
2981 }),
2982 )
2983 })
2984 .collect::<Vec<_>>()
2985 };
2986 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2987 assert!(
2988 !engine
2989 .store()
2990 .contains(&crate::entity::EntityId::new("specs", "fine-one"))
2991 );
2992 }
2993
2994 #[test]
2998 fn batch_create_refuses_whole_batch_reporting_every_failure() {
2999 let tmp = TempDir::new().unwrap();
3000 let (mut engine, seeded) = engine_with_seed(&tmp, "Existing");
3001 let (actor, client) = cli_actor();
3002 let head_before = engine
3003 .mem_head_sha("specs")
3004 .ok()
3005 .flatten()
3006 .unwrap_or_default();
3007 let count_before = engine.store().all_entities().count();
3008
3009 let plain = |title: &str| (empty_create_args("specs", title), None);
3010 let result = engine
3011 .batch_create(
3012 vec![
3013 plain("Fine One"),
3014 plain("Existing"), plain("Bad\nTitle"), plain("Fine Two"),
3017 plain("Fine Two"), ],
3019 actor,
3020 Some(&client),
3021 false,
3022 )
3023 .unwrap();
3024 assert!(!result.applied);
3025 assert_eq!(result.failed, 3, "{result:?}");
3026 assert!(result.commit_sha.is_empty());
3027 let codes: Vec<(usize, &str)> = result
3028 .results
3029 .iter()
3030 .enumerate()
3031 .filter(|(_, r)| r.action == "error")
3032 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
3033 .collect();
3034 assert_eq!(
3035 codes,
3036 vec![
3037 (1, "ENTITY_ALREADY_EXISTS"),
3038 (2, "INVALID_TITLE"),
3039 (4, "ENTITY_ALREADY_EXISTS"),
3040 ],
3041 "every failing entry named with index + typed code: {result:?}"
3042 );
3043 assert_eq!(result.results[0].action, "not_applied");
3045 assert_eq!(result.results[3].action, "not_applied");
3046 let head_after = engine
3047 .mem_head_sha("specs")
3048 .ok()
3049 .flatten()
3050 .unwrap_or_default();
3051 assert_eq!(head_before, head_after, "mem head unmoved");
3052 assert_eq!(
3053 engine.store().all_entities().count(),
3054 count_before,
3055 "no entity created, no skeleton left behind"
3056 );
3057 let _ = seeded;
3058 }
3059
3060 #[test]
3064 fn batch_create_bounds_the_failure_report() {
3065 let tmp = TempDir::new().unwrap();
3066 let mem_dir = tmp.path().to_path_buf();
3067 let writer = FilesystemMemWriter::new(mem_dir.clone());
3068 let mut engine = Engine::from_mounts(vec![(
3069 folder_mount("specs", mem_dir),
3070 Box::new(writer) as Box<dyn MemBackend>,
3071 )])
3072 .unwrap();
3073 let (actor, client) = cli_actor();
3074 let n = Engine::BATCH_ERROR_REPORT_CAP + 10;
3075 let batch: Vec<_> = (0..n)
3076 .map(|i| (empty_create_args("specs", &format!("Bad\nTitle {i}")), None))
3077 .collect();
3078 let result = engine
3079 .batch_create(batch, actor, Some(&client), false)
3080 .unwrap();
3081 assert!(!result.applied);
3082 assert_eq!(result.failed, n);
3083 let detailed = result
3084 .results
3085 .iter()
3086 .filter(|r| r.action == "error" && r.error.is_some())
3087 .count();
3088 let bare = result
3089 .results
3090 .iter()
3091 .filter(|r| r.action == "error" && r.error.is_none())
3092 .count();
3093 assert_eq!(detailed, Engine::BATCH_ERROR_REPORT_CAP);
3094 assert_eq!(bare, 10);
3095 assert_eq!(
3096 result.errors_suppressed, 10,
3097 "suppression is counted, never silent"
3098 );
3099 }
3100
3101 #[test]
3102 fn create_entity_writes_through_folder_backend_and_updates_store() {
3103 let tmp = TempDir::new().unwrap();
3104 let mem_dir = tmp.path().to_path_buf();
3105 let writer = FilesystemMemWriter::new(mem_dir.clone());
3106 let mut engine = Engine::from_mounts(vec![(
3107 folder_mount("specs", mem_dir.clone()),
3108 Box::new(writer) as Box<dyn MemBackend>,
3109 )])
3110 .unwrap();
3111 let (actor, client) = cli_actor();
3112
3113 let outcome = engine
3114 .create_entity(
3115 empty_create_args("specs", "Hello World"),
3116 actor,
3117 Some(&client),
3118 Some("first draft"),
3119 )
3120 .unwrap();
3121
3122 assert_eq!(outcome.id.to_string(), "specs--hello-world");
3124 assert_eq!(outcome.file_path, "hello-world.md");
3125 assert!(!outcome.content_hash.is_empty());
3126
3127 let entity = engine
3129 .get_entity(&crate::EntityId::new("specs", "hello-world"))
3130 .expect("entity must be in the store after create");
3131 assert_eq!(entity.title, "Hello World");
3132 assert_eq!(entity.entity_type, "spec");
3133 assert_eq!(entity.content_hash, outcome.content_hash);
3134
3135 let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
3137 assert!(on_disk.contains("# Hello World"));
3138 assert!(on_disk.contains("type: spec"));
3139
3140 let log_path = mem_dir.join(".memstead").join("changes.jsonl");
3142 let log = std::fs::read_to_string(&log_path).unwrap();
3143 assert!(log.contains("\"kind\":\"create\""));
3144 assert!(log.contains("\"entity\":\"specs--hello-world\""));
3145 assert!(log.contains("\"actor\":\"cli\""));
3146 assert!(log.contains("\"note\":\"first draft\""));
3147 }
3148
3149 #[test]
3155 fn create_entity_warns_on_supplied_auto_managed_field() {
3156 let tmp = TempDir::new().unwrap();
3157 let mem_dir = tmp.path().to_path_buf();
3158 let writer = FilesystemMemWriter::new(mem_dir.clone());
3159 let mut engine = Engine::from_mounts(vec![(
3160 folder_mount("specs", mem_dir),
3161 Box::new(writer) as Box<dyn MemBackend>,
3162 )])
3163 .unwrap();
3164 let (actor, client) = cli_actor();
3165
3166 let mut args = empty_create_args("specs", "Dated Entity");
3167 args.metadata
3168 .insert("created_date".to_string(), "2020-01-01".to_string());
3169
3170 let outcome = engine
3171 .create_entity(args, actor, Some(&client), None)
3172 .unwrap();
3173
3174 let warned = outcome.warnings.iter().any(|w| {
3175 w.code() == "IGNORED_READONLY_FIELD"
3176 && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
3177 if field == "created_date" && supplied == "2020-01-01")
3178 });
3179 assert!(
3180 warned,
3181 "expected IGNORED_READONLY_FIELD; got {:?}",
3182 outcome.warnings
3183 );
3184
3185 assert_ne!(outcome.created_date, "2020-01-01");
3187 }
3188
3189 #[test]
3192 fn create_entity_no_warning_when_auto_managed_field_absent() {
3193 let tmp = TempDir::new().unwrap();
3194 let mem_dir = tmp.path().to_path_buf();
3195 let writer = FilesystemMemWriter::new(mem_dir.clone());
3196 let mut engine = Engine::from_mounts(vec![(
3197 folder_mount("specs", mem_dir),
3198 Box::new(writer) as Box<dyn MemBackend>,
3199 )])
3200 .unwrap();
3201 let (actor, client) = cli_actor();
3202
3203 let outcome = engine
3204 .create_entity(
3205 empty_create_args("specs", "Plain Entity"),
3206 actor,
3207 Some(&client),
3208 None,
3209 )
3210 .unwrap();
3211 assert!(
3212 !outcome
3213 .warnings
3214 .iter()
3215 .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
3216 "no auto-managed field supplied — no warning expected; got {:?}",
3217 outcome.warnings
3218 );
3219 }
3220
3221 #[test]
3222 fn create_entity_returns_commit_sha_title_mem_on_real_write() {
3223 let tmp = TempDir::new().unwrap();
3224 let mem_dir = tmp.path().to_path_buf();
3225 let writer = FilesystemMemWriter::new(mem_dir.clone());
3226 let mut engine = Engine::from_mounts(vec![(
3227 folder_mount("specs", mem_dir),
3228 Box::new(writer) as Box<dyn MemBackend>,
3229 )])
3230 .unwrap();
3231 let (actor, client) = cli_actor();
3232
3233 let outcome = engine
3234 .create_entity(
3235 empty_create_args("specs", "Rich Shape"),
3236 actor,
3237 Some(&client),
3238 None,
3239 )
3240 .unwrap();
3241
3242 assert!(
3245 !outcome.commit_sha.is_empty(),
3246 "commit_sha must be populated on a real create"
3247 );
3248 assert_eq!(outcome.title, "Rich Shape");
3250 assert_eq!(outcome.mem, "specs");
3251 assert!(
3257 !outcome
3258 .warnings
3259 .iter()
3260 .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
3261 "success path must not carry MissingRequiredSection warnings — those refuse on create now",
3262 );
3263 }
3264
3265 #[test]
3271 fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
3272 let tmp = TempDir::new().unwrap();
3273 let mem_dir = tmp.path().to_path_buf();
3274 let writer = FilesystemMemWriter::new(mem_dir.clone());
3275 let mut engine = Engine::from_mounts(vec![(
3276 folder_mount("specs", mem_dir),
3277 Box::new(writer) as Box<dyn MemBackend>,
3278 )])
3279 .unwrap();
3280 let (actor, client) = cli_actor();
3281
3282 let args = CreateEntityArgs {
3284 anchors: Vec::new(),
3285 mem: "specs".to_string(),
3286 title: "Half Done".to_string(),
3287 entity_type: "spec".to_string(),
3288 sections: IndexMap::new(),
3289 metadata: IndexMap::new(),
3290 relations: Vec::new(),
3291 dry_run: false,
3292 };
3293 let err = engine
3294 .create_entity(args, actor, Some(&client), None)
3295 .unwrap_err();
3296 match err {
3297 EngineError::MissingRequiredSection {
3298 entity_type,
3299 missing_count,
3300 sections,
3301 type_guidance,
3302 } => {
3303 assert_eq!(entity_type, "spec");
3304 assert_eq!(missing_count, sections.len());
3305 assert!(
3306 missing_count >= 2,
3307 "expected ≥2 missing sections, got {missing_count}"
3308 );
3309 let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
3310 assert!(
3311 keys.contains(&"identity".to_string()),
3312 "missing keys: {keys:?}"
3313 );
3314 assert!(
3315 keys.contains(&"purpose".to_string()),
3316 "missing keys: {keys:?}"
3317 );
3318 assert!(
3319 type_guidance.contains_key("spec"),
3320 "type_guidance must include `spec` entry, got: {type_guidance:?}",
3321 );
3322 }
3323 other => panic!("expected MissingRequiredSection, got {other:?}"),
3324 }
3325
3326 let id = crate::EntityId::new("specs", "half-done");
3328 assert!(
3329 engine.store().get(&id).is_none(),
3330 "refused create must not persist any entity"
3331 );
3332 }
3333
3334 #[test]
3338 fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
3339 let tmp = TempDir::new().unwrap();
3340 let mem_dir = tmp.path().to_path_buf();
3341 let writer = FilesystemMemWriter::new(mem_dir.clone());
3342 let mut engine = Engine::from_mounts(vec![(
3343 folder_mount("specs", mem_dir),
3344 Box::new(writer) as Box<dyn MemBackend>,
3345 )])
3346 .unwrap();
3347 let (actor, client) = cli_actor();
3348
3349 let args = CreateEntityArgs {
3350 anchors: Vec::new(),
3351 mem: "specs".to_string(),
3352 title: "Half Done Dry".to_string(),
3353 entity_type: "spec".to_string(),
3354 sections: IndexMap::new(),
3355 metadata: IndexMap::new(),
3356 relations: Vec::new(),
3357 dry_run: true,
3358 };
3359 let err = engine
3360 .create_entity(args, actor, Some(&client), None)
3361 .unwrap_err();
3362 assert!(
3363 matches!(err, EngineError::MissingRequiredSection { .. }),
3364 "dry_run must surface the same refusal envelope, got {err:?}"
3365 );
3366 }
3367
3368 #[test]
3372 fn create_entity_succeeds_after_filling_in_required_sections() {
3373 let tmp = TempDir::new().unwrap();
3374 let mem_dir = tmp.path().to_path_buf();
3375 let writer = FilesystemMemWriter::new(mem_dir.clone());
3376 let mut engine = Engine::from_mounts(vec![(
3377 folder_mount("specs", mem_dir),
3378 Box::new(writer) as Box<dyn MemBackend>,
3379 )])
3380 .unwrap();
3381 let (actor, client) = cli_actor();
3382
3383 let mut sections = IndexMap::new();
3384 sections.insert("identity".to_string(), "the identity body".to_string());
3385 sections.insert("purpose".to_string(), "the purpose body".to_string());
3386 let args = CreateEntityArgs {
3387 anchors: Vec::new(),
3388 mem: "specs".to_string(),
3389 title: "Complete".to_string(),
3390 entity_type: "spec".to_string(),
3391 sections,
3392 metadata: IndexMap::new(),
3393 relations: Vec::new(),
3394 dry_run: false,
3395 };
3396 let outcome = engine
3397 .create_entity(args, actor, Some(&client), None)
3398 .expect("complete create succeeds");
3399 assert_eq!(outcome.title, "Complete");
3400 }
3401
3402 #[test]
3403 fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
3404 let tmp = TempDir::new().unwrap();
3405 let (mut engine, source) = engine_with_seed(&tmp, "Source");
3406 let (actor, client) = cli_actor();
3407
3408 let stub_target = crate::EntityId::new("specs", "ghost-target");
3411 engine
3412 .relate_entity(
3413 RelateEntityArgs {
3414 source: source.id.clone(),
3415 expected_hash: Some(source.content_hash.clone()),
3416 rel_type: "USES".to_string(),
3417 target: stub_target.clone(),
3418 remove: false,
3419 description: None,
3420 dry_run: false,
3421 },
3422 actor,
3423 Some(&client),
3424 None,
3425 )
3426 .unwrap();
3427 let stub = engine
3428 .store()
3429 .get(&stub_target)
3430 .expect("stub must be in store");
3431 assert!(stub.stub);
3432 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
3433
3434 let outcome = engine
3437 .create_entity(
3438 empty_create_args("specs", "Ghost Target"),
3439 actor,
3440 Some(&client),
3441 None,
3442 )
3443 .unwrap();
3444
3445 assert_eq!(outcome.id, stub_target);
3447 let real = engine
3449 .store()
3450 .get(&stub_target)
3451 .expect("entity must still be in store");
3452 assert!(!real.stub);
3453 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
3455 assert_eq!(outcome.incoming_count, Some(1));
3457 assert_eq!(outcome.incoming.len(), 1);
3458 assert_eq!(outcome.incoming[0].from, source.id);
3459 assert_eq!(outcome.incoming[0].rel_type, "USES");
3460 }
3461
3462 #[test]
3463 fn create_entity_reports_no_incoming_on_greenfield_create() {
3464 let tmp = TempDir::new().unwrap();
3465 let mem_dir = tmp.path().to_path_buf();
3466 let writer = FilesystemMemWriter::new(mem_dir.clone());
3467 let mut engine = Engine::from_mounts(vec![(
3468 folder_mount("specs", mem_dir),
3469 Box::new(writer) as Box<dyn MemBackend>,
3470 )])
3471 .unwrap();
3472 let (actor, client) = cli_actor();
3473
3474 let outcome = engine
3475 .create_entity(
3476 empty_create_args("specs", "Greenfield"),
3477 actor,
3478 Some(&client),
3479 None,
3480 )
3481 .unwrap();
3482 assert!(outcome.incoming_count.is_none());
3485 assert!(outcome.incoming.is_empty());
3486 }
3487
3488 #[test]
3489 fn create_entity_populates_created_date_from_schema_auto_stamp() {
3490 let tmp = TempDir::new().unwrap();
3491 let mem_dir = tmp.path().to_path_buf();
3492 let writer = FilesystemMemWriter::new(mem_dir.clone());
3493 let mut engine = Engine::from_mounts(vec![(
3494 folder_mount("specs", mem_dir),
3495 Box::new(writer) as Box<dyn MemBackend>,
3496 )])
3497 .unwrap();
3498 let (actor, client) = cli_actor();
3499
3500 let outcome = engine
3501 .create_entity(
3502 empty_create_args("specs", "Has Date"),
3503 actor,
3504 Some(&client),
3505 None,
3506 )
3507 .unwrap();
3508 assert!(
3513 !outcome.created_date.is_empty(),
3514 "created_date must be populated when the schema auto-stamps it"
3515 );
3516 }
3517
3518 #[test]
3519 fn create_overrides_user_supplied_timestamps_update_rejects_them() {
3520 let tmp = TempDir::new().unwrap();
3531 let mem_dir = tmp.path().to_path_buf();
3532 let writer = FilesystemMemWriter::new(mem_dir.clone());
3533 let mut engine = Engine::from_mounts(vec![(
3534 folder_mount("specs", mem_dir),
3535 Box::new(writer) as Box<dyn MemBackend>,
3536 )])
3537 .unwrap();
3538 let (actor, client) = cli_actor();
3539
3540 let mut args = empty_create_args("specs", "Stamped Today");
3544 args.metadata
3545 .insert("created_date".to_string(), "2020-01-01".to_string());
3546 args.metadata
3547 .insert("last_modified".to_string(), "2020-01-01".to_string());
3548
3549 let outcome = engine
3550 .create_entity(args, actor, Some(&client), None)
3551 .unwrap();
3552
3553 let today = crate::engine::mutation::today_iso();
3556 assert_eq!(outcome.created_date, today);
3557 let entity = engine
3558 .get_entity(&outcome.id)
3559 .expect("entity must be in store after create");
3560 assert_eq!(
3561 entity
3562 .metadata
3563 .get("created_date")
3564 .and_then(|v| v.as_str())
3565 .unwrap_or_default(),
3566 today,
3567 "init_timestamp field must be engine-determined on create, not user-supplied"
3568 );
3569 assert_eq!(
3570 entity
3571 .metadata
3572 .get("last_modified")
3573 .and_then(|v| v.as_str())
3574 .unwrap_or_default(),
3575 today,
3576 "auto_timestamp field must be engine-determined on create, not user-supplied"
3577 );
3578
3579 let attempt_update = |key: &str, value: &str| {
3583 let mut metadata = IndexMap::new();
3584 metadata.insert(key.to_string(), value.to_string());
3585 crate::engine::UpdateEntityArgs {
3586 anchors: Vec::new(),
3587 id: outcome.id.clone(),
3588 metadata,
3589 metadata_unset: Vec::new(),
3590 sections: IndexMap::new(),
3591 append_sections: IndexMap::new(),
3592 patch_sections: IndexMap::new(),
3593 expected_hash: Some(outcome.content_hash.clone()),
3594 dry_run: false,
3595 declare_relations: Vec::new(),
3596 relations_unset: Vec::new(),
3597 anchors_unset: Vec::new(),
3598 }
3599 };
3600 for key in ["created_date", "last_modified"] {
3601 let err = engine
3602 .update_entity(
3603 attempt_update(key, "2019-12-31"),
3604 actor,
3605 Some(&client),
3606 None,
3607 )
3608 .expect_err("schema-managed timestamp must be rejected on update");
3609 assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
3610 }
3611 let entity = engine
3613 .get_entity(&outcome.id)
3614 .expect("entity must remain in store after rejected update");
3615 assert_eq!(
3616 entity
3617 .metadata
3618 .get("last_modified")
3619 .and_then(|v| v.as_str())
3620 .unwrap_or_default(),
3621 today,
3622 "rejected update must not mutate the auto_timestamp field"
3623 );
3624 }
3625
3626 #[test]
3627 fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
3628 let tmp = TempDir::new().unwrap();
3629 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3630 let (actor, client) = cli_actor();
3631 let absent = crate::EntityId::new("specs", "future-target");
3632 assert!(!engine.store().contains(&absent));
3633
3634 let mut args = empty_create_args("specs", "Source With Relations");
3635 args.relations = vec![
3636 crate::ops::RelateArg {
3637 to: existing.id.clone(),
3638 rel_type: "USES".to_string(),
3639 description: None,
3640 },
3641 crate::ops::RelateArg {
3642 to: absent.clone(),
3643 rel_type: "USES".to_string(),
3644 description: None,
3645 },
3646 ];
3647
3648 let outcome = engine
3649 .create_entity(args, actor, Some(&client), None)
3650 .unwrap();
3651
3652 let source = engine
3654 .store()
3655 .get(&outcome.id)
3656 .expect("source must be in store");
3657 assert_eq!(source.relationships.len(), 2);
3658 assert!(
3659 source
3660 .relationships
3661 .iter()
3662 .any(|r| r.target == existing.id && r.rel_type == "USES")
3663 );
3664 assert!(
3665 source
3666 .relationships
3667 .iter()
3668 .any(|r| r.target == absent && r.rel_type == "USES")
3669 );
3670
3671 let stub = engine
3674 .store()
3675 .get(&absent)
3676 .expect("absent relation target must be auto-stubbed");
3677 assert!(stub.stub);
3678 let existing_after = engine.store().get(&existing.id).unwrap();
3680 assert!(!existing_after.stub);
3681 }
3682
3683 fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
3688 use crate::workspace::Mount;
3689 use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
3690 let mem_dir = tmp.path().to_path_buf();
3691 let writer = FilesystemMemWriter::new(mem_dir.clone());
3692 let mount = Mount {
3693 mem: "planning".to_string(),
3694 schema: Some(memstead_schema::SchemaRef::new(
3695 "planning",
3696 semver::Version::new(0, 1, 0),
3697 )),
3698 storage: MountStorage::Folder { path: mem_dir },
3699 capability: MountCapability::Write,
3700 lifecycle: MountLifecycle::Eager,
3701 cross_linkable: true,
3702 migration_target: None,
3703 };
3704 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
3705 }
3706
3707 #[test]
3716 fn create_entity_refuses_unsupplied_no_default_required_field() {
3717 let tmp = TempDir::new().unwrap();
3721 let mut engine = engine_with_planning_schema(&tmp);
3722 let (actor, client) = cli_actor();
3723
3724 let mut args = CreateEntityArgs {
3725 anchors: Vec::new(),
3726 mem: "planning".to_string(),
3727 title: "Skip Postgres".to_string(),
3728 entity_type: "decision".to_string(),
3729 sections: IndexMap::from_iter([
3730 ("decision".to_string(), "Use SQLite locally.".to_string()),
3731 ("context".to_string(), "Single-user dev.".to_string()),
3732 ("consequences".to_string(), "Lose multi-writer.".to_string()),
3733 ]),
3734 metadata: IndexMap::new(),
3735 relations: Vec::new(),
3736 dry_run: false,
3737 };
3738
3739 let err = engine
3742 .create_entity(args.clone(), actor, Some(&client), None)
3743 .unwrap_err();
3744 match err {
3745 EngineError::RequiredFieldUnset {
3746 field, entity_type, ..
3747 } => {
3748 assert!(
3749 field == "decided_on" || field == "deciders",
3750 "expected first missing field, got {field:?}"
3751 );
3752 assert_eq!(entity_type, "decision");
3753 }
3754 other => panic!("expected RequiredFieldUnset, got {other:?}"),
3755 }
3756
3757 args.title = "Different Title".to_string();
3761 args.dry_run = true;
3762 let dry_err = engine
3763 .create_entity(args, actor, Some(&client), None)
3764 .unwrap_err();
3765 assert!(
3766 matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
3767 "dry_run must surface the same refusal envelope, got {dry_err:?}"
3768 );
3769 }
3770
3771 #[test]
3775 fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
3776 let tmp = TempDir::new().unwrap();
3777 let mut engine = engine_with_planning_schema(&tmp);
3778 let (actor, client) = cli_actor();
3779
3780 let mut metadata = IndexMap::new();
3781 metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
3782 metadata.insert("deciders".to_string(), "alice, bob".to_string());
3783
3784 let outcome = engine
3785 .create_entity(
3786 CreateEntityArgs {
3787 anchors: Vec::new(),
3788 mem: "planning".to_string(),
3789 title: "Complete Decision".to_string(),
3790 entity_type: "decision".to_string(),
3791 sections: IndexMap::from_iter([
3792 ("decision".to_string(), "x".to_string()),
3793 ("context".to_string(), "y".to_string()),
3794 ("consequences".to_string(), "z".to_string()),
3795 ]),
3796 metadata,
3797 relations: Vec::new(),
3798 dry_run: false,
3799 },
3800 actor,
3801 Some(&client),
3802 None,
3803 )
3804 .expect("complete decision create succeeds");
3805 let missing_field_warnings: Vec<&WarningHint> = outcome
3808 .warnings
3809 .iter()
3810 .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
3811 .collect();
3812 assert!(
3813 missing_field_warnings.is_empty(),
3814 "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
3815 );
3816 }
3817
3818 #[test]
3823 fn create_entity_rejects_inline_relation_with_malformed_target_id() {
3824 let tmp = TempDir::new().unwrap();
3825 let mem_dir = tmp.path().to_path_buf();
3826 let writer = FilesystemMemWriter::new(mem_dir.clone());
3827 let mut engine = Engine::from_mounts(vec![(
3828 folder_mount("specs", mem_dir),
3829 Box::new(writer) as Box<dyn MemBackend>,
3830 )])
3831 .unwrap();
3832 let (actor, client) = cli_actor();
3833
3834 let mut args = empty_create_args("specs", "Source");
3835 args.relations = vec![crate::ops::RelateArg {
3836 to: crate::EntityId("specs--bad target with spaces!!".to_string()),
3837 rel_type: "USES".to_string(),
3838 description: None,
3839 }];
3840 let err = engine
3841 .create_entity(args, actor, Some(&client), None)
3842 .unwrap_err();
3843 assert!(
3844 matches!(err, EngineError::InvalidEntityId { .. }),
3845 "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
3846 );
3847 }
3848
3849 #[test]
3858 fn create_entity_rejects_inline_relation_with_shape_violation() {
3859 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3860 let tmp = TempDir::new().unwrap();
3861 let mem_dir = tmp.path().to_path_buf();
3862 let writer = FilesystemMemWriter::new(mem_dir.clone());
3863 let mount = Mount {
3864 mem: "code".to_string(),
3865 schema: Some(memstead_schema::SchemaRef::new(
3866 "software",
3867 semver::Version::new(0, 1, 0),
3868 )),
3869 storage: MountStorage::Folder { path: mem_dir },
3870 capability: MountCapability::Write,
3871 lifecycle: MountLifecycle::Eager,
3872 cross_linkable: true,
3873 migration_target: None,
3874 };
3875 let mut engine =
3876 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3877 let (actor, client) = cli_actor();
3878
3879 let target = engine
3887 .create_entity(
3888 CreateEntityArgs {
3889 anchors: Vec::new(),
3890 mem: "code".to_string(),
3891 title: "Target Requirement".to_string(),
3892 entity_type: "requirement".to_string(),
3893 sections: IndexMap::from_iter([
3894 ("statement".to_string(), "MUST hold.".to_string()),
3895 ("rationale".to_string(), "Because tests.".to_string()),
3896 ]),
3897 metadata: IndexMap::from_iter([
3898 ("verified_on".to_string(), "2026-05-19".to_string()),
3899 ("source".to_string(), "test fixture".to_string()),
3900 ]),
3901 relations: Vec::new(),
3902 dry_run: false,
3903 },
3904 actor,
3905 Some(&client),
3906 None,
3907 )
3908 .unwrap();
3909
3910 let args = CreateEntityArgs {
3916 anchors: Vec::new(),
3917 mem: "code".to_string(),
3918 title: "Misshape Source".to_string(),
3919 entity_type: "spec".to_string(),
3920 sections: IndexMap::from_iter([
3921 ("identity".to_string(), "this spec".to_string()),
3922 (
3923 "purpose".to_string(),
3924 "exercising the shape gate".to_string(),
3925 ),
3926 ]),
3927 metadata: IndexMap::new(),
3928 relations: vec![crate::ops::RelateArg {
3929 to: target.id.clone(),
3930 rel_type: "VIOLATES".to_string(),
3931 description: None,
3932 }],
3933 dry_run: false,
3934 };
3935 let err = engine
3936 .create_entity(args, actor, Some(&client), None)
3937 .unwrap_err();
3938 assert!(
3939 matches!(err, EngineError::Validation(_)),
3940 "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
3941 );
3942 }
3943
3944 #[test]
3945 fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
3946 let tmp = TempDir::new().unwrap();
3950 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3951 let (actor, client) = cli_actor();
3952
3953 let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
3954 args.relations = vec![crate::ops::RelateArg {
3955 to: existing.id.clone(),
3956 rel_type: "uses".to_string(),
3957 description: None,
3958 }];
3959
3960 let outcome = engine
3961 .create_entity(args, actor, Some(&client), None)
3962 .unwrap();
3963
3964 let source = engine
3965 .store()
3966 .get(&outcome.id)
3967 .expect("source must be in store");
3968 assert_eq!(source.relationships.len(), 1);
3969 assert_eq!(
3970 source.relationships[0].rel_type, "USES",
3971 "inline relation rel_type must be stored UPPER_SNAKE_CASE",
3972 );
3973 }
3974
3975 #[test]
3976 fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
3977 let tmp = TempDir::new().unwrap();
3978 let mem_dir = tmp.path().to_path_buf();
3979 let writer = FilesystemMemWriter::new(mem_dir.clone());
3980 let mut engine = Engine::from_mounts(vec![(
3981 folder_mount("specs", mem_dir.clone()),
3982 Box::new(writer) as Box<dyn MemBackend>,
3983 )])
3984 .unwrap();
3985 let (actor, client) = cli_actor();
3986
3987 let mut args = empty_create_args("specs", "Preview Only");
3988 args.dry_run = true;
3989
3990 let outcome = engine
3991 .create_entity(args, actor, Some(&client), None)
3992 .unwrap();
3993
3994 assert_eq!(outcome.id.to_string(), "specs--preview-only");
3996 assert!(
3997 !outcome.content_hash.is_empty(),
3998 "prospective hash populated"
3999 );
4000 assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
4001 assert!(
4003 engine.store().get(&outcome.id).is_none(),
4004 "dry_run must not mutate the store",
4005 );
4006 assert!(
4008 !mem_dir.join("preview-only.md").exists(),
4009 "dry_run must not touch disk",
4010 );
4011 let log = mem_dir.join(".memstead").join("changes.jsonl");
4013 assert!(
4014 !log.exists()
4015 || !std::fs::read_to_string(&log)
4016 .unwrap()
4017 .contains("preview-only"),
4018 "dry_run must not append provenance",
4019 );
4020 }
4021
4022 #[test]
4023 fn create_entity_rejects_read_only_mount_before_backend() {
4024 let tmp = TempDir::new().unwrap();
4025 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
4026 let mut engine = Engine::from_mounts(vec![(
4027 archive_mount("external", archive_path.clone()),
4028 Box::new(ArchiveBackend::new(archive_path)),
4029 )])
4030 .unwrap();
4031 let (actor, client) = cli_actor();
4032
4033 let err = engine
4034 .create_entity(
4035 empty_create_args("external", "Should Fail"),
4036 actor,
4037 Some(&client),
4038 None,
4039 )
4040 .unwrap_err();
4041 match err {
4042 EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
4043 other => panic!("expected ReadOnlyMount, got {other:?}"),
4044 }
4045 }
4049
4050 #[test]
4051 fn create_entity_rejects_unknown_mem() {
4052 let tmp = TempDir::new().unwrap();
4053 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
4054 let mut engine = Engine::from_mounts(vec![(
4055 folder_mount("specs", tmp.path().to_path_buf()),
4056 Box::new(writer) as Box<dyn MemBackend>,
4057 )])
4058 .unwrap();
4059 let (actor, client) = cli_actor();
4060
4061 let err = engine
4062 .create_entity(
4063 empty_create_args("does-not-exist", "Anything"),
4064 actor,
4065 Some(&client),
4066 None,
4067 )
4068 .unwrap_err();
4069 assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
4070 }
4071
4072 #[test]
4073 fn create_entity_rejects_unknown_type_against_pinned_schema() {
4074 let tmp = TempDir::new().unwrap();
4075 let mem_dir = tmp.path().to_path_buf();
4076 let writer = FilesystemMemWriter::new(mem_dir.clone());
4077 let mut engine = Engine::from_mounts(vec![(
4078 folder_mount("specs", mem_dir),
4079 Box::new(writer) as Box<dyn MemBackend>,
4080 )])
4081 .unwrap();
4082 let (actor, client) = cli_actor();
4083
4084 let mut args = empty_create_args("specs", "Anything");
4085 args.entity_type = "definitely-not-a-real-type".to_string();
4086 let err = engine
4087 .create_entity(args, actor, Some(&client), None)
4088 .unwrap_err();
4089 match err {
4090 EngineError::UnknownType { name, declared, .. } => {
4091 assert_eq!(name, "definitely-not-a-real-type");
4092 assert!(!declared.is_empty(), "declared types must be listed");
4093 }
4094 other => panic!("expected UnknownType, got {other:?}"),
4095 }
4096 }
4097
4098 #[test]
4099 fn create_entity_rejects_duplicate_id() {
4100 let tmp = TempDir::new().unwrap();
4101 let mem_dir = tmp.path().to_path_buf();
4102 let writer = FilesystemMemWriter::new(mem_dir.clone());
4103 let mut engine = Engine::from_mounts(vec![(
4104 folder_mount("specs", mem_dir),
4105 Box::new(writer) as Box<dyn MemBackend>,
4106 )])
4107 .unwrap();
4108 let (actor, client) = cli_actor();
4109
4110 engine
4111 .create_entity(
4112 empty_create_args("specs", "Same Slug"),
4113 actor,
4114 Some(&client),
4115 None,
4116 )
4117 .unwrap();
4118 let err = engine
4119 .create_entity(
4120 empty_create_args("specs", "Same Slug"),
4121 actor,
4122 Some(&client),
4123 None,
4124 )
4125 .unwrap_err();
4126 match err {
4127 EngineError::AlreadyExists {
4128 id,
4129 existing_title,
4130 existing_is_stub,
4131 } => {
4132 assert_eq!(id, "specs--same-slug");
4133 assert!(!existing_title.is_empty());
4136 assert!(!existing_is_stub);
4137 }
4138 other => panic!("expected AlreadyExists, got {other:?}"),
4139 }
4140 }
4141
4142 #[test]
4143 fn create_entity_rejects_invalid_title() {
4144 let tmp = TempDir::new().unwrap();
4145 let mem_dir = tmp.path().to_path_buf();
4146 let writer = FilesystemMemWriter::new(mem_dir.clone());
4147 let mut engine = Engine::from_mounts(vec![(
4148 folder_mount("specs", mem_dir),
4149 Box::new(writer) as Box<dyn MemBackend>,
4150 )])
4151 .unwrap();
4152 let (actor, client) = cli_actor();
4153
4154 let err = engine
4160 .create_entity(empty_create_args("specs", " "), actor, Some(&client), None)
4161 .unwrap_err();
4162 match err {
4163 EngineError::InvalidTitle(slug_err) => {
4164 assert_eq!(slug_err.reason(), "empty", "expected empty reason");
4165 }
4166 other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
4167 }
4168
4169 let outcome = engine
4173 .create_entity(
4174 empty_create_args("specs", "Hello, World!"),
4175 actor,
4176 Some(&client),
4177 None,
4178 )
4179 .expect("char-drop title lands under the widened grammar");
4180 assert_eq!(outcome.id.as_ref(), "specs--hello-world");
4181 let dropped = outcome
4182 .warnings
4183 .iter()
4184 .find_map(|w| match w {
4185 WarningHint::TitleCharsDroppedFromSlug {
4186 dropped_chars,
4187 slug,
4188 ..
4189 } => Some((dropped_chars.clone(), slug.clone())),
4190 _ => None,
4191 })
4192 .expect("divergence warning rides the outcome");
4193 assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
4194 assert_eq!(dropped.1, "hello-world");
4195
4196 let outcome = engine
4200 .create_entity(
4201 empty_create_args("specs", "../etc/passwd"),
4202 actor,
4203 Some(&client),
4204 None,
4205 )
4206 .expect("traversal-shaped title lands with a sanitised slug");
4207 assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
4208 assert!(
4209 outcome
4210 .warnings
4211 .iter()
4212 .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
4213 );
4214 }
4215
4216 #[test]
4217 fn create_entity_rejects_unknown_section_key() {
4218 let tmp = TempDir::new().unwrap();
4219 let mem_dir = tmp.path().to_path_buf();
4220 let writer = FilesystemMemWriter::new(mem_dir.clone());
4221 let mut engine = Engine::from_mounts(vec![(
4222 folder_mount("specs", mem_dir),
4223 Box::new(writer) as Box<dyn MemBackend>,
4224 )])
4225 .unwrap();
4226 let (actor, client) = cli_actor();
4227
4228 let mut args = empty_create_args("specs", "Bad Sections");
4229 args.sections
4230 .insert("not-a-real-section-key".to_string(), "body".to_string());
4231 let err = engine
4232 .create_entity(args, actor, Some(&client), None)
4233 .unwrap_err();
4234 assert!(matches!(err, EngineError::Validation(_)));
4235 }
4236
4237 #[test]
4238 fn create_entity_persists_across_engine_restart() {
4239 let tmp = TempDir::new().unwrap();
4240 let mem_dir = tmp.path().to_path_buf();
4241 {
4242 let writer = FilesystemMemWriter::new(mem_dir.clone());
4243 let mut engine = Engine::from_mounts(vec![(
4244 folder_mount("specs", mem_dir.clone()),
4245 Box::new(writer) as Box<dyn MemBackend>,
4246 )])
4247 .unwrap();
4248 let (actor, client) = cli_actor();
4249 engine
4250 .create_entity(
4251 empty_create_args("specs", "Survives Restart"),
4252 actor,
4253 Some(&client),
4254 None,
4255 )
4256 .unwrap();
4257 }
4258 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
4260 let engine2 = Engine::from_mounts(vec![(
4261 folder_mount("specs", mem_dir),
4262 Box::new(writer2) as Box<dyn MemBackend>,
4263 )])
4264 .unwrap();
4265 let entity = engine2
4266 .get_entity(&crate::EntityId::new("specs", "survives-restart"))
4267 .expect("entity must persist across engine restart");
4268 assert_eq!(entity.title, "Survives Restart");
4269 }
4270
4271 fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
4278 let mem_dir = tmp.path().to_path_buf();
4279 let writer = FilesystemMemWriter::new(mem_dir.clone());
4280 let mut engine = Engine::from_mounts(vec![(
4281 folder_mount("specs", mem_dir),
4282 Box::new(writer) as Box<dyn MemBackend>,
4283 )])
4284 .unwrap();
4285 let (actor, client) = cli_actor();
4286 let outcome = engine
4287 .create_entity(
4288 empty_create_args("specs", title),
4289 actor,
4290 Some(&client),
4291 None,
4292 )
4293 .unwrap();
4294 (engine, outcome)
4295 }
4296
4297 #[test]
4305 fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
4306 let tmp = TempDir::new().unwrap();
4307 let mem_dir = tmp.path().to_path_buf();
4308 let writer = FilesystemMemWriter::new(mem_dir.clone());
4309 let mut engine = Engine::from_mounts(vec![(
4310 folder_mount("specs", mem_dir),
4311 Box::new(writer) as Box<dyn MemBackend>,
4312 )])
4313 .unwrap();
4314 let (actor, client) = cli_actor();
4315
4316 let ghost = crate::EntityId::new("specs", "ghost-target");
4317 assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
4318
4319 let mut args = empty_create_args("specs", "Source With Body Link");
4320 args.sections.insert(
4321 "identity".to_string(),
4322 "ref [[ghost-target]] for context".to_string(),
4323 );
4324
4325 let outcome = engine
4326 .create_entity(args, actor, Some(&client), None)
4327 .unwrap();
4328 let stubbed: Vec<&crate::EntityId> = outcome
4329 .warnings
4330 .iter()
4331 .filter_map(|w| match w {
4332 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
4333 _ => None,
4334 })
4335 .flatten()
4336 .collect();
4337 assert!(
4338 stubbed.contains(&&ghost),
4339 "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
4340 outcome.warnings,
4341 );
4342 assert!(
4344 engine.store().contains(&ghost),
4345 "ghost stub must materialise"
4346 );
4347 }
4348
4349 #[test]
4354 fn create_entity_drops_self_link_keeps_other_links_and_warns() {
4355 let tmp = TempDir::new().unwrap();
4356 let mem_dir = tmp.path().to_path_buf();
4357 let writer = FilesystemMemWriter::new(mem_dir.clone());
4358 let mut engine = Engine::from_mounts(vec![(
4359 folder_mount("specs", mem_dir),
4360 Box::new(writer) as Box<dyn MemBackend>,
4361 )])
4362 .unwrap();
4363 let (actor, client) = cli_actor();
4364
4365 let mut args = empty_create_args("specs", "Selfie");
4368 args.sections.insert(
4369 "identity".to_string(),
4370 "see [[selfie]] itself and also [[other-ref]]".to_string(),
4371 );
4372 let outcome = engine
4373 .create_entity(args, actor, Some(&client), None)
4374 .unwrap();
4375 let self_id = outcome.id.clone();
4376 assert_eq!(self_id.to_string(), "specs--selfie");
4377 let other_id = crate::EntityId::new("specs", "other-ref");
4378
4379 assert!(
4381 outcome.warnings.iter().any(|w| matches!(
4382 w, WarningHint::SelfLinkIgnored { id } if *id == self_id
4383 )),
4384 "self-link must emit SELF_LINK_IGNORED; got: {:?}",
4385 outcome.warnings,
4386 );
4387
4388 let ent = engine.get_entity(&self_id).unwrap();
4390 assert!(
4391 ent.relationships.iter().all(|r| r.target != self_id),
4392 "no self-relation may be synthesised; got: {:?}",
4393 ent.relationships,
4394 );
4395 assert!(
4396 engine
4397 .store()
4398 .outgoing(&self_id)
4399 .iter()
4400 .all(|e| e.target != self_id),
4401 "self must not be its own Outgoing neighbour",
4402 );
4403 assert!(
4404 engine
4405 .store()
4406 .incoming(&self_id)
4407 .iter()
4408 .all(|e| e.from != self_id),
4409 "self must not be its own Incoming neighbour",
4410 );
4411
4412 assert!(
4415 ent.relationships
4416 .iter()
4417 .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
4418 "non-self body link must still synthesise its edge; got: {:?}",
4419 ent.relationships,
4420 );
4421 }
4422
4423 #[test]
4425 fn create_entity_dry_run_emits_same_auto_stub_warning() {
4426 let tmp = TempDir::new().unwrap();
4427 let mem_dir = tmp.path().to_path_buf();
4428 let writer = FilesystemMemWriter::new(mem_dir.clone());
4429 let mut engine = Engine::from_mounts(vec![(
4430 folder_mount("specs", mem_dir),
4431 Box::new(writer) as Box<dyn MemBackend>,
4432 )])
4433 .unwrap();
4434 let (actor, client) = cli_actor();
4435
4436 let mut args = empty_create_args("specs", "Dry Run Body Link");
4437 args.dry_run = true;
4438 args.sections
4439 .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
4440
4441 let outcome = engine
4442 .create_entity(args, actor, Some(&client), None)
4443 .unwrap();
4444 let has_warning = outcome.warnings.iter().any(|w| {
4445 matches!(
4446 w,
4447 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
4448 if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
4449 )
4450 });
4451 assert!(
4452 has_warning,
4453 "dry_run must emit the same warning as real write: {:?}",
4454 outcome.warnings
4455 );
4456 }
4457
4458 #[test]
4461 fn create_entity_no_auto_stub_warning_when_target_exists() {
4462 let tmp = TempDir::new().unwrap();
4463 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
4464 let (actor, client) = cli_actor();
4465
4466 let mut args = empty_create_args("specs", "Source Linking Existing");
4467 let body = format!("ref [[{}]]", existing.id.path());
4468 args.sections.insert("identity".to_string(), body);
4469
4470 let outcome = engine
4471 .create_entity(args, actor, Some(&client), None)
4472 .unwrap();
4473 let has_warning = outcome
4474 .warnings
4475 .iter()
4476 .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
4477 assert!(
4478 !has_warning,
4479 "no auto-stub warning when target pre-exists; got: {:?}",
4480 outcome.warnings
4481 );
4482 }
4483
4484 #[test]
4489 fn obligation_wildcard_links_into_arbitrary_destination_schema() {
4490 use crate::engine::test_helpers::write_schema_files_with_default_type;
4491 use memstead_schema::workspace_config::CrossLinkValue;
4492
4493 let tmp = TempDir::new().unwrap();
4494 let dest_dir = tmp.path().join("dest");
4495 let duties_dir = tmp.path().join("duties");
4496 std::fs::create_dir_all(&dest_dir).unwrap();
4497 std::fs::create_dir_all(&duties_dir).unwrap();
4498 let schemas_dir = tmp.path().join("schemas");
4499 let user_manifest = r#"name: casefiles
4500version: 0.1.0
4501description: a user-written, non-software destination schema
4502when_to_use: tests
4503types:
4504 - doc
4505relationships:
4506 mode: strict
4507 definitions:
4508 - name: _default
4509 description: fallback
4510 default_weight: 1.0
4511community:
4512 resolution: 1.0
4513 seed: 42
4514"#;
4515 write_schema_files_with_default_type(
4516 &schemas_dir,
4517 "casefiles@0.1.0",
4518 user_manifest,
4519 &["doc"],
4520 );
4521
4522 let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
4523 mem: mem.to_string(),
4524 schema: Some(memstead_schema::SchemaRef::new(
4525 schema,
4526 semver::Version::new(0, 1, 0),
4527 )),
4528 storage: crate::workspace::MountStorage::Folder {
4529 path: dir.to_path_buf(),
4530 },
4531 capability: crate::workspace::MountCapability::Write,
4532 lifecycle: crate::workspace::MountLifecycle::Eager,
4533 cross_linkable: true,
4534 migration_target: None,
4535 };
4536 let mounts = vec![
4537 (
4538 mount("dest", &dest_dir, "casefiles"),
4539 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4540 ),
4541 (
4542 mount("duties", &duties_dir, "obligation"),
4543 Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
4544 ),
4545 ];
4546 let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4547 .expect("obligation + user schema boot");
4548 let mut settings = crate::workspace::WorkspaceSettings::default();
4549 settings.cross_mem_links.insert(
4550 "duties".to_string(),
4551 CrossLinkValue::List(vec!["dest".to_string()]),
4552 );
4553 engine.set_settings(settings);
4554 let (actor, client) = cli_actor();
4555
4556 let target = engine
4557 .create_entity(
4558 CreateEntityArgs {
4559 anchors: Vec::new(),
4560 mem: "dest".to_string(),
4561 title: "Case File 17".to_string(),
4562 entity_type: "doc".to_string(),
4563 sections: IndexMap::from_iter([(
4564 "body".to_string(),
4565 "destination content".to_string(),
4566 )]),
4567 metadata: IndexMap::new(),
4568 relations: Vec::new(),
4569 dry_run: false,
4570 },
4571 actor,
4572 Some(&client),
4573 None,
4574 )
4575 .unwrap();
4576
4577 let entry = engine
4578 .create_entity(
4579 CreateEntityArgs {
4580 anchors: Vec::new(),
4581 mem: "duties".to_string(),
4582 title: "File Annual Report & Notice".to_string(),
4583 entity_type: "obligation".to_string(),
4584 sections: IndexMap::from_iter([
4585 (
4586 "duty".to_string(),
4587 "File the report cited in [[dest--case-file-17]].".to_string(),
4588 ),
4589 (
4590 "consequence".to_string(),
4591 "Standing lapses at the deadline.".to_string(),
4592 ),
4593 ]),
4594 metadata: IndexMap::from_iter([
4595 ("due_date".to_string(), "2026-12-31".to_string()),
4596 ("status".to_string(), "open".to_string()),
4597 ]),
4598 relations: vec![crate::ops::RelateArg {
4599 to: crate::entity::EntityId::new("duties", "subject"),
4600 rel_type: "CONCERNS".to_string(),
4601 description: None,
4602 }],
4603 dry_run: false,
4604 },
4605 actor,
4606 Some(&client),
4607 None,
4608 )
4609 .expect("wildcard admits the alias link into the non-software destination");
4610 let stored = engine.get_entity(&entry.id).unwrap();
4611 assert!(
4612 stored
4613 .relationships
4614 .iter()
4615 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4616 "alias REFERENCES edge must emit cross-mem: {:?}",
4617 stored.relationships
4618 );
4619 }
4620
4621 #[test]
4632 fn ingest_wildcard_links_into_arbitrary_destination_schema() {
4633 use crate::engine::test_helpers::write_schema_files_with_default_type;
4634 use memstead_schema::workspace_config::CrossLinkValue;
4635
4636 let tmp = TempDir::new().unwrap();
4637 let dest_dir = tmp.path().join("dest");
4638 let proc_dir = tmp.path().join("proc");
4639 std::fs::create_dir_all(&dest_dir).unwrap();
4640 std::fs::create_dir_all(&proc_dir).unwrap();
4641
4642 let schemas_dir = tmp.path().join("schemas");
4644 let user_manifest = r#"name: debate
4645version: 0.1.0
4646description: a user-written destination schema
4647when_to_use: tests
4648types:
4649 - doc
4650relationships:
4651 mode: strict
4652 definitions:
4653 - name: _default
4654 description: fallback
4655 default_weight: 1.0
4656community:
4657 resolution: 1.0
4658 seed: 42
4659"#;
4660 write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
4661
4662 let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
4663 crate::workspace::Mount {
4664 mem: mem.to_string(),
4665 schema: Some(memstead_schema::SchemaRef::new(
4666 schema,
4667 semver::Version::new(version.0, version.1, version.2),
4668 )),
4669 storage: crate::workspace::MountStorage::Folder {
4670 path: dir.to_path_buf(),
4671 },
4672 capability: crate::workspace::MountCapability::Write,
4673 lifecycle: crate::workspace::MountLifecycle::Eager,
4674 cross_linkable: true,
4675 migration_target: None,
4676 }
4677 };
4678 let boot = |grant: bool| -> Engine {
4679 let mounts = vec![
4680 (
4681 mount("dest", &dest_dir, "debate", (0, 1, 0)),
4682 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4683 ),
4684 (
4685 mount("proc", &proc_dir, "ingest", (0, 2, 0)),
4686 Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
4687 ),
4688 ];
4689 let mut engine =
4690 Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4691 .expect("ingest + user schema boot");
4692 let mut settings = crate::workspace::WorkspaceSettings::default();
4693 if grant {
4694 settings.cross_mem_links.insert(
4695 "proc".to_string(),
4696 CrossLinkValue::List(vec!["dest".to_string()]),
4697 );
4698 }
4699 engine.set_settings(settings);
4700 engine
4701 };
4702 let (actor, client) = cli_actor();
4703
4704 let mut engine = boot(true);
4705 let target = engine
4707 .create_entity(
4708 CreateEntityArgs {
4709 anchors: Vec::new(),
4710 mem: "dest".to_string(),
4711 title: "Target Doc".to_string(),
4712 entity_type: "doc".to_string(),
4713 sections: IndexMap::from_iter([(
4714 "body".to_string(),
4715 "destination content".to_string(),
4716 )]),
4717 metadata: IndexMap::new(),
4718 relations: Vec::new(),
4719 dry_run: false,
4720 },
4721 actor,
4722 Some(&client),
4723 None,
4724 )
4725 .unwrap();
4726
4727 let entry = engine
4729 .create_entity(
4730 CreateEntityArgs {
4731 anchors: Vec::new(),
4732 mem: "proc".to_string(),
4733 title: "Check The Claim".to_string(),
4734 entity_type: "verification_target".to_string(),
4735 sections: IndexMap::from_iter([
4736 (
4737 "claim".to_string(),
4738 "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
4739 ),
4740 ("source_to_check".to_string(), "dest mem".to_string()),
4741 (
4742 "verifiable_when".to_string(),
4743 "the linked entity still says so".to_string(),
4744 ),
4745 ]),
4746 metadata: IndexMap::new(),
4747 relations: Vec::new(),
4748 dry_run: false,
4749 },
4750 actor,
4751 Some(&client),
4752 None,
4753 )
4754 .expect("wildcard admits the alias link into the user-schema destination");
4755 let stored = engine.get_entity(&entry.id).unwrap();
4756 assert!(
4757 stored
4758 .relationships
4759 .iter()
4760 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4761 "alias REFERENCES edge must emit: {:?}",
4762 stored.relationships
4763 );
4764
4765 let err = engine
4767 .relate_entity(
4768 RelateEntityArgs {
4769 source: entry.id.clone(),
4770 expected_hash: None,
4771 rel_type: "REFERENCES".to_string(),
4772 target: target.id.clone(),
4773 remove: false,
4774 description: None,
4775 dry_run: false,
4776 },
4777 actor,
4778 Some(&client),
4779 None,
4780 )
4781 .unwrap_err();
4782 assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
4783
4784 let err = engine
4787 .relate_entity(
4788 RelateEntityArgs {
4789 source: entry.id.clone(),
4790 expected_hash: None,
4791 rel_type: "PART_OF".to_string(),
4792 target: target.id.clone(),
4793 remove: false,
4794 description: None,
4795 dry_run: false,
4796 },
4797 actor,
4798 Some(&client),
4799 None,
4800 )
4801 .unwrap_err();
4802 assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
4803
4804 drop(engine);
4808 let rebooted = boot(true);
4809 let reloaded = rebooted.get_entity(&entry.id).unwrap();
4810 assert!(
4811 reloaded
4812 .relationships
4813 .iter()
4814 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4815 "alias edge must survive reload: {:?}",
4816 reloaded.relationships
4817 );
4818
4819 let mut denied = boot(false);
4822 let err = denied
4823 .create_entity(
4824 CreateEntityArgs {
4825 anchors: Vec::new(),
4826 mem: "proc".to_string(),
4827 title: "Denied Entry".to_string(),
4828 entity_type: "verification_target".to_string(),
4829 sections: IndexMap::from_iter([
4830 (
4831 "claim".to_string(),
4832 "points at [[dest--target-doc]]".to_string(),
4833 ),
4834 ("source_to_check".to_string(), "dest mem".to_string()),
4835 ("verifiable_when".to_string(), "never".to_string()),
4836 ]),
4837 metadata: IndexMap::new(),
4838 relations: Vec::new(),
4839 dry_run: false,
4840 },
4841 actor,
4842 Some(&client),
4843 None,
4844 )
4845 .unwrap_err();
4846 assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
4847 }
4848
4849 fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
4854 let tmp_test = TempDir::new().unwrap();
4855 let tmp_other = TempDir::new().unwrap();
4856 let test_dir = tmp_test.path().to_path_buf();
4857 let other_dir = tmp_other.path().to_path_buf();
4858 let writer_test = FilesystemMemWriter::new(test_dir.clone());
4859 let writer_other = FilesystemMemWriter::new(other_dir.clone());
4860 let engine = Engine::from_mounts(vec![
4861 (
4862 folder_mount("test", test_dir),
4863 Box::new(writer_test) as Box<dyn MemBackend>,
4864 ),
4865 (
4866 folder_mount("other", other_dir),
4867 Box::new(writer_other) as Box<dyn MemBackend>,
4868 ),
4869 ])
4870 .unwrap();
4871 (tmp_test, tmp_other, engine)
4872 }
4873
4874 #[test]
4879 fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
4880 use crate::entity::EntityId;
4881 use crate::ops::RelateArg;
4882 use memstead_schema::workspace_config::CrossLinkValue;
4883
4884 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4885 let (actor, client) = cli_actor();
4886
4887 let mut settings = crate::workspace::WorkspaceSettings::default();
4890 settings.cross_mem_links.insert(
4891 "test".to_string(),
4892 CrossLinkValue::List(vec!["other".to_string()]),
4893 );
4894 engine.set_settings(settings);
4895
4896 let target = engine
4901 .create_entity(
4902 empty_create_args("test", "Target"),
4903 actor,
4904 Some(&client),
4905 None,
4906 )
4907 .unwrap();
4908
4909 let mut args = empty_create_args("other", "Source");
4910 args.relations = vec![RelateArg {
4911 rel_type: "IMPLEMENTS".to_string(),
4912 to: target.id.clone(),
4913 description: None,
4914 }];
4915 let err = engine
4916 .create_entity(args, actor, Some(&client), None)
4917 .unwrap_err();
4918 match err {
4919 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
4920 assert_eq!(from_mem, "other");
4921 assert_eq!(to_mem, "test");
4922 }
4923 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
4924 }
4925
4926 let would_be = EntityId::new("other", "source");
4928 assert!(
4929 engine.get_entity(&would_be).is_none(),
4930 "entity must not persist when inline relation refuses"
4931 );
4932 }
4933
4934 #[test]
4937 fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
4938 use crate::ops::RelateArg;
4939 use memstead_schema::workspace_config::CrossLinkValue;
4940
4941 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4942 let (actor, client) = cli_actor();
4943
4944 let mut settings = crate::workspace::WorkspaceSettings::default();
4945 settings.cross_mem_links.insert(
4946 "other".to_string(),
4947 CrossLinkValue::List(vec!["test".to_string()]),
4948 );
4949 engine.set_settings(settings);
4950
4951 let target = engine
4952 .create_entity(
4953 empty_create_args("test", "Target"),
4954 actor,
4955 Some(&client),
4956 None,
4957 )
4958 .unwrap();
4959
4960 let mut args = empty_create_args("other", "Source");
4961 args.relations = vec![RelateArg {
4962 rel_type: "IMPLEMENTS".to_string(),
4963 to: target.id.clone(),
4964 description: None,
4965 }];
4966 let outcome = engine
4967 .create_entity(args, actor, Some(&client), None)
4968 .unwrap();
4969 let stored = engine.get_entity(&outcome.id).expect("entity persists");
4970 assert!(
4971 stored
4972 .relationships
4973 .iter()
4974 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
4975 "IMPLEMENTS edge must persist on the source's relationships",
4976 );
4977 }
4978
4979 #[test]
4983 fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
4984 use crate::ops::RelateArg;
4985
4986 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4987 let (actor, client) = cli_actor();
4988 let target = engine
4991 .create_entity(
4992 empty_create_args("test", "Target"),
4993 actor,
4994 Some(&client),
4995 None,
4996 )
4997 .unwrap();
4998 let mut args = empty_create_args("test", "Source");
4999 args.relations = vec![RelateArg {
5000 rel_type: "USES".to_string(),
5001 to: target.id.clone(),
5002 description: None,
5003 }];
5004 let outcome = engine
5005 .create_entity(args, actor, Some(&client), None)
5006 .unwrap();
5007 let stored = engine.get_entity(&outcome.id).expect("entity persists");
5008 assert!(
5009 stored
5010 .relationships
5011 .iter()
5012 .any(|r| r.rel_type == "USES" && r.target == target.id),
5013 "same-mem USES edge must persist",
5014 );
5015 }
5016
5017 #[test]
5022 fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
5023 use crate::ops::RelateArg;
5024 use memstead_schema::workspace_config::CrossLinkValue;
5025
5026 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5027 let (actor, client) = cli_actor();
5028
5029 let mut settings = crate::workspace::WorkspaceSettings::default();
5030 settings.cross_mem_links.insert(
5031 "test".to_string(),
5032 CrossLinkValue::List(vec!["other".to_string()]),
5033 );
5034 engine.set_settings(settings);
5035
5036 let target = engine
5037 .create_entity(
5038 empty_create_args("test", "Target"),
5039 actor,
5040 Some(&client),
5041 None,
5042 )
5043 .unwrap();
5044 let src = engine
5045 .create_entity(
5046 empty_create_args("other", "Source"),
5047 actor,
5048 Some(&client),
5049 None,
5050 )
5051 .unwrap();
5052
5053 let relate_err = engine
5055 .relate_entity(
5056 RelateEntityArgs {
5057 source: src.id.clone(),
5058 rel_type: "IMPLEMENTS".to_string(),
5059 target: target.id.clone(),
5060 expected_hash: Some(src.content_hash.clone()),
5061 remove: false,
5062 description: None,
5063 dry_run: false,
5064 },
5065 actor,
5066 Some(&client),
5067 None,
5068 )
5069 .unwrap_err();
5070
5071 let mut create_args = empty_create_args("other", "Source Two");
5074 create_args.relations = vec![RelateArg {
5075 rel_type: "IMPLEMENTS".to_string(),
5076 to: target.id.clone(),
5077 description: None,
5078 }];
5079 let create_err = engine
5080 .create_entity(create_args, actor, Some(&client), None)
5081 .unwrap_err();
5082
5083 match (relate_err, create_err) {
5086 (
5087 EngineError::CrossMemLinkNotAllowed {
5088 from_mem: rfv,
5089 to_mem: rtv,
5090 },
5091 EngineError::CrossMemLinkNotAllowed {
5092 from_mem: cfv,
5093 to_mem: ctv,
5094 },
5095 ) => {
5096 assert_eq!(rfv, "other");
5097 assert_eq!(rtv, "test");
5098 assert_eq!(cfv, "other");
5099 assert_eq!(ctv, "test");
5100 }
5101 (a, b) => panic!(
5102 "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
5103 ),
5104 }
5105 }
5106
5107 #[test]
5113 fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
5114 use crate::entity::EntityId;
5115 use indexmap::IndexMap;
5116 use memstead_schema::workspace_config::CrossLinkValue;
5117
5118 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5119 let (actor, client) = cli_actor();
5120
5121 let mut settings = crate::workspace::WorkspaceSettings::default();
5122 settings.cross_mem_links.insert(
5123 "test".to_string(),
5124 CrossLinkValue::List(vec!["other".to_string()]),
5125 );
5126 engine.set_settings(settings);
5127
5128 let mut sections: IndexMap<String, String> = IndexMap::new();
5129 sections.insert(
5130 "identity".to_string(),
5131 "see [[other--target]] for details".to_string(),
5132 );
5133 sections.insert("purpose".to_string(), "source purpose".to_string());
5134 let outcome = engine
5135 .create_entity(
5136 crate::engine::CreateEntityArgs {
5137 anchors: Vec::new(),
5138 mem: "test".to_string(),
5139 title: "Source".to_string(),
5140 entity_type: "spec".to_string(),
5141 sections,
5142 metadata: IndexMap::new(),
5143 relations: Vec::new(),
5144 dry_run: false,
5145 },
5146 actor,
5147 Some(&client),
5148 None,
5149 )
5150 .unwrap();
5151
5152 let canonical = EntityId::new("other", "target");
5154 assert!(
5155 engine.get_entity(&canonical).is_some(),
5156 "auto-stub must land at the canonical cross-mem id"
5157 );
5158 let phantom = EntityId::new("test", "other--target");
5159 assert!(
5160 engine.get_entity(&phantom).is_none(),
5161 "no double-prefixed phantom stub"
5162 );
5163
5164 let source = engine.get_entity(&outcome.id).unwrap();
5166 let references_count = source
5167 .relationships
5168 .iter()
5169 .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
5170 .count();
5171 assert_eq!(
5172 references_count, 1,
5173 "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
5174 );
5175 }
5176
5177 #[test]
5181 fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
5182 use indexmap::IndexMap;
5183
5184 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5185 let (actor, client) = cli_actor();
5186 let mut sections: IndexMap<String, String> = IndexMap::new();
5189 sections.insert(
5190 "identity".to_string(),
5191 "see [[other--target]] for details".to_string(),
5192 );
5193 sections.insert("purpose".to_string(), "source purpose".to_string());
5194 let err = engine
5195 .create_entity(
5196 crate::engine::CreateEntityArgs {
5197 anchors: Vec::new(),
5198 mem: "test".to_string(),
5199 title: "Source".to_string(),
5200 entity_type: "spec".to_string(),
5201 sections,
5202 metadata: IndexMap::new(),
5203 relations: Vec::new(),
5204 dry_run: false,
5205 },
5206 actor,
5207 Some(&client),
5208 None,
5209 )
5210 .unwrap_err();
5211 match err {
5212 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
5213 assert_eq!(from_mem, "test");
5214 assert_eq!(to_mem, "other");
5215 }
5216 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
5217 }
5218 }
5219
5220 #[test]
5228 fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
5229 use crate::engine::UpdateEntityArgs;
5230 use crate::workspace::{MutationsSection, WorkspaceSettings};
5231 use indexmap::IndexMap;
5232
5233 let tmp = TempDir::new().unwrap();
5234 let mem_dir = tmp.path().to_path_buf();
5235 let writer = FilesystemMemWriter::new(mem_dir.clone());
5236 let mut engine = Engine::from_mounts(vec![(
5237 folder_mount("specs", mem_dir.clone()),
5238 Box::new(writer) as Box<dyn MemBackend>,
5239 )])
5240 .unwrap();
5241 engine.set_workspace_root(mem_dir.clone());
5242 engine.set_settings(WorkspaceSettings {
5243 mutations: MutationsSection {
5244 require_notes: Some(true),
5245 },
5246 ..Default::default()
5247 });
5248 let (actor, client) = cli_actor();
5249
5250 let note_missing = |ws: &[WarningHint]| -> usize {
5251 ws.iter()
5252 .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
5253 .count()
5254 };
5255
5256 let created = engine
5258 .create_entity(
5259 empty_create_args("specs", "Noteless"),
5260 actor,
5261 Some(&client),
5262 None,
5263 )
5264 .unwrap();
5265 assert_eq!(
5266 note_missing(&created.warnings),
5267 1,
5268 "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
5269 created.warnings,
5270 );
5271 assert!(
5272 matches!(
5273 created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
5274 Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
5275 ),
5276 "the warning names the engine-level verb",
5277 );
5278 assert!(
5279 !created.commit_sha.is_empty(),
5280 "create still commits (nudge, not block)"
5281 );
5282
5283 let mut edit: IndexMap<String, String> = IndexMap::new();
5285 edit.insert("identity".to_string(), "revised".to_string());
5286 let updated = engine
5287 .update_entity(
5288 UpdateEntityArgs {
5289 anchors: Vec::new(),
5290 id: created.id.clone(),
5291 expected_hash: Some(created.content_hash.clone()),
5292 sections: edit,
5293 append_sections: IndexMap::new(),
5294 patch_sections: IndexMap::new(),
5295 metadata: IndexMap::new(),
5296 metadata_unset: Vec::new(),
5297 declare_relations: Vec::new(),
5298 dry_run: false,
5299 relations_unset: Vec::new(),
5300 anchors_unset: Vec::new(),
5301 },
5302 actor,
5303 Some(&client),
5304 None,
5305 )
5306 .unwrap();
5307 assert_eq!(
5308 note_missing(&updated.warnings),
5309 1,
5310 "update emits NOTE_MISSING"
5311 );
5312 assert!(!updated.commit_sha.is_empty(), "update still commits");
5313
5314 let target = engine
5316 .create_entity(
5317 empty_create_args("specs", "Target"),
5318 actor,
5319 Some(&client),
5320 Some("seed"),
5321 )
5322 .unwrap();
5323 let related = engine
5324 .relate_entity(
5325 RelateEntityArgs {
5326 source: updated.id.clone(),
5327 expected_hash: Some(updated.content_hash.clone()),
5328 rel_type: "USES".to_string(),
5329 target: target.id.clone(),
5330 remove: false,
5331 description: None,
5332 dry_run: false,
5333 },
5334 actor,
5335 Some(&client),
5336 None,
5337 )
5338 .unwrap();
5339 assert_eq!(
5340 note_missing(&related.warnings),
5341 1,
5342 "relate emits NOTE_MISSING"
5343 );
5344 assert!(!related.commit_sha.is_empty(), "relate still commits");
5345
5346 let with_note = engine
5348 .create_entity(
5349 empty_create_args("specs", "Documented"),
5350 actor,
5351 Some(&client),
5352 Some("a real provenance note"),
5353 )
5354 .unwrap();
5355 assert_eq!(
5356 note_missing(&with_note.warnings),
5357 0,
5358 "a supplied note suppresses the warning",
5359 );
5360
5361 engine.set_settings(WorkspaceSettings::default());
5363 let after_off = engine
5364 .create_entity(
5365 empty_create_args("specs", "Quiet"),
5366 actor,
5367 Some(&client),
5368 None,
5369 )
5370 .unwrap();
5371 assert_eq!(
5372 note_missing(&after_off.warnings),
5373 0,
5374 "no NOTE_MISSING when require_notes is unset",
5375 );
5376 }
5377
5378 fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5381 crate::anchor::AnchorInput {
5382 artifact: Some(artifact.to_string()),
5383 grain: Some("file".to_string()),
5384 class: Some("anchored".to_string()),
5385 hash: Some(hash.to_string()),
5386 hash_stability: Some("stable".to_string()),
5387 ..Default::default()
5388 }
5389 }
5390
5391 fn folder_engine(mem: &str) -> (Engine, TempDir) {
5392 let tmp = TempDir::new().unwrap();
5393 let dir = tmp.path().to_path_buf();
5394 let writer = FilesystemMemWriter::new(dir.clone());
5395 let engine = Engine::from_mounts(vec![(
5396 folder_mount(mem, dir.clone()),
5397 Box::new(writer) as Box<dyn MemBackend>,
5398 )])
5399 .unwrap();
5400 (engine, tmp)
5401 }
5402
5403 #[test]
5404 fn create_with_anchors_persists_and_survives_reload() {
5405 let (mut engine, tmp) = folder_engine("specs");
5406 let dir = tmp.path().to_path_buf();
5407 let (actor, client) = cli_actor();
5408 let mut args = empty_create_args("specs", "Anchored Entity");
5409 args.anchors = vec![file_anchor("src/lib.rs", "h1")];
5410 engine
5411 .create_entity(args, actor, Some(&client), None)
5412 .unwrap();
5413
5414 let id = crate::EntityId::new("specs", "anchored-entity");
5415 let anchors = engine.entity_anchors(&id);
5416 assert_eq!(anchors.len(), 1);
5417 assert_eq!(anchors[0].artifact, "src/lib.rs");
5418 assert_eq!(
5419 anchors[0].class,
5420 crate::anchor::AnchorProvenanceClass::Anchored
5421 );
5422
5423 let writer = FilesystemMemWriter::new(dir.clone());
5425 let reloaded = Engine::from_mounts(vec![(
5426 folder_mount("specs", dir.clone()),
5427 Box::new(writer) as Box<dyn MemBackend>,
5428 )])
5429 .unwrap();
5430 assert_eq!(reloaded.entity_anchors(&id).len(), 1);
5431 assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
5433 }
5434
5435 #[test]
5436 fn malformed_anchor_refuses_and_entity_not_written() {
5437 let (mut engine, tmp) = folder_engine("specs");
5438 let (actor, client) = cli_actor();
5439 let mut args = empty_create_args("specs", "Bad Anchor");
5440 args.anchors = vec![crate::anchor::AnchorInput {
5441 artifact: Some("x".into()),
5442 grain: Some("paragraph".into()), class: Some("anchored".into()),
5444 ..Default::default()
5445 }];
5446 let err = engine
5447 .create_entity(args, actor, Some(&client), None)
5448 .unwrap_err();
5449 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5450 assert!(
5452 engine
5453 .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
5454 .is_none()
5455 );
5456 assert!(!tmp.path().join("bad-anchor.md").exists());
5457 }
5458
5459 #[test]
5460 fn anchors_are_not_folded_into_content_hash() {
5461 let (mut anchored, _t1) = folder_engine("specs");
5465 let (mut plain, _t2) = folder_engine("specs");
5466 let (actor, client) = cli_actor();
5467
5468 let mut a = empty_create_args("specs", "Same Title");
5469 a.anchors = vec![file_anchor("src/lib.rs", "h1")];
5470 let with = anchored
5471 .create_entity(a, actor, Some(&client), None)
5472 .unwrap();
5473
5474 let p = empty_create_args("specs", "Same Title");
5475 let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
5476
5477 assert_eq!(
5478 with.content_hash, without.content_hash,
5479 "anchors must not change the entity content hash"
5480 );
5481 }
5482
5483 #[test]
5484 fn anchorless_create_writes_no_sidecar() {
5485 let (mut engine, _tmp) = folder_engine("specs");
5486 let (actor, client) = cli_actor();
5487 engine
5488 .create_entity(
5489 empty_create_args("specs", "No Anchors"),
5490 actor,
5491 Some(&client),
5492 None,
5493 )
5494 .unwrap();
5495 assert!(
5496 engine
5497 .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
5498 .is_empty()
5499 );
5500 }
5501
5502 #[test]
5513 fn create_refuses_reserved_metadata_keys_deliberately() {
5514 let (mut engine, _tmp) = folder_engine("specs");
5515 let (actor, client) = cli_actor();
5516 for reserved in ["type", "mem", "id"] {
5517 let mut args = empty_create_args("specs", "Smuggler");
5518 args.metadata
5519 .insert(reserved.to_string(), "bogus".to_string());
5520 let err = engine
5521 .create_entity(args, actor, Some(&client), None)
5522 .expect_err("reserved key must refuse on create");
5523 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5524 assert!(
5525 engine
5526 .get_entity(&crate::EntityId::new("specs", "smuggler"))
5527 .is_none(),
5528 "entity must not be written after the '{reserved}' refusal"
5529 );
5530 }
5531 engine
5533 .create_entity(
5534 empty_create_args("specs", "Smuggler"),
5535 actor,
5536 Some(&client),
5537 None,
5538 )
5539 .expect("a clean create is untouched by the reserved-key gate");
5540 }
5541
5542 fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
5545 let mut args = empty_create_args(mem, title);
5546 args.relations = vec![crate::ops::RelateArg {
5547 to: crate::EntityId(to.to_string()),
5548 rel_type: rel_type.to_string(),
5549 description: None,
5550 }];
5551 args
5552 }
5553
5554 #[test]
5562 fn create_relations_refuse_cycle_and_self_loop_like_relate() {
5563 let (mut engine, _tmp) = folder_engine("specs");
5564 let (actor, client) = cli_actor();
5565
5566 engine
5568 .create_entity(
5569 create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
5570 actor,
5571 Some(&client),
5572 None,
5573 )
5574 .unwrap();
5575
5576 let err = engine
5578 .create_entity(
5579 create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
5580 actor,
5581 Some(&client),
5582 None,
5583 )
5584 .expect_err("cycle-closing create.relations[] must refuse");
5585 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5586 let details = err.details();
5588 assert_eq!(details["rel_type"], "PART_OF");
5589 assert!(details["existing_path"].is_array());
5590 assert!(
5591 engine
5592 .get_entity(&crate::EntityId::new("specs", "ghost"))
5593 .is_none_or(|e| e.stub),
5594 "the refused entity must not be written"
5595 );
5596
5597 let err = engine
5599 .create_entity(
5600 create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
5601 actor,
5602 Some(&client),
5603 None,
5604 )
5605 .expect_err("self-loop create.relations[] must refuse");
5606 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5607
5608 engine
5611 .create_entity(
5612 create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
5613 actor,
5614 Some(&client),
5615 None,
5616 )
5617 .expect("a non-cycle PART_OF edge must land as today");
5618 }
5619
5620 #[test]
5624 fn batch_create_refuses_intra_batch_cycle() {
5625 let (mut engine, _tmp) = folder_engine("specs");
5626 let (actor, client) = cli_actor();
5627
5628 let result = engine
5629 .batch_create(
5630 vec![
5631 (
5632 create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
5633 None,
5634 ),
5635 (
5636 create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
5637 None,
5638 ),
5639 ],
5640 actor,
5641 Some(&client),
5642 false,
5643 )
5644 .expect("batch returns a result envelope");
5645 assert!(!result.applied, "intra-batch cycle must refuse the batch");
5646 assert!(
5647 result.results.iter().any(|r| r
5648 .error
5649 .as_ref()
5650 .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
5651 "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
5652 result.results
5653 );
5654 assert!(
5655 engine
5656 .get_entity(&crate::EntityId::new("specs", "ping"))
5657 .is_none(),
5658 "nothing lands from a refused batch"
5659 );
5660
5661 let result = engine
5663 .batch_create(
5664 vec![
5665 (
5666 create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
5667 None,
5668 ),
5669 (empty_create_args("specs", "Chain Two"), None),
5670 ],
5671 actor,
5672 Some(&client),
5673 false,
5674 )
5675 .expect("acyclic batch lands");
5676 assert!(result.applied, "{:?}", result.results);
5677 assert_eq!(result.succeeded, 2);
5678 }
5679
5680 #[test]
5686 fn deep_acyclic_chain_near_path_cap_is_accepted() {
5687 let (mut engine, _tmp) = folder_engine("specs");
5688 let (actor, client) = cli_actor();
5689 let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
5690
5691 engine
5693 .create_entity(
5694 empty_create_args("specs", "Link 0"),
5695 actor,
5696 Some(&client),
5697 None,
5698 )
5699 .unwrap();
5700 for i in 1..depth {
5701 engine
5702 .create_entity(
5703 create_with_relation(
5704 "specs",
5705 &format!("Link {i}"),
5706 "PART_OF",
5707 &format!("specs--link-{}", i - 1),
5708 ),
5709 actor,
5710 Some(&client),
5711 None,
5712 )
5713 .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
5714 }
5715
5716 let last = depth - 1;
5719 let err = engine
5720 .update_entity(
5721 {
5722 let id = crate::EntityId::new("specs", "link-0");
5723 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
5724 crate::engine::UpdateEntityArgs {
5725 anchors: Vec::new(),
5726 anchors_unset: Vec::new(),
5727 id,
5728 expected_hash: Some(hash),
5729 sections: IndexMap::new(),
5730 append_sections: IndexMap::new(),
5731 patch_sections: IndexMap::new(),
5732 metadata: IndexMap::new(),
5733 metadata_unset: Vec::new(),
5734 declare_relations: vec![crate::ops::RelateArg {
5735 to: crate::EntityId::new("specs", &format!("link-{last}")),
5736 rel_type: "PART_OF".to_string(),
5737 description: None,
5738 }],
5739 dry_run: false,
5740 relations_unset: Vec::new(),
5741 }
5742 },
5743 actor,
5744 Some(&client),
5745 None,
5746 )
5747 .expect_err("closing the deep chain must refuse");
5748 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
5749 let details = err.details();
5750 assert_eq!(details["path_truncated"], true);
5751 assert_eq!(
5752 details["existing_path"].as_array().unwrap().len(),
5753 crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
5754 );
5755 }
5756
5757 const FORMAT_MANIFEST: &str = r#"name: formatproof
5758version: 0.1.0
5759description: section-format proof schema
5760when_to_use: format tests
5761types:
5762 - plan
5763relationships:
5764 mode: strict
5765 definitions:
5766 - name: PART_OF
5767 description: hier
5768 default_weight: 1.0
5769 - name: _default
5770 description: fallback
5771 default_weight: 1.0
5772community:
5773 resolution: 1.0
5774 seed: 42
5775"#;
5776
5777 const FORMAT_PLAN_TYPE: &str = r#"name: plan
5778description: a plan with formatted milestones
5779when_to_use: tests
5780sections:
5781 - key: body
5782 heading: Body
5783 required: true
5784 search_weight: 10.0
5785 catch_all: true
5786 write_rules: []
5787 - key: meilensteine
5788 heading: Meilensteine
5789 required: false
5790 search_weight: 5.0
5791 catch_all: false
5792 write_rules: []
5793 content: "(heading(3) list(bullet))+"
5794 item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
5795 example: |
5796 ### Phase 1
5797 - **Kickoff** — 2026-09-01
5798 - key: notizen
5799 heading: Notizen
5800 required: false
5801 search_weight: 5.0
5802 catch_all: false
5803 write_rules: []
5804 content: "list(bullet)"
5805 format_severity: warn
5806metadata_fields: []
5807title_weight: 100.0
5808text_fields:
5809 - body
5810hierarchy_relationship: PART_OF
5811no_self_loop_relationships: []
5812updatable_fields:
5813 - title
5814 - body
5815 - meilensteine
5816 - notizen
5817health_required_fields:
5818 - body
5819staleness_threshold_days: 90
5820write_rules: []
5821"#;
5822
5823 fn format_engine(tmp: &TempDir) -> Engine {
5824 engine_with_proof_schema(
5825 tmp,
5826 "formatproof",
5827 FORMAT_MANIFEST,
5828 &[("plan", FORMAT_PLAN_TYPE)],
5829 )
5830 }
5831
5832 fn plan_create_args(
5833 title: &str,
5834 meilensteine: Option<&str>,
5835 notizen: Option<&str>,
5836 ) -> CreateEntityArgs {
5837 let mut sections = IndexMap::new();
5838 sections.insert("body".to_string(), "a plan body.".to_string());
5839 if let Some(m) = meilensteine {
5840 sections.insert("meilensteine".to_string(), m.to_string());
5841 }
5842 if let Some(n) = notizen {
5843 sections.insert("notizen".to_string(), n.to_string());
5844 }
5845 CreateEntityArgs {
5846 anchors: Vec::new(),
5847 mem: "proof".to_string(),
5848 title: title.to_string(),
5849 entity_type: "plan".to_string(),
5850 sections,
5851 metadata: IndexMap::new(),
5852 relations: vec![],
5853 dry_run: false,
5854 }
5855 }
5856
5857 #[test]
5861 fn create_enforces_declared_section_format() {
5862 let tmp = TempDir::new().unwrap();
5863 let mut engine = format_engine(&tmp);
5864 let (actor, client) = cli_actor();
5865
5866 let err = engine
5867 .create_entity(
5868 plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
5869 actor,
5870 Some(&client),
5871 None,
5872 )
5873 .unwrap_err();
5874 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5875 let details = err.details();
5876 assert_eq!(details["section"], "meilensteine");
5877 assert!(
5878 details["example"].as_str().unwrap().contains("Kickoff"),
5879 "the conforming example is echoed: {details}"
5880 );
5881 assert_eq!(details["expected_next"][0], "list(bullet)");
5882
5883 let err = engine
5885 .create_entity(
5886 plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
5887 actor,
5888 Some(&client),
5889 None,
5890 )
5891 .unwrap_err();
5892 assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
5893
5894 engine
5896 .create_entity(
5897 plan_create_args(
5898 "Plan C",
5899 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5900 None,
5901 ),
5902 actor,
5903 Some(&client),
5904 None,
5905 )
5906 .unwrap();
5907
5908 let outcome = engine
5910 .create_entity(
5911 plan_create_args(
5912 "Plan D",
5913 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5914 Some("kein listenpunkt\n"),
5915 ),
5916 actor,
5917 Some(&client),
5918 None,
5919 )
5920 .unwrap();
5921 assert!(!outcome.commit_sha.is_empty(), "warn tier never refuses");
5922
5923 let err = engine
5929 .create_entity(
5930 plan_create_args("Plan E", None, None),
5931 actor,
5932 Some(&client),
5933 None,
5934 )
5935 .unwrap_err();
5936 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5937 }
5938
5939 #[test]
5943 fn update_judges_format_on_composed_body() {
5944 let tmp = TempDir::new().unwrap();
5945 let mut engine = format_engine(&tmp);
5946 let (actor, client) = cli_actor();
5947 let created = engine
5948 .create_entity(
5949 plan_create_args(
5950 "Plan A",
5951 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5952 None,
5953 ),
5954 actor,
5955 Some(&client),
5956 None,
5957 )
5958 .unwrap();
5959
5960 let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
5963 let mut append = IndexMap::new();
5964 append.insert(
5965 "meilensteine".to_string(),
5966 "\n\nnachtrag als absatz\n".to_string(),
5967 );
5968 let err = engine
5969 .update_entity(
5970 crate::engine::UpdateEntityArgs {
5971 anchors: Vec::new(),
5972 id: created.id.clone(),
5973 expected_hash: Some(current.clone()),
5974 sections: IndexMap::new(),
5975 append_sections: append,
5976 patch_sections: IndexMap::new(),
5977 metadata: IndexMap::new(),
5978 metadata_unset: Vec::new(),
5979 declare_relations: vec![],
5980 dry_run: false,
5981 relations_unset: Vec::new(),
5982 anchors_unset: Vec::new(),
5983 },
5984 actor,
5985 Some(&client),
5986 None,
5987 )
5988 .unwrap_err();
5989 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5990
5991 let mut append = IndexMap::new();
5993 append.insert(
5994 "meilensteine".to_string(),
5995 "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
5996 );
5997 engine
5998 .update_entity(
5999 crate::engine::UpdateEntityArgs {
6000 anchors: Vec::new(),
6001 id: created.id.clone(),
6002 expected_hash: Some(current),
6003 sections: IndexMap::new(),
6004 append_sections: append,
6005 patch_sections: IndexMap::new(),
6006 metadata: IndexMap::new(),
6007 metadata_unset: Vec::new(),
6008 declare_relations: vec![],
6009 dry_run: false,
6010 relations_unset: Vec::new(),
6011 anchors_unset: Vec::new(),
6012 },
6013 actor,
6014 Some(&client),
6015 None,
6016 )
6017 .unwrap();
6018 }
6019
6020 #[test]
6024 fn embedded_h1_refuses_in_any_section() {
6025 let tmp = TempDir::new().unwrap();
6026 let mut engine = format_engine(&tmp);
6027 let (actor, client) = cli_actor();
6028 let mut args = plan_create_args(
6029 "Plan H",
6030 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
6031 None,
6032 );
6033 args.sections.insert(
6034 "body".to_string(),
6035 "intro\n# Injected Title\ntail".to_string(),
6036 );
6037 let err = engine
6038 .create_entity(args, actor, Some(&client), None)
6039 .unwrap_err();
6040 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
6041 }
6042}