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