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 sections_unset: Vec::new(),
1931 metadata,
1932 metadata_unset: Vec::new(),
1933 declare_relations: vec![],
1934 dry_run: false,
1935 relations_unset: Vec::new(),
1936 anchors_unset: Vec::new(),
1937 },
1938 actor,
1939 Some(&client),
1940 None,
1941 )
1942 };
1943
1944 let tmp = TempDir::new().unwrap();
1946 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1947 let a = engine
1948 .create_entity(
1949 task_create_args("Task A", vec![]),
1950 actor,
1951 Some(&client),
1952 None,
1953 )
1954 .unwrap();
1955 let outcome = set_checked(&mut engine, &a.id).unwrap();
1956 assert!(!outcome.write_id.is_empty());
1957 assert!(
1958 outcome
1959 .warnings
1960 .iter()
1961 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1962 "warn-tier update carries the warning: {:?}",
1963 outcome.warnings
1964 );
1965
1966 let tmp = TempDir::new().unwrap();
1969 let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1970 let b = engine
1971 .create_entity(
1972 task_create_args("Task B", vec![]),
1973 actor,
1974 Some(&client),
1975 None,
1976 )
1977 .unwrap();
1978 let err = set_checked(&mut engine, &b.id).unwrap_err();
1979 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1980 assert!(
1981 !engine
1982 .get_entity(&b.id)
1983 .unwrap()
1984 .metadata
1985 .contains_key("status"),
1986 "refused update leaves the entity unchanged"
1987 );
1988 }
1989
1990 #[test]
1994 fn relate_remove_refuses_block_tier_required_outgoing() {
1995 let tmp = TempDir::new().unwrap();
1996 let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1997 let (actor, client) = cli_actor();
1998 let parent_id = crate::entity::EntityId("tasks--parent".to_string());
1999 let child = engine
2000 .create_entity(
2001 task_create_args(
2002 "Child Task",
2003 vec![crate::ops::RelateArg {
2004 target: parent_id.clone(),
2005 rel_type: "PART_OF".to_string(),
2006 description: None,
2007 }],
2008 ),
2009 actor,
2010 Some(&client),
2011 None,
2012 )
2013 .unwrap();
2014
2015 let err = engine
2016 .relate_entity(
2017 crate::engine::RelateEntityArgs {
2018 source: child.id.clone(),
2019 target: parent_id.clone(),
2020 rel_type: "PART_OF".to_string(),
2021 description: None,
2022 remove: true,
2023 expected_hash: None,
2024 dry_run: false,
2025 },
2026 actor,
2027 Some(&client),
2028 None,
2029 )
2030 .unwrap_err();
2031 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2032 assert!(
2033 engine
2034 .get_entity(&child.id)
2035 .unwrap()
2036 .relationships
2037 .iter()
2038 .any(|r| r.rel_type == "PART_OF" && r.target == parent_id),
2039 "refused remove leaves the edge in place"
2040 );
2041 }
2042
2043 fn engine_with_conditional_ro_schema(tmp: &TempDir, severity: &str) -> Engine {
2048 let schemas_dir = tmp.path().join("schemas");
2049 let pkg = schemas_dir.join("condro");
2050 std::fs::create_dir_all(pkg.join("types")).unwrap();
2051 std::fs::write(
2052 pkg.join("schema.yaml"),
2053 r#"name: condro
2054version: 0.1.0
2055description: conditional required_outgoing fixture
2056when_to_use: tests
2057types:
2058 - task
2059relationships:
2060 mode: strict
2061 definitions:
2062 - name: PART_OF
2063 description: hier
2064 default_weight: 3.0
2065 - name: _default
2066 description: fallback
2067 default_weight: 1.0
2068community:
2069 resolution: 1.0
2070 seed: 42
2071"#,
2072 )
2073 .unwrap();
2074 std::fs::write(
2075 pkg.join("types").join("task.yaml"),
2076 format!(
2077 r#"name: task
2078description: t
2079when_to_use: tests
2080sections:
2081 - key: body
2082 heading: Body
2083 required: true
2084 search_weight: 10.0
2085 catch_all: true
2086 write_rules: []
2087metadata_fields:
2088 - key: status
2089 description: workflow state
2090 field_type: string
2091 enum_values: [open, checked]
2092title_weight: 100.0
2093text_fields:
2094 - body
2095hierarchy_relationship: PART_OF
2096no_self_loop_relationships: []
2097updatable_fields:
2098 - title
2099 - body
2100 - status
2101health_required_fields:
2102 - body
2103staleness_threshold_days: 90
2104required_outgoing:
2105 - relationships: [PART_OF]
2106 cardinality: at_least_one
2107 severity: {severity}
2108 when_field: status
2109 when_value: checked
2110write_rules: []
2111"#
2112 ),
2113 )
2114 .unwrap();
2115 let mem_dir = tmp.path().join("mem");
2116 std::fs::create_dir_all(&mem_dir).unwrap();
2117 let writer = FilesystemMemWriter::new(mem_dir.clone());
2118 let mount = crate::workspace::Mount {
2119 mem: "tasks".to_string(),
2120 schema: Some(memstead_schema::SchemaRef::new(
2121 "condro",
2122 semver::Version::new(0, 1, 0),
2123 )),
2124 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2125 capability: crate::workspace::MountCapability::Write,
2126 lifecycle: crate::workspace::MountLifecycle::Eager,
2127 cross_linkable: true,
2128 migration_target: None,
2129 };
2130 Engine::from_mounts_with_schemas_dir(
2131 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
2132 Some(&schemas_dir),
2133 )
2134 .unwrap()
2135 }
2136
2137 #[test]
2142 fn create_ignores_conditional_required_outgoing_when_unarmed() {
2143 let tmp = TempDir::new().unwrap();
2144 let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2145 let (actor, client) = cli_actor();
2146
2147 let unset = engine
2148 .create_entity(
2149 task_create_args("Unset Status", vec![]),
2150 actor,
2151 Some(&client),
2152 None,
2153 )
2154 .expect("unset trigger field must create");
2155 let mut open_args = task_create_args("Open Task", vec![]);
2156 open_args
2157 .metadata
2158 .insert("status".to_string(), "open".to_string());
2159 let open = engine
2160 .create_entity(open_args, actor, Some(&client), None)
2161 .expect("non-trigger value must create");
2162 for outcome in [&unset, &open] {
2163 assert!(
2164 !outcome
2165 .warnings
2166 .iter()
2167 .any(|w| matches!(w, WarningHint::MissingRequiredOutgoing { .. })),
2168 "unarmed block emits no warning: {:?}",
2169 outcome.warnings
2170 );
2171 }
2172 }
2173
2174 #[test]
2180 fn create_refuses_block_tier_conditional_required_outgoing() {
2181 let tmp = TempDir::new().unwrap();
2182 let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2183 let (actor, client) = cli_actor();
2184
2185 let err = engine
2186 .create_entity(
2187 checked_task_args("Checked Orphan", vec![]),
2188 actor,
2189 Some(&client),
2190 None,
2191 )
2192 .unwrap_err();
2193 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2194 let details = err.details();
2195 assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
2196 assert_eq!(details["missing"][0]["when_field"], "status");
2197 assert_eq!(details["missing"][0]["when_value"], "checked");
2198 assert_eq!(engine.store().all_entities().count(), 0);
2199
2200 let outcome = engine.create_entity(
2201 checked_task_args(
2202 "Checked Child",
2203 vec![crate::ops::RelateArg {
2204 target: crate::entity::EntityId("tasks--parent".to_string()),
2205 rel_type: "PART_OF".to_string(),
2206 description: None,
2207 }],
2208 ),
2209 actor,
2210 Some(&client),
2211 None,
2212 );
2213 assert!(
2214 outcome.is_ok(),
2215 "satisfied armed block passes: {:?}",
2216 outcome.err()
2217 );
2218 }
2219
2220 #[test]
2224 fn create_warns_conditional_required_outgoing_at_warn_tier() {
2225 let tmp = TempDir::new().unwrap();
2226 let mut engine = engine_with_conditional_ro_schema(&tmp, "warn");
2227 let (actor, client) = cli_actor();
2228
2229 let outcome = engine
2230 .create_entity(
2231 checked_task_args("Checked Orphan", vec![]),
2232 actor,
2233 Some(&client),
2234 None,
2235 )
2236 .expect("warn tier lands the write");
2237 assert!(!outcome.write_id.is_empty());
2238 let block = outcome
2239 .warnings
2240 .iter()
2241 .find_map(|w| match w {
2242 WarningHint::MissingRequiredOutgoing { missing, .. } => missing.first(),
2243 _ => None,
2244 })
2245 .expect("warning carries the unsatisfied block");
2246 assert_eq!(block.relationships, vec!["PART_OF".to_string()]);
2247 assert_eq!(block.when_field.as_deref(), Some("status"));
2248 assert_eq!(block.when_value.as_deref(), Some("checked"));
2249 }
2250
2251 #[test]
2255 fn update_flip_to_trigger_value_enforces_conditional_block() {
2256 let (actor, client) = cli_actor();
2257 let flip_to_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
2258 let current = engine.get_entity(id).unwrap().content_hash.clone();
2259 let mut metadata = IndexMap::new();
2260 metadata.insert("status".to_string(), "checked".to_string());
2261 engine.update_entity(
2262 crate::engine::UpdateEntityArgs {
2263 anchors: Vec::new(),
2264 id: id.clone(),
2265 expected_hash: Some(current),
2266 sections: IndexMap::new(),
2267 append_sections: IndexMap::new(),
2268 patch_sections: IndexMap::new(),
2269 sections_unset: Vec::new(),
2270 metadata,
2271 metadata_unset: Vec::new(),
2272 declare_relations: vec![],
2273 dry_run: false,
2274 relations_unset: Vec::new(),
2275 anchors_unset: Vec::new(),
2276 },
2277 actor,
2278 Some(&client),
2279 None,
2280 )
2281 };
2282
2283 let tmp = TempDir::new().unwrap();
2285 let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2286 let mut open_args = task_create_args("Task A", vec![]);
2287 open_args
2288 .metadata
2289 .insert("status".to_string(), "open".to_string());
2290 let a = engine
2291 .create_entity(open_args, actor, Some(&client), None)
2292 .unwrap();
2293 let err = flip_to_checked(&mut engine, &a.id).unwrap_err();
2294 assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2295 assert_eq!(
2296 engine.get_entity(&a.id).unwrap().metadata["status"].to_frontmatter_string(),
2297 "open",
2298 "refused update leaves the entity unchanged"
2299 );
2300
2301 let tmp = TempDir::new().unwrap();
2303 let mut engine = engine_with_conditional_ro_schema(&tmp, "warn");
2304 let b = engine
2305 .create_entity(
2306 task_create_args("Task B", vec![]),
2307 actor,
2308 Some(&client),
2309 None,
2310 )
2311 .unwrap();
2312 let outcome = flip_to_checked(&mut engine, &b.id).unwrap();
2313 assert!(!outcome.write_id.is_empty());
2314 assert!(
2315 outcome
2316 .warnings
2317 .iter()
2318 .any(|w| matches!(w, WarningHint::MissingRequiredOutgoing { .. })),
2319 "warn-tier flip carries the warning: {:?}",
2320 outcome.warnings
2321 );
2322 }
2323
2324 fn engine_with_acyclic_set_schema(tmp: &TempDir, with_set: bool) -> Engine {
2329 let schemas_dir = tmp.path().join("schemas");
2330 let pkg = schemas_dir.join("argchain");
2331 std::fs::create_dir_all(pkg.join("types")).unwrap();
2332 let sets = if with_set {
2333 " acyclic_sets:\n - [GROUNDS, CONCLUDES]\n"
2334 } else {
2335 ""
2336 };
2337 std::fs::write(
2338 pkg.join("schema.yaml"),
2339 format!(
2340 r#"name: argchain
2341version: 0.1.0
2342description: acyclicity-set fixture
2343when_to_use: tests
2344types:
2345 - claim
2346relationships:
2347 mode: strict
2348{sets} definitions:
2349 - name: GROUNDS
2350 description: g
2351 default_weight: 3.0
2352 - name: CONCLUDES
2353 description: c
2354 default_weight: 3.0
2355 - name: PART_OF
2356 description: hier
2357 default_weight: 1.0
2358 - name: _default
2359 description: fallback
2360 default_weight: 1.0
2361community:
2362 resolution: 1.0
2363 seed: 42
2364"#
2365 ),
2366 )
2367 .unwrap();
2368 std::fs::write(
2369 pkg.join("types").join("claim.yaml"),
2370 r#"name: claim
2371description: t
2372when_to_use: tests
2373sections:
2374 - key: body
2375 heading: Body
2376 required: true
2377 search_weight: 10.0
2378 catch_all: true
2379 write_rules: []
2380metadata_fields: []
2381title_weight: 100.0
2382text_fields:
2383 - body
2384hierarchy_relationship: PART_OF
2385no_self_loop_relationships: []
2386updatable_fields:
2387 - title
2388 - body
2389health_required_fields:
2390 - body
2391staleness_threshold_days: 90
2392write_rules: []
2393"#,
2394 )
2395 .unwrap();
2396 let mem_dir = tmp.path().join("mem");
2397 std::fs::create_dir_all(&mem_dir).unwrap();
2398 let writer = FilesystemMemWriter::new(mem_dir.clone());
2399 let mount = crate::workspace::Mount {
2400 mem: "arg".to_string(),
2401 schema: Some(memstead_schema::SchemaRef::new(
2402 "argchain",
2403 semver::Version::new(0, 1, 0),
2404 )),
2405 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2406 capability: crate::workspace::MountCapability::Write,
2407 lifecycle: crate::workspace::MountLifecycle::Eager,
2408 cross_linkable: true,
2409 migration_target: None,
2410 };
2411 Engine::from_mounts_with_schemas_dir(
2412 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
2413 Some(&schemas_dir),
2414 )
2415 .unwrap()
2416 }
2417
2418 fn claim_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
2419 let mut sections = IndexMap::new();
2420 sections.insert("body".to_string(), "a claim body.".to_string());
2421 CreateEntityArgs {
2422 anchors: Vec::new(),
2423 mem: "arg".to_string(),
2424 title: title.to_string(),
2425 entity_type: "claim".to_string(),
2426 sections,
2427 metadata: IndexMap::new(),
2428 relations,
2429 dry_run: false,
2430 }
2431 }
2432
2433 fn relate(
2434 engine: &mut Engine,
2435 from: &str,
2436 rel: &str,
2437 to: &str,
2438 ) -> Result<crate::engine::RelateEntityOutcome, crate::engine::EngineError> {
2439 let (actor, client) = cli_actor();
2440 engine.relate_entity(
2441 crate::engine::RelateEntityArgs {
2442 source: crate::entity::EntityId(from.to_string()),
2443 target: crate::entity::EntityId(to.to_string()),
2444 rel_type: rel.to_string(),
2445 description: None,
2446 remove: false,
2447 expected_hash: None,
2448 dry_run: false,
2449 },
2450 actor,
2451 Some(&client),
2452 None,
2453 )
2454 }
2455
2456 #[test]
2463 fn relate_refuses_mixed_type_cycle_in_declared_set() {
2464 let tmp = TempDir::new().unwrap();
2465 let mut engine = engine_with_acyclic_set_schema(&tmp, true);
2466 let (actor, client) = cli_actor();
2467 for t in ["A", "B", "C"] {
2468 engine
2469 .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2470 .unwrap();
2471 }
2472 relate(&mut engine, "arg--a", "GROUNDS", "arg--b").unwrap();
2473 relate(&mut engine, "arg--b", "CONCLUDES", "arg--c").unwrap();
2474
2475 let err = relate(&mut engine, "arg--c", "GROUNDS", "arg--a").unwrap_err();
2476 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2477 let details = err.details();
2478 assert_eq!(
2479 details["acyclic_set"],
2480 serde_json::json!(["GROUNDS", "CONCLUDES"])
2481 );
2482 assert_eq!(
2483 details["existing_path"],
2484 serde_json::json!(["arg--a", "arg--b", "arg--c"])
2485 );
2486 assert_eq!(
2487 details["existing_path_rel_types"],
2488 serde_json::json!(["GROUNDS", "CONCLUDES"]),
2489 "one rel-type per hop, mixing both members"
2490 );
2491
2492 let tmp = TempDir::new().unwrap();
2495 let mut engine = engine_with_acyclic_set_schema(&tmp, false);
2496 for t in ["A", "B", "C"] {
2497 engine
2498 .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2499 .unwrap();
2500 }
2501 relate(&mut engine, "arg--a", "GROUNDS", "arg--b").unwrap();
2502 relate(&mut engine, "arg--b", "CONCLUDES", "arg--c").unwrap();
2503 relate(&mut engine, "arg--c", "GROUNDS", "arg--a")
2504 .expect("without the set declaration the cycle is legal");
2505 }
2506
2507 #[test]
2511 fn create_inline_and_update_declared_refuse_set_cycle() {
2512 let tmp = TempDir::new().unwrap();
2513 let mut engine = engine_with_acyclic_set_schema(&tmp, true);
2514 let (actor, client) = cli_actor();
2515
2516 engine
2520 .create_entity(
2521 claim_args(
2522 "Alpha",
2523 vec![crate::ops::RelateArg {
2524 target: crate::entity::EntityId("arg--ghost".to_string()),
2525 rel_type: "GROUNDS".to_string(),
2526 description: None,
2527 }],
2528 ),
2529 actor,
2530 Some(&client),
2531 None,
2532 )
2533 .unwrap();
2534 let err = engine
2535 .create_entity(
2536 claim_args(
2537 "Ghost",
2538 vec![crate::ops::RelateArg {
2539 target: crate::entity::EntityId("arg--alpha".to_string()),
2540 rel_type: "CONCLUDES".to_string(),
2541 description: None,
2542 }],
2543 ),
2544 actor,
2545 Some(&client),
2546 None,
2547 )
2548 .expect_err("cycle-closing create.relations[] must refuse");
2549 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2550 assert_eq!(
2551 err.details()["acyclic_set"],
2552 serde_json::json!(["GROUNDS", "CONCLUDES"])
2553 );
2554
2555 for t in ["D", "E"] {
2558 engine
2559 .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2560 .unwrap();
2561 }
2562 relate(&mut engine, "arg--d", "GROUNDS", "arg--e").unwrap();
2563 let e_id = crate::entity::EntityId("arg--e".to_string());
2564 let current = engine.get_entity(&e_id).unwrap().content_hash.clone();
2565 let err = engine
2566 .update_entity(
2567 crate::engine::UpdateEntityArgs {
2568 anchors: Vec::new(),
2569 id: e_id,
2570 expected_hash: Some(current),
2571 sections: IndexMap::new(),
2572 append_sections: IndexMap::new(),
2573 patch_sections: IndexMap::new(),
2574 sections_unset: Vec::new(),
2575 metadata: IndexMap::new(),
2576 metadata_unset: Vec::new(),
2577 declare_relations: vec![crate::ops::RelateArg {
2578 target: crate::entity::EntityId("arg--d".to_string()),
2579 rel_type: "CONCLUDES".to_string(),
2580 description: None,
2581 }],
2582 dry_run: false,
2583 relations_unset: Vec::new(),
2584 anchors_unset: Vec::new(),
2585 },
2586 actor,
2587 Some(&client),
2588 None,
2589 )
2590 .expect_err("cycle-closing declare_relations must refuse");
2591 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2592 assert_eq!(
2593 err.details()["acyclic_set"],
2594 serde_json::json!(["GROUNDS", "CONCLUDES"])
2595 );
2596 }
2597
2598 #[test]
2602 fn boot_drops_cycle_closing_edge_in_declared_acyclic_set() {
2603 let tmp = TempDir::new().unwrap();
2604 let mem_dir = tmp.path().join("mem");
2608 std::fs::create_dir_all(&mem_dir).unwrap();
2609 std::fs::write(
2610 mem_dir.join("a.md"),
2611 "---\ntype: claim\n---\n# A\n\n## Body\n\nfirst.\n\n## Relationships\n\n- **GROUNDS**: [[arg--b]]\n",
2612 )
2613 .unwrap();
2614 std::fs::write(
2615 mem_dir.join("b.md"),
2616 "---\ntype: claim\n---\n# B\n\n## Body\n\nsecond.\n\n## Relationships\n\n- **CONCLUDES**: [[arg--a]]\n",
2617 )
2618 .unwrap();
2619 let engine = engine_with_acyclic_set_schema(&tmp, true);
2620
2621 let surviving: usize = engine
2622 .store()
2623 .all_entities()
2624 .map(|e| {
2625 engine
2626 .store()
2627 .outgoing(&e.id)
2628 .iter()
2629 .filter(|edge| edge.rel_type == "GROUNDS" || edge.rel_type == "CONCLUDES")
2630 .count()
2631 })
2632 .sum();
2633 assert_eq!(surviving, 1, "exactly one edge survives the cycle break");
2634 let cycle_warnings: Vec<_> = engine
2635 .load_warnings()
2636 .iter()
2637 .filter(|w| {
2638 matches!(
2639 w,
2640 WarningHint::ParsedRelationInvalid { reason, .. } if reason == "cycle"
2641 )
2642 })
2643 .cloned()
2644 .collect();
2645 assert_eq!(
2646 cycle_warnings.len(),
2647 1,
2648 "exactly one cycle warning fires: {cycle_warnings:?}"
2649 );
2650 }
2651
2652 fn engine_with_labelling_schema(tmp: &TempDir, with_other_mem: bool) -> Engine {
2661 engine_with_labelling_schema_support(tmp, with_other_mem, true)
2662 }
2663
2664 fn engine_with_labelling_schema_support(
2665 tmp: &TempDir,
2666 with_other_mem: bool,
2667 with_support: bool,
2668 ) -> Engine {
2669 let schemas_dir = tmp.path().join("schemas");
2670 let pkg = schemas_dir.join("arglab");
2671 std::fs::create_dir_all(pkg.join("types")).unwrap();
2672 std::fs::write(
2673 pkg.join("schema.yaml"),
2674 format!(
2675 r#"name: arglab
2676version: 0.1.0
2677description: grounded-labelling fixture
2678when_to_use: tests
2679types:
2680 - claim
2681 - evidence
2682relationships:
2683 mode: strict
2684 labelling:
2685 attack: [REBUTS]
2686{support} definitions:
2687 - name: REBUTS
2688 description: attack
2689 default_weight: 3.0
2690 - name: GROUNDS
2691 description: support
2692 default_weight: 3.0
2693 - name: PART_OF
2694 description: hier
2695 default_weight: 1.0
2696 - name: _default
2697 description: fallback
2698 default_weight: 1.0
2699community:
2700 resolution: 1.0
2701 seed: 42
2702cross_mem_relationships:
2703 - to_schema: arglab
2704 definitions:
2705 - name: REBUTS
2706 description: cross-mem attack
2707 default_weight: 3.0
2708"#,
2709 support = if with_support {
2710 " support:\n relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n"
2711 } else {
2712 ""
2713 },
2714 ),
2715 )
2716 .unwrap();
2717 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";
2718 for t in ["claim", "evidence"] {
2719 std::fs::write(
2720 pkg.join("types").join(format!("{t}.yaml")),
2721 format!("name: {t}\ndescription: t\nwhen_to_use: tests\n{body}"),
2722 )
2723 .unwrap();
2724 }
2725 let mut mounts: Vec<(crate::workspace::Mount, Box<dyn MemBackend>)> = Vec::new();
2726 for mem in std::iter::once("arg").chain(with_other_mem.then_some("other")) {
2727 let mem_dir = tmp.path().join(format!("mem-{mem}"));
2728 std::fs::create_dir_all(&mem_dir).unwrap();
2729 let writer = FilesystemMemWriter::new(mem_dir.clone());
2730 mounts.push((
2731 crate::workspace::Mount {
2732 mem: mem.to_string(),
2733 schema: Some(memstead_schema::SchemaRef::new(
2734 "arglab",
2735 semver::Version::new(0, 1, 0),
2736 )),
2737 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2738 capability: crate::workspace::MountCapability::Write,
2739 lifecycle: crate::workspace::MountLifecycle::Eager,
2740 cross_linkable: true,
2741 migration_target: None,
2742 },
2743 Box::new(writer) as Box<dyn MemBackend>,
2744 ));
2745 }
2746 Engine::from_mounts_with_schemas_dir(mounts, Some(&schemas_dir)).unwrap()
2747 }
2748
2749 fn lab_create(engine: &mut Engine, mem: &str, title: &str, entity_type: &str) {
2750 let (actor, client) = cli_actor();
2751 let mut sections = IndexMap::new();
2752 sections.insert("body".to_string(), "a body.".to_string());
2753 engine
2754 .create_entity(
2755 CreateEntityArgs {
2756 anchors: Vec::new(),
2757 mem: mem.to_string(),
2758 title: title.to_string(),
2759 entity_type: entity_type.to_string(),
2760 sections,
2761 metadata: IndexMap::new(),
2762 relations: vec![],
2763 dry_run: false,
2764 },
2765 actor,
2766 Some(&client),
2767 None,
2768 )
2769 .unwrap();
2770 }
2771
2772 fn lab_relate(engine: &mut Engine, from: &str, rel: &str, to: &str) {
2773 let (actor, client) = cli_actor();
2774 engine
2775 .relate_entity(
2776 crate::engine::RelateEntityArgs {
2777 source: crate::entity::EntityId(from.to_string()),
2778 target: crate::entity::EntityId(to.to_string()),
2779 rel_type: rel.to_string(),
2780 description: None,
2781 remove: false,
2782 expected_hash: None,
2783 dry_run: false,
2784 },
2785 actor,
2786 Some(&client),
2787 None,
2788 )
2789 .unwrap();
2790 }
2791
2792 fn label_of(engine: &Engine, id: &str) -> (String, Vec<String>, Vec<String>) {
2793 let entity = engine
2794 .get_entity(&crate::entity::EntityId(id.to_string()))
2795 .unwrap()
2796 .clone();
2797 let view = engine.computed_labelling(&entity).unwrap();
2798 (
2799 view.label.wire().to_string(),
2800 view.defeated_by.clone(),
2801 view.undecided_by.clone(),
2802 )
2803 }
2804
2805 #[test]
2813 fn grounded_labelling_extension_evidence_and_invalidation() {
2814 let tmp = TempDir::new().unwrap();
2815 let mut engine = engine_with_labelling_schema(&tmp, false);
2816 for t in ["A", "B", "C", "D", "E", "F"] {
2817 lab_create(&mut engine, "arg", t, "claim");
2818 }
2819 lab_relate(&mut engine, "arg--a", "REBUTS", "arg--b");
2820 lab_relate(&mut engine, "arg--b", "REBUTS", "arg--c");
2821 lab_relate(&mut engine, "arg--d", "REBUTS", "arg--e");
2822 lab_relate(&mut engine, "arg--e", "REBUTS", "arg--d");
2823 lab_relate(&mut engine, "arg--d", "REBUTS", "arg--f");
2824
2825 assert_eq!(label_of(&engine, "arg--a").0, "accepted", "unattacked");
2826 let (label, defeated_by, _) = label_of(&engine, "arg--b");
2827 assert_eq!(label, "defeated");
2828 assert_eq!(defeated_by, vec!["arg--a".to_string()], "evidence ships");
2829 assert_eq!(
2830 label_of(&engine, "arg--c").0,
2831 "accepted",
2832 "reinstatement: the only attacker is itself defeated"
2833 );
2834 let (label, _, undecided_by) = label_of(&engine, "arg--d");
2835 assert_eq!(label, "undecided", "cycle member");
2836 assert_eq!(undecided_by, vec!["arg--e".to_string()]);
2837 let (label, _, undecided_by) = label_of(&engine, "arg--f");
2838 assert_eq!(label, "undecided", "victim of an undecided attacker");
2839 assert_eq!(undecided_by, vec!["arg--d".to_string()]);
2840
2841 let engine_b = engine_with_labelling_schema(&tmp, false);
2843 for id in ["arg--a", "arg--b", "arg--c", "arg--d", "arg--e", "arg--f"] {
2844 assert_eq!(label_of(&engine, id), label_of(&engine_b, id));
2845 }
2846
2847 let axis = engine.health_labelling_axis(None);
2849 assert_eq!(axis["arg"]["counts"]["accepted"], 2);
2850 assert_eq!(axis["arg"]["counts"]["defeated"], 1);
2851 assert_eq!(axis["arg"]["counts"]["undecided"], 3);
2852 assert_eq!(axis["arg"]["defeated"][0]["id"], "arg--b");
2853 assert_eq!(axis["arg"]["defeated"][0]["defeated_by"][0], "arg--a");
2854
2855 let (actor, client) = cli_actor();
2858 let b_id = crate::entity::EntityId("arg--b".to_string());
2859 let current = engine.get_entity(&b_id).unwrap().content_hash.clone();
2860 let mut sections = IndexMap::new();
2861 sections.insert("body".to_string(), "updated body.".to_string());
2862 let outcome = engine
2863 .update_entity(
2864 crate::engine::UpdateEntityArgs {
2865 anchors: Vec::new(),
2866 id: b_id,
2867 expected_hash: Some(current),
2868 sections,
2869 append_sections: IndexMap::new(),
2870 patch_sections: IndexMap::new(),
2871 sections_unset: Vec::new(),
2872 metadata: IndexMap::new(),
2873 metadata_unset: Vec::new(),
2874 declare_relations: vec![],
2875 dry_run: false,
2876 relations_unset: Vec::new(),
2877 anchors_unset: Vec::new(),
2878 },
2879 actor,
2880 Some(&client),
2881 None,
2882 )
2883 .expect("updating a defeated entity succeeds");
2884 assert!(!outcome.write_id.is_empty());
2885 lab_relate(&mut engine, "arg--f", "REBUTS", "arg--b");
2886
2887 lab_create(&mut engine, "arg", "H", "claim");
2890 lab_relate(&mut engine, "arg--h", "REBUTS", "arg--a");
2891 let (label, defeated_by, _) = label_of(&engine, "arg--a");
2892 assert_eq!(
2893 label, "defeated",
2894 "memo invalidated by the in-process mutation"
2895 );
2896 assert_eq!(defeated_by, vec!["arg--h".to_string()]);
2897
2898 let g_path = tmp.path().join("mem-arg").join("g.md");
2901 std::fs::write(
2902 &g_path,
2903 "---\ntype: claim\n---\n# G\n\n## Body\n\nout of band.\n\n## Relationships\n\n- **REBUTS**: [[arg--c]]\n",
2904 )
2905 .unwrap();
2906 engine.reload_one_mem("arg").expect("reload succeeds");
2907 let (label, defeated_by, _) = label_of(&engine, "arg--c");
2908 assert_eq!(label, "defeated", "reload invalidated the memo");
2909 assert!(defeated_by.contains(&"arg--g".to_string()));
2910 }
2911
2912 #[test]
2915 fn labelling_support_blindness_shape_and_cross_mem() {
2916 let tmp = TempDir::new().unwrap();
2917 let mut engine = engine_with_labelling_schema(&tmp, true);
2918 for (t, ty) in [
2921 ("Conclusion", "claim"),
2922 ("Inference", "claim"),
2923 ("Undercutter", "claim"),
2924 ] {
2925 lab_create(&mut engine, "arg", t, ty);
2926 }
2927 lab_create(&mut engine, "arg", "Ev One", "evidence");
2928 lab_relate(&mut engine, "arg--conclusion", "GROUNDS", "arg--inference");
2929 lab_relate(&mut engine, "arg--inference", "GROUNDS", "arg--ev-one");
2930 lab_relate(&mut engine, "arg--undercutter", "REBUTS", "arg--inference");
2931
2932 let conclusion = engine
2935 .get_entity(&crate::entity::EntityId("arg--conclusion".to_string()))
2936 .unwrap()
2937 .clone();
2938 let view = engine.computed_labelling(&conclusion).unwrap();
2939 assert_eq!(view.label.wire(), "accepted", "support-blind by design");
2940 let shape = view.shape.expect("support declared, shape served");
2941 assert_eq!(shape.depth, 2);
2942 assert!((shape.branching - 1.0).abs() < 1e-9);
2943 assert_eq!(shape.terminal_share, Some(1.0));
2944 assert_eq!(shape.defeated_in_support, 1, "the defeated inference");
2945 assert_eq!(shape.undecided_in_support, 0);
2946
2947 lab_create(&mut engine, "arg", "Loner", "claim");
2949 let loner = engine
2950 .get_entity(&crate::entity::EntityId("arg--loner".to_string()))
2951 .unwrap()
2952 .clone();
2953 let view = engine.computed_labelling(&loner).unwrap();
2954 let shape = view.shape.unwrap();
2955 assert_eq!(
2956 (shape.depth, shape.branching, shape.terminal_share),
2957 (0, 0.0, None)
2958 );
2959 assert_eq!(
2960 (shape.defeated_in_support, shape.undecided_in_support),
2961 (0, 0)
2962 );
2963
2964 let mut settings = engine.settings().clone();
2968 settings.cross_mem_links.insert(
2969 "other".to_string(),
2970 memstead_schema::workspace_config::CrossLinkValue::Wildcard,
2971 );
2972 engine.set_settings(settings);
2973 lab_create(&mut engine, "other", "Foreign", "claim");
2974 lab_relate(&mut engine, "other--foreign", "REBUTS", "arg--conclusion");
2975 let conclusion = engine
2976 .get_entity(&crate::entity::EntityId("arg--conclusion".to_string()))
2977 .unwrap()
2978 .clone();
2979 let view = engine.computed_labelling(&conclusion).unwrap();
2980 assert_eq!(
2981 view.label.wire(),
2982 "accepted",
2983 "the cross-mem attack is excluded, never guessed"
2984 );
2985 let axis = engine.health_labelling_axis(Some("arg"));
2986 assert_eq!(axis["arg"]["cross_mem_edges_excluded"], 1);
2987 assert!(axis.get("other").is_none(), "mem filter narrows");
2988
2989 let inference = engine
2993 .get_entity(&crate::entity::EntityId("arg--inference".to_string()))
2994 .unwrap()
2995 .clone();
2996 let view = engine.computed_labelling(&inference).unwrap();
2997 let md =
2998 crate::render::render_entity_markdown_with_signals(&inference, None, None, Some(&view));
2999 assert!(md.contains("_label: defeated"), "{md}");
3000 assert!(md.contains("## Labelling"), "{md}");
3001 assert!(md.contains("defeated_by: arg--undercutter"), "{md}");
3002 let env = crate::render::build_entity_envelope(
3003 &inference,
3004 0,
3005 None,
3006 None,
3007 None,
3008 crate::render::OriginClass::FirstParty,
3009 engine
3010 .store()
3011 .outgoing(&crate::entity::EntityId("arg--inference".to_string())),
3012 None,
3013 None,
3014 Some(&view),
3015 );
3016 assert_eq!(env["_labelling"]["label"], "defeated");
3017 assert_eq!(env["_labelling"]["defeated_by"][0], "arg--undercutter");
3018 assert!(env["_labelling"]["shape"].is_object());
3019 let canonical = crate::render::render_entity_markdown(&inference, None);
3020 assert!(
3021 !canonical.contains("_label") && !canonical.contains("## Labelling"),
3022 "canonical form is projection-free"
3023 );
3024
3025 let tmp2 = TempDir::new().unwrap();
3028 let mut plain = engine_with_signals_schema(&tmp2);
3029 let (actor, client) = cli_actor();
3030 let mut sections = IndexMap::new();
3031 sections.insert("body".to_string(), "a claim body.".to_string());
3032 plain
3033 .create_entity(
3034 CreateEntityArgs {
3035 anchors: Vec::new(),
3036 mem: "arg".to_string(),
3037 title: "Plain".to_string(),
3038 entity_type: "claim".to_string(),
3039 sections,
3040 metadata: IndexMap::new(),
3041 relations: vec![],
3042 dry_run: false,
3043 },
3044 actor,
3045 Some(&client),
3046 None,
3047 )
3048 .unwrap();
3049 let plain_entity = plain
3050 .get_entity(&crate::entity::EntityId("arg--plain".to_string()))
3051 .unwrap()
3052 .clone();
3053 assert!(plain.computed_labelling(&plain_entity).is_none());
3054 }
3055
3056 #[test]
3059 fn labelling_without_support_serves_no_shape() {
3060 let tmp = TempDir::new().unwrap();
3061 let mut engine = engine_with_labelling_schema_support(&tmp, false, false);
3062 lab_create(&mut engine, "arg", "Solo", "claim");
3063 let entity = engine
3064 .get_entity(&crate::entity::EntityId("arg--solo".to_string()))
3065 .unwrap()
3066 .clone();
3067 let view = engine.computed_labelling(&entity).expect("labels served");
3068 assert_eq!(view.label.wire(), "accepted");
3069 assert!(view.shape.is_none(), "no support declaration, no shape");
3070 assert!(view.to_json().get("shape").is_none());
3071 let md =
3072 crate::render::render_entity_markdown_with_signals(&entity, None, None, Some(&view));
3073 assert!(!md.contains("- shape:"), "{md}");
3074 }
3075
3076 fn engine_with_signals_schema(tmp: &TempDir) -> Engine {
3077 let schemas_dir = tmp.path().join("schemas");
3078 let pkg = schemas_dir.join("argsig");
3079 std::fs::create_dir_all(pkg.join("types")).unwrap();
3080 std::fs::write(
3081 pkg.join("schema.yaml"),
3082 r#"name: argsig
3083version: 0.1.0
3084description: aggregate-signal fixture
3085when_to_use: tests
3086types:
3087 - claim
3088 - objection
3089relationships:
3090 mode: strict
3091 definitions:
3092 - name: REBUTS
3093 description: r
3094 default_weight: 3.0
3095 - name: PART_OF
3096 description: hier
3097 default_weight: 1.0
3098 - name: _default
3099 description: fallback
3100 default_weight: 1.0
3101community:
3102 resolution: 1.0
3103 seed: 42
3104"#,
3105 )
3106 .unwrap();
3107 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";
3108 std::fs::write(
3109 pkg.join("types").join("claim.yaml"),
3110 format!(
3111 "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"
3112 ),
3113 )
3114 .unwrap();
3115 std::fs::write(
3116 pkg.join("types").join("objection.yaml"),
3117 format!(
3118 "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}"
3119 ),
3120 )
3121 .unwrap();
3122 let mem_dir = tmp.path().join("mem");
3123 std::fs::create_dir_all(&mem_dir).unwrap();
3124 let writer = FilesystemMemWriter::new(mem_dir.clone());
3125 let mount = crate::workspace::Mount {
3126 mem: "arg".to_string(),
3127 schema: Some(memstead_schema::SchemaRef::new(
3128 "argsig",
3129 semver::Version::new(0, 1, 0),
3130 )),
3131 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
3132 capability: crate::workspace::MountCapability::Write,
3133 lifecycle: crate::workspace::MountLifecycle::Eager,
3134 cross_linkable: true,
3135 migration_target: None,
3136 };
3137 Engine::from_mounts_with_schemas_dir(
3138 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3139 Some(&schemas_dir),
3140 )
3141 .unwrap()
3142 }
3143
3144 fn objection_args(title: &str, state: Option<&str>, rebuts: Option<&str>) -> CreateEntityArgs {
3145 let mut sections = IndexMap::new();
3146 sections.insert("body".to_string(), "an objection body.".to_string());
3147 let mut metadata = IndexMap::new();
3148 if let Some(s) = state {
3149 metadata.insert("state".to_string(), s.to_string());
3150 }
3151 let relations = rebuts
3152 .map(|to| {
3153 vec![crate::ops::RelateArg {
3154 target: crate::entity::EntityId(to.to_string()),
3155 rel_type: "REBUTS".to_string(),
3156 description: None,
3157 }]
3158 })
3159 .unwrap_or_default();
3160 CreateEntityArgs {
3161 anchors: Vec::new(),
3162 mem: "arg".to_string(),
3163 title: title.to_string(),
3164 entity_type: "objection".to_string(),
3165 sections,
3166 metadata,
3167 relations,
3168 dry_run: false,
3169 }
3170 }
3171
3172 fn crossing_warnings_of(
3173 warnings: &[WarningHint],
3174 ) -> Vec<(String, String, u64, String, String)> {
3175 warnings
3176 .iter()
3177 .filter_map(|w| match w {
3178 WarningHint::SignalThresholdCrossed {
3179 entity_id,
3180 signal,
3181 value,
3182 old_level,
3183 new_level,
3184 } => Some((
3185 entity_id.to_string(),
3186 signal.clone(),
3187 *value,
3188 old_level.clone(),
3189 new_level.clone(),
3190 )),
3191 _ => None,
3192 })
3193 .collect()
3194 }
3195
3196 #[test]
3203 fn signals_reads_thresholds_neighbour_filter_and_determinism() {
3204 let tmp = TempDir::new().unwrap();
3205 let mut engine = engine_with_signals_schema(&tmp);
3206 let (actor, client) = cli_actor();
3207
3208 let mut claim_sections = IndexMap::new();
3209 claim_sections.insert("body".to_string(), "a claim body.".to_string());
3210 engine
3211 .create_entity(
3212 CreateEntityArgs {
3213 anchors: Vec::new(),
3214 mem: "arg".to_string(),
3215 title: "Claim".to_string(),
3216 entity_type: "claim".to_string(),
3217 sections: claim_sections,
3218 metadata: IndexMap::new(),
3219 relations: vec![],
3220 dry_run: false,
3221 },
3222 actor,
3223 Some(&client),
3224 None,
3225 )
3226 .unwrap();
3227 let claim_id = crate::entity::EntityId("arg--claim".to_string());
3228 let sig_of = |engine: &Engine, name: &str| {
3229 let entity = engine.get_entity(&claim_id).unwrap().clone();
3230 engine
3231 .computed_signals(&entity)
3232 .unwrap()
3233 .into_iter()
3234 .find(|s| s.name == name)
3235 .unwrap()
3236 };
3237
3238 let s = sig_of(&engine, "attack_load");
3240 assert_eq!((s.value, s.level_wire()), (0, "none"));
3241
3242 let outcome = engine
3245 .create_entity(
3246 objection_args("Obj One", Some("open"), Some("arg--claim")),
3247 actor,
3248 Some(&client),
3249 None,
3250 )
3251 .unwrap();
3252 let crossings = crossing_warnings_of(&outcome.warnings);
3253 assert!(
3254 crossings.contains(&(
3255 "arg--claim".to_string(),
3256 "attack_load".to_string(),
3257 1,
3258 "none".to_string(),
3259 "notice".to_string()
3260 )),
3261 "create with inline relation crosses attack_load: {crossings:?}"
3262 );
3263 assert!(
3264 crossings.iter().any(|c| c.1 == "open_objections"),
3265 "open counterpart crosses open_objections too: {crossings:?}"
3266 );
3267
3268 engine
3271 .create_entity(
3272 objection_args("Obj Two", Some("closed"), Some("arg--claim")),
3273 actor,
3274 Some(&client),
3275 None,
3276 )
3277 .unwrap();
3278 engine
3279 .create_entity(
3280 objection_args("Obj Three", None, Some("arg--claim")),
3281 actor,
3282 Some(&client),
3283 None,
3284 )
3285 .unwrap();
3286 let attack = sig_of(&engine, "attack_load");
3287 assert_eq!((attack.value, attack.level_wire()), (3, "warn"));
3288 assert_eq!(attack.contributors.len(), 3);
3289 let open = sig_of(&engine, "open_objections");
3290 assert_eq!((open.value, open.level_wire()), (1, "notice"));
3291 assert_eq!(open.contributors[0].0, "arg--obj-one");
3292
3293 let entity = engine.get_entity(&claim_id).unwrap().clone();
3296 let signals = engine.computed_signals(&entity).unwrap();
3297 let md =
3298 crate::render::render_entity_markdown_with_signals(&entity, None, Some(&signals), None);
3299 assert!(
3300 md.contains("_signals: [attack_load: 3 (warn), open_objections: 1 (notice)]"),
3301 "frontmatter headline: {md}"
3302 );
3303 assert!(md.contains("## Signals"), "contributors section: {md}");
3304 assert!(md.contains("arg--obj-one"), "evidence ships: {md}");
3305 let env = crate::render::build_entity_envelope(
3306 &entity,
3307 0,
3308 None,
3309 None,
3310 None,
3311 crate::render::OriginClass::FirstParty,
3312 engine.store().outgoing(&claim_id),
3313 None,
3314 Some(&signals),
3315 None,
3316 );
3317 assert_eq!(env["_signals"][0]["name"], "attack_load");
3318 assert_eq!(env["_signals"][0]["value"], 3);
3319 assert_eq!(env["_signals"][0]["level"], "warn");
3320 assert_eq!(
3321 env["_signals"][0]["contributors"].as_array().unwrap().len(),
3322 3
3323 );
3324 let canonical = crate::render::render_entity_markdown(&entity, None);
3326 assert!(
3327 !canonical.contains("_signals"),
3328 "canonical form is signal-free"
3329 );
3330
3331 let obj_one = crate::entity::EntityId("arg--obj-one".to_string());
3334 let current = engine.get_entity(&obj_one).unwrap().content_hash.clone();
3335 let mut metadata = IndexMap::new();
3336 metadata.insert("state".to_string(), "closed".to_string());
3337 let outcome = engine
3338 .update_entity(
3339 crate::engine::UpdateEntityArgs {
3340 anchors: Vec::new(),
3341 id: obj_one,
3342 expected_hash: Some(current),
3343 sections: IndexMap::new(),
3344 append_sections: IndexMap::new(),
3345 patch_sections: IndexMap::new(),
3346 sections_unset: Vec::new(),
3347 metadata,
3348 metadata_unset: Vec::new(),
3349 declare_relations: vec![],
3350 dry_run: false,
3351 relations_unset: Vec::new(),
3352 anchors_unset: Vec::new(),
3353 },
3354 actor,
3355 Some(&client),
3356 None,
3357 )
3358 .unwrap();
3359 assert!(!outcome.write_id.is_empty(), "success shape kept");
3360 let crossings = crossing_warnings_of(&outcome.warnings);
3361 assert!(
3362 crossings.contains(&(
3363 "arg--claim".to_string(),
3364 "open_objections".to_string(),
3365 0,
3366 "notice".to_string(),
3367 "none".to_string()
3368 )),
3369 "the neighbour flip crosses the claim's filtered signal: {crossings:?}"
3370 );
3371 let open = sig_of(&engine, "open_objections");
3372 assert_eq!((open.value, open.level_wire()), (0, "none"));
3373
3374 let engine_b = engine_with_signals_schema(&tmp);
3377 let entity_b = engine_b.get_entity(&claim_id).unwrap().clone();
3378 let signals_b = engine_b.computed_signals(&entity_b).unwrap();
3379 let entity_a = engine.get_entity(&claim_id).unwrap().clone();
3380 let signals_a = engine.computed_signals(&entity_a).unwrap();
3381 assert_eq!(
3382 crate::ops::signals::signals_json(&signals_a),
3383 crate::ops::signals::signals_json(&signals_b),
3384 "two instances over the same mem state serve identical signals"
3385 );
3386 }
3387
3388 #[test]
3394 fn signal_crossings_on_relate_and_health_axis() {
3395 let tmp = TempDir::new().unwrap();
3396 let mut engine = engine_with_signals_schema(&tmp);
3397 let (actor, client) = cli_actor();
3398
3399 let mut claim_sections = IndexMap::new();
3400 claim_sections.insert("body".to_string(), "a claim body.".to_string());
3401 engine
3402 .create_entity(
3403 CreateEntityArgs {
3404 anchors: Vec::new(),
3405 mem: "arg".to_string(),
3406 title: "Claim".to_string(),
3407 entity_type: "claim".to_string(),
3408 sections: claim_sections,
3409 metadata: IndexMap::new(),
3410 relations: vec![],
3411 dry_run: false,
3412 },
3413 actor,
3414 Some(&client),
3415 None,
3416 )
3417 .unwrap();
3418 for (t, s) in [("Obj A", "open"), ("Obj B", "closed"), ("Obj C", "closed")] {
3419 engine
3420 .create_entity(objection_args(t, Some(s), None), actor, Some(&client), None)
3421 .unwrap();
3422 }
3423 let relate = |engine: &mut Engine, from: &str, remove: bool| {
3424 engine
3425 .relate_entity(
3426 crate::engine::RelateEntityArgs {
3427 source: crate::entity::EntityId(from.to_string()),
3428 target: crate::entity::EntityId("arg--claim".to_string()),
3429 rel_type: "REBUTS".to_string(),
3430 description: None,
3431 remove,
3432 expected_hash: None,
3433 dry_run: false,
3434 },
3435 actor,
3436 Some(&client),
3437 None,
3438 )
3439 .unwrap()
3440 };
3441
3442 let outcome = relate(&mut engine, "arg--obj-a", false);
3444 assert!(!outcome.write_id.is_empty());
3445 let crossings = crossing_warnings_of(&outcome.warnings);
3446 assert!(
3447 crossings.contains(&(
3448 "arg--claim".to_string(),
3449 "attack_load".to_string(),
3450 1,
3451 "none".to_string(),
3452 "notice".to_string()
3453 )),
3454 "{crossings:?}"
3455 );
3456
3457 let outcome = relate(&mut engine, "arg--obj-b", false);
3459 assert!(
3460 crossing_warnings_of(&outcome.warnings).is_empty(),
3461 "no threshold crossed, no warning: {:?}",
3462 outcome.warnings
3463 );
3464
3465 let outcome = relate(&mut engine, "arg--obj-c", false);
3467 let crossings = crossing_warnings_of(&outcome.warnings);
3468 assert!(
3469 crossings.contains(&(
3470 "arg--claim".to_string(),
3471 "attack_load".to_string(),
3472 3,
3473 "notice".to_string(),
3474 "warn".to_string()
3475 )),
3476 "{crossings:?}"
3477 );
3478
3479 let axis = engine.health_signals_axis(None);
3482 assert_eq!(axis["entities"].as_array().unwrap().len(), 1);
3483 assert_eq!(axis["entities"][0]["id"], "arg--claim");
3484 assert_eq!(axis["counts"]["warn"], 1, "attack_load at warn: {axis}");
3485 assert_eq!(axis["counts"]["notice"], 1, "open_objections at notice");
3486 let filtered = engine.health_signals_axis(Some("other"));
3487 assert!(filtered["entities"].as_array().unwrap().is_empty());
3488
3489 let outcome = relate(&mut engine, "arg--obj-c", true);
3491 let crossings = crossing_warnings_of(&outcome.warnings);
3492 assert!(
3493 crossings.contains(&(
3494 "arg--claim".to_string(),
3495 "attack_load".to_string(),
3496 2,
3497 "warn".to_string(),
3498 "notice".to_string()
3499 )),
3500 "{crossings:?}"
3501 );
3502 }
3503
3504 #[test]
3511 fn no_self_loop_rel_type_self_loop_refusal_is_pinned() {
3512 let tmp = TempDir::new().unwrap();
3513 let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
3516 let (actor, client) = cli_actor();
3517 let a = engine
3518 .create_entity(
3519 task_create_args("Task A", vec![]),
3520 actor,
3521 Some(&client),
3522 None,
3523 )
3524 .unwrap();
3525 let err = engine
3526 .relate_entity(
3527 crate::engine::RelateEntityArgs {
3528 source: a.id.clone(),
3529 target: a.id.clone(),
3530 rel_type: "PART_OF".to_string(),
3531 description: None,
3532 remove: false,
3533 expected_hash: None,
3534 dry_run: false,
3535 },
3536 actor,
3537 Some(&client),
3538 None,
3539 )
3540 .unwrap_err();
3541 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
3542 }
3543
3544 fn engine_with_proof_schema(
3548 tmp: &TempDir,
3549 schema_name: &str,
3550 manifest_yaml: &str,
3551 types: &[(&str, &str)],
3552 ) -> Engine {
3553 let schemas_dir = tmp.path().join("schemas");
3554 let pkg = schemas_dir.join(schema_name);
3555 std::fs::create_dir_all(pkg.join("types")).unwrap();
3556 std::fs::write(pkg.join("schema.yaml"), manifest_yaml).unwrap();
3557 for (name, yaml) in types {
3558 std::fs::write(pkg.join("types").join(format!("{name}.yaml")), yaml).unwrap();
3559 }
3560 let mem_dir = tmp.path().join("mem");
3561 std::fs::create_dir_all(&mem_dir).unwrap();
3562 let writer = FilesystemMemWriter::new(mem_dir.clone());
3563 let mount = crate::workspace::Mount {
3564 mem: "proof".to_string(),
3565 schema: Some(memstead_schema::SchemaRef::new(
3566 schema_name,
3567 semver::Version::new(0, 1, 0),
3568 )),
3569 storage: crate::workspace::MountStorage::Folder { path: mem_dir },
3570 capability: crate::workspace::MountCapability::Write,
3571 lifecycle: crate::workspace::MountLifecycle::Eager,
3572 cross_linkable: true,
3573 migration_target: None,
3574 };
3575 Engine::from_mounts_with_schemas_dir(
3576 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3577 Some(&schemas_dir),
3578 )
3579 .unwrap()
3580 }
3581
3582 fn proof_create(
3583 engine: &mut Engine,
3584 entity_type: &str,
3585 title: &str,
3586 metadata: &[(&str, &str)],
3587 relations: Vec<crate::ops::RelateArg>,
3588 ) -> Result<CreateEntityOutcome, EngineError> {
3589 let (actor, client) = cli_actor();
3590 let mut sections = IndexMap::new();
3591 sections.insert("body".to_string(), format!("{title} body."));
3592 let mut md = IndexMap::new();
3593 for (k, v) in metadata {
3594 md.insert(k.to_string(), v.to_string());
3595 }
3596 engine.create_entity(
3597 CreateEntityArgs {
3598 anchors: Vec::new(),
3599 mem: "proof".to_string(),
3600 title: title.to_string(),
3601 entity_type: entity_type.to_string(),
3602 sections,
3603 metadata: md,
3604 relations,
3605 dry_run: false,
3606 },
3607 actor,
3608 Some(&client),
3609 None,
3610 )
3611 }
3612
3613 fn rel(to: &str, rel_type: &str) -> crate::ops::RelateArg {
3614 crate::ops::RelateArg {
3615 target: crate::entity::EntityId(to.to_string()),
3616 rel_type: rel_type.to_string(),
3617 description: None,
3618 }
3619 }
3620
3621 const GROUNDING_MANIFEST: &str = r#"name: grounding
3622version: 0.1.0
3623description: anker-shaped grounding proof schema
3624when_to_use: constraint-proof tests
3625types:
3626 - anchor
3627 - tradeoff
3628relationships:
3629 mode: strict
3630 definitions:
3631 - name: FOLLOWS_FROM
3632 description: stands on
3633 default_weight: 3.0
3634 - name: SUPPORTS
3635 description: pro
3636 default_weight: 1.0
3637 - name: OPPOSES
3638 description: contra
3639 default_weight: 1.0
3640 - name: PART_OF
3641 description: hier
3642 default_weight: 1.0
3643 - name: _default
3644 description: fallback
3645 default_weight: 1.0
3646community:
3647 resolution: 1.0
3648 seed: 42
3649"#;
3650
3651 const GROUNDING_ANCHOR: &str = r#"name: anchor
3652description: a judgment standing on others
3653when_to_use: tests
3654sections:
3655 - key: body
3656 heading: Body
3657 required: true
3658 search_weight: 10.0
3659 catch_all: true
3660 write_rules: []
3661metadata_fields:
3662 - key: status
3663 description: lifecycle
3664 field_type: string
3665 enum_values: [open, checked, fallen]
3666 - key: checked_by
3667 description: who checked
3668 field_type: string
3669title_weight: 100.0
3670text_fields:
3671 - body
3672hierarchy_relationship: PART_OF
3673no_self_loop_relationships: []
3674updatable_fields:
3675 - title
3676 - body
3677 - status
3678 - checked_by
3679health_required_fields:
3680 - body
3681staleness_threshold_days: 90
3682constraints:
3683 - kind: requires_when
3684 field: checked_by
3685 when_field: status
3686 when_value: checked
3687 - kind: status_propagation
3688 field: status
3689 value: fallen
3690 rel_type: FOLLOWS_FROM
3691 direction: incoming
3692write_rules: []
3693"#;
3694
3695 const GROUNDING_TRADEOFF: &str = r#"name: tradeoff
3696description: a claim with two sides
3697when_to_use: tests
3698sections:
3699 - key: body
3700 heading: Body
3701 required: true
3702 search_weight: 10.0
3703 catch_all: true
3704 write_rules: []
3705metadata_fields: []
3706title_weight: 100.0
3707text_fields:
3708 - body
3709hierarchy_relationship: PART_OF
3710no_self_loop_relationships: []
3711updatable_fields:
3712 - title
3713 - body
3714health_required_fields:
3715 - body
3716staleness_threshold_days: 90
3717required_outgoing:
3718 - relationships: [SUPPORTS]
3719 cardinality: at_least_one
3720 - relationships: [OPPOSES]
3721 cardinality: at_least_one
3722write_rules: []
3723"#;
3724
3725 #[test]
3729 fn anker_proof_grounding_schema_answers_check_questions_from_health() {
3730 let tmp = TempDir::new().unwrap();
3731 let mut engine = engine_with_proof_schema(
3732 &tmp,
3733 "grounding",
3734 GROUNDING_MANIFEST,
3735 &[
3736 ("anchor", GROUNDING_ANCHOR),
3737 ("tradeoff", GROUNDING_TRADEOFF),
3738 ],
3739 );
3740
3741 let root = proof_create(
3744 &mut engine,
3745 "anchor",
3746 "Root",
3747 &[("status", "fallen")],
3748 vec![],
3749 )
3750 .unwrap();
3751 let child = proof_create(
3752 &mut engine,
3753 "anchor",
3754 "Child",
3755 &[],
3756 vec![rel(&root.id.0, "FOLLOWS_FROM")],
3757 )
3758 .unwrap();
3759 let grandchild = proof_create(
3760 &mut engine,
3761 "anchor",
3762 "Grandchild",
3763 &[],
3764 vec![rel(&child.id.0, "FOLLOWS_FROM")],
3765 )
3766 .unwrap();
3767 let standing = proof_create(
3768 &mut engine,
3769 "anchor",
3770 "Standing Root",
3771 &[("status", "open")],
3772 vec![],
3773 )
3774 .unwrap();
3775 let standing_child = proof_create(
3776 &mut engine,
3777 "anchor",
3778 "Standing Child",
3779 &[],
3780 vec![rel(&standing.id.0, "FOLLOWS_FROM")],
3781 )
3782 .unwrap();
3783 let unchecked = proof_create(
3785 &mut engine,
3786 "anchor",
3787 "Checked No Checker",
3788 &[("status", "checked")],
3789 vec![],
3790 )
3791 .unwrap();
3792 let onesided = proof_create(
3794 &mut engine,
3795 "tradeoff",
3796 "One Sided",
3797 &[],
3798 vec![rel(&standing.id.0, "SUPPORTS")],
3799 )
3800 .unwrap();
3801
3802 let findings = crate::ops::health::collect_constraint_findings(
3805 engine.store(),
3806 None,
3807 engine.schemas(),
3808 None,
3809 );
3810 let tainted_of = |id: &crate::entity::EntityId| -> Vec<String> {
3811 findings
3812 .iter()
3813 .filter(|r| &r.id == id)
3814 .flat_map(|r| &r.violations)
3815 .filter_map(|v| match v {
3816 crate::ops::health::UnsatisfiedConstraint::StatusPropagation {
3817 tainted_by,
3818 ..
3819 } => Some(tainted_by.clone()),
3820 _ => None,
3821 })
3822 .collect()
3823 };
3824 assert_eq!(tainted_of(&child.id), vec![root.id.to_string()]);
3825 assert_eq!(
3826 tainted_of(&grandchild.id),
3827 vec![root.id.to_string()],
3828 "the taint is transitive and names the terminal ancestor"
3829 );
3830 assert!(tainted_of(&standing_child.id).is_empty());
3831 assert!(
3832 tainted_of(&root.id).is_empty(),
3833 "the source is not its own finding"
3834 );
3835
3836 assert!(
3838 findings.iter().any(|r| r.id == unchecked.id
3839 && r.violations.iter().any(|v| matches!(
3840 v,
3841 crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. }
3842 if field == "checked_by"
3843 ))),
3844 "checked-without-checker must be a health finding"
3845 );
3846
3847 let missing = crate::ops::health::collect_missing_required_outgoing(
3850 engine.store(),
3851 None,
3852 engine.schemas(),
3853 );
3854 let onesided_report = missing
3855 .iter()
3856 .find(|r| r.id == onesided.id)
3857 .expect("one-sided trade-off flagged");
3858 assert_eq!(onesided_report.missing.len(), 1);
3859 assert_eq!(onesided_report.missing[0].relationships, vec!["OPPOSES"]);
3860 }
3861
3862 const PLENUM_MANIFEST: &str = r#"name: plenum-proof
3863version: 0.1.0
3864description: plenum-shaped uniqueness and vocabulary proof schema
3865when_to_use: constraint-proof tests
3866types:
3867 - rede
3868 - vocabulary
3869relationships:
3870 mode: strict
3871 definitions:
3872 - name: REFERENCES
3873 description: soft ref
3874 default_weight: 0.5
3875 - name: PART_OF
3876 description: hier
3877 default_weight: 1.0
3878 - name: _default
3879 description: fallback
3880 default_weight: 1.0
3881community:
3882 resolution: 1.0
3883 seed: 42
3884"#;
3885
3886 fn plenum_rede_type(unique_severity: &str) -> String {
3887 format!(
3888 r#"name: rede
3889description: one speech
3890when_to_use: tests
3891sections:
3892 - key: body
3893 heading: Body
3894 required: true
3895 search_weight: 10.0
3896 catch_all: true
3897 write_rules: []
3898metadata_fields:
3899 - key: rede_id
3900 description: source id
3901 field_type: string
3902 - key: rede_sha256
3903 description: content hash
3904 field_type: string
3905 - key: kategorie
3906 description: category from the shared vocabulary
3907 field_type: string
3908title_weight: 100.0
3909text_fields:
3910 - body
3911hierarchy_relationship: PART_OF
3912no_self_loop_relationships: []
3913updatable_fields:
3914 - title
3915 - body
3916 - rede_id
3917 - rede_sha256
3918 - kategorie
3919health_required_fields:
3920 - body
3921staleness_threshold_days: 90
3922constraints:
3923 - kind: unique
3924 fields: [rede_id, rede_sha256]
3925 severity: {unique_severity}
3926 - kind: enum_from_neighbour
3927 field: kategorie
3928 rel_type: REFERENCES
3929 section: terms
3930write_rules: []
3931"#
3932 )
3933 }
3934
3935 const PLENUM_VOCABULARY: &str = r#"name: vocabulary
3936description: the shared term list
3937when_to_use: tests
3938sections:
3939 - key: terms
3940 heading: Terms
3941 required: false
3942 search_weight: 5.0
3943 catch_all: false
3944 write_rules: []
3945 - key: body
3946 heading: Body
3947 required: true
3948 search_weight: 10.0
3949 catch_all: true
3950 write_rules: []
3951metadata_fields: []
3952title_weight: 100.0
3953text_fields:
3954 - body
3955hierarchy_relationship: PART_OF
3956no_self_loop_relationships: []
3957updatable_fields:
3958 - title
3959 - body
3960 - terms
3961health_required_fields:
3962 - body
3963staleness_threshold_days: 90
3964write_rules: []
3965"#;
3966
3967 #[test]
3973 fn plenum_proof_uniqueness_refuses_duplicates_and_health_reports_planted_ones() {
3974 let tmp = TempDir::new().unwrap();
3976 let rede = plenum_rede_type("block");
3977 let mut engine = engine_with_proof_schema(
3978 &tmp,
3979 "plenum-proof",
3980 PLENUM_MANIFEST,
3981 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
3982 );
3983 let first = proof_create(
3984 &mut engine,
3985 "rede",
3986 "Speech One",
3987 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3988 vec![],
3989 )
3990 .unwrap();
3991 let err = proof_create(
3992 &mut engine,
3993 "rede",
3994 "Speech One Duplicate",
3995 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3996 vec![],
3997 )
3998 .unwrap_err();
3999 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
4000 assert_eq!(
4001 err.details()["violations"][0]["colliding"],
4002 first.id.to_string(),
4003 "the refusal names the colliding entity"
4004 );
4005 proof_create(
4007 &mut engine,
4008 "rede",
4009 "Speech Two",
4010 &[("rede_id", "19-43"), ("rede_sha256", "def456")],
4011 vec![],
4012 )
4013 .unwrap();
4014
4015 let tmp = TempDir::new().unwrap();
4017 let rede = plenum_rede_type("warn");
4018 let mut engine = engine_with_proof_schema(
4019 &tmp,
4020 "plenum-proof",
4021 PLENUM_MANIFEST,
4022 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
4023 );
4024 proof_create(
4025 &mut engine,
4026 "rede",
4027 "Planted A",
4028 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
4029 vec![],
4030 )
4031 .unwrap();
4032 let planted = proof_create(
4033 &mut engine,
4034 "rede",
4035 "Planted B",
4036 &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
4037 vec![],
4038 )
4039 .unwrap();
4040 assert!(
4041 planted
4042 .warnings
4043 .iter()
4044 .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
4045 "warn tier surfaces the duplicate as a warning and commits"
4046 );
4047 let findings = crate::ops::health::collect_constraint_findings(
4048 engine.store(),
4049 None,
4050 engine.schemas(),
4051 None,
4052 );
4053 assert_eq!(
4054 findings.len(),
4055 2,
4056 "both sides of the planted duplicate are findings: {findings:?}"
4057 );
4058 }
4059
4060 #[test]
4064 fn plenum_proof_enum_from_neighbour_flags_stale_holders_after_rename() {
4065 let tmp = TempDir::new().unwrap();
4066 let rede = plenum_rede_type("warn");
4067 let mut engine = engine_with_proof_schema(
4068 &tmp,
4069 "plenum-proof",
4070 PLENUM_MANIFEST,
4071 &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
4072 );
4073 let (actor, client) = cli_actor();
4074
4075 let mut sections = IndexMap::new();
4077 sections.insert("body".to_string(), "the term list.".to_string());
4078 sections.insert("terms".to_string(), "- haushalt\n- verkehr\n".to_string());
4079 let vocab = engine
4080 .create_entity(
4081 CreateEntityArgs {
4082 anchors: Vec::new(),
4083 mem: "proof".to_string(),
4084 title: "Kategorien".to_string(),
4085 entity_type: "vocabulary".to_string(),
4086 sections,
4087 metadata: IndexMap::new(),
4088 relations: vec![],
4089 dry_run: false,
4090 },
4091 actor,
4092 Some(&client),
4093 None,
4094 )
4095 .unwrap();
4096
4097 let holder = proof_create(
4099 &mut engine,
4100 "rede",
4101 "Holder",
4102 &[("kategorie", "haushalt")],
4103 vec![rel(&vocab.id.0, "REFERENCES")],
4104 )
4105 .unwrap();
4106 let findings = crate::ops::health::collect_constraint_findings(
4107 engine.store(),
4108 None,
4109 engine.schemas(),
4110 None,
4111 );
4112 assert!(
4113 findings.iter().all(|r| r.id != holder.id),
4114 "backed value produces no finding: {findings:?}"
4115 );
4116
4117 let current = engine.get_entity(&vocab.id).unwrap().content_hash.clone();
4120 let mut sections = IndexMap::new();
4121 sections.insert("terms".to_string(), "- finanzen\n- verkehr\n".to_string());
4122 engine
4123 .update_entity(
4124 crate::engine::UpdateEntityArgs {
4125 anchors: Vec::new(),
4126 id: vocab.id.clone(),
4127 expected_hash: Some(current),
4128 sections,
4129 append_sections: IndexMap::new(),
4130 patch_sections: IndexMap::new(),
4131 sections_unset: Vec::new(),
4132 metadata: IndexMap::new(),
4133 metadata_unset: Vec::new(),
4134 declare_relations: vec![],
4135 dry_run: false,
4136 relations_unset: Vec::new(),
4137 anchors_unset: Vec::new(),
4138 },
4139 actor,
4140 Some(&client),
4141 None,
4142 )
4143 .unwrap();
4144 let findings = crate::ops::health::collect_constraint_findings(
4145 engine.store(),
4146 None,
4147 engine.schemas(),
4148 None,
4149 );
4150 let stale = findings
4151 .iter()
4152 .find(|r| r.id == holder.id)
4153 .expect("stale holder is flagged after the rename");
4154 assert!(stale.violations.iter().any(|v| matches!(
4155 v,
4156 crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { value, .. }
4157 if value == "haushalt"
4158 )));
4159 }
4160
4161 #[test]
4168 fn create_warns_missing_required_outgoing_and_still_commits() {
4169 let tmp = TempDir::new().unwrap();
4170 let mut engine = engine_with_required_outgoing_schema(&tmp);
4171 let (actor, client) = cli_actor();
4172
4173 let outcome = engine
4174 .create_entity(
4175 task_create_args("Orphan Task", vec![]),
4176 actor,
4177 Some(&client),
4178 None,
4179 )
4180 .unwrap();
4181 assert!(
4182 !outcome.write_id.is_empty(),
4183 "the warning never blocks the mutation"
4184 );
4185 let blocks = missing_outgoing_of(&outcome.warnings);
4186 assert_eq!(
4187 blocks,
4188 vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())],
4189 "warning names the unsatisfied block with cardinality; warnings = {:?}",
4190 outcome.warnings
4191 );
4192
4193 let reports = crate::ops::health::collect_missing_required_outgoing(
4196 engine.store(),
4197 None,
4198 engine.schemas(),
4199 );
4200 assert_eq!(reports.len(), 1);
4201 assert_eq!(reports[0].id, outcome.id);
4202 assert_eq!(reports[0].missing.len(), 1);
4203 assert_eq!(reports[0].missing[0].relationships, vec!["PART_OF"]);
4204 assert_eq!(reports[0].missing[0].cardinality, "at_least_one");
4205
4206 let satisfied = engine
4209 .create_entity(
4210 task_create_args(
4211 "Child Task",
4212 vec![crate::ops::RelateArg {
4213 target: outcome.id.clone(),
4214 rel_type: "PART_OF".to_string(),
4215 description: None,
4216 }],
4217 ),
4218 actor,
4219 Some(&client),
4220 None,
4221 )
4222 .unwrap();
4223 assert!(
4224 missing_outgoing_of(&satisfied.warnings).is_empty(),
4225 "satisfied block emits no warning: {:?}",
4226 satisfied.warnings
4227 );
4228 }
4229
4230 #[test]
4234 fn update_warns_missing_required_outgoing_until_satisfied() {
4235 let tmp = TempDir::new().unwrap();
4236 let mut engine = engine_with_required_outgoing_schema(&tmp);
4237 let (actor, client) = cli_actor();
4238 let a = engine
4239 .create_entity(
4240 task_create_args("Task A", vec![]),
4241 actor,
4242 Some(&client),
4243 None,
4244 )
4245 .unwrap();
4246 let b = engine
4247 .create_entity(
4248 task_create_args("Task B", vec![]),
4249 actor,
4250 Some(&client),
4251 None,
4252 )
4253 .unwrap();
4254
4255 let update = |engine: &mut Engine,
4256 id: &crate::entity::EntityId,
4257 declare: Vec<crate::ops::RelateArg>| {
4258 let current = engine.get_entity(id).unwrap().content_hash.clone();
4259 let mut sections = IndexMap::new();
4260 sections.insert("body".to_string(), format!("edited at {:?}", declare.len()));
4261 engine
4262 .update_entity(
4263 crate::engine::UpdateEntityArgs {
4264 anchors: Vec::new(),
4265 id: id.clone(),
4266 expected_hash: Some(current),
4267 sections,
4268 append_sections: IndexMap::new(),
4269 patch_sections: IndexMap::new(),
4270 sections_unset: Vec::new(),
4271 metadata: IndexMap::new(),
4272 metadata_unset: Vec::new(),
4273 declare_relations: declare,
4274 dry_run: false,
4275 relations_unset: Vec::new(),
4276 anchors_unset: Vec::new(),
4277 },
4278 actor,
4279 Some(&client),
4280 None,
4281 )
4282 .unwrap()
4283 };
4284
4285 let outcome = update(&mut engine, &a.id, vec![]);
4288 assert!(!outcome.write_id.is_empty());
4289 assert_eq!(
4290 missing_outgoing_of(&outcome.warnings),
4291 vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())]
4292 );
4293
4294 let outcome = update(
4296 &mut engine,
4297 &a.id,
4298 vec![crate::ops::RelateArg {
4299 target: b.id.clone(),
4300 rel_type: "PART_OF".to_string(),
4301 description: None,
4302 }],
4303 );
4304 assert!(
4305 missing_outgoing_of(&outcome.warnings).is_empty(),
4306 "satisfied block emits no warning: {:?}",
4307 outcome.warnings
4308 );
4309 }
4310
4311 #[test]
4318 fn anchor_source_validated_against_resolvable_binding() {
4319 use crate::binding::{
4320 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, hash_binding,
4321 };
4322 use crate::pipeline::{IngestTrigger, PatternEntry, PatternMode, Source};
4323
4324 let tmp = TempDir::new().unwrap();
4325 let (mut engine, _seed) = engine_with_seed(&tmp, "Seed");
4326 let (actor, client) = cli_actor();
4327
4328 let ws = TempDir::new().unwrap();
4330 let binding = Binding {
4331 version: BINDING_VERSION,
4332 intent: None,
4333 sources: ["api-docs", "guides"]
4334 .into_iter()
4335 .map(|n| Source {
4336 name: n.to_string(),
4337 medium_type: crate::pipeline::MediumType::Codebase,
4338 pointer: "../src".to_string(),
4339 change_detection: None,
4340 scope: vec![PatternEntry {
4341 path: "**/*".to_string(),
4342 mode: PatternMode::Allow,
4343 }],
4344 engagement: None,
4345 preparation: None,
4346 })
4347 .collect(),
4348 reference_mems: Vec::new(),
4349 destination_mem: "specs".to_string(),
4350 deny_paths: Vec::new(),
4351 coverage_semantics: None,
4352 rules: None,
4353 prune: None,
4354 operations: Operations {
4355 build: Some(BuildOperation {
4356 mode: BuildMode::Discovery,
4357 trigger: IngestTrigger::Loop,
4358 batch_size: 20,
4359 post_actions: None,
4360 }),
4361 sync: None,
4362 verify: None,
4363 },
4364 };
4365 let dir = ws
4366 .path()
4367 .join(".memstead")
4368 .join("projections")
4369 .join("specs");
4370 std::fs::create_dir_all(&dir).unwrap();
4371 std::fs::write(
4372 dir.join("docs.json"),
4373 serde_json::to_string_pretty(&binding).unwrap(),
4374 )
4375 .unwrap();
4376 engine.set_workspace_root(ws.path().to_path_buf());
4377 let binding_hash = hash_binding(&binding);
4378
4379 std::fs::create_dir_all(ws.path().join("src")).unwrap();
4383 std::fs::write(ws.path().join("src").join("x.rs"), "fn x() {}").unwrap();
4384
4385 let anchor = |source: &str, binding: &str| crate::anchor::AnchorInput {
4386 artifact: Some("src/x.rs".into()),
4387 grain: Some("file".into()),
4388 class: Some("anchored".into()),
4389 binding: Some(binding.into()),
4390 source: Some(source.into()),
4391 ..Default::default()
4392 };
4393 let make_args = |title: &str, a: crate::anchor::AnchorInput| {
4394 let mut args = empty_create_args("specs", title);
4395 args.anchors = vec![a];
4396 args
4397 };
4398
4399 let err = engine
4402 .create_entity(
4403 make_args("Bad Source", anchor("front-page", &binding_hash)),
4404 actor,
4405 Some(&client),
4406 None,
4407 )
4408 .unwrap_err();
4409 assert_eq!(err.code(), "INVALID_ANCHOR", "got {err:?}");
4410 let details = err.details();
4411 assert_eq!(details["field"], "source");
4412 assert_eq!(details["got"], "front-page");
4413 assert_eq!(
4414 details["declared"],
4415 serde_json::json!(["api-docs", "guides"])
4416 );
4417
4418 let ok = engine
4420 .create_entity(
4421 make_args("Good Source", anchor("api-docs", &binding_hash)),
4422 actor,
4423 Some(&client),
4424 None,
4425 )
4426 .expect("declared source name accepted");
4427 let anchors = engine.mem_anchors_resolved("specs");
4428 let stored = anchors
4429 .iter()
4430 .find(|(id, _)| id == &ok.id)
4431 .map(|(_, a)| &a.anchor)
4432 .expect("anchor stored for the new entity");
4433 assert_eq!(stored.source.as_deref(), Some("api-docs"));
4434
4435 engine
4437 .create_entity(
4438 make_args("Orphaned Binding", anchor("whatever", "deadbeef")),
4439 actor,
4440 Some(&client),
4441 None,
4442 )
4443 .expect("unresolvable binding accepts any non-empty name");
4444 }
4445
4446 #[test]
4451 fn batch_create_intra_batch_references_resolve_real() {
4452 let tmp = TempDir::new().unwrap();
4453 let mem_dir = tmp.path().to_path_buf();
4454 let writer = FilesystemMemWriter::new(mem_dir.clone());
4455 let mut engine = Engine::from_mounts(vec![(
4456 folder_mount("specs", mem_dir),
4457 Box::new(writer) as Box<dyn MemBackend>,
4458 )])
4459 .unwrap();
4460 let (actor, client) = cli_actor();
4461
4462 let with_rel = |title: &str, to: &str| {
4463 let mut args = empty_create_args("specs", title);
4464 args.relations = vec![crate::ops::RelateArg {
4465 target: crate::entity::EntityId::new("specs", to),
4466 rel_type: "USES".to_string(),
4467 description: None,
4468 }];
4469 (args, Some(format!("note for {title}")))
4470 };
4471 let result = engine
4473 .batch_create(
4474 vec![
4475 with_rel("Alpha", "beta"),
4476 with_rel("Beta", "gamma"),
4477 with_rel("Gamma", "alpha"),
4478 ],
4479 actor,
4480 Some(&client),
4481 false,
4482 )
4483 .unwrap();
4484 assert!(result.applied, "{result:?}");
4485 assert_eq!(result.succeeded, 3);
4486 assert!(!result.write_id.is_empty(), "one real commit");
4487 assert!(
4488 result.results.iter().all(|r| r.action == "created"),
4489 "{result:?}"
4490 );
4491
4492 for name in ["alpha", "beta", "gamma"] {
4494 let e = engine
4495 .get_entity(&crate::entity::EntityId::new("specs", name))
4496 .unwrap();
4497 assert!(!e.stub, "{name} must be real, not a stub");
4498 assert_eq!(e.entity_type, "spec");
4499 assert_eq!(e.relationships.len(), 1, "{name} carries its edge");
4500 }
4501 }
4506
4507 #[test]
4513 fn batch_create_dry_run_reports_receipt_and_writes_nothing() {
4514 let tmp = TempDir::new().unwrap();
4515 let mem_dir = tmp.path().to_path_buf();
4516 let writer = FilesystemMemWriter::new(mem_dir.clone());
4517 let mut engine = Engine::from_mounts(vec![(
4518 folder_mount("specs", mem_dir),
4519 Box::new(writer) as Box<dyn MemBackend>,
4520 )])
4521 .unwrap();
4522 let (actor, client) = cli_actor();
4523
4524 let with_rel = |title: &str, to: &str| {
4525 let mut args = empty_create_args("specs", title);
4526 args.relations = vec![crate::ops::RelateArg {
4527 target: crate::entity::EntityId::new("specs", to),
4528 rel_type: "USES".to_string(),
4529 description: None,
4530 }];
4531 (args, None)
4532 };
4533 let batch = || {
4534 vec![
4535 with_rel("Alpha", "beta"),
4536 with_rel("Beta", "gamma"),
4537 with_rel("Gamma", "alpha"),
4538 ]
4539 };
4540
4541 let rehearsed = engine
4542 .batch_create(batch(), actor, Some(&client), true)
4543 .unwrap();
4544 assert!(rehearsed.applied, "{rehearsed:?}");
4545 assert_eq!(rehearsed.succeeded, 3);
4546 assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
4547 assert!(rehearsed.results.iter().all(|r| r.action == "created"));
4548 for name in ["alpha", "beta", "gamma"] {
4550 let id = crate::entity::EntityId::new("specs", name);
4551 assert!(
4552 rehearsed.results.iter().any(|r| r.id == id),
4553 "receipt must name {id}: {rehearsed:?}"
4554 );
4555 assert!(
4556 !engine.store().contains(&id),
4557 "rehearsal must create nothing"
4558 );
4559 }
4560 assert_eq!(engine.store().all_entities().count(), 0);
4561
4562 let real = engine
4564 .batch_create(batch(), actor, Some(&client), false)
4565 .unwrap();
4566 assert!(real.applied, "{real:?}");
4567 assert!(!real.write_id.is_empty(), "the real batch commits");
4568 assert_eq!(real.succeeded, 3);
4569 }
4570
4571 #[test]
4576 fn batch_create_dry_run_refuses_identically_to_real() {
4577 let tmp = TempDir::new().unwrap();
4578 let (mut engine, _seeded) = engine_with_seed(&tmp, "Existing");
4579 let (actor, client) = cli_actor();
4580 let plain = |title: &str| (empty_create_args("specs", title), None);
4581 let batch = || {
4582 vec![
4583 plain("Fine One"),
4584 plain("Existing"), plain("Bad/Title"), ]
4587 };
4588
4589 let rehearsed = engine
4590 .batch_create(batch(), actor, Some(&client), true)
4591 .unwrap();
4592 let real = engine
4593 .batch_create(batch(), actor, Some(&client), false)
4594 .unwrap();
4595 assert!(!rehearsed.applied && !real.applied);
4596 assert_eq!(rehearsed.failed, real.failed);
4597 assert_eq!(rehearsed.errors_suppressed, real.errors_suppressed);
4598 let envelope = |r: &crate::ops::BatchResult| {
4599 r.results
4600 .iter()
4601 .map(|e| {
4602 (
4603 e.id.to_string(),
4604 e.action.clone(),
4605 e.error.as_ref().map(|err| {
4606 (err.code.clone(), err.message.clone(), err.details.clone())
4607 }),
4608 )
4609 })
4610 .collect::<Vec<_>>()
4611 };
4612 assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4613 assert!(
4614 !engine
4615 .store()
4616 .contains(&crate::entity::EntityId::new("specs", "fine-one"))
4617 );
4618 }
4619
4620 #[test]
4624 fn batch_create_refuses_whole_batch_reporting_every_failure() {
4625 let tmp = TempDir::new().unwrap();
4626 let (mut engine, seeded) = engine_with_seed(&tmp, "Existing");
4627 let (actor, client) = cli_actor();
4628 let head_before = engine
4629 .mem_head_sha("specs")
4630 .ok()
4631 .flatten()
4632 .unwrap_or_default();
4633 let count_before = engine.store().all_entities().count();
4634
4635 let plain = |title: &str| (empty_create_args("specs", title), None);
4636 let result = engine
4637 .batch_create(
4638 vec![
4639 plain("Fine One"),
4640 plain("Existing"), plain("Bad\nTitle"), plain("Fine Two"),
4643 plain("Fine Two"), ],
4645 actor,
4646 Some(&client),
4647 false,
4648 )
4649 .unwrap();
4650 assert!(!result.applied);
4651 assert_eq!(result.failed, 3, "{result:?}");
4652 assert!(result.write_id.is_empty());
4653 let codes: Vec<(usize, &str)> = result
4654 .results
4655 .iter()
4656 .enumerate()
4657 .filter(|(_, r)| r.action == "error")
4658 .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4659 .collect();
4660 assert_eq!(
4661 codes,
4662 vec![
4663 (1, "ENTITY_ALREADY_EXISTS"),
4664 (2, "INVALID_TITLE"),
4665 (4, "ENTITY_ALREADY_EXISTS"),
4666 ],
4667 "every failing entry named with index + typed code: {result:?}"
4668 );
4669 assert_eq!(result.results[0].action, "not_applied");
4671 assert_eq!(result.results[3].action, "not_applied");
4672 let head_after = engine
4673 .mem_head_sha("specs")
4674 .ok()
4675 .flatten()
4676 .unwrap_or_default();
4677 assert_eq!(head_before, head_after, "mem head unmoved");
4678 assert_eq!(
4679 engine.store().all_entities().count(),
4680 count_before,
4681 "no entity created, no skeleton left behind"
4682 );
4683 let _ = seeded;
4684 }
4685
4686 #[test]
4690 fn batch_create_bounds_the_failure_report() {
4691 let tmp = TempDir::new().unwrap();
4692 let mem_dir = tmp.path().to_path_buf();
4693 let writer = FilesystemMemWriter::new(mem_dir.clone());
4694 let mut engine = Engine::from_mounts(vec![(
4695 folder_mount("specs", mem_dir),
4696 Box::new(writer) as Box<dyn MemBackend>,
4697 )])
4698 .unwrap();
4699 let (actor, client) = cli_actor();
4700 let n = Engine::BATCH_ERROR_REPORT_CAP + 10;
4701 let batch: Vec<_> = (0..n)
4702 .map(|i| (empty_create_args("specs", &format!("Bad\nTitle {i}")), None))
4703 .collect();
4704 let result = engine
4705 .batch_create(batch, actor, Some(&client), false)
4706 .unwrap();
4707 assert!(!result.applied);
4708 assert_eq!(result.failed, n);
4709 let detailed = result
4710 .results
4711 .iter()
4712 .filter(|r| r.action == "error" && r.error.is_some())
4713 .count();
4714 let bare = result
4715 .results
4716 .iter()
4717 .filter(|r| r.action == "error" && r.error.is_none())
4718 .count();
4719 assert_eq!(detailed, Engine::BATCH_ERROR_REPORT_CAP);
4720 assert_eq!(bare, 10);
4721 assert_eq!(
4722 result.errors_suppressed, 10,
4723 "suppression is counted, never silent"
4724 );
4725 }
4726
4727 #[test]
4728 fn create_entity_writes_through_folder_backend_and_updates_store() {
4729 let tmp = TempDir::new().unwrap();
4730 let mem_dir = tmp.path().to_path_buf();
4731 let writer = FilesystemMemWriter::new(mem_dir.clone());
4732 let mut engine = Engine::from_mounts(vec![(
4733 folder_mount("specs", mem_dir.clone()),
4734 Box::new(writer) as Box<dyn MemBackend>,
4735 )])
4736 .unwrap();
4737 let (actor, client) = cli_actor();
4738
4739 let outcome = engine
4740 .create_entity(
4741 empty_create_args("specs", "Hello World"),
4742 actor,
4743 Some(&client),
4744 Some("first draft"),
4745 )
4746 .unwrap();
4747
4748 assert_eq!(outcome.id.to_string(), "specs--hello-world");
4750 assert_eq!(outcome.file_path, "hello-world.md");
4751 assert!(!outcome.content_hash.is_empty());
4752
4753 let entity = engine
4755 .get_entity(&crate::EntityId::new("specs", "hello-world"))
4756 .expect("entity must be in the store after create");
4757 assert_eq!(entity.title, "Hello World");
4758 assert_eq!(entity.entity_type, "spec");
4759 assert_eq!(entity.content_hash, outcome.content_hash);
4760
4761 let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
4763 assert!(on_disk.contains("# Hello World"));
4764 assert!(on_disk.contains("type: spec"));
4765
4766 let log_path = mem_dir.join(".memstead").join("changes.jsonl");
4768 let log = std::fs::read_to_string(&log_path).unwrap();
4769 assert!(log.contains("\"kind\":\"create\""));
4770 assert!(log.contains("\"entity\":\"specs--hello-world\""));
4771 assert!(log.contains("\"actor\":\"cli\""));
4772 assert!(log.contains("\"note\":\"first draft\""));
4773 }
4774
4775 #[test]
4781 fn create_entity_warns_on_supplied_auto_managed_field() {
4782 let tmp = TempDir::new().unwrap();
4783 let mem_dir = tmp.path().to_path_buf();
4784 let writer = FilesystemMemWriter::new(mem_dir.clone());
4785 let mut engine = Engine::from_mounts(vec![(
4786 folder_mount("specs", mem_dir),
4787 Box::new(writer) as Box<dyn MemBackend>,
4788 )])
4789 .unwrap();
4790 let (actor, client) = cli_actor();
4791
4792 let mut args = empty_create_args("specs", "Dated Entity");
4793 args.metadata
4794 .insert("created_date".to_string(), "2020-01-01".to_string());
4795
4796 let outcome = engine
4797 .create_entity(args, actor, Some(&client), None)
4798 .unwrap();
4799
4800 let warned = outcome.warnings.iter().any(|w| {
4801 w.code() == "IGNORED_READONLY_FIELD"
4802 && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
4803 if field == "created_date" && supplied == "2020-01-01")
4804 });
4805 assert!(
4806 warned,
4807 "expected IGNORED_READONLY_FIELD; got {:?}",
4808 outcome.warnings
4809 );
4810
4811 assert_ne!(outcome.created_date, "2020-01-01");
4813 }
4814
4815 #[test]
4818 fn create_entity_no_warning_when_auto_managed_field_absent() {
4819 let tmp = TempDir::new().unwrap();
4820 let mem_dir = tmp.path().to_path_buf();
4821 let writer = FilesystemMemWriter::new(mem_dir.clone());
4822 let mut engine = Engine::from_mounts(vec![(
4823 folder_mount("specs", mem_dir),
4824 Box::new(writer) as Box<dyn MemBackend>,
4825 )])
4826 .unwrap();
4827 let (actor, client) = cli_actor();
4828
4829 let outcome = engine
4830 .create_entity(
4831 empty_create_args("specs", "Plain Entity"),
4832 actor,
4833 Some(&client),
4834 None,
4835 )
4836 .unwrap();
4837 assert!(
4838 !outcome
4839 .warnings
4840 .iter()
4841 .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
4842 "no auto-managed field supplied — no warning expected; got {:?}",
4843 outcome.warnings
4844 );
4845 }
4846
4847 #[test]
4848 fn create_entity_returns_write_id_title_mem_on_real_write() {
4849 let tmp = TempDir::new().unwrap();
4850 let mem_dir = tmp.path().to_path_buf();
4851 let writer = FilesystemMemWriter::new(mem_dir.clone());
4852 let mut engine = Engine::from_mounts(vec![(
4853 folder_mount("specs", mem_dir),
4854 Box::new(writer) as Box<dyn MemBackend>,
4855 )])
4856 .unwrap();
4857 let (actor, client) = cli_actor();
4858
4859 let outcome = engine
4860 .create_entity(
4861 empty_create_args("specs", "Rich Shape"),
4862 actor,
4863 Some(&client),
4864 None,
4865 )
4866 .unwrap();
4867
4868 assert!(
4871 !outcome.write_id.is_empty(),
4872 "write_id must be populated on a real create"
4873 );
4874 assert_eq!(outcome.title, "Rich Shape");
4876 assert_eq!(outcome.mem, "specs");
4877 assert!(
4883 !outcome
4884 .warnings
4885 .iter()
4886 .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
4887 "success path must not carry MissingRequiredSection warnings — those refuse on create now",
4888 );
4889 }
4890
4891 #[test]
4897 fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
4898 let tmp = TempDir::new().unwrap();
4899 let mem_dir = tmp.path().to_path_buf();
4900 let writer = FilesystemMemWriter::new(mem_dir.clone());
4901 let mut engine = Engine::from_mounts(vec![(
4902 folder_mount("specs", mem_dir),
4903 Box::new(writer) as Box<dyn MemBackend>,
4904 )])
4905 .unwrap();
4906 let (actor, client) = cli_actor();
4907
4908 let args = CreateEntityArgs {
4910 anchors: Vec::new(),
4911 mem: "specs".to_string(),
4912 title: "Half Done".to_string(),
4913 entity_type: "spec".to_string(),
4914 sections: IndexMap::new(),
4915 metadata: IndexMap::new(),
4916 relations: Vec::new(),
4917 dry_run: false,
4918 };
4919 let err = engine
4920 .create_entity(args, actor, Some(&client), None)
4921 .unwrap_err();
4922 match err {
4923 EngineError::MissingRequiredSection {
4924 entity_type,
4925 missing_count,
4926 sections,
4927 type_guidance,
4928 pre_announced_missing_fields: _,
4929 } => {
4930 assert_eq!(entity_type, "spec");
4931 assert_eq!(missing_count, sections.len());
4932 assert!(
4933 missing_count >= 2,
4934 "expected ≥2 missing sections, got {missing_count}"
4935 );
4936 let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
4937 assert!(
4938 keys.contains(&"identity".to_string()),
4939 "missing keys: {keys:?}"
4940 );
4941 assert!(
4942 keys.contains(&"purpose".to_string()),
4943 "missing keys: {keys:?}"
4944 );
4945 assert!(
4946 type_guidance.contains_key("spec"),
4947 "type_guidance must include `spec` entry, got: {type_guidance:?}",
4948 );
4949 }
4950 other => panic!("expected MissingRequiredSection, got {other:?}"),
4951 }
4952
4953 let id = crate::EntityId::new("specs", "half-done");
4955 assert!(
4956 engine.store().get(&id).is_none(),
4957 "refused create must not persist any entity"
4958 );
4959 }
4960
4961 #[test]
4969 fn create_refusing_sections_pre_announces_metadata_gate_and_fixed_retry_succeeds() {
4970 let tmp = TempDir::new().unwrap();
4971 let mut engine = engine_with_planning_schema(&tmp);
4972 let (actor, client) = cli_actor();
4973
4974 let bare = CreateEntityArgs {
4979 anchors: Vec::new(),
4980 mem: "planning".to_string(),
4981 title: "Cold Write".to_string(),
4982 entity_type: "decision".to_string(),
4983 sections: IndexMap::new(),
4984 metadata: IndexMap::new(),
4985 relations: Vec::new(),
4986 dry_run: false,
4987 };
4988 let err = engine
4989 .create_entity(bare, actor, Some(&client), None)
4990 .unwrap_err();
4991 let (sections, announced) = match err {
4992 EngineError::MissingRequiredSection {
4993 sections,
4994 pre_announced_missing_fields,
4995 ..
4996 } => (sections, pre_announced_missing_fields),
4997 other => panic!("expected MissingRequiredSection, got {other:?}"),
4998 };
4999 let section_keys: Vec<&str> = sections.iter().map(|s| s.key.as_str()).collect();
5000 for key in ["decision", "context", "consequences"] {
5001 assert!(section_keys.contains(&key), "sections: {section_keys:?}");
5002 }
5003 let announced_keys: Vec<&str> = announced.iter().map(|m| m.key.as_str()).collect();
5006 assert_eq!(
5007 announced_keys,
5008 vec!["decided_on", "deciders"],
5009 "pre-announced set must equal the metadata gate's demand in declaration order"
5010 );
5011 let rebuilt = EngineError::MissingRequiredSection {
5014 entity_type: "decision".to_string(),
5015 missing_count: sections.len(),
5016 sections,
5017 type_guidance: Default::default(),
5018 pre_announced_missing_fields: announced,
5019 };
5020 let details = rebuilt.details();
5021 let wire_missing = details["pre_announced"]["required_field_unset"]["missing"]
5022 .as_array()
5023 .expect("pre_announced.required_field_unset.missing[] present");
5024 assert_eq!(wire_missing[0]["field"], "decided_on");
5025 assert!(wire_missing[0].get("description").is_some());
5026 assert!(wire_missing[0].get("enum_values").is_some());
5027
5028 let mut metadata = IndexMap::new();
5031 metadata.insert("decided_on".to_string(), "2026-08-19".to_string());
5032 metadata.insert("deciders".to_string(), "alice".to_string());
5033 engine
5034 .create_entity(
5035 CreateEntityArgs {
5036 anchors: Vec::new(),
5037 mem: "planning".to_string(),
5038 title: "Cold Write".to_string(),
5039 entity_type: "decision".to_string(),
5040 sections: IndexMap::from_iter([
5041 ("decision".to_string(), "x".to_string()),
5042 ("context".to_string(), "y".to_string()),
5043 ("consequences".to_string(), "z".to_string()),
5044 ]),
5045 metadata,
5046 relations: Vec::new(),
5047 dry_run: false,
5048 },
5049 actor,
5050 Some(&client),
5051 None,
5052 )
5053 .expect("fixing everything announced must succeed in the second round-trip");
5054 }
5055
5056 #[test]
5062 fn section_only_refusal_omits_the_pre_announced_block() {
5063 let tmp = TempDir::new().unwrap();
5064 let mut engine = engine_with_planning_schema(&tmp);
5065 let (actor, client) = cli_actor();
5066
5067 let mut metadata = IndexMap::new();
5068 metadata.insert("decided_on".to_string(), "2026-08-19".to_string());
5069 metadata.insert("deciders".to_string(), "alice".to_string());
5070 let err = engine
5071 .create_entity(
5072 CreateEntityArgs {
5073 anchors: Vec::new(),
5074 mem: "planning".to_string(),
5075 title: "Sections Only".to_string(),
5076 entity_type: "decision".to_string(),
5077 sections: IndexMap::new(),
5078 metadata,
5079 relations: Vec::new(),
5080 dry_run: false,
5081 },
5082 actor,
5083 Some(&client),
5084 None,
5085 )
5086 .unwrap_err();
5087 match &err {
5088 EngineError::MissingRequiredSection {
5089 pre_announced_missing_fields,
5090 ..
5091 } => assert!(
5092 pre_announced_missing_fields.is_empty(),
5093 "metadata gate is satisfied — nothing to pre-announce"
5094 ),
5095 other => panic!("expected MissingRequiredSection, got {other:?}"),
5096 }
5097 assert!(
5098 err.details().get("pre_announced").is_none(),
5099 "single-gate refusal must stay byte-compatible: no pre_announced key"
5100 );
5101 }
5102
5103 #[test]
5107 fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
5108 let tmp = TempDir::new().unwrap();
5109 let mem_dir = tmp.path().to_path_buf();
5110 let writer = FilesystemMemWriter::new(mem_dir.clone());
5111 let mut engine = Engine::from_mounts(vec![(
5112 folder_mount("specs", mem_dir),
5113 Box::new(writer) as Box<dyn MemBackend>,
5114 )])
5115 .unwrap();
5116 let (actor, client) = cli_actor();
5117
5118 let args = CreateEntityArgs {
5119 anchors: Vec::new(),
5120 mem: "specs".to_string(),
5121 title: "Half Done Dry".to_string(),
5122 entity_type: "spec".to_string(),
5123 sections: IndexMap::new(),
5124 metadata: IndexMap::new(),
5125 relations: Vec::new(),
5126 dry_run: true,
5127 };
5128 let err = engine
5129 .create_entity(args, actor, Some(&client), None)
5130 .unwrap_err();
5131 assert!(
5132 matches!(err, EngineError::MissingRequiredSection { .. }),
5133 "dry_run must surface the same refusal envelope, got {err:?}"
5134 );
5135 }
5136
5137 #[test]
5141 fn create_entity_succeeds_after_filling_in_required_sections() {
5142 let tmp = TempDir::new().unwrap();
5143 let mem_dir = tmp.path().to_path_buf();
5144 let writer = FilesystemMemWriter::new(mem_dir.clone());
5145 let mut engine = Engine::from_mounts(vec![(
5146 folder_mount("specs", mem_dir),
5147 Box::new(writer) as Box<dyn MemBackend>,
5148 )])
5149 .unwrap();
5150 let (actor, client) = cli_actor();
5151
5152 let mut sections = IndexMap::new();
5153 sections.insert("identity".to_string(), "the identity body".to_string());
5154 sections.insert("purpose".to_string(), "the purpose body".to_string());
5155 let args = CreateEntityArgs {
5156 anchors: Vec::new(),
5157 mem: "specs".to_string(),
5158 title: "Complete".to_string(),
5159 entity_type: "spec".to_string(),
5160 sections,
5161 metadata: IndexMap::new(),
5162 relations: Vec::new(),
5163 dry_run: false,
5164 };
5165 let outcome = engine
5166 .create_entity(args, actor, Some(&client), None)
5167 .expect("complete create succeeds");
5168 assert_eq!(outcome.title, "Complete");
5169 }
5170
5171 #[test]
5172 fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
5173 let tmp = TempDir::new().unwrap();
5174 let (mut engine, source) = engine_with_seed(&tmp, "Source");
5175 let (actor, client) = cli_actor();
5176
5177 let stub_target = crate::EntityId::new("specs", "ghost-target");
5180 engine
5181 .relate_entity(
5182 RelateEntityArgs {
5183 source: source.id.clone(),
5184 expected_hash: Some(source.content_hash.clone()),
5185 rel_type: "USES".to_string(),
5186 target: stub_target.clone(),
5187 remove: false,
5188 description: None,
5189 dry_run: false,
5190 },
5191 actor,
5192 Some(&client),
5193 None,
5194 )
5195 .unwrap();
5196 let stub = engine
5197 .store()
5198 .get(&stub_target)
5199 .expect("stub must be in store");
5200 assert!(stub.stub);
5201 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
5202
5203 let outcome = engine
5206 .create_entity(
5207 empty_create_args("specs", "Ghost Target"),
5208 actor,
5209 Some(&client),
5210 None,
5211 )
5212 .unwrap();
5213
5214 assert_eq!(outcome.id, stub_target);
5216 let real = engine
5218 .store()
5219 .get(&stub_target)
5220 .expect("entity must still be in store");
5221 assert!(!real.stub);
5222 assert_eq!(engine.store().incoming(&stub_target).len(), 1);
5224 assert_eq!(outcome.incoming_count, Some(1));
5226 assert_eq!(outcome.incoming.len(), 1);
5227 assert_eq!(outcome.incoming[0].from, source.id);
5228 assert_eq!(outcome.incoming[0].rel_type, "USES");
5229 }
5230
5231 #[test]
5232 fn create_entity_reports_no_incoming_on_greenfield_create() {
5233 let tmp = TempDir::new().unwrap();
5234 let mem_dir = tmp.path().to_path_buf();
5235 let writer = FilesystemMemWriter::new(mem_dir.clone());
5236 let mut engine = Engine::from_mounts(vec![(
5237 folder_mount("specs", mem_dir),
5238 Box::new(writer) as Box<dyn MemBackend>,
5239 )])
5240 .unwrap();
5241 let (actor, client) = cli_actor();
5242
5243 let outcome = engine
5244 .create_entity(
5245 empty_create_args("specs", "Greenfield"),
5246 actor,
5247 Some(&client),
5248 None,
5249 )
5250 .unwrap();
5251 assert!(outcome.incoming_count.is_none());
5254 assert!(outcome.incoming.is_empty());
5255 }
5256
5257 #[test]
5258 fn create_entity_populates_created_date_from_schema_auto_stamp() {
5259 let tmp = TempDir::new().unwrap();
5260 let mem_dir = tmp.path().to_path_buf();
5261 let writer = FilesystemMemWriter::new(mem_dir.clone());
5262 let mut engine = Engine::from_mounts(vec![(
5263 folder_mount("specs", mem_dir),
5264 Box::new(writer) as Box<dyn MemBackend>,
5265 )])
5266 .unwrap();
5267 let (actor, client) = cli_actor();
5268
5269 let outcome = engine
5270 .create_entity(
5271 empty_create_args("specs", "Has Date"),
5272 actor,
5273 Some(&client),
5274 None,
5275 )
5276 .unwrap();
5277 assert!(
5282 !outcome.created_date.is_empty(),
5283 "created_date must be populated when the schema auto-stamps it"
5284 );
5285 }
5286
5287 #[test]
5288 fn create_overrides_user_supplied_timestamps_update_rejects_them() {
5289 let tmp = TempDir::new().unwrap();
5300 let mem_dir = tmp.path().to_path_buf();
5301 let writer = FilesystemMemWriter::new(mem_dir.clone());
5302 let mut engine = Engine::from_mounts(vec![(
5303 folder_mount("specs", mem_dir),
5304 Box::new(writer) as Box<dyn MemBackend>,
5305 )])
5306 .unwrap();
5307 const FROZEN_SECS: u64 = 1_754_000_000;
5316 let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(FROZEN_SECS);
5317 engine.set_mutation_clock(std::sync::Arc::new(move || frozen));
5318 let (actor, client) = cli_actor();
5319
5320 let mut args = empty_create_args("specs", "Stamped Today");
5324 args.metadata
5325 .insert("created_date".to_string(), "2020-01-01".to_string());
5326 args.metadata
5327 .insert("last_modified".to_string(), "2020-01-01".to_string());
5328
5329 let outcome = engine
5330 .create_entity(args, actor, Some(&client), None)
5331 .unwrap();
5332
5333 let today = crate::engine::mutation::iso_from_system_time(frozen);
5336 assert_eq!(outcome.created_date, today);
5337 let entity = engine
5338 .get_entity(&outcome.id)
5339 .expect("entity must be in store after create");
5340 assert_eq!(
5341 entity
5342 .metadata
5343 .get("created_date")
5344 .and_then(|v| v.as_str())
5345 .unwrap_or_default(),
5346 today,
5347 "init_timestamp field must be engine-determined on create, not user-supplied"
5348 );
5349 assert_eq!(
5350 entity
5351 .metadata
5352 .get("last_modified")
5353 .and_then(|v| v.as_str())
5354 .unwrap_or_default(),
5355 today,
5356 "auto_timestamp field must be engine-determined on create, not user-supplied"
5357 );
5358
5359 let attempt_update = |key: &str, value: &str| {
5363 let mut metadata = IndexMap::new();
5364 metadata.insert(key.to_string(), value.to_string());
5365 crate::engine::UpdateEntityArgs {
5366 anchors: Vec::new(),
5367 id: outcome.id.clone(),
5368 metadata,
5369 metadata_unset: Vec::new(),
5370 sections: IndexMap::new(),
5371 append_sections: IndexMap::new(),
5372 patch_sections: IndexMap::new(),
5373 sections_unset: Vec::new(),
5374 expected_hash: Some(outcome.content_hash.clone()),
5375 dry_run: false,
5376 declare_relations: Vec::new(),
5377 relations_unset: Vec::new(),
5378 anchors_unset: Vec::new(),
5379 }
5380 };
5381 for key in ["created_date", "last_modified"] {
5382 let err = engine
5383 .update_entity(
5384 attempt_update(key, "2019-12-31"),
5385 actor,
5386 Some(&client),
5387 None,
5388 )
5389 .expect_err("schema-managed timestamp must be rejected on update");
5390 assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
5391 }
5392 let entity = engine
5394 .get_entity(&outcome.id)
5395 .expect("entity must remain in store after rejected update");
5396 assert_eq!(
5397 entity
5398 .metadata
5399 .get("last_modified")
5400 .and_then(|v| v.as_str())
5401 .unwrap_or_default(),
5402 today,
5403 "rejected update must not mutate the auto_timestamp field"
5404 );
5405 }
5406
5407 #[test]
5408 fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
5409 let tmp = TempDir::new().unwrap();
5410 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
5411 let (actor, client) = cli_actor();
5412 let absent = crate::EntityId::new("specs", "future-target");
5413 assert!(!engine.store().contains(&absent));
5414
5415 let mut args = empty_create_args("specs", "Source With Relations");
5416 args.relations = vec![
5417 crate::ops::RelateArg {
5418 target: existing.id.clone(),
5419 rel_type: "USES".to_string(),
5420 description: None,
5421 },
5422 crate::ops::RelateArg {
5423 target: absent.clone(),
5424 rel_type: "USES".to_string(),
5425 description: None,
5426 },
5427 ];
5428
5429 let outcome = engine
5430 .create_entity(args, actor, Some(&client), None)
5431 .unwrap();
5432
5433 let source = engine
5435 .store()
5436 .get(&outcome.id)
5437 .expect("source must be in store");
5438 assert_eq!(source.relationships.len(), 2);
5439 assert!(
5440 source
5441 .relationships
5442 .iter()
5443 .any(|r| r.target == existing.id && r.rel_type == "USES")
5444 );
5445 assert!(
5446 source
5447 .relationships
5448 .iter()
5449 .any(|r| r.target == absent && r.rel_type == "USES")
5450 );
5451
5452 let stub = engine
5455 .store()
5456 .get(&absent)
5457 .expect("absent relation target must be auto-stubbed");
5458 assert!(stub.stub);
5459 let existing_after = engine.store().get(&existing.id).unwrap();
5461 assert!(!existing_after.stub);
5462 }
5463
5464 fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
5469 use crate::workspace::Mount;
5470 use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
5471 let mem_dir = tmp.path().to_path_buf();
5472 let writer = FilesystemMemWriter::new(mem_dir.clone());
5473 let mount = Mount {
5474 mem: "planning".to_string(),
5475 schema: Some(memstead_schema::SchemaRef::new(
5476 "planning",
5477 semver::Version::new(0, 1, 0),
5478 )),
5479 storage: MountStorage::Folder { path: mem_dir },
5480 capability: MountCapability::Write,
5481 lifecycle: MountLifecycle::Eager,
5482 cross_linkable: true,
5483 migration_target: None,
5484 };
5485 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
5486 }
5487
5488 #[test]
5497 fn create_entity_refuses_unsupplied_no_default_required_field() {
5498 let tmp = TempDir::new().unwrap();
5502 let mut engine = engine_with_planning_schema(&tmp);
5503 let (actor, client) = cli_actor();
5504
5505 let mut args = CreateEntityArgs {
5506 anchors: Vec::new(),
5507 mem: "planning".to_string(),
5508 title: "Skip Postgres".to_string(),
5509 entity_type: "decision".to_string(),
5510 sections: IndexMap::from_iter([
5511 ("decision".to_string(), "Use SQLite locally.".to_string()),
5512 ("context".to_string(), "Single-user dev.".to_string()),
5513 ("consequences".to_string(), "Lose multi-writer.".to_string()),
5514 ]),
5515 metadata: IndexMap::new(),
5516 relations: Vec::new(),
5517 dry_run: false,
5518 };
5519
5520 let err = engine
5523 .create_entity(args.clone(), actor, Some(&client), None)
5524 .unwrap_err();
5525 match err {
5526 EngineError::RequiredFieldUnset {
5527 field, entity_type, ..
5528 } => {
5529 assert!(
5530 field == "decided_on" || field == "deciders",
5531 "expected first missing field, got {field:?}"
5532 );
5533 assert_eq!(entity_type, "decision");
5534 }
5535 other => panic!("expected RequiredFieldUnset, got {other:?}"),
5536 }
5537
5538 args.title = "Different Title".to_string();
5542 args.dry_run = true;
5543 let dry_err = engine
5544 .create_entity(args, actor, Some(&client), None)
5545 .unwrap_err();
5546 assert!(
5547 matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
5548 "dry_run must surface the same refusal envelope, got {dry_err:?}"
5549 );
5550 }
5551
5552 #[test]
5556 fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
5557 let tmp = TempDir::new().unwrap();
5558 let mut engine = engine_with_planning_schema(&tmp);
5559 let (actor, client) = cli_actor();
5560
5561 let mut metadata = IndexMap::new();
5562 metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
5563 metadata.insert("deciders".to_string(), "alice, bob".to_string());
5564
5565 let outcome = engine
5566 .create_entity(
5567 CreateEntityArgs {
5568 anchors: Vec::new(),
5569 mem: "planning".to_string(),
5570 title: "Complete Decision".to_string(),
5571 entity_type: "decision".to_string(),
5572 sections: IndexMap::from_iter([
5573 ("decision".to_string(), "x".to_string()),
5574 ("context".to_string(), "y".to_string()),
5575 ("consequences".to_string(), "z".to_string()),
5576 ]),
5577 metadata,
5578 relations: Vec::new(),
5579 dry_run: false,
5580 },
5581 actor,
5582 Some(&client),
5583 None,
5584 )
5585 .expect("complete decision create succeeds");
5586 let missing_field_warnings: Vec<&WarningHint> = outcome
5589 .warnings
5590 .iter()
5591 .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
5592 .collect();
5593 assert!(
5594 missing_field_warnings.is_empty(),
5595 "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
5596 );
5597 }
5598
5599 #[test]
5604 fn create_entity_rejects_inline_relation_with_malformed_target_id() {
5605 let tmp = TempDir::new().unwrap();
5606 let mem_dir = tmp.path().to_path_buf();
5607 let writer = FilesystemMemWriter::new(mem_dir.clone());
5608 let mut engine = Engine::from_mounts(vec![(
5609 folder_mount("specs", mem_dir),
5610 Box::new(writer) as Box<dyn MemBackend>,
5611 )])
5612 .unwrap();
5613 let (actor, client) = cli_actor();
5614
5615 let mut args = empty_create_args("specs", "Source");
5616 args.relations = vec![crate::ops::RelateArg {
5617 target: crate::EntityId("specs--bad target with spaces!!".to_string()),
5618 rel_type: "USES".to_string(),
5619 description: None,
5620 }];
5621 let err = engine
5622 .create_entity(args, actor, Some(&client), None)
5623 .unwrap_err();
5624 assert!(
5625 matches!(err, EngineError::InvalidEntityId { .. }),
5626 "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
5627 );
5628 }
5629
5630 #[test]
5639 fn create_entity_rejects_inline_relation_with_shape_violation() {
5640 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5641 let tmp = TempDir::new().unwrap();
5642 let mem_dir = tmp.path().to_path_buf();
5643 let writer = FilesystemMemWriter::new(mem_dir.clone());
5644 let mount = Mount {
5645 mem: "code".to_string(),
5646 schema: Some(memstead_schema::SchemaRef::new(
5647 "software",
5648 semver::Version::new(0, 1, 0),
5649 )),
5650 storage: MountStorage::Folder { path: mem_dir },
5651 capability: MountCapability::Write,
5652 lifecycle: MountLifecycle::Eager,
5653 cross_linkable: true,
5654 migration_target: None,
5655 };
5656 let mut engine =
5657 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
5658 let (actor, client) = cli_actor();
5659
5660 let target = engine
5668 .create_entity(
5669 CreateEntityArgs {
5670 anchors: Vec::new(),
5671 mem: "code".to_string(),
5672 title: "Target Requirement".to_string(),
5673 entity_type: "requirement".to_string(),
5674 sections: IndexMap::from_iter([
5675 ("statement".to_string(), "MUST hold.".to_string()),
5676 ("rationale".to_string(), "Because tests.".to_string()),
5677 ]),
5678 metadata: IndexMap::from_iter([
5679 ("verified_on".to_string(), "2026-05-19".to_string()),
5680 ("source".to_string(), "test fixture".to_string()),
5681 ]),
5682 relations: Vec::new(),
5683 dry_run: false,
5684 },
5685 actor,
5686 Some(&client),
5687 None,
5688 )
5689 .unwrap();
5690
5691 let args = CreateEntityArgs {
5697 anchors: Vec::new(),
5698 mem: "code".to_string(),
5699 title: "Misshape Source".to_string(),
5700 entity_type: "spec".to_string(),
5701 sections: IndexMap::from_iter([
5702 ("identity".to_string(), "this spec".to_string()),
5703 (
5704 "purpose".to_string(),
5705 "exercising the shape gate".to_string(),
5706 ),
5707 ]),
5708 metadata: IndexMap::new(),
5709 relations: vec![crate::ops::RelateArg {
5710 target: target.id.clone(),
5711 rel_type: "VIOLATES".to_string(),
5712 description: None,
5713 }],
5714 dry_run: false,
5715 };
5716 let err = engine
5717 .create_entity(args, actor, Some(&client), None)
5718 .unwrap_err();
5719 assert!(
5720 matches!(err, EngineError::Validation(_)),
5721 "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
5722 );
5723 }
5724
5725 #[test]
5726 fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
5727 let tmp = TempDir::new().unwrap();
5731 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
5732 let (actor, client) = cli_actor();
5733
5734 let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
5735 args.relations = vec![crate::ops::RelateArg {
5736 target: existing.id.clone(),
5737 rel_type: "uses".to_string(),
5738 description: None,
5739 }];
5740
5741 let outcome = engine
5742 .create_entity(args, actor, Some(&client), None)
5743 .unwrap();
5744
5745 let source = engine
5746 .store()
5747 .get(&outcome.id)
5748 .expect("source must be in store");
5749 assert_eq!(source.relationships.len(), 1);
5750 assert_eq!(
5751 source.relationships[0].rel_type, "USES",
5752 "inline relation rel_type must be stored UPPER_SNAKE_CASE",
5753 );
5754 }
5755
5756 #[test]
5757 fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
5758 let tmp = TempDir::new().unwrap();
5759 let mem_dir = tmp.path().to_path_buf();
5760 let writer = FilesystemMemWriter::new(mem_dir.clone());
5761 let mut engine = Engine::from_mounts(vec![(
5762 folder_mount("specs", mem_dir.clone()),
5763 Box::new(writer) as Box<dyn MemBackend>,
5764 )])
5765 .unwrap();
5766 let (actor, client) = cli_actor();
5767
5768 let mut args = empty_create_args("specs", "Preview Only");
5769 args.dry_run = true;
5770
5771 let outcome = engine
5772 .create_entity(args, actor, Some(&client), None)
5773 .unwrap();
5774
5775 assert_eq!(outcome.id.to_string(), "specs--preview-only");
5777 assert!(
5778 !outcome.content_hash.is_empty(),
5779 "prospective hash populated"
5780 );
5781 assert!(outcome.write_id.is_empty(), "no commit on dry_run");
5782 assert!(
5784 engine.store().get(&outcome.id).is_none(),
5785 "dry_run must not mutate the store",
5786 );
5787 assert!(
5789 !mem_dir.join("preview-only.md").exists(),
5790 "dry_run must not touch disk",
5791 );
5792 let log = mem_dir.join(".memstead").join("changes.jsonl");
5794 assert!(
5795 !log.exists()
5796 || !std::fs::read_to_string(&log)
5797 .unwrap()
5798 .contains("preview-only"),
5799 "dry_run must not append provenance",
5800 );
5801 }
5802
5803 #[test]
5804 fn create_entity_rejects_read_only_mount_before_backend() {
5805 let tmp = TempDir::new().unwrap();
5806 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
5807 let mut engine = Engine::from_mounts(vec![(
5808 archive_mount("external", archive_path.clone()),
5809 Box::new(ArchiveBackend::new(archive_path)),
5810 )])
5811 .unwrap();
5812 let (actor, client) = cli_actor();
5813
5814 let err = engine
5815 .create_entity(
5816 empty_create_args("external", "Should Fail"),
5817 actor,
5818 Some(&client),
5819 None,
5820 )
5821 .unwrap_err();
5822 match err {
5823 EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
5824 other => panic!("expected ReadOnlyMount, got {other:?}"),
5825 }
5826 }
5830
5831 #[test]
5832 fn create_entity_rejects_unknown_mem() {
5833 let tmp = TempDir::new().unwrap();
5834 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
5835 let mut engine = Engine::from_mounts(vec![(
5836 folder_mount("specs", tmp.path().to_path_buf()),
5837 Box::new(writer) as Box<dyn MemBackend>,
5838 )])
5839 .unwrap();
5840 let (actor, client) = cli_actor();
5841
5842 let err = engine
5843 .create_entity(
5844 empty_create_args("does-not-exist", "Anything"),
5845 actor,
5846 Some(&client),
5847 None,
5848 )
5849 .unwrap_err();
5850 assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
5851 }
5852
5853 #[test]
5854 fn create_entity_rejects_unknown_type_against_pinned_schema() {
5855 let tmp = TempDir::new().unwrap();
5856 let mem_dir = tmp.path().to_path_buf();
5857 let writer = FilesystemMemWriter::new(mem_dir.clone());
5858 let mut engine = Engine::from_mounts(vec![(
5859 folder_mount("specs", mem_dir),
5860 Box::new(writer) as Box<dyn MemBackend>,
5861 )])
5862 .unwrap();
5863 let (actor, client) = cli_actor();
5864
5865 let mut args = empty_create_args("specs", "Anything");
5866 args.entity_type = "definitely-not-a-real-type".to_string();
5867 let err = engine
5868 .create_entity(args, actor, Some(&client), None)
5869 .unwrap_err();
5870 match err {
5871 EngineError::UnknownType { name, declared, .. } => {
5872 assert_eq!(name, "definitely-not-a-real-type");
5873 assert!(!declared.is_empty(), "declared types must be listed");
5874 }
5875 other => panic!("expected UnknownType, got {other:?}"),
5876 }
5877 }
5878
5879 #[test]
5880 fn create_entity_rejects_duplicate_id() {
5881 let tmp = TempDir::new().unwrap();
5882 let mem_dir = tmp.path().to_path_buf();
5883 let writer = FilesystemMemWriter::new(mem_dir.clone());
5884 let mut engine = Engine::from_mounts(vec![(
5885 folder_mount("specs", mem_dir),
5886 Box::new(writer) as Box<dyn MemBackend>,
5887 )])
5888 .unwrap();
5889 let (actor, client) = cli_actor();
5890
5891 engine
5892 .create_entity(
5893 empty_create_args("specs", "Same Slug"),
5894 actor,
5895 Some(&client),
5896 None,
5897 )
5898 .unwrap();
5899 let err = engine
5900 .create_entity(
5901 empty_create_args("specs", "Same Slug"),
5902 actor,
5903 Some(&client),
5904 None,
5905 )
5906 .unwrap_err();
5907 match err {
5908 EngineError::AlreadyExists {
5909 id,
5910 existing_title,
5911 existing_is_stub,
5912 } => {
5913 assert_eq!(id, "specs--same-slug");
5914 assert!(!existing_title.is_empty());
5917 assert!(!existing_is_stub);
5918 }
5919 other => panic!("expected AlreadyExists, got {other:?}"),
5920 }
5921 }
5922
5923 #[test]
5924 fn create_entity_rejects_invalid_title() {
5925 let tmp = TempDir::new().unwrap();
5926 let mem_dir = tmp.path().to_path_buf();
5927 let writer = FilesystemMemWriter::new(mem_dir.clone());
5928 let mut engine = Engine::from_mounts(vec![(
5929 folder_mount("specs", mem_dir),
5930 Box::new(writer) as Box<dyn MemBackend>,
5931 )])
5932 .unwrap();
5933 let (actor, client) = cli_actor();
5934
5935 let err = engine
5941 .create_entity(empty_create_args("specs", " "), actor, Some(&client), None)
5942 .unwrap_err();
5943 match err {
5944 EngineError::InvalidTitle(slug_err) => {
5945 assert_eq!(slug_err.reason(), "empty", "expected empty reason");
5946 }
5947 other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
5948 }
5949
5950 let outcome = engine
5954 .create_entity(
5955 empty_create_args("specs", "Hello, World!"),
5956 actor,
5957 Some(&client),
5958 None,
5959 )
5960 .expect("char-drop title lands under the widened grammar");
5961 assert_eq!(outcome.id.as_ref(), "specs--hello-world");
5962 let dropped = outcome
5963 .warnings
5964 .iter()
5965 .find_map(|w| match w {
5966 WarningHint::TitleCharsDroppedFromSlug {
5967 dropped_chars,
5968 slug,
5969 ..
5970 } => Some((dropped_chars.clone(), slug.clone())),
5971 _ => None,
5972 })
5973 .expect("divergence warning rides the outcome");
5974 assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
5975 assert_eq!(dropped.1, "hello-world");
5976
5977 let outcome = engine
5981 .create_entity(
5982 empty_create_args("specs", "../etc/passwd"),
5983 actor,
5984 Some(&client),
5985 None,
5986 )
5987 .expect("traversal-shaped title lands with a sanitised slug");
5988 assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
5989 assert!(
5990 outcome
5991 .warnings
5992 .iter()
5993 .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
5994 );
5995 }
5996
5997 #[test]
5998 fn create_entity_rejects_unknown_section_key() {
5999 let tmp = TempDir::new().unwrap();
6000 let mem_dir = tmp.path().to_path_buf();
6001 let writer = FilesystemMemWriter::new(mem_dir.clone());
6002 let mut engine = Engine::from_mounts(vec![(
6003 folder_mount("specs", mem_dir),
6004 Box::new(writer) as Box<dyn MemBackend>,
6005 )])
6006 .unwrap();
6007 let (actor, client) = cli_actor();
6008
6009 let mut args = empty_create_args("specs", "Bad Sections");
6010 args.sections
6011 .insert("not-a-real-section-key".to_string(), "body".to_string());
6012 let err = engine
6013 .create_entity(args, actor, Some(&client), None)
6014 .unwrap_err();
6015 assert!(matches!(err, EngineError::Validation(_)));
6016 }
6017
6018 #[test]
6019 fn create_entity_persists_across_engine_restart() {
6020 let tmp = TempDir::new().unwrap();
6021 let mem_dir = tmp.path().to_path_buf();
6022 {
6023 let writer = FilesystemMemWriter::new(mem_dir.clone());
6024 let mut engine = Engine::from_mounts(vec![(
6025 folder_mount("specs", mem_dir.clone()),
6026 Box::new(writer) as Box<dyn MemBackend>,
6027 )])
6028 .unwrap();
6029 let (actor, client) = cli_actor();
6030 engine
6031 .create_entity(
6032 empty_create_args("specs", "Survives Restart"),
6033 actor,
6034 Some(&client),
6035 None,
6036 )
6037 .unwrap();
6038 }
6039 let writer2 = FilesystemMemWriter::new(mem_dir.clone());
6041 let engine2 = Engine::from_mounts(vec![(
6042 folder_mount("specs", mem_dir),
6043 Box::new(writer2) as Box<dyn MemBackend>,
6044 )])
6045 .unwrap();
6046 let entity = engine2
6047 .get_entity(&crate::EntityId::new("specs", "survives-restart"))
6048 .expect("entity must persist across engine restart");
6049 assert_eq!(entity.title, "Survives Restart");
6050 }
6051
6052 fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
6059 let mem_dir = tmp.path().to_path_buf();
6060 let writer = FilesystemMemWriter::new(mem_dir.clone());
6061 let mut engine = Engine::from_mounts(vec![(
6062 folder_mount("specs", mem_dir),
6063 Box::new(writer) as Box<dyn MemBackend>,
6064 )])
6065 .unwrap();
6066 let (actor, client) = cli_actor();
6067 let outcome = engine
6068 .create_entity(
6069 empty_create_args("specs", title),
6070 actor,
6071 Some(&client),
6072 None,
6073 )
6074 .unwrap();
6075 (engine, outcome)
6076 }
6077
6078 #[test]
6086 fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
6087 let tmp = TempDir::new().unwrap();
6088 let mem_dir = tmp.path().to_path_buf();
6089 let writer = FilesystemMemWriter::new(mem_dir.clone());
6090 let mut engine = Engine::from_mounts(vec![(
6091 folder_mount("specs", mem_dir),
6092 Box::new(writer) as Box<dyn MemBackend>,
6093 )])
6094 .unwrap();
6095 let (actor, client) = cli_actor();
6096
6097 let ghost = crate::EntityId::new("specs", "ghost-target");
6098 assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
6099
6100 let mut args = empty_create_args("specs", "Source With Body Link");
6101 args.sections.insert(
6102 "identity".to_string(),
6103 "ref [[ghost-target]] for context".to_string(),
6104 );
6105
6106 let outcome = engine
6107 .create_entity(args, actor, Some(&client), None)
6108 .unwrap();
6109 let stubbed: Vec<&crate::EntityId> = outcome
6110 .warnings
6111 .iter()
6112 .filter_map(|w| match w {
6113 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
6114 _ => None,
6115 })
6116 .flatten()
6117 .collect();
6118 assert!(
6119 stubbed.contains(&&ghost),
6120 "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
6121 outcome.warnings,
6122 );
6123 assert!(
6125 engine.store().contains(&ghost),
6126 "ghost stub must materialise"
6127 );
6128 }
6129
6130 #[test]
6135 fn create_entity_drops_self_link_keeps_other_links_and_warns() {
6136 let tmp = TempDir::new().unwrap();
6137 let mem_dir = tmp.path().to_path_buf();
6138 let writer = FilesystemMemWriter::new(mem_dir.clone());
6139 let mut engine = Engine::from_mounts(vec![(
6140 folder_mount("specs", mem_dir),
6141 Box::new(writer) as Box<dyn MemBackend>,
6142 )])
6143 .unwrap();
6144 let (actor, client) = cli_actor();
6145
6146 let mut args = empty_create_args("specs", "Selfie");
6149 args.sections.insert(
6150 "identity".to_string(),
6151 "see [[selfie]] itself and also [[other-ref]]".to_string(),
6152 );
6153 let outcome = engine
6154 .create_entity(args, actor, Some(&client), None)
6155 .unwrap();
6156 let self_id = outcome.id.clone();
6157 assert_eq!(self_id.to_string(), "specs--selfie");
6158 let other_id = crate::EntityId::new("specs", "other-ref");
6159
6160 assert!(
6162 outcome.warnings.iter().any(|w| matches!(
6163 w, WarningHint::SelfLinkIgnored { id } if *id == self_id
6164 )),
6165 "self-link must emit SELF_LINK_IGNORED; got: {:?}",
6166 outcome.warnings,
6167 );
6168
6169 let ent = engine.get_entity(&self_id).unwrap();
6171 assert!(
6172 ent.relationships.iter().all(|r| r.target != self_id),
6173 "no self-relation may be synthesised; got: {:?}",
6174 ent.relationships,
6175 );
6176 assert!(
6177 engine
6178 .store()
6179 .outgoing(&self_id)
6180 .iter()
6181 .all(|e| e.target != self_id),
6182 "self must not be its own Outgoing neighbour",
6183 );
6184 assert!(
6185 engine
6186 .store()
6187 .incoming(&self_id)
6188 .iter()
6189 .all(|e| e.from != self_id),
6190 "self must not be its own Incoming neighbour",
6191 );
6192
6193 assert!(
6196 ent.relationships
6197 .iter()
6198 .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
6199 "non-self body link must still synthesise its edge; got: {:?}",
6200 ent.relationships,
6201 );
6202 }
6203
6204 #[test]
6206 fn create_entity_dry_run_emits_same_auto_stub_warning() {
6207 let tmp = TempDir::new().unwrap();
6208 let mem_dir = tmp.path().to_path_buf();
6209 let writer = FilesystemMemWriter::new(mem_dir.clone());
6210 let mut engine = Engine::from_mounts(vec![(
6211 folder_mount("specs", mem_dir),
6212 Box::new(writer) as Box<dyn MemBackend>,
6213 )])
6214 .unwrap();
6215 let (actor, client) = cli_actor();
6216
6217 let mut args = empty_create_args("specs", "Dry Run Body Link");
6218 args.dry_run = true;
6219 args.sections
6220 .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
6221
6222 let outcome = engine
6223 .create_entity(args, actor, Some(&client), None)
6224 .unwrap();
6225 let has_warning = outcome.warnings.iter().any(|w| {
6226 matches!(
6227 w,
6228 WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
6229 if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
6230 )
6231 });
6232 assert!(
6233 has_warning,
6234 "dry_run must emit the same warning as real write: {:?}",
6235 outcome.warnings
6236 );
6237 }
6238
6239 #[test]
6242 fn create_entity_no_auto_stub_warning_when_target_exists() {
6243 let tmp = TempDir::new().unwrap();
6244 let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
6245 let (actor, client) = cli_actor();
6246
6247 let mut args = empty_create_args("specs", "Source Linking Existing");
6248 let body = format!("ref [[{}]]", existing.id.path());
6249 args.sections.insert("identity".to_string(), body);
6250
6251 let outcome = engine
6252 .create_entity(args, actor, Some(&client), None)
6253 .unwrap();
6254 let has_warning = outcome
6255 .warnings
6256 .iter()
6257 .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
6258 assert!(
6259 !has_warning,
6260 "no auto-stub warning when target pre-exists; got: {:?}",
6261 outcome.warnings
6262 );
6263 }
6264
6265 #[test]
6270 fn obligation_wildcard_links_into_arbitrary_destination_schema() {
6271 use crate::engine::test_helpers::write_schema_files_with_default_type;
6272 use memstead_schema::workspace_config::CrossLinkValue;
6273
6274 let tmp = TempDir::new().unwrap();
6275 let dest_dir = tmp.path().join("dest");
6276 let duties_dir = tmp.path().join("duties");
6277 std::fs::create_dir_all(&dest_dir).unwrap();
6278 std::fs::create_dir_all(&duties_dir).unwrap();
6279 let schemas_dir = tmp.path().join("schemas");
6280 let user_manifest = r#"name: casefiles
6281version: 0.1.0
6282description: a user-written, non-software destination schema
6283when_to_use: tests
6284types:
6285 - doc
6286relationships:
6287 mode: strict
6288 definitions:
6289 - name: _default
6290 description: fallback
6291 default_weight: 1.0
6292community:
6293 resolution: 1.0
6294 seed: 42
6295"#;
6296 write_schema_files_with_default_type(
6297 &schemas_dir,
6298 "casefiles@0.1.0",
6299 user_manifest,
6300 &["doc"],
6301 );
6302
6303 let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
6304 mem: mem.to_string(),
6305 schema: Some(memstead_schema::SchemaRef::new(
6306 schema,
6307 semver::Version::new(0, 1, 0),
6308 )),
6309 storage: crate::workspace::MountStorage::Folder {
6310 path: dir.to_path_buf(),
6311 },
6312 capability: crate::workspace::MountCapability::Write,
6313 lifecycle: crate::workspace::MountLifecycle::Eager,
6314 cross_linkable: true,
6315 migration_target: None,
6316 };
6317 let mounts = vec![
6318 (
6319 mount("dest", &dest_dir, "casefiles"),
6320 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
6321 ),
6322 (
6323 mount("duties", &duties_dir, "obligation"),
6324 Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
6325 ),
6326 ];
6327 let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
6328 .expect("obligation + user schema boot");
6329 let mut settings = crate::workspace::WorkspaceSettings::default();
6330 settings.cross_mem_links.insert(
6331 "duties".to_string(),
6332 CrossLinkValue::List(vec!["dest".to_string()]),
6333 );
6334 engine.set_settings(settings);
6335 let (actor, client) = cli_actor();
6336
6337 let target = engine
6338 .create_entity(
6339 CreateEntityArgs {
6340 anchors: Vec::new(),
6341 mem: "dest".to_string(),
6342 title: "Case File 17".to_string(),
6343 entity_type: "doc".to_string(),
6344 sections: IndexMap::from_iter([(
6345 "body".to_string(),
6346 "destination content".to_string(),
6347 )]),
6348 metadata: IndexMap::new(),
6349 relations: Vec::new(),
6350 dry_run: false,
6351 },
6352 actor,
6353 Some(&client),
6354 None,
6355 )
6356 .unwrap();
6357
6358 let entry = engine
6359 .create_entity(
6360 CreateEntityArgs {
6361 anchors: Vec::new(),
6362 mem: "duties".to_string(),
6363 title: "File Annual Report & Notice".to_string(),
6364 entity_type: "obligation".to_string(),
6365 sections: IndexMap::from_iter([
6366 (
6367 "duty".to_string(),
6368 "File the report cited in [[dest--case-file-17]].".to_string(),
6369 ),
6370 (
6371 "consequence".to_string(),
6372 "Standing lapses at the deadline.".to_string(),
6373 ),
6374 ]),
6375 metadata: IndexMap::from_iter([
6376 ("due_date".to_string(), "2026-12-31".to_string()),
6377 ("status".to_string(), "open".to_string()),
6378 ]),
6379 relations: vec![crate::ops::RelateArg {
6380 target: crate::entity::EntityId::new("duties", "subject"),
6381 rel_type: "CONCERNS".to_string(),
6382 description: None,
6383 }],
6384 dry_run: false,
6385 },
6386 actor,
6387 Some(&client),
6388 None,
6389 )
6390 .expect("wildcard admits the alias link into the non-software destination");
6391 let stored = engine.get_entity(&entry.id).unwrap();
6392 assert!(
6393 stored
6394 .relationships
6395 .iter()
6396 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6397 "alias REFERENCES edge must emit cross-mem: {:?}",
6398 stored.relationships
6399 );
6400 }
6401
6402 #[test]
6419 fn undeclared_cross_schema_body_link_warns_instead_of_vanishing() {
6420 use memstead_schema::workspace_config::CrossLinkValue;
6421
6422 let tmp = TempDir::new().unwrap();
6423 let scratch_dir = tmp.path().join("scratch");
6424 let plans_dir = tmp.path().join("plans");
6425 std::fs::create_dir_all(&scratch_dir).unwrap();
6426 std::fs::create_dir_all(&plans_dir).unwrap();
6427
6428 let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
6429 crate::workspace::Mount {
6430 mem: mem.to_string(),
6431 schema: Some(memstead_schema::SchemaRef::new(
6432 schema,
6433 semver::Version::new(version.0, version.1, version.2),
6434 )),
6435 storage: crate::workspace::MountStorage::Folder {
6436 path: dir.to_path_buf(),
6437 },
6438 capability: crate::workspace::MountCapability::Write,
6439 lifecycle: crate::workspace::MountLifecycle::Eager,
6440 cross_linkable: true,
6441 migration_target: None,
6442 }
6443 };
6444 let mut engine = Engine::from_mounts(vec![
6445 (
6446 mount("scratch", &scratch_dir, "default", (1, 3, 0)),
6447 Box::new(FilesystemMemWriter::new(scratch_dir.clone())) as Box<dyn MemBackend>,
6448 ),
6449 (
6450 mount("plans", &plans_dir, "planning", (0, 4, 0)),
6451 Box::new(FilesystemMemWriter::new(plans_dir.clone())) as Box<dyn MemBackend>,
6452 ),
6453 ])
6454 .unwrap();
6455 let mut settings = crate::workspace::WorkspaceSettings::default();
6456 settings.cross_mem_links.insert(
6457 "scratch".to_string(),
6458 CrossLinkValue::List(vec!["plans".to_string()]),
6459 );
6460 engine.set_settings(settings);
6461 let (actor, client) = cli_actor();
6462
6463 let target = engine
6464 .create_entity(
6465 CreateEntityArgs {
6466 anchors: Vec::new(),
6467 mem: "plans".to_string(),
6468 title: "Target Plan Note".to_string(),
6469 entity_type: "goal".to_string(),
6470 sections: IndexMap::from_iter([
6471 (
6472 "statement".to_string(),
6473 "the plan goal being cited".to_string(),
6474 ),
6475 (
6476 "rationale".to_string(),
6477 "cited from a scratch mem in the repro".to_string(),
6478 ),
6479 (
6480 "success_criteria".to_string(),
6481 "the citation edge exists or the drop is warned".to_string(),
6482 ),
6483 ]),
6484 metadata: IndexMap::new(),
6485 relations: Vec::new(),
6486 dry_run: false,
6487 },
6488 actor,
6489 Some(&client),
6490 None,
6491 )
6492 .unwrap();
6493
6494 let entry = engine
6495 .create_entity(
6496 CreateEntityArgs {
6497 anchors: Vec::new(),
6498 mem: "scratch".to_string(),
6499 title: "Session Scratch".to_string(),
6500 entity_type: "memo".to_string(),
6501 sections: IndexMap::from_iter([
6502 (
6503 "claim".to_string(),
6504 "the pilot follows [[plans--target-plan-note]] step by step"
6505 .to_string(),
6506 ),
6507 ("context".to_string(), "exec session scratch".to_string()),
6508 ]),
6509 metadata: IndexMap::new(),
6510 relations: Vec::new(),
6511 dry_run: false,
6512 },
6513 actor,
6514 Some(&client),
6515 None,
6516 )
6517 .expect("prose citing an undeclared destination schema must not refuse the write");
6518
6519 let stored = engine.get_entity(&entry.id).unwrap();
6521 assert!(
6522 !stored.relationships.iter().any(|r| r.target == target.id),
6523 "default→planning declares no entry, so no edge is emitted: {:?}",
6524 stored.relationships
6525 );
6526 assert!(
6528 entry.warnings.iter().any(|w| matches!(
6529 w,
6530 WarningHint::CrossSchemaLinkUndeclared { target, .. }
6531 if target.as_ref() == "plans--target-plan-note"
6532 )),
6533 "the dropped body link must surface as a typed warning: {:?}",
6534 entry.warnings
6535 );
6536 }
6537
6538 #[test]
6541 fn ingest_wildcard_links_into_arbitrary_destination_schema() {
6542 use crate::engine::test_helpers::write_schema_files_with_default_type;
6543 use memstead_schema::workspace_config::CrossLinkValue;
6544
6545 let tmp = TempDir::new().unwrap();
6546 let dest_dir = tmp.path().join("dest");
6547 let proc_dir = tmp.path().join("proc");
6548 std::fs::create_dir_all(&dest_dir).unwrap();
6549 std::fs::create_dir_all(&proc_dir).unwrap();
6550
6551 let schemas_dir = tmp.path().join("schemas");
6553 let user_manifest = r#"name: debate
6554version: 0.1.0
6555description: a user-written destination schema
6556when_to_use: tests
6557types:
6558 - doc
6559relationships:
6560 mode: strict
6561 definitions:
6562 - name: _default
6563 description: fallback
6564 default_weight: 1.0
6565community:
6566 resolution: 1.0
6567 seed: 42
6568"#;
6569 write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
6570
6571 let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
6572 crate::workspace::Mount {
6573 mem: mem.to_string(),
6574 schema: Some(memstead_schema::SchemaRef::new(
6575 schema,
6576 semver::Version::new(version.0, version.1, version.2),
6577 )),
6578 storage: crate::workspace::MountStorage::Folder {
6579 path: dir.to_path_buf(),
6580 },
6581 capability: crate::workspace::MountCapability::Write,
6582 lifecycle: crate::workspace::MountLifecycle::Eager,
6583 cross_linkable: true,
6584 migration_target: None,
6585 }
6586 };
6587 let boot = |grant: bool| -> Engine {
6588 let mounts = vec![
6589 (
6590 mount("dest", &dest_dir, "debate", (0, 1, 0)),
6591 Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
6592 ),
6593 (
6594 mount("proc", &proc_dir, "ingest", (0, 2, 0)),
6595 Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
6596 ),
6597 ];
6598 let mut engine =
6599 Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
6600 .expect("ingest + user schema boot");
6601 let mut settings = crate::workspace::WorkspaceSettings::default();
6602 if grant {
6603 settings.cross_mem_links.insert(
6604 "proc".to_string(),
6605 CrossLinkValue::List(vec!["dest".to_string()]),
6606 );
6607 }
6608 engine.set_settings(settings);
6609 engine
6610 };
6611 let (actor, client) = cli_actor();
6612
6613 let mut engine = boot(true);
6614 let target = engine
6616 .create_entity(
6617 CreateEntityArgs {
6618 anchors: Vec::new(),
6619 mem: "dest".to_string(),
6620 title: "Target Doc".to_string(),
6621 entity_type: "doc".to_string(),
6622 sections: IndexMap::from_iter([(
6623 "body".to_string(),
6624 "destination content".to_string(),
6625 )]),
6626 metadata: IndexMap::new(),
6627 relations: Vec::new(),
6628 dry_run: false,
6629 },
6630 actor,
6631 Some(&client),
6632 None,
6633 )
6634 .unwrap();
6635
6636 let entry = engine
6638 .create_entity(
6639 CreateEntityArgs {
6640 anchors: Vec::new(),
6641 mem: "proc".to_string(),
6642 title: "Check The Claim".to_string(),
6643 entity_type: "verification_target".to_string(),
6644 sections: IndexMap::from_iter([
6645 (
6646 "claim".to_string(),
6647 "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
6648 ),
6649 ("source_to_check".to_string(), "dest mem".to_string()),
6650 (
6651 "verifiable_when".to_string(),
6652 "the linked entity still says so".to_string(),
6653 ),
6654 ]),
6655 metadata: IndexMap::new(),
6656 relations: Vec::new(),
6657 dry_run: false,
6658 },
6659 actor,
6660 Some(&client),
6661 None,
6662 )
6663 .expect("wildcard admits the alias link into the user-schema destination");
6664 let stored = engine.get_entity(&entry.id).unwrap();
6665 assert!(
6666 stored
6667 .relationships
6668 .iter()
6669 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6670 "alias REFERENCES edge must emit: {:?}",
6671 stored.relationships
6672 );
6673
6674 let err = engine
6676 .relate_entity(
6677 RelateEntityArgs {
6678 source: entry.id.clone(),
6679 expected_hash: None,
6680 rel_type: "REFERENCES".to_string(),
6681 target: target.id.clone(),
6682 remove: false,
6683 description: None,
6684 dry_run: false,
6685 },
6686 actor,
6687 Some(&client),
6688 None,
6689 )
6690 .unwrap_err();
6691 assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
6692
6693 let err = engine
6696 .relate_entity(
6697 RelateEntityArgs {
6698 source: entry.id.clone(),
6699 expected_hash: None,
6700 rel_type: "PART_OF".to_string(),
6701 target: target.id.clone(),
6702 remove: false,
6703 description: None,
6704 dry_run: false,
6705 },
6706 actor,
6707 Some(&client),
6708 None,
6709 )
6710 .unwrap_err();
6711 assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
6712
6713 drop(engine);
6717 let rebooted = boot(true);
6718 let reloaded = rebooted.get_entity(&entry.id).unwrap();
6719 assert!(
6720 reloaded
6721 .relationships
6722 .iter()
6723 .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6724 "alias edge must survive reload: {:?}",
6725 reloaded.relationships
6726 );
6727
6728 let mut denied = boot(false);
6731 let err = denied
6732 .create_entity(
6733 CreateEntityArgs {
6734 anchors: Vec::new(),
6735 mem: "proc".to_string(),
6736 title: "Denied Entry".to_string(),
6737 entity_type: "verification_target".to_string(),
6738 sections: IndexMap::from_iter([
6739 (
6740 "claim".to_string(),
6741 "points at [[dest--target-doc]]".to_string(),
6742 ),
6743 ("source_to_check".to_string(), "dest mem".to_string()),
6744 ("verifiable_when".to_string(), "never".to_string()),
6745 ]),
6746 metadata: IndexMap::new(),
6747 relations: Vec::new(),
6748 dry_run: false,
6749 },
6750 actor,
6751 Some(&client),
6752 None,
6753 )
6754 .unwrap_err();
6755 assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
6756 }
6757
6758 fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
6763 let tmp_test = TempDir::new().unwrap();
6764 let tmp_other = TempDir::new().unwrap();
6765 let test_dir = tmp_test.path().to_path_buf();
6766 let other_dir = tmp_other.path().to_path_buf();
6767 let writer_test = FilesystemMemWriter::new(test_dir.clone());
6768 let writer_other = FilesystemMemWriter::new(other_dir.clone());
6769 let engine = Engine::from_mounts(vec![
6770 (
6771 folder_mount("test", test_dir),
6772 Box::new(writer_test) as Box<dyn MemBackend>,
6773 ),
6774 (
6775 folder_mount("other", other_dir),
6776 Box::new(writer_other) as Box<dyn MemBackend>,
6777 ),
6778 ])
6779 .unwrap();
6780 (tmp_test, tmp_other, engine)
6781 }
6782
6783 #[test]
6788 fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
6789 use crate::entity::EntityId;
6790 use crate::ops::RelateArg;
6791 use memstead_schema::workspace_config::CrossLinkValue;
6792
6793 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6794 let (actor, client) = cli_actor();
6795
6796 let mut settings = crate::workspace::WorkspaceSettings::default();
6799 settings.cross_mem_links.insert(
6800 "test".to_string(),
6801 CrossLinkValue::List(vec!["other".to_string()]),
6802 );
6803 engine.set_settings(settings);
6804
6805 let target = engine
6810 .create_entity(
6811 empty_create_args("test", "Target"),
6812 actor,
6813 Some(&client),
6814 None,
6815 )
6816 .unwrap();
6817
6818 let mut args = empty_create_args("other", "Source");
6819 args.relations = vec![RelateArg {
6820 rel_type: "IMPLEMENTS".to_string(),
6821 target: target.id.clone(),
6822 description: None,
6823 }];
6824 let err = engine
6825 .create_entity(args, actor, Some(&client), None)
6826 .unwrap_err();
6827 match err {
6828 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
6829 assert_eq!(from_mem, "other");
6830 assert_eq!(to_mem, "test");
6831 }
6832 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
6833 }
6834
6835 let would_be = EntityId::new("other", "source");
6837 assert!(
6838 engine.get_entity(&would_be).is_none(),
6839 "entity must not persist when inline relation refuses"
6840 );
6841 }
6842
6843 #[test]
6846 fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
6847 use crate::ops::RelateArg;
6848 use memstead_schema::workspace_config::CrossLinkValue;
6849
6850 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6851 let (actor, client) = cli_actor();
6852
6853 let mut settings = crate::workspace::WorkspaceSettings::default();
6854 settings.cross_mem_links.insert(
6855 "other".to_string(),
6856 CrossLinkValue::List(vec!["test".to_string()]),
6857 );
6858 engine.set_settings(settings);
6859
6860 let target = engine
6861 .create_entity(
6862 empty_create_args("test", "Target"),
6863 actor,
6864 Some(&client),
6865 None,
6866 )
6867 .unwrap();
6868
6869 let mut args = empty_create_args("other", "Source");
6870 args.relations = vec![RelateArg {
6871 rel_type: "IMPLEMENTS".to_string(),
6872 target: target.id.clone(),
6873 description: None,
6874 }];
6875 let outcome = engine
6876 .create_entity(args, actor, Some(&client), None)
6877 .unwrap();
6878 let stored = engine.get_entity(&outcome.id).expect("entity persists");
6879 assert!(
6880 stored
6881 .relationships
6882 .iter()
6883 .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
6884 "IMPLEMENTS edge must persist on the source's relationships",
6885 );
6886 }
6887
6888 #[test]
6892 fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
6893 use crate::ops::RelateArg;
6894
6895 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6896 let (actor, client) = cli_actor();
6897 let target = engine
6900 .create_entity(
6901 empty_create_args("test", "Target"),
6902 actor,
6903 Some(&client),
6904 None,
6905 )
6906 .unwrap();
6907 let mut args = empty_create_args("test", "Source");
6908 args.relations = vec![RelateArg {
6909 rel_type: "USES".to_string(),
6910 target: target.id.clone(),
6911 description: None,
6912 }];
6913 let outcome = engine
6914 .create_entity(args, actor, Some(&client), None)
6915 .unwrap();
6916 let stored = engine.get_entity(&outcome.id).expect("entity persists");
6917 assert!(
6918 stored
6919 .relationships
6920 .iter()
6921 .any(|r| r.rel_type == "USES" && r.target == target.id),
6922 "same-mem USES edge must persist",
6923 );
6924 }
6925
6926 #[test]
6931 fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
6932 use crate::ops::RelateArg;
6933 use memstead_schema::workspace_config::CrossLinkValue;
6934
6935 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6936 let (actor, client) = cli_actor();
6937
6938 let mut settings = crate::workspace::WorkspaceSettings::default();
6939 settings.cross_mem_links.insert(
6940 "test".to_string(),
6941 CrossLinkValue::List(vec!["other".to_string()]),
6942 );
6943 engine.set_settings(settings);
6944
6945 let target = engine
6946 .create_entity(
6947 empty_create_args("test", "Target"),
6948 actor,
6949 Some(&client),
6950 None,
6951 )
6952 .unwrap();
6953 let src = engine
6954 .create_entity(
6955 empty_create_args("other", "Source"),
6956 actor,
6957 Some(&client),
6958 None,
6959 )
6960 .unwrap();
6961
6962 let relate_err = engine
6964 .relate_entity(
6965 RelateEntityArgs {
6966 source: src.id.clone(),
6967 rel_type: "IMPLEMENTS".to_string(),
6968 target: target.id.clone(),
6969 expected_hash: Some(src.content_hash.clone()),
6970 remove: false,
6971 description: None,
6972 dry_run: false,
6973 },
6974 actor,
6975 Some(&client),
6976 None,
6977 )
6978 .unwrap_err();
6979
6980 let mut create_args = empty_create_args("other", "Source Two");
6983 create_args.relations = vec![RelateArg {
6984 rel_type: "IMPLEMENTS".to_string(),
6985 target: target.id.clone(),
6986 description: None,
6987 }];
6988 let create_err = engine
6989 .create_entity(create_args, actor, Some(&client), None)
6990 .unwrap_err();
6991
6992 match (relate_err, create_err) {
6995 (
6996 EngineError::CrossMemLinkNotAllowed {
6997 from_mem: rfv,
6998 to_mem: rtv,
6999 },
7000 EngineError::CrossMemLinkNotAllowed {
7001 from_mem: cfv,
7002 to_mem: ctv,
7003 },
7004 ) => {
7005 assert_eq!(rfv, "other");
7006 assert_eq!(rtv, "test");
7007 assert_eq!(cfv, "other");
7008 assert_eq!(ctv, "test");
7009 }
7010 (a, b) => panic!(
7011 "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
7012 ),
7013 }
7014 }
7015
7016 #[test]
7022 fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
7023 use crate::entity::EntityId;
7024 use indexmap::IndexMap;
7025 use memstead_schema::workspace_config::CrossLinkValue;
7026
7027 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
7028 let (actor, client) = cli_actor();
7029
7030 let mut settings = crate::workspace::WorkspaceSettings::default();
7031 settings.cross_mem_links.insert(
7032 "test".to_string(),
7033 CrossLinkValue::List(vec!["other".to_string()]),
7034 );
7035 engine.set_settings(settings);
7036
7037 let mut sections: IndexMap<String, String> = IndexMap::new();
7038 sections.insert(
7039 "identity".to_string(),
7040 "see [[other--target]] for details".to_string(),
7041 );
7042 sections.insert("purpose".to_string(), "source purpose".to_string());
7043 let outcome = engine
7044 .create_entity(
7045 crate::engine::CreateEntityArgs {
7046 anchors: Vec::new(),
7047 mem: "test".to_string(),
7048 title: "Source".to_string(),
7049 entity_type: "spec".to_string(),
7050 sections,
7051 metadata: IndexMap::new(),
7052 relations: Vec::new(),
7053 dry_run: false,
7054 },
7055 actor,
7056 Some(&client),
7057 None,
7058 )
7059 .unwrap();
7060
7061 let canonical = EntityId::new("other", "target");
7063 assert!(
7064 engine.get_entity(&canonical).is_some(),
7065 "auto-stub must land at the canonical cross-mem id"
7066 );
7067 let phantom = EntityId::new("test", "other--target");
7068 assert!(
7069 engine.get_entity(&phantom).is_none(),
7070 "no double-prefixed phantom stub"
7071 );
7072
7073 let source = engine.get_entity(&outcome.id).unwrap();
7075 let references_count = source
7076 .relationships
7077 .iter()
7078 .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
7079 .count();
7080 assert_eq!(
7081 references_count, 1,
7082 "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
7083 );
7084 }
7085
7086 #[test]
7090 fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
7091 use indexmap::IndexMap;
7092
7093 let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
7094 let (actor, client) = cli_actor();
7095 let mut sections: IndexMap<String, String> = IndexMap::new();
7098 sections.insert(
7099 "identity".to_string(),
7100 "see [[other--target]] for details".to_string(),
7101 );
7102 sections.insert("purpose".to_string(), "source purpose".to_string());
7103 let err = engine
7104 .create_entity(
7105 crate::engine::CreateEntityArgs {
7106 anchors: Vec::new(),
7107 mem: "test".to_string(),
7108 title: "Source".to_string(),
7109 entity_type: "spec".to_string(),
7110 sections,
7111 metadata: IndexMap::new(),
7112 relations: Vec::new(),
7113 dry_run: false,
7114 },
7115 actor,
7116 Some(&client),
7117 None,
7118 )
7119 .unwrap_err();
7120 match err {
7121 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
7122 assert_eq!(from_mem, "test");
7123 assert_eq!(to_mem, "other");
7124 }
7125 other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
7126 }
7127 }
7128
7129 #[test]
7137 fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
7138 use crate::engine::UpdateEntityArgs;
7139 use crate::workspace::{MutationsSection, WorkspaceSettings};
7140 use indexmap::IndexMap;
7141
7142 let tmp = TempDir::new().unwrap();
7143 let mem_dir = tmp.path().to_path_buf();
7144 let writer = FilesystemMemWriter::new(mem_dir.clone());
7145 let mut engine = Engine::from_mounts(vec![(
7146 folder_mount("specs", mem_dir.clone()),
7147 Box::new(writer) as Box<dyn MemBackend>,
7148 )])
7149 .unwrap();
7150 engine.set_workspace_root(mem_dir.clone());
7151 engine.set_settings(WorkspaceSettings {
7152 mutations: MutationsSection {
7153 require_notes: Some(true),
7154 },
7155 ..Default::default()
7156 });
7157 let (actor, client) = cli_actor();
7158
7159 let note_missing = |ws: &[WarningHint]| -> usize {
7160 ws.iter()
7161 .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
7162 .count()
7163 };
7164
7165 let created = engine
7167 .create_entity(
7168 empty_create_args("specs", "Noteless"),
7169 actor,
7170 Some(&client),
7171 None,
7172 )
7173 .unwrap();
7174 assert_eq!(
7175 note_missing(&created.warnings),
7176 1,
7177 "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
7178 created.warnings,
7179 );
7180 assert!(
7181 matches!(
7182 created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
7183 Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
7184 ),
7185 "the warning names the engine-level verb",
7186 );
7187 assert!(
7188 !created.write_id.is_empty(),
7189 "create still commits (nudge, not block)"
7190 );
7191
7192 let mut edit: IndexMap<String, String> = IndexMap::new();
7194 edit.insert("identity".to_string(), "revised".to_string());
7195 let updated = engine
7196 .update_entity(
7197 UpdateEntityArgs {
7198 anchors: Vec::new(),
7199 id: created.id.clone(),
7200 expected_hash: Some(created.content_hash.clone()),
7201 sections: edit,
7202 append_sections: IndexMap::new(),
7203 patch_sections: IndexMap::new(),
7204 sections_unset: Vec::new(),
7205 metadata: IndexMap::new(),
7206 metadata_unset: Vec::new(),
7207 declare_relations: Vec::new(),
7208 dry_run: false,
7209 relations_unset: Vec::new(),
7210 anchors_unset: Vec::new(),
7211 },
7212 actor,
7213 Some(&client),
7214 None,
7215 )
7216 .unwrap();
7217 assert_eq!(
7218 note_missing(&updated.warnings),
7219 1,
7220 "update emits NOTE_MISSING"
7221 );
7222 assert!(!updated.write_id.is_empty(), "update still commits");
7223
7224 let target = engine
7226 .create_entity(
7227 empty_create_args("specs", "Target"),
7228 actor,
7229 Some(&client),
7230 Some("seed"),
7231 )
7232 .unwrap();
7233 let related = engine
7234 .relate_entity(
7235 RelateEntityArgs {
7236 source: updated.id.clone(),
7237 expected_hash: Some(updated.content_hash.clone()),
7238 rel_type: "USES".to_string(),
7239 target: target.id.clone(),
7240 remove: false,
7241 description: None,
7242 dry_run: false,
7243 },
7244 actor,
7245 Some(&client),
7246 None,
7247 )
7248 .unwrap();
7249 assert_eq!(
7250 note_missing(&related.warnings),
7251 1,
7252 "relate emits NOTE_MISSING"
7253 );
7254 assert!(!related.write_id.is_empty(), "relate still commits");
7255
7256 let with_note = engine
7258 .create_entity(
7259 empty_create_args("specs", "Documented"),
7260 actor,
7261 Some(&client),
7262 Some("a real provenance note"),
7263 )
7264 .unwrap();
7265 assert_eq!(
7266 note_missing(&with_note.warnings),
7267 0,
7268 "a supplied note suppresses the warning",
7269 );
7270
7271 engine.set_settings(WorkspaceSettings::default());
7273 let after_off = engine
7274 .create_entity(
7275 empty_create_args("specs", "Quiet"),
7276 actor,
7277 Some(&client),
7278 None,
7279 )
7280 .unwrap();
7281 assert_eq!(
7282 note_missing(&after_off.warnings),
7283 0,
7284 "no NOTE_MISSING when require_notes is unset",
7285 );
7286 }
7287
7288 fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
7291 crate::anchor::AnchorInput {
7292 artifact: Some(artifact.to_string()),
7293 grain: Some("file".to_string()),
7294 class: Some("anchored".to_string()),
7295 hash: Some(hash.to_string()),
7296 hash_stability: Some("stable".to_string()),
7297 ..Default::default()
7298 }
7299 }
7300
7301 fn folder_engine(mem: &str) -> (Engine, TempDir) {
7302 let tmp = TempDir::new().unwrap();
7303 let dir = tmp.path().to_path_buf();
7304 let writer = FilesystemMemWriter::new(dir.clone());
7305 let engine = Engine::from_mounts(vec![(
7306 folder_mount(mem, dir.clone()),
7307 Box::new(writer) as Box<dyn MemBackend>,
7308 )])
7309 .unwrap();
7310 (engine, tmp)
7311 }
7312
7313 #[test]
7314 fn create_with_anchors_persists_and_survives_reload() {
7315 let (mut engine, tmp) = folder_engine("specs");
7316 let dir = tmp.path().to_path_buf();
7317 let (actor, client) = cli_actor();
7318 let mut args = empty_create_args("specs", "Anchored Entity");
7319 args.anchors = vec![file_anchor("src/lib.rs", "h1")];
7320 engine
7321 .create_entity(args, actor, Some(&client), None)
7322 .unwrap();
7323
7324 let id = crate::EntityId::new("specs", "anchored-entity");
7325 let anchors = engine.entity_anchors(&id);
7326 assert_eq!(anchors.len(), 1);
7327 assert_eq!(anchors[0].artifact, "src/lib.rs");
7328 assert_eq!(
7329 anchors[0].class,
7330 crate::anchor::AnchorProvenanceClass::Anchored
7331 );
7332
7333 let writer = FilesystemMemWriter::new(dir.clone());
7335 let reloaded = Engine::from_mounts(vec![(
7336 folder_mount("specs", dir.clone()),
7337 Box::new(writer) as Box<dyn MemBackend>,
7338 )])
7339 .unwrap();
7340 assert_eq!(reloaded.entity_anchors(&id).len(), 1);
7341 assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
7343 }
7344
7345 #[test]
7346 fn malformed_anchor_refuses_and_entity_not_written() {
7347 let (mut engine, tmp) = folder_engine("specs");
7348 let (actor, client) = cli_actor();
7349 let mut args = empty_create_args("specs", "Bad Anchor");
7350 args.anchors = vec![crate::anchor::AnchorInput {
7351 artifact: Some("x".into()),
7352 grain: Some("paragraph".into()), class: Some("anchored".into()),
7354 ..Default::default()
7355 }];
7356 let err = engine
7357 .create_entity(args, actor, Some(&client), None)
7358 .unwrap_err();
7359 assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
7360 assert!(
7362 engine
7363 .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
7364 .is_none()
7365 );
7366 assert!(!tmp.path().join("bad-anchor.md").exists());
7367 }
7368
7369 #[test]
7370 fn anchors_are_not_folded_into_content_hash() {
7371 let (mut anchored, _t1) = folder_engine("specs");
7382 let (mut plain, _t2) = folder_engine("specs");
7383 let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000);
7384 anchored.set_mutation_clock(std::sync::Arc::new(move || frozen));
7385 plain.set_mutation_clock(std::sync::Arc::new(move || frozen));
7386 let (actor, client) = cli_actor();
7387
7388 let mut a = empty_create_args("specs", "Same Title");
7389 a.anchors = vec![file_anchor("src/lib.rs", "h1")];
7390 let with = anchored
7391 .create_entity(a, actor, Some(&client), None)
7392 .unwrap();
7393
7394 let p = empty_create_args("specs", "Same Title");
7395 let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
7396
7397 assert_eq!(
7398 with.content_hash, without.content_hash,
7399 "anchors must not change the entity content hash"
7400 );
7401 }
7402
7403 #[test]
7404 fn anchorless_create_writes_no_sidecar() {
7405 let (mut engine, _tmp) = folder_engine("specs");
7406 let (actor, client) = cli_actor();
7407 engine
7408 .create_entity(
7409 empty_create_args("specs", "No Anchors"),
7410 actor,
7411 Some(&client),
7412 None,
7413 )
7414 .unwrap();
7415 assert!(
7416 engine
7417 .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
7418 .is_empty()
7419 );
7420 }
7421
7422 #[test]
7433 fn create_refuses_reserved_metadata_keys_deliberately() {
7434 let (mut engine, _tmp) = folder_engine("specs");
7435 let (actor, client) = cli_actor();
7436 for reserved in ["type", "mem", "id"] {
7437 let mut args = empty_create_args("specs", "Smuggler");
7438 args.metadata
7439 .insert(reserved.to_string(), "bogus".to_string());
7440 let err = engine
7441 .create_entity(args, actor, Some(&client), None)
7442 .expect_err("reserved key must refuse on create");
7443 assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
7444 assert!(
7445 engine
7446 .get_entity(&crate::EntityId::new("specs", "smuggler"))
7447 .is_none(),
7448 "entity must not be written after the '{reserved}' refusal"
7449 );
7450 }
7451 engine
7453 .create_entity(
7454 empty_create_args("specs", "Smuggler"),
7455 actor,
7456 Some(&client),
7457 None,
7458 )
7459 .expect("a clean create is untouched by the reserved-key gate");
7460 }
7461
7462 fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
7465 let mut args = empty_create_args(mem, title);
7466 args.relations = vec![crate::ops::RelateArg {
7467 target: crate::EntityId(to.to_string()),
7468 rel_type: rel_type.to_string(),
7469 description: None,
7470 }];
7471 args
7472 }
7473
7474 #[test]
7482 fn create_relations_refuse_cycle_and_self_loop_like_relate() {
7483 let (mut engine, _tmp) = folder_engine("specs");
7484 let (actor, client) = cli_actor();
7485
7486 engine
7488 .create_entity(
7489 create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
7490 actor,
7491 Some(&client),
7492 None,
7493 )
7494 .unwrap();
7495
7496 let err = engine
7498 .create_entity(
7499 create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
7500 actor,
7501 Some(&client),
7502 None,
7503 )
7504 .expect_err("cycle-closing create.relations[] must refuse");
7505 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
7506 let details = err.details();
7508 assert_eq!(details["rel_type"], "PART_OF");
7509 assert!(details["existing_path"].is_array());
7510 assert!(
7511 engine
7512 .get_entity(&crate::EntityId::new("specs", "ghost"))
7513 .is_none_or(|e| e.stub),
7514 "the refused entity must not be written"
7515 );
7516
7517 let err = engine
7519 .create_entity(
7520 create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
7521 actor,
7522 Some(&client),
7523 None,
7524 )
7525 .expect_err("self-loop create.relations[] must refuse");
7526 assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
7527
7528 engine
7531 .create_entity(
7532 create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
7533 actor,
7534 Some(&client),
7535 None,
7536 )
7537 .expect("a non-cycle PART_OF edge must land as today");
7538 }
7539
7540 #[test]
7544 fn batch_create_refuses_intra_batch_cycle() {
7545 let (mut engine, _tmp) = folder_engine("specs");
7546 let (actor, client) = cli_actor();
7547
7548 let result = engine
7549 .batch_create(
7550 vec![
7551 (
7552 create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
7553 None,
7554 ),
7555 (
7556 create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
7557 None,
7558 ),
7559 ],
7560 actor,
7561 Some(&client),
7562 false,
7563 )
7564 .expect("batch returns a result envelope");
7565 assert!(!result.applied, "intra-batch cycle must refuse the batch");
7566 assert!(
7567 result.results.iter().any(|r| r
7568 .error
7569 .as_ref()
7570 .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
7571 "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
7572 result.results
7573 );
7574 assert!(
7575 engine
7576 .get_entity(&crate::EntityId::new("specs", "ping"))
7577 .is_none(),
7578 "nothing lands from a refused batch"
7579 );
7580
7581 let result = engine
7583 .batch_create(
7584 vec![
7585 (
7586 create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
7587 None,
7588 ),
7589 (empty_create_args("specs", "Chain Two"), None),
7590 ],
7591 actor,
7592 Some(&client),
7593 false,
7594 )
7595 .expect("acyclic batch lands");
7596 assert!(result.applied, "{:?}", result.results);
7597 assert_eq!(result.succeeded, 2);
7598 }
7599
7600 #[test]
7606 fn deep_acyclic_chain_near_path_cap_is_accepted() {
7607 let (mut engine, _tmp) = folder_engine("specs");
7608 let (actor, client) = cli_actor();
7609 let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
7610
7611 engine
7613 .create_entity(
7614 empty_create_args("specs", "Link 0"),
7615 actor,
7616 Some(&client),
7617 None,
7618 )
7619 .unwrap();
7620 for i in 1..depth {
7621 engine
7622 .create_entity(
7623 create_with_relation(
7624 "specs",
7625 &format!("Link {i}"),
7626 "PART_OF",
7627 &format!("specs--link-{}", i - 1),
7628 ),
7629 actor,
7630 Some(&client),
7631 None,
7632 )
7633 .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
7634 }
7635
7636 let last = depth - 1;
7639 let err = engine
7640 .update_entity(
7641 {
7642 let id = crate::EntityId::new("specs", "link-0");
7643 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
7644 crate::engine::UpdateEntityArgs {
7645 anchors: Vec::new(),
7646 anchors_unset: Vec::new(),
7647 id,
7648 expected_hash: Some(hash),
7649 sections: IndexMap::new(),
7650 append_sections: IndexMap::new(),
7651 patch_sections: IndexMap::new(),
7652 sections_unset: Vec::new(),
7653 metadata: IndexMap::new(),
7654 metadata_unset: Vec::new(),
7655 declare_relations: vec![crate::ops::RelateArg {
7656 target: crate::EntityId::new("specs", &format!("link-{last}")),
7657 rel_type: "PART_OF".to_string(),
7658 description: None,
7659 }],
7660 dry_run: false,
7661 relations_unset: Vec::new(),
7662 }
7663 },
7664 actor,
7665 Some(&client),
7666 None,
7667 )
7668 .expect_err("closing the deep chain must refuse");
7669 assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
7670 let details = err.details();
7671 assert_eq!(details["path_truncated"], true);
7672 assert_eq!(
7673 details["existing_path"].as_array().unwrap().len(),
7674 crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
7675 );
7676 }
7677
7678 const FORMAT_MANIFEST: &str = r#"name: formatproof
7679version: 0.1.0
7680description: section-format proof schema
7681when_to_use: format tests
7682types:
7683 - plan
7684relationships:
7685 mode: strict
7686 definitions:
7687 - name: PART_OF
7688 description: hier
7689 default_weight: 1.0
7690 - name: _default
7691 description: fallback
7692 default_weight: 1.0
7693community:
7694 resolution: 1.0
7695 seed: 42
7696"#;
7697
7698 const FORMAT_PLAN_TYPE: &str = r#"name: plan
7699description: a plan with formatted milestones
7700when_to_use: tests
7701sections:
7702 - key: body
7703 heading: Body
7704 required: true
7705 search_weight: 10.0
7706 catch_all: true
7707 write_rules: []
7708 - key: meilensteine
7709 heading: Meilensteine
7710 required: false
7711 search_weight: 5.0
7712 catch_all: false
7713 write_rules: []
7714 content: "(heading(3) list(bullet))+"
7715 item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
7716 example: |
7717 ### Phase 1
7718 - **Kickoff** — 2026-09-01
7719 - key: notizen
7720 heading: Notizen
7721 required: false
7722 search_weight: 5.0
7723 catch_all: false
7724 write_rules: []
7725 content: "list(bullet)"
7726 format_severity: warn
7727metadata_fields: []
7728title_weight: 100.0
7729text_fields:
7730 - body
7731hierarchy_relationship: PART_OF
7732no_self_loop_relationships: []
7733updatable_fields:
7734 - title
7735 - body
7736 - meilensteine
7737 - notizen
7738health_required_fields:
7739 - body
7740staleness_threshold_days: 90
7741write_rules: []
7742"#;
7743
7744 fn format_engine(tmp: &TempDir) -> Engine {
7745 engine_with_proof_schema(
7746 tmp,
7747 "formatproof",
7748 FORMAT_MANIFEST,
7749 &[("plan", FORMAT_PLAN_TYPE)],
7750 )
7751 }
7752
7753 fn plan_create_args(
7754 title: &str,
7755 meilensteine: Option<&str>,
7756 notizen: Option<&str>,
7757 ) -> CreateEntityArgs {
7758 let mut sections = IndexMap::new();
7759 sections.insert("body".to_string(), "a plan body.".to_string());
7760 if let Some(m) = meilensteine {
7761 sections.insert("meilensteine".to_string(), m.to_string());
7762 }
7763 if let Some(n) = notizen {
7764 sections.insert("notizen".to_string(), n.to_string());
7765 }
7766 CreateEntityArgs {
7767 anchors: Vec::new(),
7768 mem: "proof".to_string(),
7769 title: title.to_string(),
7770 entity_type: "plan".to_string(),
7771 sections,
7772 metadata: IndexMap::new(),
7773 relations: vec![],
7774 dry_run: false,
7775 }
7776 }
7777
7778 #[test]
7782 fn create_enforces_declared_section_format() {
7783 let tmp = TempDir::new().unwrap();
7784 let mut engine = format_engine(&tmp);
7785 let (actor, client) = cli_actor();
7786
7787 let err = engine
7788 .create_entity(
7789 plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
7790 actor,
7791 Some(&client),
7792 None,
7793 )
7794 .unwrap_err();
7795 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7796 let details = err.details();
7797 assert_eq!(details["section"], "meilensteine");
7798 assert!(
7799 details["example"].as_str().unwrap().contains("Kickoff"),
7800 "the conforming example is echoed: {details}"
7801 );
7802 assert_eq!(details["expected_next"][0], "list(bullet)");
7803
7804 let err = engine
7806 .create_entity(
7807 plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
7808 actor,
7809 Some(&client),
7810 None,
7811 )
7812 .unwrap_err();
7813 assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
7814
7815 engine
7817 .create_entity(
7818 plan_create_args(
7819 "Plan C",
7820 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7821 None,
7822 ),
7823 actor,
7824 Some(&client),
7825 None,
7826 )
7827 .unwrap();
7828
7829 let outcome = engine
7831 .create_entity(
7832 plan_create_args(
7833 "Plan D",
7834 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7835 Some("kein listenpunkt\n"),
7836 ),
7837 actor,
7838 Some(&client),
7839 None,
7840 )
7841 .unwrap();
7842 assert!(!outcome.write_id.is_empty(), "warn tier never refuses");
7843
7844 let err = engine
7850 .create_entity(
7851 plan_create_args("Plan E", None, None),
7852 actor,
7853 Some(&client),
7854 None,
7855 )
7856 .unwrap_err();
7857 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7858 }
7859
7860 #[test]
7864 fn update_judges_format_on_composed_body() {
7865 let tmp = TempDir::new().unwrap();
7866 let mut engine = format_engine(&tmp);
7867 let (actor, client) = cli_actor();
7868 let created = engine
7869 .create_entity(
7870 plan_create_args(
7871 "Plan A",
7872 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7873 None,
7874 ),
7875 actor,
7876 Some(&client),
7877 None,
7878 )
7879 .unwrap();
7880
7881 let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
7884 let mut append = IndexMap::new();
7885 append.insert(
7886 "meilensteine".to_string(),
7887 "\n\nnachtrag als absatz\n".to_string(),
7888 );
7889 let err = engine
7890 .update_entity(
7891 crate::engine::UpdateEntityArgs {
7892 anchors: Vec::new(),
7893 id: created.id.clone(),
7894 expected_hash: Some(current.clone()),
7895 sections: IndexMap::new(),
7896 append_sections: append,
7897 patch_sections: IndexMap::new(),
7898 sections_unset: Vec::new(),
7899 metadata: IndexMap::new(),
7900 metadata_unset: Vec::new(),
7901 declare_relations: vec![],
7902 dry_run: false,
7903 relations_unset: Vec::new(),
7904 anchors_unset: Vec::new(),
7905 },
7906 actor,
7907 Some(&client),
7908 None,
7909 )
7910 .unwrap_err();
7911 assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7912
7913 let mut append = IndexMap::new();
7915 append.insert(
7916 "meilensteine".to_string(),
7917 "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
7918 );
7919 engine
7920 .update_entity(
7921 crate::engine::UpdateEntityArgs {
7922 anchors: Vec::new(),
7923 id: created.id.clone(),
7924 expected_hash: Some(current),
7925 sections: IndexMap::new(),
7926 append_sections: append,
7927 patch_sections: IndexMap::new(),
7928 sections_unset: Vec::new(),
7929 metadata: IndexMap::new(),
7930 metadata_unset: Vec::new(),
7931 declare_relations: vec![],
7932 dry_run: false,
7933 relations_unset: Vec::new(),
7934 anchors_unset: Vec::new(),
7935 },
7936 actor,
7937 Some(&client),
7938 None,
7939 )
7940 .unwrap();
7941 }
7942
7943 #[test]
7947 fn embedded_h1_refuses_in_any_section() {
7948 let tmp = TempDir::new().unwrap();
7949 let mut engine = format_engine(&tmp);
7950 let (actor, client) = cli_actor();
7951 let mut args = plan_create_args(
7952 "Plan H",
7953 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7954 None,
7955 );
7956 args.sections.insert(
7957 "body".to_string(),
7958 "intro\n# Injected Title\ntail".to_string(),
7959 );
7960 let err = engine
7961 .create_entity(args, actor, Some(&client), None)
7962 .unwrap_err();
7963 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
7964 }
7965}