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