1use std::cell::OnceCell;
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22use crate::backend::MemBackend;
23use crate::engine_fallback_type;
24use crate::entity::loader::parse_entries;
25use crate::entity::source::{SourceEntry, SourceReadError};
26use crate::entity::store_builder::push_entities_into_store;
27use crate::mem::{MemOrigin, MemRouterSnapshot};
28use crate::ops::WarningHint;
29use crate::store::Store;
30use crate::workspace::{Mount, MountCapability, MountStorage, WorkspaceSettings};
31
32use super::{BootError, Engine, EngineError, MountedBackend};
33
34impl Engine {
35 pub fn from_mounts(mounts: Vec<(Mount, Box<dyn MemBackend>)>) -> Result<Self, EngineError> {
47 Self::from_mounts_inner(mounts, Vec::new(), Vec::new())
48 }
49
50 pub fn from_mounts_with_schemas_dir(
61 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
62 schemas_dir: Option<&Path>,
63 ) -> Result<Self, EngineError> {
64 let (extra_schemas, failed) = load_workspace_schemas_with_failures(schemas_dir);
65 Self::from_mounts_inner(mounts, extra_schemas, failed)
66 }
67
68 pub fn from_mounts_with_schemas_dir_and_extra(
77 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
78 schemas_dir: Option<&Path>,
79 mut extra: Vec<Arc<memstead_schema::Schema>>,
80 ) -> Result<Self, EngineError> {
81 let (mut local, failed) = load_workspace_schemas_with_failures(schemas_dir);
82 local.append(&mut extra);
83 Self::from_mounts_inner(mounts, local, failed)
84 }
85
86 pub(crate) fn from_mounts_inner(
87 mounts: Vec<(Mount, Box<dyn MemBackend>)>,
88 extra_schemas: Vec<Arc<memstead_schema::Schema>>,
89 failed_schema_packages: Vec<FailedSchemaPackage>,
90 ) -> Result<Self, EngineError> {
91 let mut seen: std::collections::HashSet<String> =
92 std::collections::HashSet::with_capacity(mounts.len());
93 let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
94 for (mount, backend) in mounts {
95 if !seen.insert(mount.mem.clone()) {
96 return Err(EngineError::DuplicateMem(mount.mem));
97 }
98 let last_known_head = backend.current_head().ok().flatten();
104 let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
113 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
114 memstead_schema::config::parse_mem_config(&value).ok()
115 });
116 let archive_provenance =
121 backend
122 .read_archive_provenance()
123 .ok()
124 .flatten()
125 .and_then(|bytes| {
126 memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
127 });
128 mounted.push(MountedBackend {
129 mount,
130 backend,
131 last_known_head,
132 mem_config,
133 archive_provenance,
134 deferred: false,
138 });
139 }
140
141 let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
151 .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
152 let workspace_schemas = extra_schemas.clone();
155 let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
156 Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
157 catalogue.extend(extra_schemas);
158 catalogue.extend(builtin_schemas_only.clone());
159 let builtin_schemas = catalogue;
160 let mut store = Store::new();
161 let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
162 let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
163 let fallback = engine_fallback_type();
164
165 let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
172 let known_suffixes: Vec<String> = mem_names
173 .iter()
174 .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
175 .collect();
176 let mut load_warnings: Vec<WarningHint> = Vec::new();
177
178 let mut quarantined: Vec<crate::engine::QuarantinedMem> = Vec::new();
185 let mut quarantined_idx: std::collections::HashSet<usize> =
186 std::collections::HashSet::new();
187 let mut deferred_idx: std::collections::HashSet<usize> = std::collections::HashSet::new();
193
194 for (m_idx, m) in mounted.iter().enumerate() {
195 let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
204 let mount_pin = m.mount.schema.as_ref();
205 if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
209 && cfg != mp
210 {
211 load_warnings.push(WarningHint::SchemaPinMismatch {
212 mem: m.mount.mem.clone(),
213 config_pin: cfg.as_display(),
214 mount_pin: mp.as_display(),
215 });
216 }
217 if let Some(stamp) = m
232 .mem_config
233 .as_ref()
234 .and_then(|c| c.mutation_stamp.as_ref())
235 && let Some(direction) = crate::build_info::skew_direction(
236 &stamp.engine_version,
237 crate::build_info::full_version(),
238 )
239 {
240 load_warnings.push(WarningHint::EngineVersionSkew {
241 mem: m.mount.mem.clone(),
242 stamped_engine: stamp.engine_version.clone(),
243 running_engine: crate::build_info::full_version().to_string(),
244 stamped_schema: stamp.schema.clone(),
245 direction,
246 });
247 }
248 let settled_pin = config_pin.or(mount_pin);
251 let Some(effective_pin) = m.mount.migration_target.as_ref().or(settled_pin) else {
254 let e = EngineError::MemConfigIncomplete {
256 mem: m.mount.mem.clone(),
257 missing_fields: vec!["schema".to_string()],
258 };
259 quarantined.push(crate::engine::QuarantinedMem {
260 mount: m.mount.clone(),
261 reason_code: e.code().to_string(),
262 reason_message: e.to_string(),
263 });
264 quarantined_idx.insert(m_idx);
265 continue;
266 };
267 let schema = match SchemaResolver::new(&builtin_schemas).resolve(effective_pin) {
268 Ok(schema) => schema,
269 Err(sources) => {
270 let failed = failed_schema_packages.iter().find(|f| {
278 f.name.as_deref() == Some(effective_pin.name.as_str())
279 && f.version
280 .as_deref()
281 .is_none_or(|v| v == effective_pin.version.to_string())
282 });
283 let (reason_code, reason_message) = match failed {
284 Some(f) => (
285 "SCHEMA_LOAD_FAILED".to_string(),
286 format!(
287 "schema package at {} failed to load: {}",
288 f.path.display(),
289 f.error
290 ),
291 ),
292 None => {
293 let e = EngineError::SchemaNotFound {
294 mem: m.mount.mem.clone(),
295 pin: effective_pin.as_display(),
296 sources,
297 install_hint: None,
298 };
299 (e.code().to_string(), e.to_string())
300 }
301 };
302 quarantined.push(crate::engine::QuarantinedMem {
303 mount: m.mount.clone(),
304 reason_code,
305 reason_message,
306 });
307 quarantined_idx.insert(m_idx);
308 continue;
309 }
310 };
311 schemas.insert(m.mount.mem.clone(), schema.clone());
312
313 let locally_installed = workspace_schemas.iter().any(|s| {
323 s.manifest.name == effective_pin.name && s.version == effective_pin.version
324 });
325 let is_builtin = builtin_schemas_only.iter().any(|s| {
326 s.manifest.name == effective_pin.name && s.version == effective_pin.version
327 });
328 if !locally_installed
329 && is_builtin
330 && let Some(newest) =
331 newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
332 && *newest > effective_pin.version
333 {
334 load_warnings.push(WarningHint::SchemaGenerationsBehind {
335 mem: m.mount.mem.clone(),
336 pinned: effective_pin.as_display(),
337 newest: newest.to_string(),
338 });
339 }
340
341 if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
346 memstead_schema::check_section_heading_roundtrip(&schema)
347 {
348 let (name, version) = schema.id();
349 load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
350 mem: m.mount.mem.clone(),
351 schema_ref: format!("{name}@{version}"),
352 violations: violations.iter().map(Into::into).collect(),
353 });
354 }
355
356 if m.mount.lifecycle == crate::workspace::MountLifecycle::Lazy {
362 if let Some(w) = unbacked_mount_warning(&m.mount, m.backend.as_ref(), None) {
363 load_warnings.push(w);
364 }
365 deferred_idx.insert(m_idx);
366 continue;
367 }
368
369 let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
370 Ok(pair) => pair,
371 Err(e) => {
372 quarantined.push(crate::engine::QuarantinedMem {
375 mount: m.mount.clone(),
376 reason_code: e.code().to_string(),
377 reason_message: e.to_string(),
378 });
379 quarantined_idx.insert(m_idx);
380 schemas.remove(&m.mount.mem);
381 continue;
382 }
383 };
384 let path_backed = matches!(
411 m.mount.storage,
412 crate::workspace::MountStorage::Folder { .. }
413 | crate::workspace::MountStorage::Archive { .. }
414 );
415 if path_backed && !m.backend.storage_present().unwrap_or(true) {
416 let location = match &m.mount.storage {
417 crate::workspace::MountStorage::GitBranch { branch, .. } => branch.clone(),
418 crate::workspace::MountStorage::Folder { path }
419 | crate::workspace::MountStorage::Archive { path } => {
420 path.display().to_string()
421 }
422 crate::workspace::MountStorage::InMemory => String::new(),
423 };
424 quarantined.push(crate::engine::QuarantinedMem {
425 mount: m.mount.clone(),
426 reason_code: "MOUNT_UNBACKED".to_string(),
427 reason_message: format!(
428 "the mount's storage is gone ({location}); it is configured but cannot \
429 serve, so it is held out of the roster rather than answering reads \
430 with an empty graph"
431 ),
432 });
433 quarantined_idx.insert(m_idx);
434 schemas.remove(&m.mount.mem);
435 continue;
436 }
437 if let Some(w) =
441 unbacked_mount_warning(&m.mount, m.backend.as_ref(), Some(entries.len()))
442 {
443 load_warnings.push(w);
444 }
445 let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
446 push_entities_into_store(
452 &mut store,
453 load_result.entities,
454 fallback.as_ref(),
455 Some(crate::entity::store_builder::LoadCollector {
456 warnings: &mut load_warnings,
457 known_suffixes: &known_suffixes,
458 mem_names: &mem_names,
459 }),
460 );
461 if let crate::workspace::MountStorage::Folder { path } = &m.mount.storage {
466 let root = path.clone();
467 load_errors.extend(load_result.errors.into_iter().map(|(p, msg)| {
468 let abs = if p.is_relative() { root.join(&p) } else { p };
469 (abs, msg)
470 }));
471 } else {
472 load_errors.extend(load_result.errors);
473 }
474 }
475
476 for idx in &deferred_idx {
479 mounted[*idx].deferred = true;
480 }
481
482 if !quarantined_idx.is_empty() {
487 let mut keep_idx = 0usize;
488 mounted.retain(|_| {
489 let keep = !quarantined_idx.contains(&keep_idx);
490 keep_idx += 1;
491 keep
492 });
493 }
494
495 let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
503 mounted
504 .iter()
505 .map(|m| (m.mount.mem.clone(), m.mount.capability))
506 .collect();
507 crate::entity::store_builder::validate_loaded_relations(
508 &mut store,
509 &schemas,
510 &mount_caps,
511 &mut load_warnings,
512 );
513
514 crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
519
520 load_warnings.retain(|w| match w {
529 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
530 store.get(resolved_id).is_none_or(|e| e.stub)
531 }
532 _ => true,
533 });
534
535 let mem_router = build_mem_router_from_mounts(&mounted);
541
542 let mounts_baseline =
548 std::cell::RefCell::new(mounted.iter().map(|m| m.mount.clone()).collect::<Vec<_>>());
549
550 Ok(Self {
551 mounts: mounted,
552 mounts_baseline,
553 store,
554 schemas,
555 workspace_schemas,
556 builtin_schemas: builtin_schemas_only,
557 load_errors,
558 community_memo: OnceCell::new(),
559 labelling_memo: OnceCell::new(),
560 #[cfg(not(target_arch = "wasm32"))]
561 search_indexes_memo: OnceCell::new(),
562 settings: WorkspaceSettings::default(),
563 create_rule_set_memo: OnceCell::new(),
564 declared_origins: HashMap::new(),
565 workspace_root: None,
566 load_warnings,
567 quarantined,
568 boot_diagnosis: None,
569 pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
570 mem_router: Arc::new(mem_router),
571 backend_factory: crate::workspace_store::instantiate_lean_backend,
572 unmounted_storage_prober: None,
573 schemas_epoch: 0,
574 git_branch_ops: None,
575 event_subscribers: Arc::new(std::sync::Mutex::new(
576 crate::engine::events::SubscriberRegistry::new(),
577 )),
578 pending_mem_changed: Vec::new(),
579 roster_fingerprint: None,
580 roster_subscribers: Arc::new(std::sync::Mutex::new((0, Vec::new()))),
581 recently_unmounted: std::collections::HashSet::new(),
582 #[cfg(test)]
583 inject_unmount_failure: None,
584 mutation_clock: Arc::new(std::time::SystemTime::now),
585 current_role: crate::vcs::Role::Unspecified,
586 current_identity: None,
587 })
588 }
589
590 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
608 use crate::workspace_store::{
609 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
610 instantiate_lean_backend,
611 };
612
613 let workspace = match detect_layout(workspace_root) {
614 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
618 Some(ws) => ws,
619 None => {
620 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
621 }
622 },
623 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
624 };
625
626 let settings = workspace.settings.clone();
627 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
628 Vec::with_capacity(workspace.mounts.len());
629 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
633 for mount in workspace.mounts {
634 match instantiate_lean_backend(&mount) {
635 Ok(backend) => mounts.push((mount, backend)),
636 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
637 reason_code: e.code().to_string(),
638 reason_message: e.to_string(),
639 mount,
640 }),
641 }
642 }
643 let fixed_dir = workspace_root.join(".memstead").join("schemas");
652 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
653 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
656 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
657 engine.quarantined.extend(instantiate_quarantine);
658 engine.set_settings(settings);
659 engine.workspace_root = Some(workspace_root.to_path_buf());
660 engine.capture_roster_fingerprint();
661 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
672 workspace_root,
673 )?);
674 Ok(engine)
685 }
686}
687
688pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
721 let mut router = MemRouterSnapshot::new();
722 for m in mounts {
723 match m.mount.capability {
724 MountCapability::Write => {
725 let dir: Option<PathBuf> = match &m.mount.storage {
726 MountStorage::Folder { path } => Some(path.clone()),
727 MountStorage::GitBranch { .. } => None,
728 MountStorage::Archive { .. } => None,
729 MountStorage::InMemory => None,
733 };
734 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
735 }
736 MountCapability::ReadOnly => match &m.mount.storage {
737 MountStorage::Archive { path } => {
738 router.add_read_only(m.mount.mem.clone(), path.clone());
739 }
740 MountStorage::Folder { path } => {
741 router.add_read_only(m.mount.mem.clone(), path.clone());
742 }
743 MountStorage::GitBranch { gitdir, .. } => {
744 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
745 }
746 MountStorage::InMemory => {}
752 },
753 }
754 }
755 router
756}
757
758pub fn resolve_builtin_schema_pin_pub(
763 pin: &memstead_schema::SchemaRef,
764 catalogue: &[Arc<memstead_schema::Schema>],
765) -> Option<Arc<memstead_schema::Schema>> {
766 resolve_builtin_schema_pin(pin, catalogue)
767}
768
769fn newest_builtin_version<'a>(
774 name: &str,
775 builtins: &'a [Arc<memstead_schema::Schema>],
776) -> Option<&'a semver::Version> {
777 builtins
778 .iter()
779 .filter(|s| s.manifest.name == name)
780 .map(|s| &s.version)
781 .max()
782}
783
784pub struct SchemaResolver<'a> {
799 catalogue: &'a [Arc<memstead_schema::Schema>],
800}
801
802impl<'a> SchemaResolver<'a> {
803 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
806 Self { catalogue }
807 }
808
809 pub fn resolve(
813 &self,
814 pin: &memstead_schema::SchemaRef,
815 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
816 {
817 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
818 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
819 &pin.name,
820 &pin.version,
821 self.catalogue,
822 )
823 })
824 }
825}
826
827pub fn load_workspace_schemas(
835 schemas_dir: Option<&Path>,
836) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
837 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
838}
839
840#[derive(Debug, Clone)]
847pub struct FailedSchemaPackage {
848 pub path: PathBuf,
849 pub name: Option<String>,
850 pub version: Option<String>,
851 pub error: String,
853}
854
855pub fn load_workspace_schemas_with_failures(
861 schemas_dir: Option<&Path>,
862) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
863 let Some(dir) = schemas_dir else {
864 return (Vec::new(), Vec::new());
865 };
866 if !dir.is_dir() {
867 return (Vec::new(), Vec::new());
868 }
869 let entries = match std::fs::read_dir(dir) {
870 Ok(e) => e,
871 Err(_) => return (Vec::new(), Vec::new()),
872 };
873 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
874 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
875 for entry in entries.flatten() {
876 let path = entry.path();
877 if !path.is_dir() {
878 continue;
879 }
880 if !path.join("schema.yaml").is_file() {
881 continue;
882 }
883 match memstead_schema::load_schema_from_dir(&path) {
884 Ok(schema) => schemas.push(Arc::new(schema)),
885 Err(e) => {
886 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
890 let peek = |k: &str| {
891 header
892 .lines()
893 .find_map(|l| l.strip_prefix(&format!("{k}:")))
894 .map(|v| v.trim().trim_matches('"').to_string())
895 .filter(|v| !v.is_empty())
896 };
897 failures.push(FailedSchemaPackage {
898 path: path.clone(),
899 name: peek("name"),
900 version: peek("version"),
901 error: e.to_string(),
902 });
903 }
904 }
905 }
906 (schemas, failures)
907}
908
909pub(super) fn resolve_builtin_schema_pin(
910 pin: &memstead_schema::SchemaRef,
911 catalogue: &[Arc<memstead_schema::Schema>],
912) -> Option<Arc<memstead_schema::Schema>> {
913 catalogue
914 .iter()
915 .find(|s| {
916 let id = s.id();
917 id.0 == pin.name && id.1 == pin.version
918 })
919 .cloned()
920}
921
922pub(super) fn unbacked_mount_warning(
930 mount: &crate::workspace::Mount,
931 backend: &dyn MemBackend,
932 entity_count: Option<usize>,
933) -> Option<WarningHint> {
934 use crate::ops::MountUnbackedReason;
935 use crate::workspace::MountStorage;
936 let (location, missing_reason) = match &mount.storage {
937 MountStorage::GitBranch { branch, .. } => (branch.clone(), MountUnbackedReason::MissingRef),
938 MountStorage::Folder { path } => {
939 (path.display().to_string(), MountUnbackedReason::MissingPath)
940 }
941 MountStorage::Archive { path } => {
942 (path.display().to_string(), MountUnbackedReason::MissingPath)
943 }
944 MountStorage::InMemory => return None,
945 };
946 if !backend.storage_present().unwrap_or(true) {
947 return Some(WarningHint::MountUnbacked {
948 mem: mount.mem.clone(),
949 reason: missing_reason,
950 location,
951 });
952 }
953 if entity_count == Some(0) {
954 return Some(WarningHint::MountUnbacked {
955 mem: mount.mem.clone(),
956 reason: MountUnbackedReason::Empty,
957 location,
958 });
959 }
960 None
961}
962
963pub(super) fn collect_source_entries(
964 backend: &dyn MemBackend,
965) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
966 let paths = backend.list_entities()?;
967 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
968 let mut errors: Vec<SourceReadError> = Vec::new();
969 for path in paths {
970 match backend.read_entity(&path) {
971 Ok(Some(bytes)) => match String::from_utf8(bytes) {
972 Ok(content) => entries.push(SourceEntry {
973 relative_path: path.to_string_lossy().into_owned(),
974 source_path: path.clone(),
975 content,
976 }),
977 Err(e) => errors.push(SourceReadError {
978 source_path: path,
979 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
980 }),
981 },
982 Ok(None) => {
983 }
985 Err(e) => errors.push(SourceReadError {
986 source_path: path,
987 error: std::io::Error::other(e.to_string()),
988 }),
989 }
990 }
991 Ok((entries, errors))
992}
993
994#[cfg(test)]
995mod tests {
996
997 use std::path::Path;
998
999 use memstead_schema::SchemaRef;
1000 use tempfile::TempDir;
1001
1002 use crate::backend::MemBackend;
1003 use crate::engine::test_helpers::*;
1004 use crate::engine::{Engine, EngineError};
1005 use crate::ops::WarningHint;
1006 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1007 use crate::vcs::CommitContext;
1008 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1009
1010 #[test]
1015 fn unbacked_mount_probe_classes_missing_path_empty_and_present() {
1016 use crate::engine::boot::unbacked_mount_warning;
1017 use crate::ops::MountUnbackedReason;
1018 let tmp = TempDir::new().unwrap();
1019 let mount = |path: std::path::PathBuf| Mount {
1020 mem: "probe".into(),
1021 schema: None,
1022 storage: MountStorage::Folder { path },
1023 capability: MountCapability::Write,
1024 lifecycle: MountLifecycle::Eager,
1025 cross_linkable: true,
1026 migration_target: None,
1027 };
1028
1029 let gone = tmp.path().join("gone");
1030 let backend = FilesystemMemWriter::new(gone.clone());
1031 let w = unbacked_mount_warning(&mount(gone.clone()), &backend, Some(0))
1032 .expect("a missing folder is unbacked");
1033 match &w {
1034 WarningHint::MountUnbacked {
1035 mem,
1036 reason,
1037 location,
1038 } => {
1039 assert_eq!(mem, "probe");
1040 assert_eq!(*reason, MountUnbackedReason::MissingPath);
1041 assert_eq!(location, &gone.display().to_string());
1042 }
1043 other => panic!("unexpected variant: {other:?}"),
1044 }
1045 assert_eq!(w.code(), "MOUNT_UNBACKED");
1046 let json = serde_json::to_value(&w).unwrap();
1047 assert_eq!(json["details"]["reason"], "missing_path");
1048 assert!(unbacked_mount_warning(&mount(gone), &backend, None).is_some());
1050
1051 let hollow = tmp.path().join("hollow");
1052 std::fs::create_dir_all(&hollow).unwrap();
1053 let backend = FilesystemMemWriter::new(hollow.clone());
1054 let w = unbacked_mount_warning(&mount(hollow.clone()), &backend, Some(0))
1055 .expect("an entity-less folder is unbacked");
1056 assert_eq!(
1057 serde_json::to_value(&w).unwrap()["details"]["reason"],
1058 "empty"
1059 );
1060 assert!(unbacked_mount_warning(&mount(hollow.clone()), &backend, None).is_none());
1062 assert!(unbacked_mount_warning(&mount(hollow), &backend, Some(1)).is_none());
1064 }
1065
1066 #[test]
1070 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
1071 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
1072 let resolver = super::SchemaResolver::new(&catalogue);
1073
1074 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
1075 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
1076
1077 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
1078 let sources = resolver.resolve(&miss).unwrap_err();
1079 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1080 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1081 assert!(sources.iter().all(|s| !s.pinned_version_match));
1082 }
1083
1084 #[test]
1085 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
1086 let engine = Engine::from_mounts(Vec::new()).unwrap();
1087 assert!(engine.mem_names().is_empty());
1088 match engine.list_entities("missing") {
1089 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
1090 other => panic!("expected UnknownMem, got {other:?}"),
1091 }
1092 }
1093
1094 #[test]
1095 fn duplicate_mem_names_rejected_at_construction() {
1096 let tmp = TempDir::new().unwrap();
1097 let writer1: Box<dyn MemBackend> =
1098 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1099 let writer2: Box<dyn MemBackend> =
1100 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1101 let err = Engine::from_mounts(vec![
1102 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
1103 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
1104 ])
1105 .unwrap_err();
1106 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
1107 }
1108
1109 #[test]
1110 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
1111 let tmp = TempDir::new().unwrap();
1117 let mem_dir = tmp.path().to_path_buf();
1118 let body =
1119 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1120 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1121
1122 let writer = FilesystemMemWriter::new(mem_dir.clone());
1123 let engine = Engine::from_mounts(vec![(
1124 folder_mount("specs", mem_dir),
1125 Box::new(writer) as Box<dyn MemBackend>,
1126 )])
1127 .unwrap();
1128
1129 let warnings = engine.load_warnings();
1130 assert!(
1131 warnings
1132 .iter()
1133 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1134 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
1135 );
1136 }
1137
1138 #[test]
1146 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
1147 let tmp = TempDir::new().unwrap();
1149 let mem_dir = tmp.path().to_path_buf();
1150 let writer = FilesystemMemWriter::new(mem_dir.clone());
1151 let mut engine = Engine::from_mounts(vec![(
1152 folder_mount("specs", mem_dir),
1153 Box::new(writer) as Box<dyn MemBackend>,
1154 )])
1155 .unwrap();
1156 let behind: Vec<_> = engine
1157 .load_warnings()
1158 .iter()
1159 .filter_map(|w| match w {
1160 WarningHint::SchemaGenerationsBehind {
1161 mem,
1162 pinned,
1163 newest,
1164 } => Some((mem.clone(), pinned.clone(), newest.clone())),
1165 _ => None,
1166 })
1167 .collect();
1168 assert_eq!(
1169 behind,
1170 vec![(
1171 "specs".to_string(),
1172 "default@1.0.0".to_string(),
1173 "1.3.0".to_string()
1174 )],
1175 "old built-in pin must surface the generation-behind hint"
1176 );
1177 assert!(
1178 engine
1179 .health()
1180 .warnings
1181 .iter()
1182 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1183 "the hint rides health without an include gate"
1184 );
1185 engine
1187 .create_entity_with_ctx(
1188 crate::engine::CreateEntityArgs {
1189 anchors: Vec::new(),
1190 mem: "specs".to_string(),
1191 title: "Still writable".to_string(),
1192 entity_type: "spec".to_string(),
1193 sections: indexmap::IndexMap::from_iter([
1194 ("identity".to_string(), "i".to_string()),
1195 ("purpose".to_string(), "p".to_string()),
1196 ]),
1197 metadata: indexmap::IndexMap::new(),
1198 relations: Vec::new(),
1199 dry_run: false,
1200 },
1201 &crate::vcs::CommitContext::internal(),
1202 )
1203 .expect("generation-behind hint must never block mutations");
1204
1205 let tmp = TempDir::new().unwrap();
1207 let mem_dir = tmp.path().to_path_buf();
1208 let writer = FilesystemMemWriter::new(mem_dir.clone());
1209 let mut mount = folder_mount("specs", mem_dir);
1210 mount.schema = Some("default@1.3.0".parse().unwrap());
1211 let engine =
1212 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1213 assert!(
1214 !engine
1215 .load_warnings()
1216 .iter()
1217 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1218 "newest built-in pin must stay silent: {:?}",
1219 engine.load_warnings()
1220 );
1221 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1222 assert!(
1223 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1224 "newest pin: health output carries no generation hint"
1225 );
1226 }
1227
1228 #[test]
1232 fn newest_builtin_version_orders_by_semver_not_string() {
1233 let manifest = |version: &str| {
1234 format!(
1235 r#"name: gen-test
1236version: {version}
1237description: test
1238when_to_use: test
1239types:
1240 - note
1241relationships:
1242 mode: strict
1243 definitions:
1244 - name: _default
1245 description: default
1246 default_weight: 1.0
1247 - name: PART_OF
1248 description: hier
1249 default_weight: 3.0
1250community:
1251 resolution: 1.0
1252 seed: 42
1253"#
1254 )
1255 };
1256 let type_yaml = r#"name: note
1257description: test
1258when_to_use: test
1259sections:
1260 - key: body
1261 heading: Body
1262 required: true
1263 search_weight: 10.0
1264 catch_all: true
1265metadata_fields: []
1266title_weight: 1.0
1267text_fields: [body]
1268hierarchy_relationship: PART_OF
1269no_self_loop_relationships: []
1270updatable_fields: [title, body]
1271health_required_fields: [body]
1272staleness_threshold_days: 30
1273write_rules: []
1274"#;
1275 let types = vec![("note".to_string(), type_yaml.to_string())];
1276 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1277 .iter()
1278 .map(|v| {
1279 std::sync::Arc::new(
1280 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1281 .expect("fixture schema loads"),
1282 )
1283 })
1284 .collect();
1285 let newest = super::newest_builtin_version("gen-test", &catalogue)
1286 .expect("name present in catalogue");
1287 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1288 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1289 }
1290
1291 #[test]
1297 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1298 let tmp = TempDir::new().unwrap();
1299 let mem_dir = tmp.path().to_path_buf();
1300 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1303 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1304 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1305 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1306
1307 let writer = FilesystemMemWriter::new(mem_dir.clone());
1308 let engine = Engine::from_mounts(vec![(
1309 folder_mount("specs", mem_dir),
1310 Box::new(writer) as Box<dyn MemBackend>,
1311 )])
1312 .unwrap();
1313
1314 let source_id = crate::entity::EntityId::new("specs", "source");
1315 let target_id = crate::entity::EntityId::new("specs", "target");
1316 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1317 assert!(
1320 source_entity.relationships.is_empty(),
1321 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1322 source_entity.relationships,
1323 );
1324 let outgoing: Vec<_> = engine
1326 .store()
1327 .outgoing(&source_id)
1328 .iter()
1329 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1330 .collect();
1331 assert!(
1332 outgoing.is_empty(),
1333 "MADE_UP_TYPE edge must be dropped from the store"
1334 );
1335 let parsed_invalid: Vec<_> = engine
1337 .load_warnings()
1338 .iter()
1339 .filter_map(|w| match w {
1340 WarningHint::ParsedRelationInvalid {
1341 entity_id,
1342 rel_type,
1343 target,
1344 reason,
1345 origin,
1346 recovery,
1347 } => Some((
1348 entity_id.clone(),
1349 rel_type.clone(),
1350 target.clone(),
1351 reason.clone(),
1352 origin.clone(),
1353 recovery.clone(),
1354 )),
1355 _ => None,
1356 })
1357 .collect();
1358 assert_eq!(
1359 parsed_invalid.len(),
1360 1,
1361 "expected one warning, got {parsed_invalid:?}"
1362 );
1363 assert_eq!(parsed_invalid[0].0, source_id);
1364 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1365 assert_eq!(parsed_invalid[0].2, target_id);
1366 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1367 assert_eq!(parsed_invalid[0].4, "writable");
1368 let recovery = parsed_invalid[0]
1370 .5
1371 .as_ref()
1372 .expect("writable-origin warning must carry recovery");
1373 assert_eq!(
1374 recovery.kind,
1375 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1376 );
1377 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1378 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1379 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1380 }
1381
1382 #[test]
1390 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1391 let tmp = TempDir::new().unwrap();
1392 let mem_dir = tmp.path().to_path_buf();
1393 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1397 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1398 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1399 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1400
1401 let writer = FilesystemMemWriter::new(mem_dir.clone());
1402 let engine = Engine::from_mounts(vec![(
1403 folder_mount("specs", mem_dir),
1404 Box::new(writer) as Box<dyn MemBackend>,
1405 )])
1406 .unwrap();
1407
1408 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1409 let beta_id = crate::entity::EntityId::new("specs", "beta");
1410
1411 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1414 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1415
1416 let surviving: Vec<_> = engine
1419 .store()
1420 .all_entities()
1421 .flat_map(|e| {
1422 engine
1423 .store()
1424 .outgoing(&e.id)
1425 .iter()
1426 .filter(|edge| edge.rel_type == "PART_OF")
1427 .map(|edge| (e.id.clone(), edge.target.clone()))
1428 .collect::<Vec<_>>()
1429 })
1430 .collect();
1431 assert_eq!(
1432 surviving.len(),
1433 1,
1434 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1435 );
1436
1437 let cycle_drops: Vec<_> = engine
1440 .load_warnings()
1441 .iter()
1442 .filter_map(|w| match w {
1443 WarningHint::ParsedRelationInvalid {
1444 entity_id,
1445 rel_type,
1446 target,
1447 reason,
1448 ..
1449 } if reason == "cycle" => {
1450 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1451 }
1452 _ => None,
1453 })
1454 .collect();
1455 assert_eq!(
1456 cycle_drops.len(),
1457 1,
1458 "exactly one cycle warning must fire, got {cycle_drops:?}",
1459 );
1460 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1462 assert_eq!(dropped_rel_type, "PART_OF");
1463 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1464 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1465 assert!(
1466 is_alpha_to_beta || is_beta_to_alpha,
1467 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1468 );
1469 assert_ne!(
1471 (&surviving[0].0, &surviving[0].1),
1472 (dropped_from, dropped_to),
1473 "surviving edge must differ from the dropped one",
1474 );
1475 }
1476
1477 #[test]
1485 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1486 let tmp = TempDir::new().unwrap();
1487 let project_dir = tmp.path().join("project");
1488 let registry_dir = tmp.path().join("registry");
1489 std::fs::create_dir_all(&project_dir).unwrap();
1490 std::fs::create_dir_all(®istry_dir).unwrap();
1491
1492 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1495 std::fs::write(project_dir.join("source.md"), source).unwrap();
1496
1497 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1500 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1501
1502 let engine = Engine::from_mounts(vec![
1503 (
1504 folder_mount("project", project_dir.clone()),
1505 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1506 ),
1507 (
1508 folder_mount("registry", registry_dir.clone()),
1509 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1510 ),
1511 ])
1512 .unwrap();
1513
1514 let nested: Vec<_> = engine
1515 .load_warnings()
1516 .iter()
1517 .filter_map(|w| match w {
1518 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1519 Some(resolved_id.to_string())
1520 }
1521 _ => None,
1522 })
1523 .collect();
1524 assert!(
1525 !nested.contains(&"registry--registry-service".to_string()),
1526 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1527 );
1528 assert!(
1529 nested.contains(&"registry--never-created".to_string()),
1530 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1531 );
1532 }
1533
1534 #[test]
1544 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1545 use crate::engine::test_helpers::write_schema_files_with_default_type;
1546
1547 let tmp = TempDir::new().unwrap();
1548 let schemas_dir = tmp.path().join("schemas");
1549 std::fs::create_dir_all(&schemas_dir).unwrap();
1550 let manifest = r#"name: shape-test
1554version: 0.1.0
1555description: shape-constraint schema
1556when_to_use: tests
1557types:
1558 - doc
1559 - actor
1560relationships:
1561 mode: strict
1562 definitions:
1563 - name: OWNS
1564 description: actor owns doc
1565 default_weight: 1.0
1566 source_types: [actor]
1567 target_types: [doc]
1568 - name: _default
1569 description: fallback
1570 default_weight: 1.0
1571community:
1572 resolution: 1.0
1573 seed: 42
1574"#;
1575 write_schema_files_with_default_type(
1576 &schemas_dir,
1577 "shape-test",
1578 manifest,
1579 &["doc", "actor"],
1580 );
1581
1582 let mem_dir = tmp.path().join("mem");
1583 std::fs::create_dir_all(&mem_dir).unwrap();
1584 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1588 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1589 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1590 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1591
1592 let writer = FilesystemMemWriter::new(mem_dir.clone());
1593 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1594 let mount = Mount {
1595 mem: "specs".to_string(),
1596 schema: Some(pin),
1597 storage: MountStorage::Folder { path: mem_dir },
1598 capability: MountCapability::Write,
1599 lifecycle: MountLifecycle::Eager,
1600 cross_linkable: true,
1601 migration_target: None,
1602 };
1603 let engine = Engine::from_mounts_with_schemas_dir(
1604 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1605 Some(&schemas_dir),
1606 )
1607 .unwrap();
1608
1609 let source_id = crate::entity::EntityId::new("specs", "source");
1610 let target_id = crate::entity::EntityId::new("specs", "target");
1611
1612 let shape_drops: Vec<_> = engine
1613 .load_warnings()
1614 .iter()
1615 .filter_map(|w| match w {
1616 WarningHint::ParsedRelationInvalid {
1617 entity_id,
1618 rel_type,
1619 target,
1620 reason,
1621 origin,
1622 recovery,
1623 } if reason == "shape" => Some((
1624 entity_id.clone(),
1625 rel_type.clone(),
1626 target.clone(),
1627 origin.clone(),
1628 recovery.clone(),
1629 )),
1630 _ => None,
1631 })
1632 .collect();
1633 assert_eq!(
1634 shape_drops.len(),
1635 1,
1636 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1637 engine.load_warnings(),
1638 );
1639 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1640 shape_drops.into_iter().next().unwrap();
1641 assert_eq!(drop_from, source_id);
1642 assert_eq!(drop_type, "OWNS");
1643 assert_eq!(drop_to, target_id);
1644 assert_eq!(drop_origin, "writable");
1645 let recovery = drop_recovery.expect("writable origin must carry recovery");
1648 assert_eq!(
1649 recovery.kind,
1650 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1651 );
1652 assert_eq!(recovery.source_id, source_id);
1653 assert_eq!(recovery.target_id, target_id);
1654 assert_eq!(recovery.rel_type, "OWNS");
1655 }
1656
1657 #[test]
1663 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1664 let tmp = TempDir::new().unwrap();
1665 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1669 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1670 let archive_path = build_archive(
1671 tmp.path(),
1672 "ext",
1673 &[
1674 ("target.md", target.as_bytes()),
1675 ("source.md", source.as_bytes()),
1676 ],
1677 );
1678
1679 let engine = Engine::from_mounts(vec![(
1680 archive_mount("external", archive_path.clone()),
1681 Box::new(ArchiveBackend::new(archive_path)),
1682 )])
1683 .unwrap();
1684
1685 let invalid: Vec<_> = engine
1686 .load_warnings()
1687 .iter()
1688 .filter_map(|w| match w {
1689 WarningHint::ParsedRelationInvalid {
1690 rel_type,
1691 reason,
1692 origin,
1693 recovery,
1694 ..
1695 } => Some((
1696 rel_type.clone(),
1697 reason.clone(),
1698 origin.clone(),
1699 recovery.clone(),
1700 )),
1701 _ => None,
1702 })
1703 .collect();
1704 assert_eq!(
1705 invalid.len(),
1706 1,
1707 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1708 );
1709 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1710 assert_eq!(invalid[0].1, "unknown_rel_type");
1711 assert_eq!(invalid[0].2, "readonly");
1712 assert!(
1713 invalid[0].3.is_none(),
1714 "readonly-origin warning must omit the recovery hint, got {:?}",
1715 invalid[0].3,
1716 );
1717 }
1718
1719 #[test]
1720 fn load_on_init_populates_store_from_folder_mount() {
1721 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1724
1725 let tmp = TempDir::new().unwrap();
1726 let mem_dir = tmp.path().to_path_buf();
1727 let writer = FilesystemMemWriter::new(mem_dir.clone());
1728 <FilesystemMemWriter as MemWriter>::write_entity(
1729 &writer,
1730 Path::new("hello.md"),
1731 body.as_bytes(),
1732 )
1733 .unwrap();
1734 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1735 .unwrap();
1736
1737 let engine = Engine::from_mounts(vec![(
1738 folder_mount("specs", mem_dir),
1739 Box::new(writer) as Box<dyn MemBackend>,
1740 )])
1741 .unwrap();
1742
1743 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1745 let id = crate::EntityId::new("specs", "hello");
1746 let entity = engine.get_entity(&id).expect("entity must be present");
1747 assert_eq!(entity.title, "Hello");
1748 assert_eq!(entity.entity_type, "spec");
1749 assert!(engine.load_errors().is_empty());
1750 assert_eq!(engine.schemas().len(), 1);
1752 assert!(engine.schemas().contains_key("specs"));
1753 }
1754
1755 #[test]
1756 fn load_on_init_populates_store_from_archive_mount() {
1757 let body =
1758 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1759
1760 let tmp = TempDir::new().unwrap();
1761 let archive_path =
1762 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1763
1764 let engine = Engine::from_mounts(vec![(
1765 archive_mount("external", archive_path.clone()),
1766 Box::new(ArchiveBackend::new(archive_path)),
1767 )])
1768 .unwrap();
1769
1770 let id = crate::EntityId::new("external", "from-archive");
1771 let entity = engine.get_entity(&id).expect("entity must be present");
1772 assert_eq!(entity.title, "From Archive");
1773 assert!(engine.load_errors().is_empty());
1774 }
1775
1776 #[test]
1777 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1778 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1779 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1780
1781 let tmp = TempDir::new().unwrap();
1782
1783 let folder_dir = tmp.path().join("folder-mem");
1784 std::fs::create_dir_all(&folder_dir).unwrap();
1785 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1786 <FilesystemMemWriter as MemWriter>::write_entity(
1787 &folder_writer,
1788 Path::new("local.md"),
1789 folder_body.as_bytes(),
1790 )
1791 .unwrap();
1792 <FilesystemMemWriter as MemWriter>::commit(
1793 &folder_writer,
1794 "seed",
1795 &CommitContext::internal(),
1796 )
1797 .unwrap();
1798
1799 let archive_path = build_archive(
1800 tmp.path(),
1801 "external",
1802 &[("external.md", archive_body.as_bytes())],
1803 );
1804
1805 let engine = Engine::from_mounts(vec![
1806 (
1807 folder_mount("local", folder_dir),
1808 Box::new(folder_writer) as Box<dyn MemBackend>,
1809 ),
1810 (
1811 archive_mount("external", archive_path.clone()),
1812 Box::new(ArchiveBackend::new(archive_path)),
1813 ),
1814 ])
1815 .unwrap();
1816
1817 assert_eq!(engine.store().len(), 2);
1819 assert!(
1820 engine
1821 .get_entity(&crate::EntityId::new("local", "local"))
1822 .is_some()
1823 );
1824 assert!(
1825 engine
1826 .get_entity(&crate::EntityId::new("external", "external"))
1827 .is_some()
1828 );
1829 }
1830
1831 #[test]
1832 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1833 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1837 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1838
1839 let tmp = TempDir::new().unwrap();
1840 let mem_dir = tmp.path().to_path_buf();
1841 let writer = FilesystemMemWriter::new(mem_dir.clone());
1842 <FilesystemMemWriter as MemWriter>::write_entity(
1843 &writer,
1844 Path::new("good.md"),
1845 good.as_bytes(),
1846 )
1847 .unwrap();
1848 <FilesystemMemWriter as MemWriter>::write_entity(
1849 &writer,
1850 Path::new("bad.md"),
1851 bad.as_bytes(),
1852 )
1853 .unwrap();
1854 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1855 .unwrap();
1856
1857 let engine = Engine::from_mounts(vec![(
1858 folder_mount("specs", mem_dir),
1859 Box::new(writer) as Box<dyn MemBackend>,
1860 )])
1861 .unwrap();
1862
1863 assert!(
1865 engine
1866 .get_entity(&crate::EntityId::new("specs", "good"))
1867 .is_some(),
1868 "good.md must parse and reach the store"
1869 );
1870 let bad_known_to_engine = engine
1875 .get_entity(&crate::EntityId::new("specs", "bad"))
1876 .is_some()
1877 || !engine.load_errors().is_empty();
1878 assert!(
1879 bad_known_to_engine,
1880 "bad.md must either parse or surface in load_errors"
1881 );
1882 }
1883
1884 #[test]
1885 fn empty_mount_list_yields_empty_store() {
1886 let engine = Engine::from_mounts(Vec::new()).unwrap();
1887 assert!(engine.store().is_empty());
1888 assert!(engine.schemas().is_empty());
1889 assert!(engine.load_errors().is_empty());
1890 }
1891
1892 #[test]
1895 fn from_workspace_root_errors_for_empty_layout() {
1896 let tmp = TempDir::new().unwrap();
1897 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1898 match err {
1899 crate::BootError::NotInitialised(p) => {
1900 assert_eq!(p, tmp.path());
1901 }
1902 other => panic!("expected NotInitialised, got {other:?}"),
1903 }
1904 }
1905
1906 #[test]
1907 fn from_workspace_root_loads_new_two_layer_layout() {
1908 let tmp = TempDir::new().unwrap();
1909 let mem_dir = tmp.path().join("mem");
1910 std::fs::create_dir_all(&mem_dir).unwrap();
1911 std::fs::write(
1912 mem_dir.join("hello.md"),
1913 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1914 )
1915 .unwrap();
1916
1917 let memstead = tmp.path().join(".memstead");
1918 std::fs::create_dir_all(&memstead).unwrap();
1919 std::fs::write(
1920 memstead.join("workspace.toml"),
1921 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1922 )
1923 .unwrap();
1924 use crate::workspace_store::WorkspaceStoreAdapter;
1927 let store = crate::FileWorkspaceStore::new();
1928 store
1929 .save_state(
1930 tmp.path(),
1931 &crate::workspace::Workspace {
1932 mounts: vec![folder_mount("specs", mem_dir)],
1933 settings: crate::workspace::WorkspaceSettings::default(),
1934 },
1935 )
1936 .unwrap();
1937
1938 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1939 assert_eq!(engine.mem_names(), vec!["specs"]);
1940 let entity = engine
1941 .get_entity(&crate::EntityId::new("specs", "hello"))
1942 .expect("seeded entity must load through from_workspace_root");
1943 assert_eq!(entity.title, "Hello");
1944 }
1945
1946 #[test]
1951 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1952 use crate::pipeline::{MediumType, Projection};
1953 let tmp = TempDir::new().unwrap();
1954 let mem_dir = tmp.path().join("mem");
1955 std::fs::create_dir_all(&mem_dir).unwrap();
1956
1957 let memstead = tmp.path().join(".memstead");
1958 std::fs::create_dir_all(&memstead).unwrap();
1959 std::fs::write(
1960 memstead.join("workspace.toml"),
1961 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1962 )
1963 .unwrap();
1964 use crate::workspace_store::WorkspaceStoreAdapter;
1965 crate::FileWorkspaceStore::new()
1966 .save_state(
1967 tmp.path(),
1968 &crate::workspace::Workspace {
1969 mounts: vec![folder_mount("specs", mem_dir)],
1970 settings: crate::workspace::WorkspaceSettings::default(),
1971 },
1972 )
1973 .unwrap();
1974
1975 crate::pipeline_store::write_binding(
1977 tmp.path(),
1978 "specs",
1979 "graph",
1980 &sample_v2_binding("specs"),
1981 )
1982 .unwrap();
1983
1984 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1985 let pc = engine.pipeline_configs();
1986 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1987 assert_eq!(pc.bindings[0].mem, "specs");
1988 assert_eq!(pc.bindings[0].name, "graph");
1989 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1990 assert_eq!(
1991 pc.bindings[0].config.sources[0].medium_type,
1992 MediumType::Codebase
1993 );
1994
1995 crate::pipeline_store::write_projection(
2002 tmp.path(),
2003 "specs",
2004 "legacy",
2005 &Projection {
2006 intent: None,
2007 source_facets: vec!["view".to_string()],
2008 reference_mems: Vec::new(),
2009 destination_mem: "specs".to_string(),
2010 rules: None,
2011 },
2012 )
2013 .unwrap();
2014 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2015 let pc = engine.pipeline_configs();
2016 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
2017 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
2018 assert_eq!(pc.quarantined[0].name, "legacy");
2019 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
2020 assert!(
2021 pc.quarantined[0]
2022 .reason_message
2023 .contains("memstead projection migrate"),
2024 "quarantine reason names the migrate command, got: {}",
2025 pc.quarantined[0].reason_message
2026 );
2027 }
2028
2029 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
2032 v2_binding_with_pointer(dest, "..")
2033 }
2034
2035 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
2036 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
2037 use crate::pipeline::{IngestTrigger, MediumType, Source};
2038 Binding {
2039 version: BINDING_VERSION,
2040 intent: None,
2041 sources: vec![Source {
2042 name: "src".to_string(),
2043 medium_type: MediumType::Codebase,
2044 pointer: pointer.to_string(),
2045 change_detection: None,
2046 scope: Vec::new(),
2047 engagement: None,
2048 preparation: None,
2049 }],
2050 reference_mems: Vec::new(),
2051 destination_mem: dest.to_string(),
2052 deny_paths: Vec::new(),
2053 coverage_semantics: None,
2054 rules: None,
2055 prune: None,
2056 operations: Operations {
2057 build: Some(BuildOperation {
2058 mode: BuildMode::Discovery,
2059 trigger: IngestTrigger::Loop,
2060 batch_size: 10,
2061 post_actions: None,
2062 }),
2063 sync: None,
2064 verify: None,
2065 },
2066 }
2067 }
2068
2069 #[test]
2078 fn url_anchors_adjudicate_from_supplied_observations_and_age() {
2079 use crate::anchor::{
2080 AnchorInput, AnchorState, SuppliedObservationInput, validate_supplied_observations,
2081 };
2082 use crate::vcs::Actor;
2083 use crate::workspace_store::WorkspaceStoreAdapter;
2084 use indexmap::IndexMap;
2085
2086 let tmp = TempDir::new().unwrap();
2087 let mem_dir = tmp.path().join("mem");
2088 std::fs::create_dir_all(&mem_dir).unwrap();
2089 std::fs::write(
2090 mem_dir.join("hello.md"),
2091 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
2092 )
2093 .unwrap();
2094
2095 let memstead = tmp.path().join(".memstead");
2096 std::fs::create_dir_all(&memstead).unwrap();
2097 std::fs::write(
2098 memstead.join("workspace.toml"),
2099 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2100 )
2101 .unwrap();
2102 crate::FileWorkspaceStore::new()
2103 .save_state(
2104 tmp.path(),
2105 &crate::workspace::Workspace {
2106 mounts: vec![folder_mount("specs", mem_dir.clone())],
2107 settings: crate::workspace::WorkspaceSettings::default(),
2108 },
2109 )
2110 .unwrap();
2111 crate::pipeline_store::write_binding(
2114 tmp.path(),
2115 "specs",
2116 "graph",
2117 &v2_binding_with_pointer("specs", "src"),
2118 )
2119 .unwrap();
2120 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2121
2122 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2123 let clock_now = "2026-09-02T12:00:00Z";
2124 let pinned = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_788_350_400);
2125 engine.set_mutation_clock(std::sync::Arc::new(move || pinned));
2126 assert_eq!(
2127 engine.now_iso(),
2128 clock_now,
2129 "clock pin is the date the ages count from"
2130 );
2131
2132 let url = |artifact: &str, stability: &str, content: &str| AnchorInput {
2133 artifact: Some(artifact.to_string()),
2134 grain: Some("url".to_string()),
2135 class: Some("anchored".to_string()),
2136 content: Some(content.to_string()),
2137 hash_stability: Some(stability.to_string()),
2138 ..Default::default()
2139 };
2140 let mut sections = IndexMap::new();
2141 sections.insert("identity".to_string(), "Cites the web.".to_string());
2142 sections.insert("purpose".to_string(), "Four url anchors.".to_string());
2143 let created = engine
2144 .create_entity(
2145 crate::CreateEntityArgs {
2146 mem: "specs".to_string(),
2147 title: "Cites".to_string(),
2148 entity_type: "spec".to_string(),
2149 sections,
2150 metadata: IndexMap::new(),
2151 relations: Vec::new(),
2152 anchors: vec![
2153 url("https://w.test/stable", "stable", "immutable text"),
2154 url("https://w.test/living", "unstable", "page v1"),
2155 url("https://w.test/gone", "unstable", "was here"),
2156 url("https://w.test/never", "unstable", "never observed again"),
2157 ],
2158 dry_run: false,
2159 },
2160 Actor::Agent,
2161 None,
2162 None,
2163 )
2164 .expect("url anchors beside a single path source are legal");
2165 let before = engine.entity_anchors_resolved(&created.id);
2166 assert_eq!(before.len(), 4);
2167 assert!(
2168 before
2169 .iter()
2170 .all(|r| r.state.is_none() && r.observed_at.is_none()),
2171 "no observation yet: every url row unobserved"
2172 );
2173 assert!(
2174 before
2175 .iter()
2176 .all(|r| r.anchor.hash_source == Some(crate::anchor::AnchorHashSource::Author)),
2177 "content supplied at write: author baseline"
2178 );
2179
2180 let rows = vec![
2183 SuppliedObservationInput {
2184 artifact: Some("https://w.test/stable".into()),
2185 hash: Some(crate::anchor::prepared_content_hash(b"immutable text")),
2186 ..Default::default()
2187 },
2188 SuppliedObservationInput {
2189 artifact: Some("https://w.test/living".into()),
2190 content: Some("page v2".into()),
2191 observed_at: Some("2026-08-03T09:00:00Z".into()),
2192 ..Default::default()
2193 },
2194 SuppliedObservationInput {
2195 artifact: Some("https://w.test/gone".into()),
2196 absent: Some(true),
2197 ..Default::default()
2198 },
2199 SuppliedObservationInput {
2200 artifact: Some("https://elsewhere.test/unrelated".into()),
2201 hash: Some("zz".into()),
2202 ..Default::default()
2203 },
2204 ];
2205 let supplied = validate_supplied_observations(&rows, clock_now).unwrap();
2206 let report = engine.verify_mem_anchors_with("specs", &supplied).unwrap();
2207 let state_of = |artifact: &str| {
2208 report
2209 .anchors
2210 .iter()
2211 .find(|a| a.artifact == artifact)
2212 .map(|a| {
2213 (
2214 a.state.clone(),
2215 a.unobserved_for_days,
2216 a.observation_supplied,
2217 )
2218 })
2219 .unwrap()
2220 };
2221 assert_eq!(
2222 state_of("https://w.test/stable"),
2223 ("resolves".into(), Some(0), true)
2224 );
2225 assert_eq!(
2226 state_of("https://w.test/living"),
2227 ("recheck".into(), Some(30), true)
2228 );
2229 assert_eq!(
2230 state_of("https://w.test/gone"),
2231 ("recheck".into(), Some(0), true)
2232 );
2233 assert_eq!(
2234 state_of("https://w.test/never"),
2235 ("unobserved".into(), None, false)
2236 );
2237 assert_eq!(
2238 report.unmatched_observations,
2239 vec!["https://elsewhere.test/unrelated"]
2240 );
2241 assert_eq!(report.recordable_observations.len(), 3);
2242 assert_eq!(
2243 (report.resolves, report.recheck, report.unobserved),
2244 (1, 2, 1)
2245 );
2246
2247 assert!(
2249 engine
2250 .entity_anchors_resolved(&created.id)
2251 .iter()
2252 .all(|r| r.state.is_none()),
2253 "verify writes nothing"
2254 );
2255
2256 let written = engine
2259 .record_anchor_observations("specs", &report.recordable_observations, None)
2260 .unwrap();
2261 assert_eq!(written, 3);
2262 let after = engine.entity_anchors_resolved(&created.id);
2263 let row = |artifact: &str| {
2264 after
2265 .iter()
2266 .find(|r| r.anchor.artifact == artifact)
2267 .unwrap()
2268 };
2269 assert_eq!(
2270 row("https://w.test/stable").state,
2271 Some(AnchorState::Resolves)
2272 );
2273 assert_eq!(
2274 row("https://w.test/living").state,
2275 Some(AnchorState::Recheck)
2276 );
2277 assert_eq!(
2278 row("https://w.test/living").observed_at.as_deref(),
2279 Some("2026-08-03T09:00:00Z")
2280 );
2281 assert_eq!(row("https://w.test/gone").state, Some(AnchorState::Recheck));
2282 assert_eq!(row("https://w.test/never").state, None, "still unobserved");
2283 let sidecar_bytes = std::fs::read(mem_dir.join(".memstead").join("anchors.json")).unwrap();
2284 let sidecar: serde_json::Value = serde_json::from_slice(&sidecar_bytes).unwrap();
2285 assert_eq!(sidecar["version"], 2);
2286 assert_eq!(
2288 engine
2289 .record_anchor_observations("specs", &report.recordable_observations, None)
2290 .unwrap(),
2291 0
2292 );
2293
2294 let report2 = engine.verify_mem_anchors("specs").unwrap();
2299 let aged = report2
2300 .anchors
2301 .iter()
2302 .find(|a| a.artifact == "https://w.test/living")
2303 .unwrap();
2304 assert_eq!(aged.unobserved_for_days, Some(30));
2305 assert!(!aged.observation_supplied);
2306 let axis = crate::ops::health::health_anchors_axis(&engine);
2307 let aging = axis["specs"]["aging"].as_array().unwrap();
2308 assert!(
2309 aging
2310 .iter()
2311 .any(|a| a["artifact"] == "https://w.test/living"
2312 && a["note"] == "unobserved for 30 days"),
2313 "{axis}"
2314 );
2315 let open = crate::ops::health::health_open_questions_axis(&engine, Some("specs"));
2316 assert!(
2317 open["specs"]["anchors_aging"]["items"]
2318 .as_array()
2319 .is_some_and(|items| items.iter().any(|i| i["note"] == "unobserved for 30 days")),
2320 "{open}"
2321 );
2322
2323 let drift_rows = vec![SuppliedObservationInput {
2324 artifact: Some("https://w.test/stable".into()),
2325 content: Some("immutable text, edited after all".into()),
2326 ..Default::default()
2327 }];
2328 let supplied = validate_supplied_observations(&drift_rows, clock_now).unwrap();
2329 let report3 = engine.verify_mem_anchors_with("specs", &supplied).unwrap();
2330 assert_eq!(
2331 state_of_report(&report3, "https://w.test/stable"),
2332 "drifted"
2333 );
2334 assert_eq!(report3.drifted, 1);
2335 }
2336
2337 #[test]
2343 fn sidecar_v1_fixture_loads_and_next_anchor_write_rewrites_v2_while_v3_refuses() {
2344 use crate::anchor::AnchorInput;
2345 use crate::vcs::Actor;
2346 use crate::workspace_store::WorkspaceStoreAdapter;
2347 use indexmap::IndexMap;
2348
2349 let tmp = TempDir::new().unwrap();
2350 let mem_dir = tmp.path().join("mem");
2351 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2352 std::fs::write(
2353 mem_dir.join("hello.md"),
2354 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
2355 )
2356 .unwrap();
2357 let v1 = r#"{"version":1,"entities":{"specs--hello":[{"artifact":"https://fixture.test/doc","grain":"url","class":"anchored","hash":"author-pinned","hash_stability":"stable","hash_source":"author"}]}}"#;
2358 std::fs::write(mem_dir.join(".memstead").join("anchors.json"), v1).unwrap();
2359 let memstead = tmp.path().join(".memstead");
2360 std::fs::create_dir_all(&memstead).unwrap();
2361 std::fs::write(
2362 memstead.join("workspace.toml"),
2363 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2364 )
2365 .unwrap();
2366 crate::FileWorkspaceStore::new()
2367 .save_state(
2368 tmp.path(),
2369 &crate::workspace::Workspace {
2370 mounts: vec![folder_mount("specs", mem_dir.clone())],
2371 settings: crate::workspace::WorkspaceSettings::default(),
2372 },
2373 )
2374 .unwrap();
2375 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2376
2377 let rows = engine.entity_anchors_resolved(&crate::EntityId::canonical("specs--hello"));
2378 assert_eq!(rows.len(), 1, "the version-1 row loads");
2379 assert_eq!(rows[0].anchor.hash.as_deref(), Some("author-pinned"));
2380 assert!(rows[0].anchor.last_observed.is_none());
2381 assert!(rows[0].state.is_none(), "never observed: unobserved");
2382 let on_disk =
2383 std::fs::read_to_string(mem_dir.join(".memstead").join("anchors.json")).unwrap();
2384 assert_eq!(on_disk, v1, "reading rewrites nothing");
2385
2386 let mut sections = IndexMap::new();
2389 sections.insert("identity".to_string(), "Second.".to_string());
2390 sections.insert("purpose".to_string(), "Second.".to_string());
2391 engine
2392 .create_entity(
2393 crate::CreateEntityArgs {
2394 mem: "specs".to_string(),
2395 title: "Second".to_string(),
2396 entity_type: "spec".to_string(),
2397 sections,
2398 metadata: IndexMap::new(),
2399 relations: Vec::new(),
2400 anchors: vec![AnchorInput {
2401 artifact: Some("https://fixture.test/other".to_string()),
2402 grain: Some("url".to_string()),
2403 class: Some("informed-by".to_string()),
2404 ..Default::default()
2405 }],
2406 dry_run: false,
2407 },
2408 Actor::Agent,
2409 None,
2410 None,
2411 )
2412 .unwrap();
2413 let after: serde_json::Value = serde_json::from_slice(
2414 &std::fs::read(mem_dir.join(".memstead").join("anchors.json")).unwrap(),
2415 )
2416 .unwrap();
2417 assert_eq!(after["version"], 2);
2418 assert_eq!(
2419 after["entities"]["specs--hello"][0]["hash"],
2420 "author-pinned"
2421 );
2422 assert!(
2423 after["entities"]["specs--hello"][0]
2424 .get("last_observed")
2425 .is_none()
2426 );
2427
2428 std::fs::write(
2430 mem_dir.join(".memstead").join("anchors.json"),
2431 r#"{"version":3,"entities":{}}"#,
2432 )
2433 .unwrap();
2434 let mut sections = IndexMap::new();
2435 sections.insert("identity".to_string(), "Third.".to_string());
2436 sections.insert("purpose".to_string(), "Third.".to_string());
2437 let err = engine
2438 .create_entity(
2439 crate::CreateEntityArgs {
2440 mem: "specs".to_string(),
2441 title: "Third".to_string(),
2442 entity_type: "spec".to_string(),
2443 sections,
2444 metadata: IndexMap::new(),
2445 relations: Vec::new(),
2446 anchors: vec![AnchorInput {
2447 artifact: Some("https://fixture.test/third".to_string()),
2448 grain: Some("url".to_string()),
2449 class: Some("informed-by".to_string()),
2450 ..Default::default()
2451 }],
2452 dry_run: false,
2453 },
2454 Actor::Agent,
2455 None,
2456 None,
2457 )
2458 .expect_err("a version-3 sidecar refuses");
2459 assert!(
2460 err.to_string()
2461 .contains("unsupported anchors sidecar version 3"),
2462 "{err}"
2463 );
2464 }
2465
2466 fn state_of_report(
2467 report: &crate::engine::query::MemAnchorVerification,
2468 artifact: &str,
2469 ) -> String {
2470 report
2471 .anchors
2472 .iter()
2473 .find(|a| a.artifact == artifact)
2474 .map(|a| a.state.clone())
2475 .unwrap()
2476 }
2477
2478 #[test]
2486 fn entity_anchors_resolve_live_state_for_path_medium() {
2487 use crate::anchor::{AnchorInput, AnchorState};
2488 use crate::vcs::Actor;
2489 use crate::workspace_store::WorkspaceStoreAdapter;
2490 use indexmap::IndexMap;
2491
2492 let tmp = TempDir::new().unwrap();
2493 let mem_dir = tmp.path().join("mem");
2494 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2495 std::fs::write(
2496 mem_dir.join(".memstead").join("config.json"),
2497 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2498 )
2499 .unwrap();
2500
2501 let memstead = tmp.path().join(".memstead");
2502 std::fs::create_dir_all(&memstead).unwrap();
2503 std::fs::write(
2504 memstead.join("workspace.toml"),
2505 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2506 )
2507 .unwrap();
2508 crate::FileWorkspaceStore::new()
2509 .save_state(
2510 tmp.path(),
2511 &crate::workspace::Workspace {
2512 mounts: vec![folder_mount("specs", mem_dir.clone())],
2513 settings: crate::workspace::WorkspaceSettings::default(),
2514 },
2515 )
2516 .unwrap();
2517
2518 crate::pipeline_store::write_binding(
2524 tmp.path(),
2525 "specs",
2526 "graph",
2527 &v2_binding_with_pointer("specs", "src"),
2528 )
2529 .unwrap();
2530 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2531 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
2532 std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
2535
2536 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2537
2538 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
2539 artifact: Some(artifact.to_string()),
2540 grain: Some("file".to_string()),
2541 class: Some(class.to_string()),
2542 hash: hash.map(str::to_string),
2543 hash_stability: Some("stable".to_string()),
2544 ..Default::default()
2545 };
2546 let mut sections = IndexMap::new();
2547 sections.insert("identity".to_string(), "Covers src.".to_string());
2548 sections.insert("purpose".to_string(), "Track sources.".to_string());
2549 let present_hash = crate::anchor::prepared_content_hash(
2552 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
2553 );
2554 let created = engine
2555 .create_entity(
2556 crate::CreateEntityArgs {
2557 mem: "specs".to_string(),
2558 title: "Covers".to_string(),
2559 entity_type: "spec".to_string(),
2560 sections,
2561 metadata: IndexMap::new(),
2562 relations: Vec::new(),
2563 anchors: vec![
2564 anchor("src/present.rs", "anchored", Some(&present_hash)), anchor("src/present.rs", "informed-by", None), anchor("src/gone.rs", "anchored", Some("h2")), anchor("src/present.rs", "derived", Some("stale")), ],
2569 dry_run: false,
2570 },
2571 Actor::Agent,
2572 None,
2573 None,
2574 )
2575 .unwrap();
2576
2577 std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
2578 let resolved = engine.entity_anchors_resolved(&created.id);
2579 assert_eq!(resolved.len(), 4);
2580 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
2581 resolved
2582 .iter()
2583 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
2584 .and_then(|r| r.state)
2585 };
2586 assert_eq!(
2587 state_of(
2588 "src/present.rs",
2589 crate::anchor::AnchorProvenanceClass::Anchored
2590 ),
2591 Some(AnchorState::Resolves),
2592 "recorded hash matches the observed prepared form → resolves"
2593 );
2594 assert_eq!(
2595 state_of(
2596 "src/present.rs",
2597 crate::anchor::AnchorProvenanceClass::Derived
2598 ),
2599 Some(AnchorState::Drifted),
2600 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2601 );
2602 assert_eq!(
2603 state_of(
2604 "src/present.rs",
2605 crate::anchor::AnchorProvenanceClass::InformedBy
2606 ),
2607 Some(AnchorState::Resolves),
2608 "present non-hash anchor resolves on existence"
2609 );
2610 assert_eq!(
2611 state_of(
2612 "src/gone.rs",
2613 crate::anchor::AnchorProvenanceClass::Anchored
2614 ),
2615 Some(AnchorState::Orphaned),
2616 "absent artifact is orphaned"
2617 );
2618 }
2619
2620 #[test]
2625 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2626 use crate::workspace_store::WorkspaceStoreAdapter;
2627
2628 let tmp = TempDir::new().unwrap();
2629 let mem_dir = tmp.path().join("mem");
2630 std::fs::create_dir_all(&mem_dir).unwrap();
2631 let memstead = tmp.path().join(".memstead");
2632 std::fs::create_dir_all(&memstead).unwrap();
2633 std::fs::write(
2634 memstead.join("workspace.toml"),
2635 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2636 )
2637 .unwrap();
2638 crate::FileWorkspaceStore::new()
2639 .save_state(
2640 tmp.path(),
2641 &crate::workspace::Workspace {
2642 mounts: vec![folder_mount("specs", mem_dir)],
2643 settings: crate::workspace::WorkspaceSettings::default(),
2644 },
2645 )
2646 .unwrap();
2647
2648 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2649 assert!(engine.pipeline_configs().bindings.is_empty());
2650
2651 engine
2653 .add_projection_json(
2654 "specs",
2655 "graph",
2656 r#"{
2657 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2658 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2659 "destination_mem": "specs"
2660 }"#,
2661 None,
2662 )
2663 .unwrap();
2664 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2666 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2667 assert_eq!(
2668 engine.pipeline_configs().bindings[0].config.sources[0].name,
2669 "src"
2670 );
2671
2672 let err = engine
2674 .add_projection_json("specs", "bad", "{ not json", None)
2675 .unwrap_err();
2676 assert!(
2677 matches!(
2678 err,
2679 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2680 ),
2681 "got {err:?}"
2682 );
2683
2684 engine
2686 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2687 .unwrap();
2688 assert_eq!(
2689 engine.pipeline_configs().bindings[0]
2690 .config
2691 .intent
2692 .as_deref(),
2693 Some("i2")
2694 );
2695
2696 engine
2698 .rename_projection("specs", "graph", "graph2", None)
2699 .unwrap();
2700 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2701
2702 let json = engine.pipeline_configs_json();
2705 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2706 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2707 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2708 let bindings = parsed["bindings"].as_array().unwrap();
2709 assert_eq!(bindings.len(), 1);
2710 assert_eq!(bindings[0]["name"], "graph2");
2711 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2712
2713 engine.delete_projection("specs", "graph2", None).unwrap();
2714 assert!(engine.pipeline_configs().bindings.is_empty());
2715 }
2716
2717 #[test]
2723 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2724 use crate::engine::test_helpers::write_schema_files_with_default_type;
2725
2726 let tmp = TempDir::new().unwrap();
2727 let mem_dir = tmp.path().join("mem");
2728 std::fs::create_dir_all(&mem_dir).unwrap();
2729
2730 let authored_dir = tmp.path().join(".memstead").join("schemas");
2732 let manifest = r#"name: authored
2733version: 0.1.0
2734description: an authored-in-workspace test schema
2735when_to_use: tests
2736types:
2737 - doc
2738relationships:
2739 mode: strict
2740 definitions:
2741 - name: _default
2742 description: fallback
2743 default_weight: 1.0
2744community:
2745 resolution: 1.0
2746 seed: 42
2747"#;
2748 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2749
2750 let memstead = tmp.path().join(".memstead");
2752 std::fs::create_dir_all(&memstead).unwrap();
2753 std::fs::write(
2754 memstead.join("workspace.toml"),
2755 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2756 )
2757 .unwrap();
2758 let mount = Mount {
2759 mem: "specs".to_string(),
2760 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2761 storage: MountStorage::Folder { path: mem_dir },
2762 capability: MountCapability::Write,
2763 lifecycle: MountLifecycle::Eager,
2764 cross_linkable: true,
2765 migration_target: None,
2766 };
2767 use crate::workspace_store::WorkspaceStoreAdapter;
2768 crate::FileWorkspaceStore::new()
2769 .save_state(
2770 tmp.path(),
2771 &crate::workspace::Workspace {
2772 mounts: vec![mount],
2773 settings: crate::workspace::WorkspaceSettings::default(),
2774 },
2775 )
2776 .unwrap();
2777
2778 let engine = Engine::from_workspace_root(tmp.path())
2781 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2782 assert_eq!(engine.mem_names(), vec!["specs"]);
2783 }
2784
2785 #[test]
2791 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2792 use crate::engine::test_helpers::write_schema_files_with_default_type;
2793
2794 let tmp = TempDir::new().unwrap();
2795 let mem_dir = tmp.path().join("mem");
2796 std::fs::create_dir_all(&mem_dir).unwrap();
2797 let manifest = r#"name: authored
2798version: 0.1.0
2799description: an authored-in-workspace test schema
2800when_to_use: tests
2801types:
2802 - doc
2803relationships:
2804 mode: strict
2805 definitions:
2806 - name: _default
2807 description: fallback
2808 default_weight: 1.0
2809community:
2810 resolution: 1.0
2811 seed: 42
2812"#;
2813 let sealed_root = tmp.path().join(".memstead").join("schemas");
2816 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2817 let author_root = tmp.path().join("author");
2818 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2819 let authoring_dir = author_root.join("authored@0.1.0");
2820 let sealed_dir = sealed_root.join("authored@0.1.0");
2821 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2823 std::fs::write(
2824 &stamp_path,
2825 serde_json::to_vec_pretty(&serde_json::json!({
2826 "authoring_path": authoring_dir.display().to_string(),
2827 }))
2828 .unwrap(),
2829 )
2830 .unwrap();
2831
2832 let memstead = tmp.path().join(".memstead");
2833 std::fs::write(
2834 memstead.join("workspace.toml"),
2835 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2836 )
2837 .unwrap();
2838 let mount = Mount {
2839 mem: "specs".to_string(),
2840 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2841 storage: MountStorage::Folder { path: mem_dir },
2842 capability: MountCapability::Write,
2843 lifecycle: MountLifecycle::Eager,
2844 cross_linkable: true,
2845 migration_target: None,
2846 };
2847 use crate::workspace_store::WorkspaceStoreAdapter;
2848 crate::FileWorkspaceStore::new()
2849 .save_state(
2850 tmp.path(),
2851 &crate::workspace::Workspace {
2852 mounts: vec![mount],
2853 settings: crate::workspace::WorkspaceSettings::default(),
2854 },
2855 )
2856 .unwrap();
2857 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2858 let drift_codes = |e: &Engine| -> Vec<String> {
2859 e.health()
2860 .warnings
2861 .iter()
2862 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2863 .map(|w| w.code().to_string())
2864 .collect()
2865 };
2866
2867 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2870 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2871 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2872 assert_eq!(
2873 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2874 sealed_before,
2875 "health must not touch the sealed copy"
2876 );
2877 assert_eq!(
2878 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2879 author_before,
2880 "health must not touch the authoring copy"
2881 );
2882
2883 std::fs::write(
2887 authoring_dir.join("schema.yaml"),
2888 format!(
2889 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2890 ),
2891 )
2892 .unwrap();
2893 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2894
2895 std::fs::write(
2898 authoring_dir.join("schema.yaml"),
2899 manifest.replace(
2900 "an authored-in-workspace test schema",
2901 "a semantically different description",
2902 ),
2903 )
2904 .unwrap();
2905 let warnings = engine.health().warnings;
2906 let diverged = warnings
2907 .iter()
2908 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2909 .expect("semantic change must surface as DIVERGED");
2910 let d = serde_json::to_value(diverged).unwrap();
2911 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2912 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2913
2914 std::fs::remove_dir_all(&authoring_dir).unwrap();
2916 let warnings = engine.health().warnings;
2917 let missing = warnings
2918 .iter()
2919 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2920 .expect("vanished authoring package must surface as MISSING");
2921 let m = serde_json::to_value(missing).unwrap();
2922 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2923 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2924 assert_eq!(
2925 m["details"]["stamped_path"],
2926 authoring_dir.display().to_string()
2927 );
2928 assert!(
2929 !warnings
2930 .iter()
2931 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2932 "missing and diverged are distinct findings"
2933 );
2934
2935 std::fs::remove_file(&stamp_path).unwrap();
2937 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2938 }
2939
2940 #[test]
2945 fn full_refresh_is_additive_and_reports_skipped_removals() {
2946 use crate::engine::test_helpers::write_schema_files_with_default_type;
2947 use crate::workspace_store::WorkspaceStoreAdapter;
2948
2949 let tmp = TempDir::new().unwrap();
2950 let root = tmp.path();
2951 let mem_a = root.join("mem-a");
2952 std::fs::create_dir_all(&mem_a).unwrap();
2953 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2954 std::fs::write(
2955 root.join(".memstead").join("workspace.toml"),
2956 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2957 )
2958 .unwrap();
2959 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2960 mem: mem.to_string(),
2961 schema: Some(SchemaRef::new(schema, version)),
2962 storage: MountStorage::Folder {
2963 path: dir.to_path_buf(),
2964 },
2965 capability: MountCapability::Write,
2966 lifecycle: MountLifecycle::Eager,
2967 cross_linkable: true,
2968 migration_target: None,
2969 };
2970 let save = |mounts: Vec<Mount>| {
2971 crate::FileWorkspaceStore::new()
2972 .save_state(
2973 root,
2974 &crate::workspace::Workspace {
2975 mounts,
2976 settings: crate::workspace::WorkspaceSettings::default(),
2977 },
2978 )
2979 .unwrap();
2980 };
2981 save(vec![mount(
2982 "specs",
2983 &mem_a,
2984 "default",
2985 semver::Version::new(1, 0, 0),
2986 )]);
2987 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2988 assert_eq!(engine.mem_names(), vec!["specs"]);
2989
2990 let manifest = r#"name: authored
2993version: 0.1.0
2994description: an out-of-band installed schema
2995when_to_use: tests
2996types:
2997 - doc
2998relationships:
2999 mode: strict
3000 definitions:
3001 - name: _default
3002 description: fallback
3003 default_weight: 1.0
3004community:
3005 resolution: 1.0
3006 seed: 42
3007"#;
3008 write_schema_files_with_default_type(
3009 &root.join(".memstead").join("schemas"),
3010 "authored@0.1.0",
3011 manifest,
3012 &["doc"],
3013 );
3014 let mem_b = root.join("mem-b");
3015 std::fs::create_dir_all(&mem_b).unwrap();
3016 save(vec![
3017 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
3018 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
3019 ]);
3020
3021 let (actor, client) = cli_actor();
3025 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
3026 pre.entity_type = "doc".to_string();
3027 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
3028
3029 let report = engine.full_refresh();
3031 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
3032 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
3033 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
3034 assert!(report.mems_unmounted.is_empty(), "{report:?}");
3035 assert!(report.failures.is_empty(), "{report:?}");
3036 engine
3037 .create_entity(pre, actor, Some(&client), None)
3038 .expect("newly mounted mem accepts writes after the refresh");
3039
3040 std::fs::remove_dir_all(
3043 root.join(".memstead")
3044 .join("schemas")
3045 .join("authored@0.1.0"),
3046 )
3047 .unwrap();
3048 save(vec![mount(
3049 "notes",
3050 &mem_b,
3051 "authored",
3052 semver::Version::new(0, 1, 0),
3053 )]);
3054 let report = engine.full_refresh();
3055 assert_eq!(report.mems_unmounted, vec!["specs".to_string()]);
3058 assert_eq!(engine.mem_names(), vec!["notes"]);
3059 assert_eq!(
3060 report.schema_removals_skipped,
3061 vec!["authored@0.1.0".to_string()]
3062 );
3063 assert!(report.schemas_added.is_empty());
3064 assert!(report.mems_mounted.is_empty());
3065 let gone = engine
3068 .create_entity(
3069 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
3070 actor,
3071 Some(&client),
3072 None,
3073 )
3074 .expect_err("an unmounted mem refuses writes");
3075 assert_eq!(gone.code(), "MEM_UNMOUNTED", "{gone:?}");
3076 let mut into_notes =
3077 crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
3078 into_notes.entity_type = "doc".to_string();
3079 into_notes.sections =
3080 indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
3081 engine
3082 .create_entity(into_notes, actor, Some(&client), None)
3083 .expect("removed-from-source schema stays resolvable");
3084
3085 let broken = root.join("broken-mem");
3088 std::fs::write(&broken, b"not a directory").unwrap();
3089 save(vec![
3090 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
3091 mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
3092 ]);
3093 let report = engine.full_refresh();
3094 assert!(
3095 report.mems_quarantined.contains(&"broken".to_string()),
3096 "a mount that cannot attach is quarantined per-item, as boot would: {report:?}"
3097 );
3098 assert!(engine.quarantine_reason("broken").is_some());
3099 assert!(
3100 !report.mems_mounted.contains(&"broken".to_string()),
3101 "a failed mount never surfaces as newly available"
3102 );
3103 assert!(
3104 engine
3105 .get_entity(&crate::EntityId::new("broken", "anything"))
3106 .is_none()
3107 && engine.mem_names().contains(&"notes"),
3108 "other mounts unaffected"
3109 );
3110 }
3111
3112 #[test]
3113 fn from_workspace_root_propagates_mem_management_settings() {
3114 let tmp = TempDir::new().unwrap();
3119 let mem_dir = tmp.path().join("mem");
3120 std::fs::create_dir_all(&mem_dir).unwrap();
3121
3122 let memstead = tmp.path().join(".memstead");
3123 std::fs::create_dir_all(&memstead).unwrap();
3124 std::fs::write(
3125 memstead.join("workspace.toml"),
3126 r#"format = "memstead-git-branch-2"
3127
3128[persistence_adapter]
3129name = "file-two-layer"
3130
3131[[mem_management.create]]
3132pattern = "exec-*"
3133schemas = ["default@1.0.0"]
3134
3135[[mem_management.delete]]
3136pattern = "exec-*"
3137"#,
3138 )
3139 .unwrap();
3140 use crate::workspace_store::WorkspaceStoreAdapter;
3141 let store = crate::FileWorkspaceStore::new();
3142 store
3143 .save_state(
3144 tmp.path(),
3145 &crate::workspace::Workspace {
3146 mounts: vec![folder_mount("specs", mem_dir)],
3147 settings: crate::workspace::WorkspaceSettings::default(),
3148 },
3149 )
3150 .unwrap();
3151
3152 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
3153 let s = engine.settings();
3154 assert_eq!(s.mem_create_rules.len(), 1);
3155 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
3156 assert_eq!(
3157 s.mem_create_rules[0].schemas,
3158 vec!["default@1.0.0".to_string()]
3159 );
3160 assert_eq!(s.mem_delete_rules.len(), 1);
3161 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
3162 }
3163
3164 #[test]
3172 fn from_mounts_quarantines_unknown_schema_pin() {
3173 let tmp = TempDir::new().unwrap();
3174 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
3175 let mount = Mount {
3176 mem: "specs".to_string(),
3177 schema: Some(SchemaRef::new(
3178 "totally-not-a-schema",
3179 semver::Version::new(1, 0, 0),
3180 )),
3181 storage: MountStorage::Folder {
3182 path: tmp.path().to_path_buf(),
3183 },
3184 capability: MountCapability::Write,
3185 lifecycle: MountLifecycle::Eager,
3186 cross_linkable: true,
3187 migration_target: None,
3188 };
3189 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3190 .expect("a broken mem quarantines, never fails the workspace");
3191
3192 let roster = engine.quarantined_mems();
3193 assert_eq!(roster.len(), 1);
3194 assert_eq!(roster[0].mount.mem, "specs");
3195 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
3196 assert!(
3197 roster[0].reason_message.contains("totally-not-a-schema"),
3198 "reason carries the failing pin: {}",
3199 roster[0].reason_message
3200 );
3201 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
3203 let err = engine.unknown_mem_error("specs");
3206 assert_eq!(err.code(), "MEM_QUARANTINED");
3207 assert!(
3208 err.to_string().contains("SCHEMA_NOT_FOUND"),
3209 "quarantine refusal carries the underlying reason: {err}"
3210 );
3211 let health = engine.health();
3213 assert_eq!(health.quarantined.len(), 1);
3214 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
3215 }
3216
3217 #[test]
3223 fn reload_returns_repaired_mem_from_quarantine() {
3224 let tmp = TempDir::new().unwrap();
3225 let dir = tmp.path().to_path_buf();
3226 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
3227 let config_path = dir.join(".memstead").join("config.json");
3228 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
3229 let writer = FilesystemMemWriter::new(dir.clone());
3230 let mount = Mount {
3231 mem: "specs".to_string(),
3232 schema: None,
3233 storage: MountStorage::Folder { path: dir },
3234 capability: MountCapability::Write,
3235 lifecycle: MountLifecycle::Eager,
3236 cross_linkable: true,
3237 migration_target: None,
3238 };
3239 let mut engine =
3240 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3241 assert_eq!(engine.quarantined_mems().len(), 1);
3242 let err = engine.reload_one_mem("specs").unwrap_err();
3245 assert_eq!(err.code(), "MEM_QUARANTINED");
3246 assert_eq!(engine.quarantined_mems().len(), 1);
3247
3248 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
3251 engine
3252 .reload_one_mem("specs")
3253 .expect("repaired mem re-attaches on reload");
3254 assert!(
3255 engine.quarantined_mems().is_empty(),
3256 "roster entry disappears after re-attach"
3257 );
3258 let mut sections = indexmap::IndexMap::new();
3260 sections.insert("identity".to_string(), "back".to_string());
3261 sections.insert("purpose".to_string(), "post-repair service".to_string());
3262 engine
3263 .create_entity_with_ctx(
3264 crate::engine::CreateEntityArgs {
3265 anchors: Vec::new(),
3266 mem: "specs".to_string(),
3267 title: "Back".to_string(),
3268 entity_type: "spec".to_string(),
3269 sections,
3270 metadata: indexmap::IndexMap::new(),
3271 relations: Vec::new(),
3272 dry_run: false,
3273 },
3274 &crate::vcs::CommitContext::internal(),
3275 )
3276 .expect("reattached mem serves writes");
3277 }
3278
3279 #[test]
3284 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
3285 let tmp = TempDir::new().unwrap();
3286 let healthy_dir = tmp.path().join("healthy");
3287 std::fs::create_dir_all(&healthy_dir).unwrap();
3288 std::fs::write(
3289 healthy_dir.join("linker.md"),
3290 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
3291 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
3292 )
3293 .unwrap();
3294 let badpin_dir = tmp.path().join("badpin");
3295 std::fs::create_dir_all(&badpin_dir).unwrap();
3296 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
3297 (
3298 Mount {
3299 mem: mem.to_string(),
3300 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
3301 storage: MountStorage::Folder { path: dir.clone() },
3302 capability: MountCapability::Write,
3303 lifecycle: MountLifecycle::Eager,
3304 cross_linkable: true,
3305 migration_target: None,
3306 },
3307 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
3308 )
3309 };
3310 let engine = Engine::from_mounts(vec![
3311 mount("healthy", healthy_dir, "default"),
3312 mount("badpin", badpin_dir, "ghost"),
3313 ])
3314 .expect("boot survives the cross-mem link into the quarantined mem");
3315 assert_eq!(engine.quarantined_mems().len(), 1);
3316 let linker = engine
3320 .get_entity(&crate::EntityId::new("healthy", "linker"))
3321 .expect("linking entity loads");
3322 assert_eq!(linker.entity_type, "spec");
3323 assert!(
3324 engine
3325 .get_entity(&crate::EntityId::new("badpin", "target"))
3326 .is_none_or(|e| e.stub),
3327 "the quarantined-side target is at most a stub, never real data"
3328 );
3329 }
3330
3331 #[test]
3337 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
3338 let tmp = TempDir::new().unwrap();
3339 let root = tmp.path();
3340 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
3342 std::fs::write(
3343 root.join(".memstead").join("workspace.toml"),
3344 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3345 )
3346 .unwrap();
3347 for m in ["healthy", "oldkey"] {
3348 std::fs::create_dir_all(root.join(m)).unwrap();
3349 }
3350 std::fs::write(
3351 root.join(".memstead").join("state").join("mounts.json"),
3352 r#"{ "format": "memstead-mounts-3", "mounts": [
3353 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
3354 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
3355 ] }"#,
3356 )
3357 .unwrap();
3358 let pkg = root
3360 .join(".memstead")
3361 .join("schemas")
3362 .join("fieldschema@0.1.0");
3363 std::fs::create_dir_all(pkg.join("types")).unwrap();
3364 std::fs::write(
3365 pkg.join("schema.yaml"),
3366 "name: fieldschema\nversion: 0.1.0\ndescription: field schema\nwhen_to_use: tests\ntypes:\n - thing\nrelationships:\n mode: strict\n definitions:\n - name: PART_OF\n description: h\n default_weight: 1.0\n acyclic: true\n - name: _default\n description: f\n default_weight: 1.0\ncommunity:\n resolution: 1.0\n seed: 42\n",
3367 )
3368 .unwrap();
3369 std::fs::write(
3370 pkg.join("types").join("thing.yaml"),
3371 "name: thing\ndescription: t\nwhen_to_use: h\nsections:\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\npropagating_relationships: []\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
3372 )
3373 .unwrap();
3374
3375 let engine = Engine::from_workspace_root(root)
3376 .expect("the old-key schema quarantines its mem, never the workspace");
3377 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
3378 let q = engine
3379 .quarantine_reason("oldkey")
3380 .expect("oldkey mem is quarantined");
3381 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
3382 assert!(
3383 q.reason_message.contains("no_self_loop_relationships"),
3384 "quarantine reason is the rename error naming the new key: {}",
3385 q.reason_message
3386 );
3387 }
3388
3389 #[test]
3394 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
3395 let entry_md = "---\ntype: coverage_gap\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nstatus: open\n---\n# Gap\n\n## Area\n\nan uncovered area.\n";
3396 let boot = |pin: &str| {
3397 let tmp = TempDir::new().unwrap();
3398 let dir = tmp.path().to_path_buf();
3399 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
3400 std::fs::write(
3401 dir.join(".memstead").join("config.json"),
3402 format!("{{ \"schema\": \"{pin}\" }}"),
3403 )
3404 .unwrap();
3405 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
3406 let writer = FilesystemMemWriter::new(dir.clone());
3407 let mount = Mount {
3408 mem: "proc".to_string(),
3409 schema: None,
3410 storage: MountStorage::Folder { path: dir },
3411 capability: MountCapability::Write,
3412 lifecycle: MountLifecycle::Eager,
3413 cross_linkable: true,
3414 migration_target: None,
3415 };
3416 let engine =
3417 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3418 .unwrap();
3419 (engine.health(), tmp)
3420 };
3421
3422 let (health_new, _t1) = boot("ingest@0.3.0");
3423 assert_eq!(
3424 health_new.orphan_count, 0,
3425 "0.3.0 entry types are leaves — zero false orphans"
3426 );
3427 assert_eq!(
3428 health_new
3429 .leaf_entities_by_type
3430 .get("ingest@0.3.0:coverage_gap"),
3431 Some(&1),
3432 "the population stays visible: {:?}",
3433 health_new.leaf_entities_by_type
3434 );
3435
3436 let (health_old, _t2) = boot("ingest@0.2.0");
3437 assert_eq!(
3438 health_old.orphan_count, 1,
3439 "the prior version's behaviour is unchanged"
3440 );
3441 assert!(health_old.leaf_entities_by_type.is_empty());
3442 }
3443
3444 #[test]
3450 fn broken_mem_quarantines_while_healthy_siblings_serve() {
3451 let tmp = TempDir::new().unwrap();
3452 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
3453 let dir = tmp.path().join(mem);
3454 std::fs::create_dir_all(&dir).unwrap();
3455 let writer = FilesystemMemWriter::new(dir.clone());
3456 (
3457 Mount {
3458 mem: mem.to_string(),
3459 schema: pin,
3460 storage: MountStorage::Folder { path: dir },
3461 capability: MountCapability::Write,
3462 lifecycle: MountLifecycle::Eager,
3463 cross_linkable: true,
3464 migration_target: None,
3465 },
3466 Box::new(writer) as Box<dyn MemBackend>,
3467 )
3468 };
3469 let healthy = make_mount(
3470 "healthy",
3471 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3472 );
3473 let bad_pin = make_mount(
3474 "badpin",
3475 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
3476 );
3477 let missing_pin = make_mount("nopin", None);
3478 let bad_io_path = tmp.path().join("badio");
3481 std::fs::write(&bad_io_path, "not a directory").unwrap();
3482 let bad_io = (
3483 Mount {
3484 mem: "badio".to_string(),
3485 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3486 storage: MountStorage::Folder {
3487 path: bad_io_path.clone(),
3488 },
3489 capability: MountCapability::Write,
3490 lifecycle: MountLifecycle::Eager,
3491 cross_linkable: true,
3492 migration_target: None,
3493 },
3494 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
3495 );
3496 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
3497 .expect("mixed workspace boots");
3498
3499 let codes: std::collections::HashMap<String, String> = engine
3501 .quarantined_mems()
3502 .iter()
3503 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
3504 .collect();
3505 assert_eq!(
3506 codes.get("badpin").map(String::as_str),
3507 Some("SCHEMA_NOT_FOUND")
3508 );
3509 assert_eq!(
3510 codes.get("nopin").map(String::as_str),
3511 Some("MEM_CONFIG_INCOMPLETE")
3512 );
3513 assert!(
3514 codes.contains_key("badio"),
3515 "backend read failure quarantines too: {codes:?}"
3516 );
3517
3518 let mut sections = indexmap::IndexMap::new();
3520 sections.insert("identity".to_string(), "alive".to_string());
3521 sections.insert("purpose".to_string(), "proof of service".to_string());
3522 let created = engine
3523 .create_entity_with_ctx(
3524 crate::engine::CreateEntityArgs {
3525 anchors: Vec::new(),
3526 mem: "healthy".to_string(),
3527 title: "Alive".to_string(),
3528 entity_type: "spec".to_string(),
3529 sections,
3530 metadata: indexmap::IndexMap::new(),
3531 relations: Vec::new(),
3532 dry_run: false,
3533 },
3534 &crate::vcs::CommitContext::internal(),
3535 )
3536 .expect("healthy mem serves writes");
3537 assert_eq!(created.id.to_string(), "healthy--alive");
3538
3539 let mut sections = indexmap::IndexMap::new();
3541 sections.insert("identity".to_string(), "x".to_string());
3542 let err = engine
3543 .create_entity_with_ctx(
3544 crate::engine::CreateEntityArgs {
3545 anchors: Vec::new(),
3546 mem: "badpin".to_string(),
3547 title: "Nope".to_string(),
3548 entity_type: "spec".to_string(),
3549 sections,
3550 metadata: indexmap::IndexMap::new(),
3551 relations: Vec::new(),
3552 dry_run: false,
3553 },
3554 &crate::vcs::CommitContext::internal(),
3555 )
3556 .unwrap_err();
3557 assert_eq!(err.code(), "MEM_QUARANTINED");
3558 }
3559
3560 #[test]
3567 fn mem_config_schema_is_authoritative_over_mount_pin() {
3568 let tmp = TempDir::new().unwrap();
3569 let mem_dir = tmp.path().to_path_buf();
3570 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3571 std::fs::write(
3572 mem_dir.join(".memstead").join("config.json"),
3573 r#"{"schema":"software@0.1.0"}"#,
3574 )
3575 .unwrap();
3576 let writer = FilesystemMemWriter::new(mem_dir.clone());
3577 let mount = Mount {
3578 mem: "specs".to_string(),
3579 schema: Some(SchemaRef::new(
3580 "totally-not-a-schema",
3581 semver::Version::new(9, 9, 9),
3582 )),
3583 storage: MountStorage::Folder { path: mem_dir },
3584 capability: MountCapability::Write,
3585 lifecycle: MountLifecycle::Eager,
3586 cross_linkable: true,
3587 migration_target: None,
3588 };
3589 let engine = Engine::from_mounts(vec![(
3590 mount,
3591 Box::new(writer) as Box<dyn MemBackend>,
3592 )])
3593 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3594
3595 let mismatch = engine
3596 .load_warnings()
3597 .iter()
3598 .find_map(|w| match w {
3599 WarningHint::SchemaPinMismatch {
3600 mem,
3601 config_pin,
3602 mount_pin,
3603 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3604 _ => None,
3605 })
3606 .expect("SchemaPinMismatch warning must surface naming both pins");
3607 assert_eq!(mismatch.0, "specs");
3608 assert_eq!(mismatch.1, "software@0.1.0");
3609 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3610 }
3611
3612 #[test]
3613 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3614 let tmp = TempDir::new().unwrap();
3615 let memstead = tmp.path().join(".memstead");
3616 std::fs::create_dir_all(&memstead).unwrap();
3617 std::fs::write(
3618 memstead.join("workspace.toml"),
3619 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3620 )
3621 .unwrap();
3622 let state_dir = memstead.join("state");
3625 std::fs::create_dir_all(&state_dir).unwrap();
3626 std::fs::write(
3627 state_dir.join("mounts.json"),
3628 r#"{
3629 "format": "memstead-mounts-3",
3630 "mounts": [
3631 {
3632 "mem": "specs",
3633 "schema": "default@1.0.0",
3634 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3635 "capability": "write",
3636 "lifecycle": "eager",
3637 "cross_linkable": true
3638 }
3639 ]
3640 }"#,
3641 )
3642 .unwrap();
3643 let engine = Engine::from_workspace_root(tmp.path())
3651 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3652 let roster = engine.quarantined_mems();
3653 assert_eq!(roster.len(), 1);
3654 assert_eq!(roster[0].mount.mem, "specs");
3655 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3656 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3657 }
3658
3659 #[test]
3660 fn from_workspace_root_roots_standalone_folder_mem() {
3661 let tmp = TempDir::new().unwrap();
3665 let root = tmp.path();
3666 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3667 std::fs::write(
3668 root.join(".memstead").join("config.json"),
3669 r#"{"schema":"default@1.0.0"}"#,
3670 )
3671 .unwrap();
3672 std::fs::write(
3674 root.join("hello.md"),
3675 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3676 )
3677 .unwrap();
3678
3679 let engine = Engine::from_workspace_root(root)
3680 .expect("a bare folder mem must root as a one-mount workspace");
3681 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3682 assert!(
3683 engine.status().entity_count >= 1,
3684 "the standalone mem's entity must load"
3685 );
3686 }
3687
3688 #[test]
3689 fn booting_a_workspace_writes_nothing_into_it() {
3690 let tmp = TempDir::new().unwrap();
3699 let root = tmp.path();
3700 std::fs::create_dir_all(root.join(".memstead").join("meta-schemas")).unwrap();
3701 std::fs::write(
3702 root.join(".memstead").join("config.json"),
3703 r#"{"schema":"default@1.0.0"}"#,
3704 )
3705 .unwrap();
3706 let stale = root
3709 .join(".memstead")
3710 .join("meta-schemas")
3711 .join("type-definition.schema.json");
3712 std::fs::write(&stale, "{\"title\":\"stale, from an older binary\"}").unwrap();
3713 std::fs::write(
3714 root.join("hello.md"),
3715 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3716 )
3717 .unwrap();
3718
3719 fn snapshot(dir: &Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> {
3720 let mut out = std::collections::BTreeMap::new();
3721 let mut stack = vec![dir.to_path_buf()];
3722 while let Some(d) = stack.pop() {
3723 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
3724 let p = entry.path();
3725 if p.is_dir() {
3726 stack.push(p);
3727 } else if let Ok(bytes) = std::fs::read(&p) {
3728 out.insert(p, bytes);
3729 }
3730 }
3731 }
3732 out
3733 }
3734
3735 let before = snapshot(root);
3736 let engine = Engine::from_workspace_root(root).expect("the fixture must boot");
3737 assert_eq!(engine.status().mem_count, 1, "fixture sanity: one mount");
3738 let after = snapshot(root);
3739
3740 assert_eq!(
3741 before.keys().collect::<Vec<_>>(),
3742 after.keys().collect::<Vec<_>>(),
3743 "boot created or removed a file in the workspace it read"
3744 );
3745 for (path, bytes) in &before {
3746 assert_eq!(
3747 bytes,
3748 after.get(path).unwrap(),
3749 "boot rewrote {} in the workspace it read",
3750 path.display()
3751 );
3752 }
3753 }
3754
3755 #[test]
3756 fn from_workspace_root_still_rejects_truly_empty_dir() {
3757 let tmp = TempDir::new().unwrap();
3761 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3762 assert!(
3763 matches!(err, crate::BootError::NotInitialised(_)),
3764 "got {err:?}"
3765 );
3766 }
3767
3768 #[test]
3775 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3776 let tmp = TempDir::new().unwrap();
3777 let schemas_dir = tmp.path().join("schemas");
3778 let pkg = schemas_dir.join("debate");
3779 std::fs::create_dir_all(pkg.join("types")).unwrap();
3780 std::fs::write(
3781 pkg.join("schema.yaml"),
3782 r#"name: debate
3783version: 0.1.0
3784description: sealed-violator fixture
3785when_to_use: tests
3786types:
3787 - question
3788relationships:
3789 mode: strict
3790 definitions:
3791 - name: PART_OF
3792 description: hier
3793 default_weight: 3.0
3794 - name: _default
3795 description: fallback
3796 default_weight: 1.0
3797community:
3798 resolution: 1.0
3799 seed: 42
3800"#,
3801 )
3802 .unwrap();
3803 std::fs::write(
3804 pkg.join("types").join("question.yaml"),
3805 r#"name: question
3806description: t
3807when_to_use: tests
3808sections:
3809 - key: answers
3810 heading: Answers argued
3811 required: true
3812 search_weight: 10.0
3813 write_rules: []
3814 - key: notes
3815 heading: Notes
3816 required: false
3817 search_weight: 3.0
3818 catch_all: true
3819 write_rules: []
3820metadata_fields: []
3821title_weight: 100.0
3822text_fields:
3823 - answers
3824 - notes
3825hierarchy_relationship: PART_OF
3826no_self_loop_relationships: []
3827updatable_fields:
3828 - title
3829 - answers
3830 - notes
3831health_required_fields:
3832 - answers
3833staleness_threshold_days: 90
3834write_rules: []
3835"#,
3836 )
3837 .unwrap();
3838
3839 let mem_dir = tmp.path().join("mem");
3840 std::fs::create_dir_all(&mem_dir).unwrap();
3841 std::fs::write(
3842 mem_dir.join("q.md"),
3843 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3844 )
3845 .unwrap();
3846
3847 let writer = FilesystemMemWriter::new(mem_dir.clone());
3848 let mount = Mount {
3849 mem: "debate-mem".to_string(),
3850 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3851 storage: MountStorage::Folder { path: mem_dir },
3852 capability: MountCapability::Write,
3853 lifecycle: MountLifecycle::Eager,
3854 cross_linkable: true,
3855 migration_target: None,
3856 };
3857 let engine = Engine::from_mounts_with_schemas_dir(
3858 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3859 Some(&schemas_dir),
3860 )
3861 .expect("a violating sealed schema must keep loading, never refuse boot");
3862
3863 assert!(
3865 engine.status().entity_count >= 1,
3866 "entities load despite the schema violation"
3867 );
3868
3869 let hits: Vec<_> = engine
3871 .load_warnings()
3872 .iter()
3873 .filter_map(|w| match w {
3874 WarningHint::SchemaHeadingRoundtripViolation {
3875 mem,
3876 schema_ref,
3877 violations,
3878 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3879 _ => None,
3880 })
3881 .collect();
3882 assert_eq!(
3883 hits.len(),
3884 1,
3885 "exactly one schema-level finding; all warnings = {:?}",
3886 engine.load_warnings()
3887 );
3888 let (mem, schema_ref, violations) = &hits[0];
3889 assert_eq!(mem, "debate-mem");
3890 assert_eq!(schema_ref, "debate@0.1.0");
3891 assert_eq!(violations.len(), 1);
3892 assert_eq!(violations[0].type_name, "question");
3893 assert_eq!(violations[0].key, "answers");
3894 assert_eq!(violations[0].heading, "Answers argued");
3895 assert_eq!(violations[0].derived_key, "answers_argued");
3896 }
3897
3898 #[test]
3906 fn sealed_violator_mem_still_serves_writes() {
3907 let tmp = TempDir::new().unwrap();
3909 let schemas_dir = tmp.path().join("schemas");
3910 let pkg = schemas_dir.join("debate");
3911 std::fs::create_dir_all(pkg.join("types")).unwrap();
3912 std::fs::write(
3913 pkg.join("schema.yaml"),
3914 r#"name: debate
3915version: 0.1.0
3916description: sealed-violator fixture
3917when_to_use: tests
3918types:
3919 - question
3920relationships:
3921 mode: strict
3922 definitions:
3923 - name: PART_OF
3924 description: hier
3925 default_weight: 3.0
3926 - name: _default
3927 description: fallback
3928 default_weight: 1.0
3929community:
3930 resolution: 1.0
3931 seed: 42
3932"#,
3933 )
3934 .unwrap();
3935 std::fs::write(
3936 pkg.join("types").join("question.yaml"),
3937 r#"name: question
3938description: t
3939when_to_use: tests
3940sections:
3941 - key: answers
3942 heading: Answers argued
3943 required: true
3944 search_weight: 10.0
3945 write_rules: []
3946 - key: notes
3947 heading: Notes
3948 required: false
3949 search_weight: 3.0
3950 catch_all: true
3951 write_rules: []
3952metadata_fields: []
3953title_weight: 100.0
3954text_fields:
3955 - answers
3956 - notes
3957hierarchy_relationship: PART_OF
3958no_self_loop_relationships: []
3959updatable_fields:
3960 - title
3961 - answers
3962 - notes
3963health_required_fields:
3964 - answers
3965staleness_threshold_days: 90
3966write_rules: []
3967"#,
3968 )
3969 .unwrap();
3970
3971 let mem_dir = tmp.path().join("mem");
3972 std::fs::create_dir_all(&mem_dir).unwrap();
3973 std::fs::write(
3977 mem_dir.join("q.md"),
3978 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3979 )
3980 .unwrap();
3981
3982 let writer = FilesystemMemWriter::new(mem_dir.clone());
3983 let mount = Mount {
3984 mem: "debate-mem".to_string(),
3985 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3986 storage: MountStorage::Folder { path: mem_dir },
3987 capability: MountCapability::Write,
3988 lifecycle: MountLifecycle::Eager,
3989 cross_linkable: true,
3990 migration_target: None,
3991 };
3992 let mut engine = Engine::from_mounts_with_schemas_dir(
3993 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3994 Some(&schemas_dir),
3995 )
3996 .expect("a violating sealed schema must keep loading");
3997
3998 let id = crate::EntityId::new("debate-mem", "q");
3999 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
4000 let mut sections = indexmap::IndexMap::new();
4001 sections.insert("answers".to_string(), "Updated answers body.".to_string());
4002 let outcome = engine
4003 .update_entity(
4004 crate::engine::UpdateEntityArgs {
4005 anchors: Vec::new(),
4006 anchors_unset: Vec::new(),
4007 id: id.clone(),
4008 expected_hash: Some(hash),
4009 sections,
4010 append_sections: indexmap::IndexMap::new(),
4011 patch_sections: indexmap::IndexMap::new(),
4012 sections_unset: Vec::new(),
4013 metadata: indexmap::IndexMap::new(),
4014 metadata_unset: Vec::new(),
4015 declare_relations: Vec::new(),
4016 dry_run: false,
4017 relations_unset: Vec::new(),
4018 },
4019 crate::vcs::Actor::Cli,
4020 None,
4021 None,
4022 )
4023 .expect("a write against a sealed-violator mem must NOT be refused");
4024 assert!(!outcome.write_id.is_empty(), "the write commits");
4025 assert!(
4026 outcome
4027 .warnings
4028 .iter()
4029 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
4030 "the write-path divergence warning fires where its condition holds: {:?}",
4031 outcome.warnings
4032 );
4033
4034 assert!(
4036 engine.load_warnings().iter().any(|w| matches!(
4037 w,
4038 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
4039 )),
4040 "the health finding persists across writes"
4041 );
4042 let entity = engine.get_entity(&id).unwrap();
4051 assert!(
4052 entity
4053 .sections
4054 .values()
4055 .any(|s| s.contains("Updated answers body.")),
4056 "written content survives the round-trip (in the catch-all): {:?}",
4057 entity.sections
4058 );
4059 }
4060
4061 #[test]
4068 fn search_mem_filter_gates_against_visible_roster() {
4069 use crate::vcs::Actor;
4070 use crate::workspace_store::WorkspaceStoreAdapter;
4071 use indexmap::IndexMap;
4072
4073 let tmp = TempDir::new().unwrap();
4074 let mem_dir = tmp.path().join("mem");
4075 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4076 std::fs::write(
4077 mem_dir.join(".memstead").join("config.json"),
4078 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4079 )
4080 .unwrap();
4081 let memstead = tmp.path().join(".memstead");
4082 std::fs::create_dir_all(&memstead).unwrap();
4083 std::fs::write(
4084 memstead.join("workspace.toml"),
4085 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4086 )
4087 .unwrap();
4088 crate::FileWorkspaceStore::new()
4089 .save_state(
4090 tmp.path(),
4091 &crate::workspace::Workspace {
4092 mounts: vec![folder_mount("specs", mem_dir.clone())],
4093 settings: crate::workspace::WorkspaceSettings::default(),
4094 },
4095 )
4096 .unwrap();
4097 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4098 let mut sections = IndexMap::new();
4099 sections.insert(
4100 "identity".to_string(),
4101 "Zebra searching fixture.".to_string(),
4102 );
4103 sections.insert("purpose".to_string(), "Search gate test.".to_string());
4104 engine
4105 .create_entity(
4106 crate::CreateEntityArgs {
4107 mem: "specs".to_string(),
4108 title: "Zebra".to_string(),
4109 entity_type: "spec".to_string(),
4110 sections,
4111 metadata: IndexMap::new(),
4112 relations: Vec::new(),
4113 anchors: Vec::new(),
4114 dry_run: false,
4115 },
4116 Actor::Agent,
4117 None,
4118 None,
4119 )
4120 .unwrap();
4121
4122 let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
4123 query: Some(crate::ops::Query {
4124 any: vec![term.to_string()],
4125 ..Default::default()
4126 }),
4127 mem: mem.map(str::to_string),
4128 ..Default::default()
4129 };
4130
4131 let err = engine
4133 .search(&scope(Some("no-such-mem"), "zebra"))
4134 .expect_err("a nonexistent mem filter must refuse");
4135 assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
4136
4137 let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
4139 assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
4140
4141 let none = engine
4144 .search(&scope(Some("specs"), "quixotic-nonword"))
4145 .unwrap();
4146 assert_eq!(none.total, 0, "{none:?}");
4147 }
4148
4149 fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
4154 Mount {
4155 lifecycle: MountLifecycle::Lazy,
4156 ..folder_mount(mem, path)
4157 }
4158 }
4159
4160 fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
4161 std::fs::write(
4162 dir.join(format!("{slug}.md")),
4163 format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
4164 )
4165 .unwrap();
4166 }
4167
4168 fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
4169 let eager_dir = tmp.path().join("eag");
4170 let lazy_dir = tmp.path().join("laz");
4171 std::fs::create_dir_all(&eager_dir).unwrap();
4172 std::fs::create_dir_all(&lazy_dir).unwrap();
4173 write_spec(&eager_dir, "alpha", "Alpha", "");
4174 write_spec(&lazy_dir, "omega", "Omega", "");
4175 (eager_dir, lazy_dir)
4176 }
4177
4178 fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
4179 Engine::from_mounts(vec![
4180 (
4181 folder_mount("eag", eager_dir.to_path_buf()),
4182 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4183 ),
4184 (
4185 lazy_folder_mount("laz", lazy_dir.to_path_buf()),
4186 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4187 ),
4188 ])
4189 .unwrap()
4190 }
4191
4192 #[test]
4198 fn lazy_mount_defers_and_first_read_loads() {
4199 let tmp = TempDir::new().unwrap();
4200 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4201 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4202
4203 assert!(
4205 engine
4206 .get_entity(&crate::EntityId::new("eag", "alpha"))
4207 .is_some()
4208 );
4209 assert_eq!(engine.deferred_mems(), vec!["laz"]);
4210 assert!(engine.mem_is_deferred("laz"));
4211 assert!(
4212 engine
4213 .get_entity(&crate::EntityId::new("laz", "omega"))
4214 .is_none(),
4215 "deferred mem's entities are not in the store yet"
4216 );
4217 assert!(engine.schema_for("laz").is_some());
4219
4220 engine.reload_if_stale(Some("laz"));
4222 assert!(engine.deferred_mems().is_empty());
4223 let omega = engine
4224 .get_entity(&crate::EntityId::new("laz", "omega"))
4225 .expect("first read loads the mem");
4226 assert_eq!(omega.title, "Omega");
4227
4228 let (actor, client) = cli_actor();
4230 engine
4231 .create_entity(
4232 empty_create_args("laz", "Later"),
4233 actor,
4234 Some(&client),
4235 None,
4236 )
4237 .unwrap();
4238 assert!(
4239 engine
4240 .get_entity(&crate::EntityId::new("laz", "later"))
4241 .is_some()
4242 );
4243 }
4244
4245 #[test]
4249 fn scoped_operation_never_loads_lazy_sibling() {
4250 let tmp = TempDir::new().unwrap();
4251 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4252 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4253
4254 engine.reload_if_stale(Some("eag"));
4255 assert_eq!(
4256 engine.deferred_mems(),
4257 vec!["laz"],
4258 "an eager-scoped operation must not load the lazy sibling"
4259 );
4260
4261 engine.reload_if_stale(None);
4262 assert!(
4263 engine.deferred_mems().is_empty(),
4264 "a workspace-scoped pass loads every deferred mem"
4265 );
4266 }
4267
4268 #[test]
4272 fn lazy_load_failure_quarantines_typed() {
4273 let tmp = TempDir::new().unwrap();
4274 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4275 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4276
4277 std::fs::remove_dir_all(&lazy_dir).unwrap();
4281 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4282 engine.reload_if_stale(Some("laz"));
4283
4284 let q = engine
4285 .quarantine_reason("laz")
4286 .expect("failed deferred load quarantines, never serves empty");
4287 assert!(!q.reason_code.is_empty());
4288 assert!(
4289 !engine.mem_names().contains(&"laz"),
4290 "a quarantined mem leaves the serving roster"
4291 );
4292 assert!(engine.deferred_mems().is_empty());
4293 }
4294
4295 #[test]
4303 fn quarantine_entry_invalidates_both_memos() {
4304 let tmp = TempDir::new().unwrap();
4305 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4306 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4307
4308 let _ = engine.communities();
4310 let _ = engine.search_indexes();
4311 assert!(engine.search_indexes_memo.get().is_some());
4312
4313 std::fs::remove_dir_all(&lazy_dir).unwrap();
4315 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4316 engine.reload_if_stale(Some("laz"));
4317 assert!(engine.quarantine_reason("laz").is_some());
4318 assert!(
4319 engine.search_indexes_memo.get().is_none(),
4320 "quarantine entry bumps the schemas epoch — the search memo must clear"
4321 );
4322 assert!(
4323 engine.community_memo.get().is_none(),
4324 "quarantine entry must clear the community memo too"
4325 );
4326 let _ = engine.search_indexes();
4328
4329 let _ = engine.communities();
4331 let _ = engine.search_indexes();
4332 let _ = engine.reload_one_mem("laz");
4333 assert!(engine.quarantine_reason("laz").is_some());
4334 assert!(
4335 engine.search_indexes_memo.get().is_none(),
4336 "a failed reattach bumps the epoch — the search memo must clear"
4337 );
4338 let _ = engine.search_indexes();
4339 }
4340
4341 #[test]
4346 fn quarantine_reattach_invalidates_both_memos() {
4347 let tmp = TempDir::new().unwrap();
4348 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4349 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4350
4351 std::fs::remove_dir_all(&lazy_dir).unwrap();
4353 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4354 engine.reload_if_stale(Some("laz"));
4355 assert!(engine.quarantine_reason("laz").is_some());
4356
4357 let _ = engine.communities();
4359 let _ = engine.search_indexes();
4360 assert!(engine.community_memo.get().is_some());
4361 assert!(engine.search_indexes_memo.get().is_some());
4362
4363 std::fs::remove_file(&lazy_dir).unwrap();
4365 std::fs::create_dir_all(&lazy_dir).unwrap();
4366 write_spec(&lazy_dir, "omega", "Omega", "");
4367 engine
4368 .reload_one_mem("laz")
4369 .expect("reattach succeeds once the backend is back");
4370 assert!(engine.quarantine_reason("laz").is_none());
4371
4372 assert!(
4375 engine.community_memo.get().is_none(),
4376 "reattach must invalidate the community memo"
4377 );
4378 assert!(
4379 engine.search_indexes_memo.get().is_none(),
4380 "reattach must invalidate the search memo"
4381 );
4382 }
4383
4384 #[test]
4389 fn lazy_load_runs_the_same_validation_gauntlet() {
4390 let tmp = TempDir::new().unwrap();
4391 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4392 std::fs::write(
4394 lazy_dir.join("bad.md"),
4395 "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
4396 )
4397 .unwrap();
4398
4399 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4400 let warned_before = engine
4401 .load_warnings()
4402 .iter()
4403 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
4404 engine.reload_if_stale(Some("laz"));
4405 let warned_after = engine
4406 .load_warnings()
4407 .iter()
4408 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
4409 assert!(
4410 !warned_before && warned_after,
4411 "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
4412 warnings: {:?}",
4413 engine.load_warnings()
4414 );
4415 let bad = engine
4417 .get_entity(&crate::EntityId::new("laz", "bad"))
4418 .unwrap();
4419 assert!(bad.relationships.is_empty());
4420 }
4421
4422 #[test]
4430 fn delete_guard_sees_referrers_in_deferred_mems() {
4431 use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
4432
4433 let tmp = TempDir::new().unwrap();
4434 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4435 let (actor, client) = cli_actor();
4436
4437 {
4441 let mut authoring = Engine::from_mounts(vec![
4442 (
4443 folder_mount("eag", eager_dir.to_path_buf()),
4444 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
4445 as Box<dyn MemBackend>,
4446 ),
4447 (
4448 folder_mount("laz", lazy_dir.to_path_buf()),
4449 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
4450 as Box<dyn MemBackend>,
4451 ),
4452 ])
4453 .unwrap();
4454 let mut settings = crate::workspace::WorkspaceSettings::default();
4455 settings.cross_mem_links.insert(
4456 "laz".to_string(),
4457 memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
4458 );
4459 authoring.set_settings(settings);
4460 authoring
4461 .relate_entity(
4462 RelateEntityArgs {
4463 source: crate::EntityId::new("laz", "omega"),
4464 expected_hash: None,
4465 rel_type: "USES".to_string(),
4466 target: crate::EntityId::new("eag", "alpha"),
4467 remove: false,
4468 description: None,
4469 dry_run: false,
4470 },
4471 actor,
4472 Some(&client),
4473 None,
4474 )
4475 .expect("cross-mem relate lands under the grant");
4476 }
4477
4478 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4482 assert!(engine.mem_is_deferred("laz"));
4483 let err = engine
4484 .delete_entity(
4485 DeleteEntityArgs {
4486 id: crate::EntityId::new("eag", "alpha"),
4487 expected_hash: None,
4488 },
4489 actor,
4490 Some(&client),
4491 None,
4492 )
4493 .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
4494 assert!(
4495 matches!(err, EngineError::HasIncomingRefs { .. }),
4496 "expected HAS_INCOMING_REFS, got {err:?}"
4497 );
4498 assert!(
4499 engine
4500 .get_entity(&crate::EntityId::new("eag", "alpha"))
4501 .is_some(),
4502 "the entity survives"
4503 );
4504 }
4505
4506 #[test]
4517 fn acyclicity_guard_sees_edges_in_deferred_mems() {
4518 use crate::engine::RelateEntityArgs;
4519 use memstead_schema::workspace_config::CrossLinkValue;
4520
4521 let tmp = TempDir::new().unwrap();
4522 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4523 .iter()
4524 .map(|m| {
4525 let d = tmp.path().join(m);
4526 std::fs::create_dir_all(&d).unwrap();
4527 d
4528 })
4529 .collect();
4530 write_spec(&dirs[0], "node", "Node A", "");
4531 write_spec(&dirs[1], "node", "Node B", "");
4532 write_spec(&dirs[2], "node", "Node C", "");
4533
4534 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4535 ["ma", "mb", "mc"]
4536 .iter()
4537 .zip(dirs.iter())
4538 .map(|(m, d)| {
4539 let mut mount = folder_mount(m, d.clone());
4540 if lazy_mid && *m == "ma" {
4541 mount.lifecycle = MountLifecycle::Lazy;
4542 }
4543 (
4544 mount,
4545 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4546 )
4547 })
4548 .collect()
4549 };
4550 let grants = || {
4551 let mut settings = crate::workspace::WorkspaceSettings::default();
4552 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4553 settings
4554 .cross_mem_links
4555 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4556 }
4557 settings
4558 };
4559 let relate = |engine: &mut Engine, from: &str, to: &str| {
4560 let (actor, client) = cli_actor();
4561 engine.relate_entity(
4562 RelateEntityArgs {
4563 source: crate::EntityId::new(from, "node"),
4564 expected_hash: None,
4565 rel_type: "DEPENDS_ON".to_string(),
4566 target: crate::EntityId::new(to, "node"),
4567 remove: false,
4568 description: None,
4569 dry_run: false,
4570 },
4571 actor,
4572 Some(&client),
4573 None,
4574 )
4575 };
4576
4577 {
4579 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4580 authoring.set_settings(grants());
4581 relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
4582 relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
4583 }
4584
4585 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4588 engine.set_settings(grants());
4589 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4590 let err = relate(&mut engine, "mc", "mb")
4591 .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
4592 assert!(
4593 matches!(err, EngineError::RelationshipCycle { .. }),
4594 "expected RELATIONSHIP_CYCLE, got {err:?}"
4595 );
4596 }
4597
4598 #[test]
4606 fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
4607 use crate::engine::RelateEntityArgs;
4608 use memstead_schema::workspace_config::CrossLinkValue;
4609
4610 let tmp = TempDir::new().unwrap();
4611 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4612 .iter()
4613 .map(|m| {
4614 let d = tmp.path().join(m);
4615 std::fs::create_dir_all(&d).unwrap();
4616 d
4617 })
4618 .collect();
4619 write_spec(&dirs[0], "node", "Node A", "");
4620 write_spec(&dirs[1], "node", "Node B", "");
4621 write_spec(&dirs[2], "node", "Node C", "");
4622 write_spec(&dirs[2], "node2", "Node C2", "");
4627
4628 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4629 ["ma", "mb", "mc"]
4630 .iter()
4631 .zip(dirs.iter())
4632 .map(|(m, d)| {
4633 let mut mount = folder_mount(m, d.clone());
4634 if lazy_mid && *m == "ma" {
4635 mount.lifecycle = MountLifecycle::Lazy;
4636 }
4637 (
4638 mount,
4639 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4640 )
4641 })
4642 .collect()
4643 };
4644 let grants = || {
4645 let mut settings = crate::workspace::WorkspaceSettings::default();
4646 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4647 settings
4648 .cross_mem_links
4649 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4650 }
4651 settings
4652 };
4653 let relate_args = |from: &str, to: &str| RelateEntityArgs {
4654 source: crate::EntityId::new(from, "node"),
4655 expected_hash: None,
4656 rel_type: "DEPENDS_ON".to_string(),
4657 target: crate::EntityId::new(to, "node"),
4658 remove: false,
4659 description: None,
4660 dry_run: false,
4661 };
4662
4663 {
4665 let (actor, client) = cli_actor();
4666 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4667 authoring.set_settings(grants());
4668 authoring
4669 .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4670 .expect("mb→ma lands");
4671 let (actor2, client2) = cli_actor();
4672 authoring
4673 .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4674 .expect("ma→mc lands");
4675 }
4676
4677 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4681 engine.set_settings(grants());
4682 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4683 let (actor, client) = cli_actor();
4684 let result = engine
4685 .batch_relate(
4686 vec![
4687 (relate_args("mc", "mb"), None),
4688 (
4694 RelateEntityArgs {
4695 source: crate::EntityId::new("mc", "node2"),
4696 expected_hash: None,
4697 rel_type: "DEPENDS_ON".to_string(),
4698 target: crate::EntityId::new("mc", "node"),
4699 remove: false,
4700 description: None,
4701 dry_run: false,
4702 },
4703 None,
4704 ),
4705 ],
4706 actor,
4707 Some(&client),
4708 false,
4709 )
4710 .expect("the batch call itself returns a report-all envelope");
4711 assert!(
4712 !result.applied,
4713 "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4714 result.succeeded
4715 );
4716 }
4717
4718 #[test]
4726 fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4727 use crate::engine::RelateEntityArgs;
4728 use memstead_schema::workspace_config::CrossLinkValue;
4729
4730 let tmp = TempDir::new().unwrap();
4731 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4732 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4733 let mut settings = crate::workspace::WorkspaceSettings::default();
4734 settings.cross_mem_links.insert(
4735 "eag".to_string(),
4736 CrossLinkValue::List(vec!["laz".to_string()]),
4737 );
4738 engine.set_settings(settings);
4739
4740 let relate = |engine: &mut Engine, to: &str| {
4741 let (actor, client) = cli_actor();
4742 engine.relate_entity(
4743 RelateEntityArgs {
4744 source: crate::EntityId::new("eag", "alpha"),
4745 expected_hash: None,
4746 rel_type: "SUPPORTS".to_string(),
4747 target: crate::EntityId::new("laz", to),
4748 remove: false,
4749 description: None,
4750 dry_run: false,
4751 },
4752 actor,
4753 Some(&client),
4754 None,
4755 )
4756 };
4757
4758 let outcome = relate(&mut engine, "omega").expect("verified target admits");
4760 assert!(
4761 engine.mem_is_deferred("laz"),
4762 "verification never loads the mem"
4763 );
4764 assert!(
4765 !outcome
4766 .warnings
4767 .iter()
4768 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4769 "a storage-verified target is not an auto-stub case: {:?}",
4770 outcome.warnings
4771 );
4772 let stub = engine
4773 .store()
4774 .get(&crate::EntityId::new("laz", "omega"))
4775 .expect("until-load stub present");
4776 assert!(stub.stub);
4777 assert_eq!(
4778 stub.stub_kind,
4779 Some(crate::entity::StubKind::LoadTime),
4780 "verified-in-storage stub carries the load-time kind"
4781 );
4782
4783 let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4785 assert!(engine.mem_is_deferred("laz"), "still no load");
4786 assert!(
4787 outcome
4788 .warnings
4789 .iter()
4790 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4791 "absent target keeps the auto-stub warning: {:?}",
4792 outcome.warnings
4793 );
4794 let stub = engine
4795 .store()
4796 .get(&crate::EntityId::new("laz", "missing"))
4797 .expect("forward-reference stub present");
4798 assert_eq!(
4799 stub.stub_kind,
4800 Some(crate::entity::StubKind::ForwardReference)
4801 );
4802 }
4803
4804 #[test]
4810 fn readonly_deferred_target_answers_from_storage() {
4811 use crate::engine::RelateEntityArgs;
4812 use memstead_schema::workspace_config::CrossLinkValue;
4813
4814 let tmp = TempDir::new().unwrap();
4815 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4816 let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4817 ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4818 let mut engine = Engine::from_mounts(vec![
4819 (
4820 folder_mount("eag", eager_dir.to_path_buf()),
4821 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4822 ),
4823 (
4824 ro_mount,
4825 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4826 ),
4827 ])
4828 .unwrap();
4829 let mut settings = crate::workspace::WorkspaceSettings::default();
4830 settings.cross_mem_links.insert(
4831 "eag".to_string(),
4832 CrossLinkValue::List(vec!["laz".to_string()]),
4833 );
4834 engine.set_settings(settings);
4835
4836 let relate = |engine: &mut Engine, to: &str| {
4837 let (actor, client) = cli_actor();
4838 engine.relate_entity(
4839 RelateEntityArgs {
4840 source: crate::EntityId::new("eag", "alpha"),
4841 expected_hash: None,
4842 rel_type: "SUPPORTS".to_string(),
4843 target: crate::EntityId::new("laz", to),
4844 remove: false,
4845 description: None,
4846 dry_run: false,
4847 },
4848 actor,
4849 Some(&client),
4850 None,
4851 )
4852 };
4853
4854 relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4855 assert!(
4856 engine.mem_is_deferred("laz"),
4857 "the admit never loads the mem"
4858 );
4859
4860 let err =
4861 relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4862 assert!(
4863 matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4864 "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4865 );
4866 assert!(
4867 engine.mem_is_deferred("laz"),
4868 "the refusal never loads the mem either"
4869 );
4870 }
4871
4872 #[test]
4877 fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4878 let tmp = TempDir::new().unwrap();
4879 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4880 write_spec(
4881 &eager_dir,
4882 "linker",
4883 "Linker",
4884 "\nSee [[laz--omega]] for detail.\n",
4885 );
4886
4887 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4888 let target = crate::EntityId::new("laz", "omega");
4889 assert!(
4890 engine.get_entity(&target).is_none_or(|e| e.stub),
4891 "before the lazy load the cross-mem target is at most a stub, never real"
4892 );
4893
4894 engine.reload_if_stale(Some("laz"));
4895 let resolved = engine.get_entity(&target).expect("target loaded");
4896 assert!(!resolved.stub, "after the load the target is real");
4897 assert!(
4898 !engine.load_warnings().iter().any(
4899 |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4900 ),
4901 "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4902 engine.load_warnings()
4903 );
4904 }
4905
4906 fn roster_change(warnings: &[WarningHint]) -> Option<(Vec<String>, Vec<String>)> {
4910 warnings.iter().find_map(|w| match w {
4911 WarningHint::MemRosterChanged { added, removed, .. } => {
4912 Some((added.clone(), removed.clone()))
4913 }
4914 _ => None,
4915 })
4916 }
4917
4918 fn search_scope(mem: Option<&str>, term: &str) -> crate::ops::SearchScope {
4919 crate::ops::SearchScope {
4920 query: Some(crate::ops::Query {
4921 any: vec![term.to_string()],
4922 ..Default::default()
4923 }),
4924 mem: mem.map(str::to_string),
4925 ..Default::default()
4926 }
4927 }
4928
4929 fn roster_fixture() -> (TempDir, Engine) {
4930 use crate::workspace_store::WorkspaceStoreAdapter;
4931 let tmp = TempDir::new().unwrap();
4932 let root = tmp.path();
4933 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4934 std::fs::write(
4935 root.join(".memstead").join("workspace.toml"),
4936 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4937 )
4938 .unwrap();
4939 for mem in ["alpha", "beta"] {
4940 let dir = root.join(mem);
4941 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
4942 std::fs::write(
4943 dir.join(".memstead").join("config.json"),
4944 br#"{"format":1,"schema":"default@1.0.0"}"#,
4945 )
4946 .unwrap();
4947 }
4948 std::fs::write(
4949 root.join("alpha").join("one.md"),
4950 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# One\n\n## Identity\n\nalpha one\n\n## Purpose\n\nseed\n",
4951 )
4952 .unwrap();
4953 std::fs::write(
4954 root.join("beta").join("two.md"),
4955 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Two\n\n## Identity\n\nbeta two\n\n## Purpose\n\nseed\n\n## Relationships\n\n- **DEPENDS_ON**: [[alpha--one]]\n",
4956 )
4957 .unwrap();
4958 let mount = |mem: &str| Mount {
4959 mem: mem.to_string(),
4960 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
4961 storage: MountStorage::Folder {
4962 path: root.join(mem),
4963 },
4964 capability: MountCapability::Write,
4965 lifecycle: MountLifecycle::Eager,
4966 cross_linkable: true,
4967 migration_target: None,
4968 };
4969 crate::FileWorkspaceStore::new()
4970 .save_state(
4971 root,
4972 &crate::workspace::Workspace {
4973 mounts: vec![mount("alpha"), mount("beta")],
4974 settings: crate::workspace::WorkspaceSettings::default(),
4975 },
4976 )
4977 .unwrap();
4978 let engine = Engine::from_workspace_root(root).expect("workspace boots");
4979 (tmp, engine)
4980 }
4981
4982 fn save_roster(root: &Path, mems: &[(&str, &Path)]) {
4983 let with_schema: Vec<(&str, &Path, &str)> =
4984 mems.iter().map(|(m, d)| (*m, *d, "default")).collect();
4985 save_roster_pinned(root, &with_schema);
4986 }
4987
4988 fn save_roster_pinned(root: &Path, mems: &[(&str, &Path, &str)]) {
4989 use crate::workspace_store::WorkspaceStoreAdapter;
4990 let mounts = mems
4991 .iter()
4992 .map(|(mem, dir, schema)| Mount {
4993 mem: mem.to_string(),
4994 schema: Some(SchemaRef::new(*schema, semver::Version::new(1, 0, 0))),
4995 storage: MountStorage::Folder {
4996 path: dir.to_path_buf(),
4997 },
4998 capability: MountCapability::Write,
4999 lifecycle: MountLifecycle::Eager,
5000 cross_linkable: true,
5001 migration_target: None,
5002 })
5003 .collect();
5004 crate::FileWorkspaceStore::new()
5007 .save_state(
5008 root,
5009 &crate::workspace::Workspace {
5010 mounts,
5011 settings: crate::workspace::WorkspaceSettings::default(),
5012 },
5013 )
5014 .unwrap();
5015 }
5016
5017 #[test]
5022 fn roster_membership_follows_reload_before_operation() {
5023 let (tmp, mut engine) = roster_fixture();
5024 let root = tmp.path();
5025 assert_eq!(engine.mem_names(), vec!["alpha", "beta"]);
5026 assert!(
5027 engine.reload_if_stale(None).is_empty(),
5028 "no change, no warning"
5029 );
5030
5031 save_roster(root, &[("alpha", &root.join("alpha"))]);
5033 let warnings = engine.reload_if_stale(None);
5034 let (added, removed) = roster_change(&warnings).expect("the roster change is reported");
5035 assert_eq!(removed, vec!["beta".to_string()]);
5036 assert!(added.is_empty());
5037 assert_eq!(engine.mem_names(), vec!["alpha"]);
5038 assert!(
5039 engine
5040 .get_entity(&crate::EntityId("beta--two".into()))
5041 .is_none()
5042 );
5043 let unscoped = engine.search(&search_scope(None, "beta two")).unwrap();
5044 assert_eq!(
5045 unscoped.total, 0,
5046 "no hit from the removed mem: {unscoped:?}"
5047 );
5048 let scoped = engine
5049 .search(&search_scope(Some("beta"), "beta two"))
5050 .expect_err("a search scoped to the removed mem refuses");
5051 assert_eq!(scoped.code(), "MEM_UNMOUNTED", "{scoped}");
5052 let err = engine.unknown_mem_error("beta");
5053 assert_eq!(err.code(), "MEM_UNMOUNTED", "{err}");
5054 assert!(
5055 engine.reload_if_stale(None).is_empty(),
5056 "reconciled once, quiet after"
5057 );
5058
5059 save_roster(
5061 root,
5062 &[("alpha", &root.join("alpha")), ("beta", &root.join("beta"))],
5063 );
5064 let warnings = engine.reload_if_stale(None);
5065 let (added, _) = roster_change(&warnings).expect("the return is reported");
5066 assert_eq!(added, vec!["beta".to_string()]);
5067 assert_eq!(engine.mem_names(), vec!["alpha", "beta"]);
5068 assert!(
5069 engine
5070 .get_entity(&crate::EntityId("beta--two".into()))
5071 .is_some()
5072 );
5073 assert_eq!(
5074 engine.unknown_mem_error("beta").code(),
5075 "UNKNOWN_MEM",
5076 "the memory clears"
5077 );
5078 }
5079
5080 #[test]
5084 fn roster_entry_with_missing_storage_quarantines_like_boot() {
5085 let (tmp, mut engine) = roster_fixture();
5086 let root = tmp.path();
5087 std::fs::create_dir_all(root.join("ghost").join(".memstead")).unwrap();
5092 std::fs::write(
5093 root.join("ghost").join(".memstead").join("config.json"),
5094 br#"{"format":1,"schema":"nope@1.0.0"}"#,
5095 )
5096 .unwrap();
5097 save_roster_pinned(
5098 root,
5099 &[
5100 ("alpha", &root.join("alpha"), "default"),
5101 ("beta", &root.join("beta"), "default"),
5102 ("ghost", &root.join("ghost"), "nope"),
5103 ("hollow", &root.join("does-not-exist"), "default"),
5104 ],
5105 );
5106 let warnings = engine.reload_if_stale(None);
5107 let quarantined = warnings
5108 .iter()
5109 .find_map(|w| match w {
5110 WarningHint::MemRosterChanged { quarantined, .. } => Some(quarantined.clone()),
5111 _ => None,
5112 })
5113 .expect("reported");
5114 assert_eq!(quarantined, vec!["ghost".to_string(), "hollow".to_string()]);
5115 let q = engine
5116 .quarantine_reason("ghost")
5117 .expect("on the quarantine roster");
5118 let booted = Engine::from_workspace_root(root).unwrap();
5120 let at_boot = booted
5121 .quarantine_reason("ghost")
5122 .expect("boot quarantines it too");
5123 assert_eq!(q.reason_code, at_boot.reason_code);
5124 assert_eq!(q.reason_code, "SCHEMA_NOT_FOUND");
5125 for name in ["ghost", "hollow"] {
5128 assert_eq!(
5129 engine
5130 .quarantine_reason(name)
5131 .map(|q| q.reason_code.clone()),
5132 booted
5133 .quarantine_reason(name)
5134 .map(|q| q.reason_code.clone()),
5135 "{name}: quarantine parity with boot"
5136 );
5137 assert_eq!(
5138 engine.mem_names().contains(&name),
5139 booted.mem_names().contains(&name),
5140 "{name}: membership parity with boot (reconciled {:?}, boot {:?})",
5141 engine.mem_names(),
5142 booted.mem_names()
5143 );
5144 }
5145 assert!(engine.mem_names().contains(&"alpha") && engine.mem_names().contains(&"beta"));
5146 assert!(
5147 engine
5148 .get_entity(&crate::EntityId("alpha--one".into()))
5149 .is_some()
5150 );
5151 assert_eq!(engine.unknown_mem_error("ghost").code(), "MEM_QUARANTINED");
5152 }
5153
5154 #[test]
5160 fn unmount_is_atomic_and_cross_mem_edges_go_dangling() {
5161 let (tmp, mut engine) = roster_fixture();
5162 let root = tmp.path();
5163 save_roster(root, &[("beta", &root.join("beta"))]);
5165 let _ = engine.reload_if_stale(None);
5166 assert_eq!(engine.mem_names(), vec!["beta"]);
5167 let findings = engine.consistency_findings("beta").unwrap();
5168 assert!(
5169 findings
5170 .iter()
5171 .any(|f| f.code == "DANGLING_RELATION_TARGET_MISSING" && f.id == "beta--two"),
5172 "the edge into the unmounted mem is dangling: {findings:?}"
5173 );
5174 let hits = engine.search(&search_scope(None, "alpha one")).unwrap();
5175 assert_eq!(hits.total, 0, "{hits:?}");
5176 let communities = engine.communities();
5178 assert!(
5179 communities
5180 .entity_cluster_map
5181 .keys()
5182 .all(|id| !id.starts_with("alpha--")),
5183 "the community partition carries no entity of the removed mem"
5184 );
5185 assert!(
5186 communities
5187 .clusters
5188 .values()
5189 .all(|c| c.entities.iter().all(|e| !e.starts_with("alpha--")))
5190 );
5191 assert!(engine.store().all_entities().all(|e| e.mem != "alpha"));
5192
5193 let start = std::time::Instant::now();
5196 for _ in 0..100 {
5197 let _ = engine.reload_if_stale(None);
5198 }
5199 let with_roster = start.elapsed();
5200 let start = std::time::Instant::now();
5201 for _ in 0..100 {
5202 let _ = engine.reload_if_stale_content_only(None);
5203 }
5204 let content_only = start.elapsed();
5205 eprintln!(
5206 "BENCH roster probe: 100 ops with roster {with_roster:?}, content-only {content_only:?}"
5207 );
5208 assert!(
5209 with_roster < content_only * 4 + std::time::Duration::from_millis(50),
5210 "roster probe left the branch-tip band: {with_roster:?} vs {content_only:?}"
5211 );
5212 }
5213
5214 #[test]
5218 fn failed_unmount_leaves_the_roster_unapplied_and_the_mem_served() {
5219 let (tmp, mut engine) = roster_fixture();
5220 let root = tmp.path();
5221 engine.inject_unmount_failure = Some("beta".to_string());
5222 save_roster(root, &[("alpha", &root.join("alpha"))]);
5223 let warnings = engine.reload_if_stale(None);
5224 let (removed, failures) = warnings
5225 .iter()
5226 .find_map(|w| match w {
5227 WarningHint::MemRosterChanged {
5228 removed, failures, ..
5229 } => Some((removed.clone(), failures.clone())),
5230 _ => None,
5231 })
5232 .expect("reported");
5233 assert!(removed.is_empty());
5234 assert!(
5235 failures
5236 .iter()
5237 .any(|f| f.contains("unmount:beta") && f.contains("injected")),
5238 "{failures:?}"
5239 );
5240 assert_eq!(
5241 engine.mem_names(),
5242 vec!["alpha", "beta"],
5243 "beta still served"
5244 );
5245 assert!(
5246 engine
5247 .get_entity(&crate::EntityId("beta--two".into()))
5248 .is_some()
5249 );
5250 assert_eq!(
5251 engine.unknown_mem_error("beta").code(),
5252 "UNKNOWN_MEM",
5253 "not remembered as unmounted"
5254 );
5255 engine.inject_unmount_failure = None;
5258 let warnings = engine.reload_if_stale(None);
5259 let (_, removed) = roster_change(&warnings).expect("retried");
5260 assert_eq!(removed, vec!["beta".to_string()]);
5261 assert_eq!(engine.mem_names(), vec!["alpha"]);
5262 }
5263}