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 const FROZEN_SECS: u64 = 1_754_000_000;
3547 let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(FROZEN_SECS);
3548 engine.set_mutation_clock(std::sync::Arc::new(move || frozen));
3549 let (actor, client) = cli_actor();
3550
3551 let mut args = empty_create_args("specs", "Stamped Today");
3555 args.metadata
3556 .insert("created_date".to_string(), "2020-01-01".to_string());
3557 args.metadata
3558 .insert("last_modified".to_string(), "2020-01-01".to_string());
3559
3560 let outcome = engine
3561 .create_entity(args, actor, Some(&client), None)
3562 .unwrap();
3563
3564 let today = crate::engine::mutation::iso_from_system_time(frozen);
3567 assert_eq!(outcome.created_date, today);
3568 let entity = engine
3569 .get_entity(&outcome.id)
3570 .expect("entity must be in store after create");
3571 assert_eq!(
3572 entity
3573 .metadata
3574 .get("created_date")
3575 .and_then(|v| v.as_str())
3576 .unwrap_or_default(),
3577 today,
3578 "init_timestamp field must be engine-determined on create, not user-supplied"
3579 );
3580 assert_eq!(
3581 entity
3582 .metadata
3583 .get("last_modified")
3584 .and_then(|v| v.as_str())
3585 .unwrap_or_default(),
3586 today,
3587 "auto_timestamp field must be engine-determined on create, not user-supplied"
3588 );
3589
3590 let attempt_update = |key: &str, value: &str| {
3594 let mut metadata = IndexMap::new();
3595 metadata.insert(key.to_string(), value.to_string());
3596 crate::engine::UpdateEntityArgs {
3597 anchors: Vec::new(),
3598 id: outcome.id.clone(),
3599 metadata,
3600 metadata_unset: Vec::new(),
3601 sections: IndexMap::new(),
3602 append_sections: IndexMap::new(),
3603 patch_sections: IndexMap::new(),
3604 expected_hash: Some(outcome.content_hash.clone()),
3605 dry_run: false,
3606 declare_relations: Vec::new(),
3607 relations_unset: Vec::new(),
3608 anchors_unset: Vec::new(),
3609 }
3610 };
3611 for key in ["created_date", "last_modified"] {
3612 let err = engine
3613 .update_entity(
3614 attempt_update(key, "2019-12-31"),
3615 actor,
3616 Some(&client),
3617 None,
3618 )
3619 .expect_err("schema-managed timestamp must be rejected on update");
3620 assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
3621 }
3622 let entity = engine
3624 .get_entity(&outcome.id)
3625 .expect("entity must remain in store after rejected update");
3626 assert_eq!(
3627 entity
3628 .metadata
3629 .get("last_modified")
3630 .and_then(|v| v.as_str())
3631 .unwrap_or_default(),
3632 today,
3633 "rejected update must not mutate the auto_timestamp field"
3634 );
3635 }
3636
3637 #[test]
3638 fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
3639 let tmp = TempDir::new().unwrap();
3640 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3641 let (actor, client) = cli_actor();
3642 let absent = crate::EntityId::new("specs", "future-target");
3643 assert!(!engine.store().contains(&absent));
3644
3645 let mut args = empty_create_args("specs", "Source With Relations");
3646 args.relations = vec![
3647 crate::ops::RelateArg {
3648 to: existing.id.clone(),
3649 rel_type: "USES".to_string(),
3650 description: None,
3651 },
3652 crate::ops::RelateArg {
3653 to: absent.clone(),
3654 rel_type: "USES".to_string(),
3655 description: None,
3656 },
3657 ];
3658
3659 let outcome = engine
3660 .create_entity(args, actor, Some(&client), None)
3661 .unwrap();
3662
3663 let source = engine
3665 .store()
3666 .get(&outcome.id)
3667 .expect("source must be in store");
3668 assert_eq!(source.relationships.len(), 2);
3669 assert!(
3670 source
3671 .relationships
3672 .iter()
3673 .any(|r| r.target == existing.id && r.rel_type == "USES")
3674 );
3675 assert!(
3676 source
3677 .relationships
3678 .iter()
3679 .any(|r| r.target == absent && r.rel_type == "USES")
3680 );
3681
3682 let stub = engine
3685 .store()
3686 .get(&absent)
3687 .expect("absent relation target must be auto-stubbed");
3688 assert!(stub.stub);
3689 let existing_after = engine.store().get(&existing.id).unwrap();
3691 assert!(!existing_after.stub);
3692 }
3693
3694 fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
3699 use crate::workspace::Mount;
3700 use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
3701 let mem_dir = tmp.path().to_path_buf();
3702 let writer = FilesystemMemWriter::new(mem_dir.clone());
3703 let mount = Mount {
3704 mem: "planning".to_string(),
3705 schema: Some(memstead_schema::SchemaRef::new(
3706 "planning",
3707 semver::Version::new(0, 1, 0),
3708 )),
3709 storage: MountStorage::Folder { path: mem_dir },
3710 capability: MountCapability::Write,
3711 lifecycle: MountLifecycle::Eager,
3712 cross_linkable: true,
3713 migration_target: None,
3714 };
3715 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
3716 }
3717
3718 #[test]
3727 fn create_entity_refuses_unsupplied_no_default_required_field() {
3728 let tmp = TempDir::new().unwrap();
3732 let mut engine = engine_with_planning_schema(&tmp);
3733 let (actor, client) = cli_actor();
3734
3735 let mut args = CreateEntityArgs {
3736 anchors: Vec::new(),
3737 mem: "planning".to_string(),
3738 title: "Skip Postgres".to_string(),
3739 entity_type: "decision".to_string(),
3740 sections: IndexMap::from_iter([
3741 ("decision".to_string(), "Use SQLite locally.".to_string()),
3742 ("context".to_string(), "Single-user dev.".to_string()),
3743 ("consequences".to_string(), "Lose multi-writer.".to_string()),
3744 ]),
3745 metadata: IndexMap::new(),
3746 relations: Vec::new(),
3747 dry_run: false,
3748 };
3749
3750 let err = engine
3753 .create_entity(args.clone(), actor, Some(&client), None)
3754 .unwrap_err();
3755 match err {
3756 EngineError::RequiredFieldUnset {
3757 field, entity_type, ..
3758 } => {
3759 assert!(
3760 field == "decided_on" || field == "deciders",
3761 "expected first missing field, got {field:?}"
3762 );
3763 assert_eq!(entity_type, "decision");
3764 }
3765 other => panic!("expected RequiredFieldUnset, got {other:?}"),
3766 }
3767
3768 args.title = "Different Title".to_string();
3772 args.dry_run = true;
3773 let dry_err = engine
3774 .create_entity(args, actor, Some(&client), None)
3775 .unwrap_err();
3776 assert!(
3777 matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
3778 "dry_run must surface the same refusal envelope, got {dry_err:?}"
3779 );
3780 }
3781
3782 #[test]
3786 fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
3787 let tmp = TempDir::new().unwrap();
3788 let mut engine = engine_with_planning_schema(&tmp);
3789 let (actor, client) = cli_actor();
3790
3791 let mut metadata = IndexMap::new();
3792 metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
3793 metadata.insert("deciders".to_string(), "alice, bob".to_string());
3794
3795 let outcome = engine
3796 .create_entity(
3797 CreateEntityArgs {
3798 anchors: Vec::new(),
3799 mem: "planning".to_string(),
3800 title: "Complete Decision".to_string(),
3801 entity_type: "decision".to_string(),
3802 sections: IndexMap::from_iter([
3803 ("decision".to_string(), "x".to_string()),
3804 ("context".to_string(), "y".to_string()),
3805 ("consequences".to_string(), "z".to_string()),
3806 ]),
3807 metadata,
3808 relations: Vec::new(),
3809 dry_run: false,
3810 },
3811 actor,
3812 Some(&client),
3813 None,
3814 )
3815 .expect("complete decision create succeeds");
3816 let missing_field_warnings: Vec<&WarningHint> = outcome
3819 .warnings
3820 .iter()
3821 .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
3822 .collect();
3823 assert!(
3824 missing_field_warnings.is_empty(),
3825 "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
3826 );
3827 }
3828
3829 #[test]
3834 fn create_entity_rejects_inline_relation_with_malformed_target_id() {
3835 let tmp = TempDir::new().unwrap();
3836 let mem_dir = tmp.path().to_path_buf();
3837 let writer = FilesystemMemWriter::new(mem_dir.clone());
3838 let mut engine = Engine::from_mounts(vec![(
3839 folder_mount("specs", mem_dir),
3840 Box::new(writer) as Box<dyn MemBackend>,
3841 )])
3842 .unwrap();
3843 let (actor, client) = cli_actor();
3844
3845 let mut args = empty_create_args("specs", "Source");
3846 args.relations = vec![crate::ops::RelateArg {
3847 to: crate::EntityId("specs--bad target with spaces!!".to_string()),
3848 rel_type: "USES".to_string(),
3849 description: None,
3850 }];
3851 let err = engine
3852 .create_entity(args, actor, Some(&client), None)
3853 .unwrap_err();
3854 assert!(
3855 matches!(err, EngineError::InvalidEntityId { .. }),
3856 "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
3857 );
3858 }
3859
3860 #[test]
3869 fn create_entity_rejects_inline_relation_with_shape_violation() {
3870 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3871 let tmp = TempDir::new().unwrap();
3872 let mem_dir = tmp.path().to_path_buf();
3873 let writer = FilesystemMemWriter::new(mem_dir.clone());
3874 let mount = Mount {
3875 mem: "code".to_string(),
3876 schema: Some(memstead_schema::SchemaRef::new(
3877 "software",
3878 semver::Version::new(0, 1, 0),
3879 )),
3880 storage: MountStorage::Folder { path: mem_dir },
3881 capability: MountCapability::Write,
3882 lifecycle: MountLifecycle::Eager,
3883 cross_linkable: true,
3884 migration_target: None,
3885 };
3886 let mut engine =
3887 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3888 let (actor, client) = cli_actor();
3889
3890 let target = engine
3898 .create_entity(
3899 CreateEntityArgs {
3900 anchors: Vec::new(),
3901 mem: "code".to_string(),
3902 title: "Target Requirement".to_string(),
3903 entity_type: "requirement".to_string(),
3904 sections: IndexMap::from_iter([
3905 ("statement".to_string(), "MUST hold.".to_string()),
3906 ("rationale".to_string(), "Because tests.".to_string()),
3907 ]),
3908 metadata: IndexMap::from_iter([
3909 ("verified_on".to_string(), "2026-05-19".to_string()),
3910 ("source".to_string(), "test fixture".to_string()),
3911 ]),
3912 relations: Vec::new(),
3913 dry_run: false,
3914 },
3915 actor,
3916 Some(&client),
3917 None,
3918 )
3919 .unwrap();
3920
3921 let args = CreateEntityArgs {
3927 anchors: Vec::new(),
3928 mem: "code".to_string(),
3929 title: "Misshape Source".to_string(),
3930 entity_type: "spec".to_string(),
3931 sections: IndexMap::from_iter([
3932 ("identity".to_string(), "this spec".to_string()),
3933 (
3934 "purpose".to_string(),
3935 "exercising the shape gate".to_string(),
3936 ),
3937 ]),
3938 metadata: IndexMap::new(),
3939 relations: vec![crate::ops::RelateArg {
3940 to: target.id.clone(),
3941 rel_type: "VIOLATES".to_string(),
3942 description: None,
3943 }],
3944 dry_run: false,
3945 };
3946 let err = engine
3947 .create_entity(args, actor, Some(&client), None)
3948 .unwrap_err();
3949 assert!(
3950 matches!(err, EngineError::Validation(_)),
3951 "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
3952 );
3953 }
3954
3955 #[test]
3956 fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
3957 let tmp = TempDir::new().unwrap();
3961 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3962 let (actor, client) = cli_actor();
3963
3964 let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
3965 args.relations = vec![crate::ops::RelateArg {
3966 to: existing.id.clone(),
3967 rel_type: "uses".to_string(),
3968 description: None,
3969 }];
3970
3971 let outcome = engine
3972 .create_entity(args, actor, Some(&client), None)
3973 .unwrap();
3974
3975 let source = engine
3976 .store()
3977 .get(&outcome.id)
3978 .expect("source must be in store");
3979 assert_eq!(source.relationships.len(), 1);
3980 assert_eq!(
3981 source.relationships[0].rel_type, "USES",
3982 "inline relation rel_type must be stored UPPER_SNAKE_CASE",
3983 );
3984 }
3985
3986 #[test]
3987 fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
3988 let tmp = TempDir::new().unwrap();
3989 let mem_dir = tmp.path().to_path_buf();
3990 let writer = FilesystemMemWriter::new(mem_dir.clone());
3991 let mut engine = Engine::from_mounts(vec![(
3992 folder_mount("specs", mem_dir.clone()),
3993 Box::new(writer) as Box<dyn MemBackend>,
3994 )])
3995 .unwrap();
3996 let (actor, client) = cli_actor();
3997
3998 let mut args = empty_create_args("specs", "Preview Only");
3999 args.dry_run = true;
4000
4001 let outcome = engine
4002 .create_entity(args, actor, Some(&client), None)
4003 .unwrap();
4004
4005 assert_eq!(outcome.id.to_string(), "specs--preview-only");
4007 assert!(
4008 !outcome.content_hash.is_empty(),
4009 "prospective hash populated"
4010 );
4011 assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
4012 assert!(
4014 engine.store().get(&outcome.id).is_none(),
4015 "dry_run must not mutate the store",
4016 );
4017 assert!(
4019 !mem_dir.join("preview-only.md").exists(),
4020 "dry_run must not touch disk",
4021 );
4022 let log = mem_dir.join(".memstead").join("changes.jsonl");
4024 assert!(
4025 !log.exists()
4026 || !std::fs::read_to_string(&log)
4027 .unwrap()
4028 .contains("preview-only"),
4029 "dry_run must not append provenance",
4030 );
4031 }
4032
4033 #[test]
4034 fn create_entity_rejects_read_only_mount_before_backend() {
4035 let tmp = TempDir::new().unwrap();
4036 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
4037 let mut engine = Engine::from_mounts(vec![(
4038 archive_mount("external", archive_path.clone()),
4039 Box::new(ArchiveBackend::new(archive_path)),
4040 )])
4041 .unwrap();
4042 let (actor, client) = cli_actor();
4043
4044 let err = engine
4045 .create_entity(
4046 empty_create_args("external", "Should Fail"),
4047 actor,
4048 Some(&client),
4049 None,
4050 )
4051 .unwrap_err();
4052 match err {
4053 EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
4054 other => panic!("expected ReadOnlyMount, got {other:?}"),
4055 }
4056 }
4060
4061 #[test]
4062 fn create_entity_rejects_unknown_mem() {
4063 let tmp = TempDir::new().unwrap();
4064 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
4065 let mut engine = Engine::from_mounts(vec![(
4066 folder_mount("specs", tmp.path().to_path_buf()),
4067 Box::new(writer) as Box<dyn MemBackend>,
4068 )])
4069 .unwrap();
4070 let (actor, client) = cli_actor();
4071
4072 let err = engine
4073 .create_entity(
4074 empty_create_args("does-not-exist", "Anything"),
4075 actor,
4076 Some(&client),
4077 None,
4078 )
4079 .unwrap_err();
4080 assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
4081 }
4082
4083 #[test]
4084 fn create_entity_rejects_unknown_type_against_pinned_schema() {
4085 let tmp = TempDir::new().unwrap();
4086 let mem_dir = tmp.path().to_path_buf();
4087 let writer = FilesystemMemWriter::new(mem_dir.clone());
4088 let mut engine = Engine::from_mounts(vec![(
4089 folder_mount("specs", mem_dir),
4090 Box::new(writer) as Box<dyn MemBackend>,
4091 )])
4092 .unwrap();
4093 let (actor, client) = cli_actor();
4094
4095 let mut args = empty_create_args("specs", "Anything");
4096 args.entity_type = "definitely-not-a-real-type".to_string();
4097 let err = engine
4098 .create_entity(args, actor, Some(&client), None)
4099 .unwrap_err();
4100 match err {
4101 EngineError::UnknownType { name, declared, .. } => {
4102 assert_eq!(name, "definitely-not-a-real-type");
4103 assert!(!declared.is_empty(), "declared types must be listed");
4104 }
4105 other => panic!("expected UnknownType, got {other:?}"),
4106 }
4107 }
4108
4109 #[test]
4110 fn create_entity_rejects_duplicate_id() {
4111 let tmp = TempDir::new().unwrap();
4112 let mem_dir = tmp.path().to_path_buf();
4113 let writer = FilesystemMemWriter::new(mem_dir.clone());
4114 let mut engine = Engine::from_mounts(vec![(
4115 folder_mount("specs", mem_dir),
4116 Box::new(writer) as Box<dyn MemBackend>,
4117 )])
4118 .unwrap();
4119 let (actor, client) = cli_actor();
4120
4121 engine
4122 .create_entity(
4123 empty_create_args("specs", "Same Slug"),
4124 actor,
4125 Some(&client),
4126 None,
4127 )
4128 .unwrap();
4129 let err = engine
4130 .create_entity(
4131 empty_create_args("specs", "Same Slug"),
4132 actor,
4133 Some(&client),
4134 None,
4135 )
4136 .unwrap_err();
4137 match err {
4138 EngineError::AlreadyExists {
4139 id,
4140 existing_title,
4141 existing_is_stub,
4142 } => {
4143 assert_eq!(id, "specs--same-slug");
4144 assert!(!existing_title.is_empty());
4147 assert!(!existing_is_stub);
4148 }
4149 other => panic!("expected AlreadyExists, got {other:?}"),
4150 }
4151 }
4152
4153 #[test]
4154 fn create_entity_rejects_invalid_title() {
4155 let tmp = TempDir::new().unwrap();
4156 let mem_dir = tmp.path().to_path_buf();
4157 let writer = FilesystemMemWriter::new(mem_dir.clone());
4158 let mut engine = Engine::from_mounts(vec![(
4159 folder_mount("specs", mem_dir),
4160 Box::new(writer) as Box<dyn MemBackend>,
4161 )])
4162 .unwrap();
4163 let (actor, client) = cli_actor();
4164
4165 let err = engine
4171 .create_entity(empty_create_args("specs", " "), actor, Some(&client), None)
4172 .unwrap_err();
4173 match err {
4174 EngineError::InvalidTitle(slug_err) => {
4175 assert_eq!(slug_err.reason(), "empty", "expected empty reason");
4176 }
4177 other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
4178 }
4179
4180 let outcome = engine
4184 .create_entity(
4185 empty_create_args("specs", "Hello, World!"),
4186 actor,
4187 Some(&client),
4188 None,
4189 )
4190 .expect("char-drop title lands under the widened grammar");
4191 assert_eq!(outcome.id.as_ref(), "specs--hello-world");
4192 let dropped = outcome
4193 .warnings
4194 .iter()
4195 .find_map(|w| match w {
4196 WarningHint::TitleCharsDroppedFromSlug {
4197 dropped_chars,
4198 slug,
4199 ..
4200 } => Some((dropped_chars.clone(), slug.clone())),
4201 _ => None,
4202 })
4203 .expect("divergence warning rides the outcome");
4204 assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
4205 assert_eq!(dropped.1, "hello-world");
4206
4207 let outcome = engine
4211 .create_entity(
4212 empty_create_args("specs", "../etc/passwd"),
4213 actor,
4214 Some(&client),
4215 None,
4216 )
4217 .expect("traversal-shaped title lands with a sanitised slug");
4218 assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
4219 assert!(
4220 outcome
4221 .warnings
4222 .iter()
4223 .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
4224 );
4225 }
4226
4227 #[test]
4228 fn create_entity_rejects_unknown_section_key() {
4229 let tmp = TempDir::new().unwrap();
4230 let mem_dir = tmp.path().to_path_buf();
4231 let writer = FilesystemMemWriter::new(mem_dir.clone());
4232 let mut engine = Engine::from_mounts(vec![(
4233 folder_mount("specs", mem_dir),
4234 Box::new(writer) as Box<dyn MemBackend>,
4235 )])
4236 .unwrap();
4237 let (actor, client) = cli_actor();
4238
4239 let mut args = empty_create_args("specs", "Bad Sections");
4240 args.sections
4241 .insert("not-a-real-section-key".to_string(), "body".to_string());
4242 let err = engine
4243 .create_entity(args, actor, Some(&client), None)
4244 .unwrap_err();
4245 assert!(matches!(err, EngineError::Validation(_)));
4246 }
4247
4248 #[test]
4249 fn create_entity_persists_across_engine_restart() {
4250 let tmp = TempDir::new().unwrap();
4251 let mem_dir = tmp.path().to_path_buf();
4252 {
4253 let writer = FilesystemMemWriter::new(mem_dir.clone());
4254 let mut engine = Engine::from_mounts(vec![(
4255 folder_mount("specs", mem_dir.clone()),
4256 Box::new(writer) as Box<dyn MemBackend>,
4257 )])
4258 .unwrap();
4259 let (actor, client) = cli_actor();
4260 engine
4261 .create_entity(
4262 empty_create_args("specs", "Survives Restart"),
4263 actor,
4264 Some(&client),
4265 None,
4266 )
4267 .unwrap();
4268 }
4269 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
4271 let engine2 = Engine::from_mounts(vec![(
4272 folder_mount("specs", mem_dir),
4273 Box::new(writer2) as Box<dyn MemBackend>,
4274 )])
4275 .unwrap();
4276 let entity = engine2
4277 .get_entity(&crate::EntityId::new("specs", "survives-restart"))
4278 .expect("entity must persist across engine restart");
4279 assert_eq!(entity.title, "Survives Restart");
4280 }
4281
4282 fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
4289 let mem_dir = tmp.path().to_path_buf();
4290 let writer = FilesystemMemWriter::new(mem_dir.clone());
4291 let mut engine = Engine::from_mounts(vec![(
4292 folder_mount("specs", mem_dir),
4293 Box::new(writer) as Box<dyn MemBackend>,
4294 )])
4295 .unwrap();
4296 let (actor, client) = cli_actor();
4297 let outcome = engine
4298 .create_entity(
4299 empty_create_args("specs", title),
4300 actor,
4301 Some(&client),
4302 None,
4303 )
4304 .unwrap();
4305 (engine, outcome)
4306 }
4307
4308 #[test]
4316 fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
4317 let tmp = TempDir::new().unwrap();
4318 let mem_dir = tmp.path().to_path_buf();
4319 let writer = FilesystemMemWriter::new(mem_dir.clone());
4320 let mut engine = Engine::from_mounts(vec![(
4321 folder_mount("specs", mem_dir),
4322 Box::new(writer) as Box<dyn MemBackend>,
4323 )])
4324 .unwrap();
4325 let (actor, client) = cli_actor();
4326
4327 let ghost = crate::EntityId::new("specs", "ghost-target");
4328 assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
4329
4330 let mut args = empty_create_args("specs", "Source With Body Link");
4331 args.sections.insert(
4332 "identity".to_string(),
4333 "ref [[ghost-target]] for context".to_string(),
4334 );
4335
4336 let outcome = engine
4337 .create_entity(args, actor, Some(&client), None)
4338 .unwrap();
4339 let stubbed: Vec<&crate::EntityId> = outcome
4340 .warnings
4341 .iter()
4342 .filter_map(|w| match w {
4343 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
4344 _ => None,
4345 })
4346 .flatten()
4347 .collect();
4348 assert!(
4349 stubbed.contains(&&ghost),
4350 "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
4351 outcome.warnings,
4352 );
4353 assert!(
4355 engine.store().contains(&ghost),
4356 "ghost stub must materialise"
4357 );
4358 }
4359
4360 #[test]
4365 fn create_entity_drops_self_link_keeps_other_links_and_warns() {
4366 let tmp = TempDir::new().unwrap();
4367 let mem_dir = tmp.path().to_path_buf();
4368 let writer = FilesystemMemWriter::new(mem_dir.clone());
4369 let mut engine = Engine::from_mounts(vec![(
4370 folder_mount("specs", mem_dir),
4371 Box::new(writer) as Box<dyn MemBackend>,
4372 )])
4373 .unwrap();
4374 let (actor, client) = cli_actor();
4375
4376 let mut args = empty_create_args("specs", "Selfie");
4379 args.sections.insert(
4380 "identity".to_string(),
4381 "see [[selfie]] itself and also [[other-ref]]".to_string(),
4382 );
4383 let outcome = engine
4384 .create_entity(args, actor, Some(&client), None)
4385 .unwrap();
4386 let self_id = outcome.id.clone();
4387 assert_eq!(self_id.to_string(), "specs--selfie");
4388 let other_id = crate::EntityId::new("specs", "other-ref");
4389
4390 assert!(
4392 outcome.warnings.iter().any(|w| matches!(
4393 w, WarningHint::SelfLinkIgnored { id } if *id == self_id
4394 )),
4395 "self-link must emit SELF_LINK_IGNORED; got: {:?}",
4396 outcome.warnings,
4397 );
4398
4399 let ent = engine.get_entity(&self_id).unwrap();
4401 assert!(
4402 ent.relationships.iter().all(|r| r.target != self_id),
4403 "no self-relation may be synthesised; got: {:?}",
4404 ent.relationships,
4405 );
4406 assert!(
4407 engine
4408 .store()
4409 .outgoing(&self_id)
4410 .iter()
4411 .all(|e| e.target != self_id),
4412 "self must not be its own Outgoing neighbour",
4413 );
4414 assert!(
4415 engine
4416 .store()
4417 .incoming(&self_id)
4418 .iter()
4419 .all(|e| e.from != self_id),
4420 "self must not be its own Incoming neighbour",
4421 );
4422
4423 assert!(
4426 ent.relationships
4427 .iter()
4428 .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
4429 "non-self body link must still synthesise its edge; got: {:?}",
4430 ent.relationships,
4431 );
4432 }
4433
4434 #[test]
4436 fn create_entity_dry_run_emits_same_auto_stub_warning() {
4437 let tmp = TempDir::new().unwrap();
4438 let mem_dir = tmp.path().to_path_buf();
4439 let writer = FilesystemMemWriter::new(mem_dir.clone());
4440 let mut engine = Engine::from_mounts(vec![(
4441 folder_mount("specs", mem_dir),
4442 Box::new(writer) as Box<dyn MemBackend>,
4443 )])
4444 .unwrap();
4445 let (actor, client) = cli_actor();
4446
4447 let mut args = empty_create_args("specs", "Dry Run Body Link");
4448 args.dry_run = true;
4449 args.sections
4450 .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
4451
4452 let outcome = engine
4453 .create_entity(args, actor, Some(&client), None)
4454 .unwrap();
4455 let has_warning = outcome.warnings.iter().any(|w| {
4456 matches!(
4457 w,
4458 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
4459 if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
4460 )
4461 });
4462 assert!(
4463 has_warning,
4464 "dry_run must emit the same warning as real write: {:?}",
4465 outcome.warnings
4466 );
4467 }
4468
4469 #[test]
4472 fn create_entity_no_auto_stub_warning_when_target_exists() {
4473 let tmp = TempDir::new().unwrap();
4474 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
4475 let (actor, client) = cli_actor();
4476
4477 let mut args = empty_create_args("specs", "Source Linking Existing");
4478 let body = format!("ref [[{}]]", existing.id.path());
4479 args.sections.insert("identity".to_string(), body);
4480
4481 let outcome = engine
4482 .create_entity(args, actor, Some(&client), None)
4483 .unwrap();
4484 let has_warning = outcome
4485 .warnings
4486 .iter()
4487 .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
4488 assert!(
4489 !has_warning,
4490 "no auto-stub warning when target pre-exists; got: {:?}",
4491 outcome.warnings
4492 );
4493 }
4494
4495 #[test]
4500 fn obligation_wildcard_links_into_arbitrary_destination_schema() {
4501 use crate::engine::test_helpers::write_schema_files_with_default_type;
4502 use memstead_schema::workspace_config::CrossLinkValue;
4503
4504 let tmp = TempDir::new().unwrap();
4505 let dest_dir = tmp.path().join("dest");
4506 let duties_dir = tmp.path().join("duties");
4507 std::fs::create_dir_all(&dest_dir).unwrap();
4508 std::fs::create_dir_all(&duties_dir).unwrap();
4509 let schemas_dir = tmp.path().join("schemas");
4510 let user_manifest = r#"name: casefiles
4511version: 0.1.0
4512description: a user-written, non-software destination schema
4513when_to_use: tests
4514types:
4515 - doc
4516relationships:
4517 mode: strict
4518 definitions:
4519 - name: _default
4520 description: fallback
4521 default_weight: 1.0
4522community:
4523 resolution: 1.0
4524 seed: 42
4525"#;
4526 write_schema_files_with_default_type(
4527 &schemas_dir,
4528 "casefiles@0.1.0",
4529 user_manifest,
4530 &["doc"],
4531 );
4532
4533 let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
4534 mem: mem.to_string(),
4535 schema: Some(memstead_schema::SchemaRef::new(
4536 schema,
4537 semver::Version::new(0, 1, 0),
4538 )),
4539 storage: crate::workspace::MountStorage::Folder {
4540 path: dir.to_path_buf(),
4541 },
4542 capability: crate::workspace::MountCapability::Write,
4543 lifecycle: crate::workspace::MountLifecycle::Eager,
4544 cross_linkable: true,
4545 migration_target: None,
4546 };
4547 let mounts = vec![
4548 (
4549 mount("dest", &dest_dir, "casefiles"),
4550 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4551 ),
4552 (
4553 mount("duties", &duties_dir, "obligation"),
4554 Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
4555 ),
4556 ];
4557 let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4558 .expect("obligation + user schema boot");
4559 let mut settings = crate::workspace::WorkspaceSettings::default();
4560 settings.cross_mem_links.insert(
4561 "duties".to_string(),
4562 CrossLinkValue::List(vec!["dest".to_string()]),
4563 );
4564 engine.set_settings(settings);
4565 let (actor, client) = cli_actor();
4566
4567 let target = engine
4568 .create_entity(
4569 CreateEntityArgs {
4570 anchors: Vec::new(),
4571 mem: "dest".to_string(),
4572 title: "Case File 17".to_string(),
4573 entity_type: "doc".to_string(),
4574 sections: IndexMap::from_iter([(
4575 "body".to_string(),
4576 "destination content".to_string(),
4577 )]),
4578 metadata: IndexMap::new(),
4579 relations: Vec::new(),
4580 dry_run: false,
4581 },
4582 actor,
4583 Some(&client),
4584 None,
4585 )
4586 .unwrap();
4587
4588 let entry = engine
4589 .create_entity(
4590 CreateEntityArgs {
4591 anchors: Vec::new(),
4592 mem: "duties".to_string(),
4593 title: "File Annual Report & Notice".to_string(),
4594 entity_type: "obligation".to_string(),
4595 sections: IndexMap::from_iter([
4596 (
4597 "duty".to_string(),
4598 "File the report cited in [[dest--case-file-17]].".to_string(),
4599 ),
4600 (
4601 "consequence".to_string(),
4602 "Standing lapses at the deadline.".to_string(),
4603 ),
4604 ]),
4605 metadata: IndexMap::from_iter([
4606 ("due_date".to_string(), "2026-12-31".to_string()),
4607 ("status".to_string(), "open".to_string()),
4608 ]),
4609 relations: vec![crate::ops::RelateArg {
4610 to: crate::entity::EntityId::new("duties", "subject"),
4611 rel_type: "CONCERNS".to_string(),
4612 description: None,
4613 }],
4614 dry_run: false,
4615 },
4616 actor,
4617 Some(&client),
4618 None,
4619 )
4620 .expect("wildcard admits the alias link into the non-software destination");
4621 let stored = engine.get_entity(&entry.id).unwrap();
4622 assert!(
4623 stored
4624 .relationships
4625 .iter()
4626 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4627 "alias REFERENCES edge must emit cross-mem: {:?}",
4628 stored.relationships
4629 );
4630 }
4631
4632 #[test]
4643 fn ingest_wildcard_links_into_arbitrary_destination_schema() {
4644 use crate::engine::test_helpers::write_schema_files_with_default_type;
4645 use memstead_schema::workspace_config::CrossLinkValue;
4646
4647 let tmp = TempDir::new().unwrap();
4648 let dest_dir = tmp.path().join("dest");
4649 let proc_dir = tmp.path().join("proc");
4650 std::fs::create_dir_all(&dest_dir).unwrap();
4651 std::fs::create_dir_all(&proc_dir).unwrap();
4652
4653 let schemas_dir = tmp.path().join("schemas");
4655 let user_manifest = r#"name: debate
4656version: 0.1.0
4657description: a user-written destination schema
4658when_to_use: tests
4659types:
4660 - doc
4661relationships:
4662 mode: strict
4663 definitions:
4664 - name: _default
4665 description: fallback
4666 default_weight: 1.0
4667community:
4668 resolution: 1.0
4669 seed: 42
4670"#;
4671 write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
4672
4673 let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
4674 crate::workspace::Mount {
4675 mem: mem.to_string(),
4676 schema: Some(memstead_schema::SchemaRef::new(
4677 schema,
4678 semver::Version::new(version.0, version.1, version.2),
4679 )),
4680 storage: crate::workspace::MountStorage::Folder {
4681 path: dir.to_path_buf(),
4682 },
4683 capability: crate::workspace::MountCapability::Write,
4684 lifecycle: crate::workspace::MountLifecycle::Eager,
4685 cross_linkable: true,
4686 migration_target: None,
4687 }
4688 };
4689 let boot = |grant: bool| -> Engine {
4690 let mounts = vec![
4691 (
4692 mount("dest", &dest_dir, "debate", (0, 1, 0)),
4693 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4694 ),
4695 (
4696 mount("proc", &proc_dir, "ingest", (0, 2, 0)),
4697 Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
4698 ),
4699 ];
4700 let mut engine =
4701 Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4702 .expect("ingest + user schema boot");
4703 let mut settings = crate::workspace::WorkspaceSettings::default();
4704 if grant {
4705 settings.cross_mem_links.insert(
4706 "proc".to_string(),
4707 CrossLinkValue::List(vec!["dest".to_string()]),
4708 );
4709 }
4710 engine.set_settings(settings);
4711 engine
4712 };
4713 let (actor, client) = cli_actor();
4714
4715 let mut engine = boot(true);
4716 let target = engine
4718 .create_entity(
4719 CreateEntityArgs {
4720 anchors: Vec::new(),
4721 mem: "dest".to_string(),
4722 title: "Target Doc".to_string(),
4723 entity_type: "doc".to_string(),
4724 sections: IndexMap::from_iter([(
4725 "body".to_string(),
4726 "destination content".to_string(),
4727 )]),
4728 metadata: IndexMap::new(),
4729 relations: Vec::new(),
4730 dry_run: false,
4731 },
4732 actor,
4733 Some(&client),
4734 None,
4735 )
4736 .unwrap();
4737
4738 let entry = engine
4740 .create_entity(
4741 CreateEntityArgs {
4742 anchors: Vec::new(),
4743 mem: "proc".to_string(),
4744 title: "Check The Claim".to_string(),
4745 entity_type: "verification_target".to_string(),
4746 sections: IndexMap::from_iter([
4747 (
4748 "claim".to_string(),
4749 "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
4750 ),
4751 ("source_to_check".to_string(), "dest mem".to_string()),
4752 (
4753 "verifiable_when".to_string(),
4754 "the linked entity still says so".to_string(),
4755 ),
4756 ]),
4757 metadata: IndexMap::new(),
4758 relations: Vec::new(),
4759 dry_run: false,
4760 },
4761 actor,
4762 Some(&client),
4763 None,
4764 )
4765 .expect("wildcard admits the alias link into the user-schema destination");
4766 let stored = engine.get_entity(&entry.id).unwrap();
4767 assert!(
4768 stored
4769 .relationships
4770 .iter()
4771 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4772 "alias REFERENCES edge must emit: {:?}",
4773 stored.relationships
4774 );
4775
4776 let err = engine
4778 .relate_entity(
4779 RelateEntityArgs {
4780 source: entry.id.clone(),
4781 expected_hash: None,
4782 rel_type: "REFERENCES".to_string(),
4783 target: target.id.clone(),
4784 remove: false,
4785 description: None,
4786 dry_run: false,
4787 },
4788 actor,
4789 Some(&client),
4790 None,
4791 )
4792 .unwrap_err();
4793 assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
4794
4795 let err = engine
4798 .relate_entity(
4799 RelateEntityArgs {
4800 source: entry.id.clone(),
4801 expected_hash: None,
4802 rel_type: "PART_OF".to_string(),
4803 target: target.id.clone(),
4804 remove: false,
4805 description: None,
4806 dry_run: false,
4807 },
4808 actor,
4809 Some(&client),
4810 None,
4811 )
4812 .unwrap_err();
4813 assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
4814
4815 drop(engine);
4819 let rebooted = boot(true);
4820 let reloaded = rebooted.get_entity(&entry.id).unwrap();
4821 assert!(
4822 reloaded
4823 .relationships
4824 .iter()
4825 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4826 "alias edge must survive reload: {:?}",
4827 reloaded.relationships
4828 );
4829
4830 let mut denied = boot(false);
4833 let err = denied
4834 .create_entity(
4835 CreateEntityArgs {
4836 anchors: Vec::new(),
4837 mem: "proc".to_string(),
4838 title: "Denied Entry".to_string(),
4839 entity_type: "verification_target".to_string(),
4840 sections: IndexMap::from_iter([
4841 (
4842 "claim".to_string(),
4843 "points at [[dest--target-doc]]".to_string(),
4844 ),
4845 ("source_to_check".to_string(), "dest mem".to_string()),
4846 ("verifiable_when".to_string(), "never".to_string()),
4847 ]),
4848 metadata: IndexMap::new(),
4849 relations: Vec::new(),
4850 dry_run: false,
4851 },
4852 actor,
4853 Some(&client),
4854 None,
4855 )
4856 .unwrap_err();
4857 assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
4858 }
4859
4860 fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
4865 let tmp_test = TempDir::new().unwrap();
4866 let tmp_other = TempDir::new().unwrap();
4867 let test_dir = tmp_test.path().to_path_buf();
4868 let other_dir = tmp_other.path().to_path_buf();
4869 let writer_test = FilesystemMemWriter::new(test_dir.clone());
4870 let writer_other = FilesystemMemWriter::new(other_dir.clone());
4871 let engine = Engine::from_mounts(vec![
4872 (
4873 folder_mount("test", test_dir),
4874 Box::new(writer_test) as Box<dyn MemBackend>,
4875 ),
4876 (
4877 folder_mount("other", other_dir),
4878 Box::new(writer_other) as Box<dyn MemBackend>,
4879 ),
4880 ])
4881 .unwrap();
4882 (tmp_test, tmp_other, engine)
4883 }
4884
4885 #[test]
4890 fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
4891 use crate::entity::EntityId;
4892 use crate::ops::RelateArg;
4893 use memstead_schema::workspace_config::CrossLinkValue;
4894
4895 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4896 let (actor, client) = cli_actor();
4897
4898 let mut settings = crate::workspace::WorkspaceSettings::default();
4901 settings.cross_mem_links.insert(
4902 "test".to_string(),
4903 CrossLinkValue::List(vec!["other".to_string()]),
4904 );
4905 engine.set_settings(settings);
4906
4907 let target = engine
4912 .create_entity(
4913 empty_create_args("test", "Target"),
4914 actor,
4915 Some(&client),
4916 None,
4917 )
4918 .unwrap();
4919
4920 let mut args = empty_create_args("other", "Source");
4921 args.relations = vec![RelateArg {
4922 rel_type: "IMPLEMENTS".to_string(),
4923 to: target.id.clone(),
4924 description: None,
4925 }];
4926 let err = engine
4927 .create_entity(args, actor, Some(&client), None)
4928 .unwrap_err();
4929 match err {
4930 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
4931 assert_eq!(from_mem, "other");
4932 assert_eq!(to_mem, "test");
4933 }
4934 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
4935 }
4936
4937 let would_be = EntityId::new("other", "source");
4939 assert!(
4940 engine.get_entity(&would_be).is_none(),
4941 "entity must not persist when inline relation refuses"
4942 );
4943 }
4944
4945 #[test]
4948 fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
4949 use crate::ops::RelateArg;
4950 use memstead_schema::workspace_config::CrossLinkValue;
4951
4952 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4953 let (actor, client) = cli_actor();
4954
4955 let mut settings = crate::workspace::WorkspaceSettings::default();
4956 settings.cross_mem_links.insert(
4957 "other".to_string(),
4958 CrossLinkValue::List(vec!["test".to_string()]),
4959 );
4960 engine.set_settings(settings);
4961
4962 let target = engine
4963 .create_entity(
4964 empty_create_args("test", "Target"),
4965 actor,
4966 Some(&client),
4967 None,
4968 )
4969 .unwrap();
4970
4971 let mut args = empty_create_args("other", "Source");
4972 args.relations = vec![RelateArg {
4973 rel_type: "IMPLEMENTS".to_string(),
4974 to: target.id.clone(),
4975 description: None,
4976 }];
4977 let outcome = engine
4978 .create_entity(args, actor, Some(&client), None)
4979 .unwrap();
4980 let stored = engine.get_entity(&outcome.id).expect("entity persists");
4981 assert!(
4982 stored
4983 .relationships
4984 .iter()
4985 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
4986 "IMPLEMENTS edge must persist on the source's relationships",
4987 );
4988 }
4989
4990 #[test]
4994 fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
4995 use crate::ops::RelateArg;
4996
4997 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4998 let (actor, client) = cli_actor();
4999 let target = engine
5002 .create_entity(
5003 empty_create_args("test", "Target"),
5004 actor,
5005 Some(&client),
5006 None,
5007 )
5008 .unwrap();
5009 let mut args = empty_create_args("test", "Source");
5010 args.relations = vec![RelateArg {
5011 rel_type: "USES".to_string(),
5012 to: target.id.clone(),
5013 description: None,
5014 }];
5015 let outcome = engine
5016 .create_entity(args, actor, Some(&client), None)
5017 .unwrap();
5018 let stored = engine.get_entity(&outcome.id).expect("entity persists");
5019 assert!(
5020 stored
5021 .relationships
5022 .iter()
5023 .any(|r| r.rel_type == "USES" && r.target == target.id),
5024 "same-mem USES edge must persist",
5025 );
5026 }
5027
5028 #[test]
5033 fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
5034 use crate::ops::RelateArg;
5035 use memstead_schema::workspace_config::CrossLinkValue;
5036
5037 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5038 let (actor, client) = cli_actor();
5039
5040 let mut settings = crate::workspace::WorkspaceSettings::default();
5041 settings.cross_mem_links.insert(
5042 "test".to_string(),
5043 CrossLinkValue::List(vec!["other".to_string()]),
5044 );
5045 engine.set_settings(settings);
5046
5047 let target = engine
5048 .create_entity(
5049 empty_create_args("test", "Target"),
5050 actor,
5051 Some(&client),
5052 None,
5053 )
5054 .unwrap();
5055 let src = engine
5056 .create_entity(
5057 empty_create_args("other", "Source"),
5058 actor,
5059 Some(&client),
5060 None,
5061 )
5062 .unwrap();
5063
5064 let relate_err = engine
5066 .relate_entity(
5067 RelateEntityArgs {
5068 source: src.id.clone(),
5069 rel_type: "IMPLEMENTS".to_string(),
5070 target: target.id.clone(),
5071 expected_hash: Some(src.content_hash.clone()),
5072 remove: false,
5073 description: None,
5074 dry_run: false,
5075 },
5076 actor,
5077 Some(&client),
5078 None,
5079 )
5080 .unwrap_err();
5081
5082 let mut create_args = empty_create_args("other", "Source Two");
5085 create_args.relations = vec![RelateArg {
5086 rel_type: "IMPLEMENTS".to_string(),
5087 to: target.id.clone(),
5088 description: None,
5089 }];
5090 let create_err = engine
5091 .create_entity(create_args, actor, Some(&client), None)
5092 .unwrap_err();
5093
5094 match (relate_err, create_err) {
5097 (
5098 EngineError::CrossMemLinkNotAllowed {
5099 from_mem: rfv,
5100 to_mem: rtv,
5101 },
5102 EngineError::CrossMemLinkNotAllowed {
5103 from_mem: cfv,
5104 to_mem: ctv,
5105 },
5106 ) => {
5107 assert_eq!(rfv, "other");
5108 assert_eq!(rtv, "test");
5109 assert_eq!(cfv, "other");
5110 assert_eq!(ctv, "test");
5111 }
5112 (a, b) => panic!(
5113 "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
5114 ),
5115 }
5116 }
5117
5118 #[test]
5124 fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
5125 use crate::entity::EntityId;
5126 use indexmap::IndexMap;
5127 use memstead_schema::workspace_config::CrossLinkValue;
5128
5129 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5130 let (actor, client) = cli_actor();
5131
5132 let mut settings = crate::workspace::WorkspaceSettings::default();
5133 settings.cross_mem_links.insert(
5134 "test".to_string(),
5135 CrossLinkValue::List(vec!["other".to_string()]),
5136 );
5137 engine.set_settings(settings);
5138
5139 let mut sections: IndexMap<String, String> = IndexMap::new();
5140 sections.insert(
5141 "identity".to_string(),
5142 "see [[other--target]] for details".to_string(),
5143 );
5144 sections.insert("purpose".to_string(), "source purpose".to_string());
5145 let outcome = engine
5146 .create_entity(
5147 crate::engine::CreateEntityArgs {
5148 anchors: Vec::new(),
5149 mem: "test".to_string(),
5150 title: "Source".to_string(),
5151 entity_type: "spec".to_string(),
5152 sections,
5153 metadata: IndexMap::new(),
5154 relations: Vec::new(),
5155 dry_run: false,
5156 },
5157 actor,
5158 Some(&client),
5159 None,
5160 )
5161 .unwrap();
5162
5163 let canonical = EntityId::new("other", "target");
5165 assert!(
5166 engine.get_entity(&canonical).is_some(),
5167 "auto-stub must land at the canonical cross-mem id"
5168 );
5169 let phantom = EntityId::new("test", "other--target");
5170 assert!(
5171 engine.get_entity(&phantom).is_none(),
5172 "no double-prefixed phantom stub"
5173 );
5174
5175 let source = engine.get_entity(&outcome.id).unwrap();
5177 let references_count = source
5178 .relationships
5179 .iter()
5180 .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
5181 .count();
5182 assert_eq!(
5183 references_count, 1,
5184 "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
5185 );
5186 }
5187
5188 #[test]
5192 fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
5193 use indexmap::IndexMap;
5194
5195 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5196 let (actor, client) = cli_actor();
5197 let mut sections: IndexMap<String, String> = IndexMap::new();
5200 sections.insert(
5201 "identity".to_string(),
5202 "see [[other--target]] for details".to_string(),
5203 );
5204 sections.insert("purpose".to_string(), "source purpose".to_string());
5205 let err = engine
5206 .create_entity(
5207 crate::engine::CreateEntityArgs {
5208 anchors: Vec::new(),
5209 mem: "test".to_string(),
5210 title: "Source".to_string(),
5211 entity_type: "spec".to_string(),
5212 sections,
5213 metadata: IndexMap::new(),
5214 relations: Vec::new(),
5215 dry_run: false,
5216 },
5217 actor,
5218 Some(&client),
5219 None,
5220 )
5221 .unwrap_err();
5222 match err {
5223 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
5224 assert_eq!(from_mem, "test");
5225 assert_eq!(to_mem, "other");
5226 }
5227 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
5228 }
5229 }
5230
5231 #[test]
5239 fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
5240 use crate::engine::UpdateEntityArgs;
5241 use crate::workspace::{MutationsSection, WorkspaceSettings};
5242 use indexmap::IndexMap;
5243
5244 let tmp = TempDir::new().unwrap();
5245 let mem_dir = tmp.path().to_path_buf();
5246 let writer = FilesystemMemWriter::new(mem_dir.clone());
5247 let mut engine = Engine::from_mounts(vec![(
5248 folder_mount("specs", mem_dir.clone()),
5249 Box::new(writer) as Box<dyn MemBackend>,
5250 )])
5251 .unwrap();
5252 engine.set_workspace_root(mem_dir.clone());
5253 engine.set_settings(WorkspaceSettings {
5254 mutations: MutationsSection {
5255 require_notes: Some(true),
5256 },
5257 ..Default::default()
5258 });
5259 let (actor, client) = cli_actor();
5260
5261 let note_missing = |ws: &[WarningHint]| -> usize {
5262 ws.iter()
5263 .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
5264 .count()
5265 };
5266
5267 let created = engine
5269 .create_entity(
5270 empty_create_args("specs", "Noteless"),
5271 actor,
5272 Some(&client),
5273 None,
5274 )
5275 .unwrap();
5276 assert_eq!(
5277 note_missing(&created.warnings),
5278 1,
5279 "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
5280 created.warnings,
5281 );
5282 assert!(
5283 matches!(
5284 created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
5285 Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
5286 ),
5287 "the warning names the engine-level verb",
5288 );
5289 assert!(
5290 !created.commit_sha.is_empty(),
5291 "create still commits (nudge, not block)"
5292 );
5293
5294 let mut edit: IndexMap<String, String> = IndexMap::new();
5296 edit.insert("identity".to_string(), "revised".to_string());
5297 let updated = engine
5298 .update_entity(
5299 UpdateEntityArgs {
5300 anchors: Vec::new(),
5301 id: created.id.clone(),
5302 expected_hash: Some(created.content_hash.clone()),
5303 sections: edit,
5304 append_sections: IndexMap::new(),
5305 patch_sections: IndexMap::new(),
5306 metadata: IndexMap::new(),
5307 metadata_unset: Vec::new(),
5308 declare_relations: Vec::new(),
5309 dry_run: false,
5310 relations_unset: Vec::new(),
5311 anchors_unset: Vec::new(),
5312 },
5313 actor,
5314 Some(&client),
5315 None,
5316 )
5317 .unwrap();
5318 assert_eq!(
5319 note_missing(&updated.warnings),
5320 1,
5321 "update emits NOTE_MISSING"
5322 );
5323 assert!(!updated.commit_sha.is_empty(), "update still commits");
5324
5325 let target = engine
5327 .create_entity(
5328 empty_create_args("specs", "Target"),
5329 actor,
5330 Some(&client),
5331 Some("seed"),
5332 )
5333 .unwrap();
5334 let related = engine
5335 .relate_entity(
5336 RelateEntityArgs {
5337 source: updated.id.clone(),
5338 expected_hash: Some(updated.content_hash.clone()),
5339 rel_type: "USES".to_string(),
5340 target: target.id.clone(),
5341 remove: false,
5342 description: None,
5343 dry_run: false,
5344 },
5345 actor,
5346 Some(&client),
5347 None,
5348 )
5349 .unwrap();
5350 assert_eq!(
5351 note_missing(&related.warnings),
5352 1,
5353 "relate emits NOTE_MISSING"
5354 );
5355 assert!(!related.commit_sha.is_empty(), "relate still commits");
5356
5357 let with_note = engine
5359 .create_entity(
5360 empty_create_args("specs", "Documented"),
5361 actor,
5362 Some(&client),
5363 Some("a real provenance note"),
5364 )
5365 .unwrap();
5366 assert_eq!(
5367 note_missing(&with_note.warnings),
5368 0,
5369 "a supplied note suppresses the warning",
5370 );
5371
5372 engine.set_settings(WorkspaceSettings::default());
5374 let after_off = engine
5375 .create_entity(
5376 empty_create_args("specs", "Quiet"),
5377 actor,
5378 Some(&client),
5379 None,
5380 )
5381 .unwrap();
5382 assert_eq!(
5383 note_missing(&after_off.warnings),
5384 0,
5385 "no NOTE_MISSING when require_notes is unset",
5386 );
5387 }
5388
5389 fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5392 crate::anchor::AnchorInput {
5393 artifact: Some(artifact.to_string()),
5394 grain: Some("file".to_string()),
5395 class: Some("anchored".to_string()),
5396 hash: Some(hash.to_string()),
5397 hash_stability: Some("stable".to_string()),
5398 ..Default::default()
5399 }
5400 }
5401
5402 fn folder_engine(mem: &str) -> (Engine, TempDir) {
5403 let tmp = TempDir::new().unwrap();
5404 let dir = tmp.path().to_path_buf();
5405 let writer = FilesystemMemWriter::new(dir.clone());
5406 let engine = Engine::from_mounts(vec![(
5407 folder_mount(mem, dir.clone()),
5408 Box::new(writer) as Box<dyn MemBackend>,
5409 )])
5410 .unwrap();
5411 (engine, tmp)
5412 }
5413
5414 #[test]
5415 fn create_with_anchors_persists_and_survives_reload() {
5416 let (mut engine, tmp) = folder_engine("specs");
5417 let dir = tmp.path().to_path_buf();
5418 let (actor, client) = cli_actor();
5419 let mut args = empty_create_args("specs", "Anchored Entity");
5420 args.anchors = vec![file_anchor("src/lib.rs", "h1")];
5421 engine
5422 .create_entity(args, actor, Some(&client), None)
5423 .unwrap();
5424
5425 let id = crate::EntityId::new("specs", "anchored-entity");
5426 let anchors = engine.entity_anchors(&id);
5427 assert_eq!(anchors.len(), 1);
5428 assert_eq!(anchors[0].artifact, "src/lib.rs");
5429 assert_eq!(
5430 anchors[0].class,
5431 crate::anchor::AnchorProvenanceClass::Anchored
5432 );
5433
5434 let writer = FilesystemMemWriter::new(dir.clone());
5436 let reloaded = Engine::from_mounts(vec![(
5437 folder_mount("specs", dir.clone()),
5438 Box::new(writer) as Box<dyn MemBackend>,
5439 )])
5440 .unwrap();
5441 assert_eq!(reloaded.entity_anchors(&id).len(), 1);
5442 assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
5444 }
5445
5446 #[test]
5447 fn malformed_anchor_refuses_and_entity_not_written() {
5448 let (mut engine, tmp) = folder_engine("specs");
5449 let (actor, client) = cli_actor();
5450 let mut args = empty_create_args("specs", "Bad Anchor");
5451 args.anchors = vec![crate::anchor::AnchorInput {
5452 artifact: Some("x".into()),
5453 grain: Some("paragraph".into()), class: Some("anchored".into()),
5455 ..Default::default()
5456 }];
5457 let err = engine
5458 .create_entity(args, actor, Some(&client), None)
5459 .unwrap_err();
5460 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5461 assert!(
5463 engine
5464 .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
5465 .is_none()
5466 );
5467 assert!(!tmp.path().join("bad-anchor.md").exists());
5468 }
5469
5470 #[test]
5471 fn anchors_are_not_folded_into_content_hash() {
5472 let (mut anchored, _t1) = folder_engine("specs");
5476 let (mut plain, _t2) = folder_engine("specs");
5477 let (actor, client) = cli_actor();
5478
5479 let mut a = empty_create_args("specs", "Same Title");
5480 a.anchors = vec![file_anchor("src/lib.rs", "h1")];
5481 let with = anchored
5482 .create_entity(a, actor, Some(&client), None)
5483 .unwrap();
5484
5485 let p = empty_create_args("specs", "Same Title");
5486 let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
5487
5488 assert_eq!(
5489 with.content_hash, without.content_hash,
5490 "anchors must not change the entity content hash"
5491 );
5492 }
5493
5494 #[test]
5495 fn anchorless_create_writes_no_sidecar() {
5496 let (mut engine, _tmp) = folder_engine("specs");
5497 let (actor, client) = cli_actor();
5498 engine
5499 .create_entity(
5500 empty_create_args("specs", "No Anchors"),
5501 actor,
5502 Some(&client),
5503 None,
5504 )
5505 .unwrap();
5506 assert!(
5507 engine
5508 .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
5509 .is_empty()
5510 );
5511 }
5512
5513 #[test]
5524 fn create_refuses_reserved_metadata_keys_deliberately() {
5525 let (mut engine, _tmp) = folder_engine("specs");
5526 let (actor, client) = cli_actor();
5527 for reserved in ["type", "mem", "id"] {
5528 let mut args = empty_create_args("specs", "Smuggler");
5529 args.metadata
5530 .insert(reserved.to_string(), "bogus".to_string());
5531 let err = engine
5532 .create_entity(args, actor, Some(&client), None)
5533 .expect_err("reserved key must refuse on create");
5534 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5535 assert!(
5536 engine
5537 .get_entity(&crate::EntityId::new("specs", "smuggler"))
5538 .is_none(),
5539 "entity must not be written after the '{reserved}' refusal"
5540 );
5541 }
5542 engine
5544 .create_entity(
5545 empty_create_args("specs", "Smuggler"),
5546 actor,
5547 Some(&client),
5548 None,
5549 )
5550 .expect("a clean create is untouched by the reserved-key gate");
5551 }
5552
5553 fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
5556 let mut args = empty_create_args(mem, title);
5557 args.relations = vec![crate::ops::RelateArg {
5558 to: crate::EntityId(to.to_string()),
5559 rel_type: rel_type.to_string(),
5560 description: None,
5561 }];
5562 args
5563 }
5564
5565 #[test]
5573 fn create_relations_refuse_cycle_and_self_loop_like_relate() {
5574 let (mut engine, _tmp) = folder_engine("specs");
5575 let (actor, client) = cli_actor();
5576
5577 engine
5579 .create_entity(
5580 create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
5581 actor,
5582 Some(&client),
5583 None,
5584 )
5585 .unwrap();
5586
5587 let err = engine
5589 .create_entity(
5590 create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
5591 actor,
5592 Some(&client),
5593 None,
5594 )
5595 .expect_err("cycle-closing create.relations[] must refuse");
5596 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5597 let details = err.details();
5599 assert_eq!(details["rel_type"], "PART_OF");
5600 assert!(details["existing_path"].is_array());
5601 assert!(
5602 engine
5603 .get_entity(&crate::EntityId::new("specs", "ghost"))
5604 .is_none_or(|e| e.stub),
5605 "the refused entity must not be written"
5606 );
5607
5608 let err = engine
5610 .create_entity(
5611 create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
5612 actor,
5613 Some(&client),
5614 None,
5615 )
5616 .expect_err("self-loop create.relations[] must refuse");
5617 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5618
5619 engine
5622 .create_entity(
5623 create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
5624 actor,
5625 Some(&client),
5626 None,
5627 )
5628 .expect("a non-cycle PART_OF edge must land as today");
5629 }
5630
5631 #[test]
5635 fn batch_create_refuses_intra_batch_cycle() {
5636 let (mut engine, _tmp) = folder_engine("specs");
5637 let (actor, client) = cli_actor();
5638
5639 let result = engine
5640 .batch_create(
5641 vec![
5642 (
5643 create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
5644 None,
5645 ),
5646 (
5647 create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
5648 None,
5649 ),
5650 ],
5651 actor,
5652 Some(&client),
5653 false,
5654 )
5655 .expect("batch returns a result envelope");
5656 assert!(!result.applied, "intra-batch cycle must refuse the batch");
5657 assert!(
5658 result.results.iter().any(|r| r
5659 .error
5660 .as_ref()
5661 .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
5662 "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
5663 result.results
5664 );
5665 assert!(
5666 engine
5667 .get_entity(&crate::EntityId::new("specs", "ping"))
5668 .is_none(),
5669 "nothing lands from a refused batch"
5670 );
5671
5672 let result = engine
5674 .batch_create(
5675 vec![
5676 (
5677 create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
5678 None,
5679 ),
5680 (empty_create_args("specs", "Chain Two"), None),
5681 ],
5682 actor,
5683 Some(&client),
5684 false,
5685 )
5686 .expect("acyclic batch lands");
5687 assert!(result.applied, "{:?}", result.results);
5688 assert_eq!(result.succeeded, 2);
5689 }
5690
5691 #[test]
5697 fn deep_acyclic_chain_near_path_cap_is_accepted() {
5698 let (mut engine, _tmp) = folder_engine("specs");
5699 let (actor, client) = cli_actor();
5700 let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
5701
5702 engine
5704 .create_entity(
5705 empty_create_args("specs", "Link 0"),
5706 actor,
5707 Some(&client),
5708 None,
5709 )
5710 .unwrap();
5711 for i in 1..depth {
5712 engine
5713 .create_entity(
5714 create_with_relation(
5715 "specs",
5716 &format!("Link {i}"),
5717 "PART_OF",
5718 &format!("specs--link-{}", i - 1),
5719 ),
5720 actor,
5721 Some(&client),
5722 None,
5723 )
5724 .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
5725 }
5726
5727 let last = depth - 1;
5730 let err = engine
5731 .update_entity(
5732 {
5733 let id = crate::EntityId::new("specs", "link-0");
5734 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
5735 crate::engine::UpdateEntityArgs {
5736 anchors: Vec::new(),
5737 anchors_unset: Vec::new(),
5738 id,
5739 expected_hash: Some(hash),
5740 sections: IndexMap::new(),
5741 append_sections: IndexMap::new(),
5742 patch_sections: IndexMap::new(),
5743 metadata: IndexMap::new(),
5744 metadata_unset: Vec::new(),
5745 declare_relations: vec![crate::ops::RelateArg {
5746 to: crate::EntityId::new("specs", &format!("link-{last}")),
5747 rel_type: "PART_OF".to_string(),
5748 description: None,
5749 }],
5750 dry_run: false,
5751 relations_unset: Vec::new(),
5752 }
5753 },
5754 actor,
5755 Some(&client),
5756 None,
5757 )
5758 .expect_err("closing the deep chain must refuse");
5759 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
5760 let details = err.details();
5761 assert_eq!(details["path_truncated"], true);
5762 assert_eq!(
5763 details["existing_path"].as_array().unwrap().len(),
5764 crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
5765 );
5766 }
5767
5768 const FORMAT_MANIFEST: &str = r#"name: formatproof
5769version: 0.1.0
5770description: section-format proof schema
5771when_to_use: format tests
5772types:
5773 - plan
5774relationships:
5775 mode: strict
5776 definitions:
5777 - name: PART_OF
5778 description: hier
5779 default_weight: 1.0
5780 - name: _default
5781 description: fallback
5782 default_weight: 1.0
5783community:
5784 resolution: 1.0
5785 seed: 42
5786"#;
5787
5788 const FORMAT_PLAN_TYPE: &str = r#"name: plan
5789description: a plan with formatted milestones
5790when_to_use: tests
5791sections:
5792 - key: body
5793 heading: Body
5794 required: true
5795 search_weight: 10.0
5796 catch_all: true
5797 write_rules: []
5798 - key: meilensteine
5799 heading: Meilensteine
5800 required: false
5801 search_weight: 5.0
5802 catch_all: false
5803 write_rules: []
5804 content: "(heading(3) list(bullet))+"
5805 item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
5806 example: |
5807 ### Phase 1
5808 - **Kickoff** — 2026-09-01
5809 - key: notizen
5810 heading: Notizen
5811 required: false
5812 search_weight: 5.0
5813 catch_all: false
5814 write_rules: []
5815 content: "list(bullet)"
5816 format_severity: warn
5817metadata_fields: []
5818title_weight: 100.0
5819text_fields:
5820 - body
5821hierarchy_relationship: PART_OF
5822no_self_loop_relationships: []
5823updatable_fields:
5824 - title
5825 - body
5826 - meilensteine
5827 - notizen
5828health_required_fields:
5829 - body
5830staleness_threshold_days: 90
5831write_rules: []
5832"#;
5833
5834 fn format_engine(tmp: &TempDir) -> Engine {
5835 engine_with_proof_schema(
5836 tmp,
5837 "formatproof",
5838 FORMAT_MANIFEST,
5839 &[("plan", FORMAT_PLAN_TYPE)],
5840 )
5841 }
5842
5843 fn plan_create_args(
5844 title: &str,
5845 meilensteine: Option<&str>,
5846 notizen: Option<&str>,
5847 ) -> CreateEntityArgs {
5848 let mut sections = IndexMap::new();
5849 sections.insert("body".to_string(), "a plan body.".to_string());
5850 if let Some(m) = meilensteine {
5851 sections.insert("meilensteine".to_string(), m.to_string());
5852 }
5853 if let Some(n) = notizen {
5854 sections.insert("notizen".to_string(), n.to_string());
5855 }
5856 CreateEntityArgs {
5857 anchors: Vec::new(),
5858 mem: "proof".to_string(),
5859 title: title.to_string(),
5860 entity_type: "plan".to_string(),
5861 sections,
5862 metadata: IndexMap::new(),
5863 relations: vec![],
5864 dry_run: false,
5865 }
5866 }
5867
5868 #[test]
5872 fn create_enforces_declared_section_format() {
5873 let tmp = TempDir::new().unwrap();
5874 let mut engine = format_engine(&tmp);
5875 let (actor, client) = cli_actor();
5876
5877 let err = engine
5878 .create_entity(
5879 plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
5880 actor,
5881 Some(&client),
5882 None,
5883 )
5884 .unwrap_err();
5885 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5886 let details = err.details();
5887 assert_eq!(details["section"], "meilensteine");
5888 assert!(
5889 details["example"].as_str().unwrap().contains("Kickoff"),
5890 "the conforming example is echoed: {details}"
5891 );
5892 assert_eq!(details["expected_next"][0], "list(bullet)");
5893
5894 let err = engine
5896 .create_entity(
5897 plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
5898 actor,
5899 Some(&client),
5900 None,
5901 )
5902 .unwrap_err();
5903 assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
5904
5905 engine
5907 .create_entity(
5908 plan_create_args(
5909 "Plan C",
5910 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5911 None,
5912 ),
5913 actor,
5914 Some(&client),
5915 None,
5916 )
5917 .unwrap();
5918
5919 let outcome = engine
5921 .create_entity(
5922 plan_create_args(
5923 "Plan D",
5924 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5925 Some("kein listenpunkt\n"),
5926 ),
5927 actor,
5928 Some(&client),
5929 None,
5930 )
5931 .unwrap();
5932 assert!(!outcome.commit_sha.is_empty(), "warn tier never refuses");
5933
5934 let err = engine
5940 .create_entity(
5941 plan_create_args("Plan E", None, None),
5942 actor,
5943 Some(&client),
5944 None,
5945 )
5946 .unwrap_err();
5947 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5948 }
5949
5950 #[test]
5954 fn update_judges_format_on_composed_body() {
5955 let tmp = TempDir::new().unwrap();
5956 let mut engine = format_engine(&tmp);
5957 let (actor, client) = cli_actor();
5958 let created = engine
5959 .create_entity(
5960 plan_create_args(
5961 "Plan A",
5962 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5963 None,
5964 ),
5965 actor,
5966 Some(&client),
5967 None,
5968 )
5969 .unwrap();
5970
5971 let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
5974 let mut append = IndexMap::new();
5975 append.insert(
5976 "meilensteine".to_string(),
5977 "\n\nnachtrag als absatz\n".to_string(),
5978 );
5979 let err = engine
5980 .update_entity(
5981 crate::engine::UpdateEntityArgs {
5982 anchors: Vec::new(),
5983 id: created.id.clone(),
5984 expected_hash: Some(current.clone()),
5985 sections: IndexMap::new(),
5986 append_sections: append,
5987 patch_sections: IndexMap::new(),
5988 metadata: IndexMap::new(),
5989 metadata_unset: Vec::new(),
5990 declare_relations: vec![],
5991 dry_run: false,
5992 relations_unset: Vec::new(),
5993 anchors_unset: Vec::new(),
5994 },
5995 actor,
5996 Some(&client),
5997 None,
5998 )
5999 .unwrap_err();
6000 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
6001
6002 let mut append = IndexMap::new();
6004 append.insert(
6005 "meilensteine".to_string(),
6006 "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
6007 );
6008 engine
6009 .update_entity(
6010 crate::engine::UpdateEntityArgs {
6011 anchors: Vec::new(),
6012 id: created.id.clone(),
6013 expected_hash: Some(current),
6014 sections: IndexMap::new(),
6015 append_sections: append,
6016 patch_sections: IndexMap::new(),
6017 metadata: IndexMap::new(),
6018 metadata_unset: Vec::new(),
6019 declare_relations: vec![],
6020 dry_run: false,
6021 relations_unset: Vec::new(),
6022 anchors_unset: Vec::new(),
6023 },
6024 actor,
6025 Some(&client),
6026 None,
6027 )
6028 .unwrap();
6029 }
6030
6031 #[test]
6035 fn embedded_h1_refuses_in_any_section() {
6036 let tmp = TempDir::new().unwrap();
6037 let mut engine = format_engine(&tmp);
6038 let (actor, client) = cli_actor();
6039 let mut args = plan_create_args(
6040 "Plan H",
6041 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
6042 None,
6043 );
6044 args.sections.insert(
6045 "body".to_string(),
6046 "intro\n# Injected Title\ntail".to_string(),
6047 );
6048 let err = engine
6049 .create_entity(args, actor, Some(&client), None)
6050 .unwrap_err();
6051 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
6052 }
6053}