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 mutation_clock: Arc::new(std::time::SystemTime::now),
580 current_role: crate::vcs::Role::Unspecified,
581 current_identity: None,
582 })
583 }
584
585 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
603 use crate::workspace_store::{
604 FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
605 instantiate_lean_backend,
606 };
607
608 let workspace = match detect_layout(workspace_root) {
609 Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
613 Some(ws) => ws,
614 None => {
615 return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
616 }
617 },
618 Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
619 };
620
621 let settings = workspace.settings.clone();
622 let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
623 Vec::with_capacity(workspace.mounts.len());
624 let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
628 for mount in workspace.mounts {
629 match instantiate_lean_backend(&mount) {
630 Ok(backend) => mounts.push((mount, backend)),
631 Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
632 reason_code: e.code().to_string(),
633 reason_message: e.to_string(),
634 mount,
635 }),
636 }
637 }
638 let fixed_dir = workspace_root.join(".memstead").join("schemas");
647 let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
648 let mut engine = Engine::from_mounts_inner(mounts, local, failed)
651 .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
652 engine.quarantined.extend(instantiate_quarantine);
653 engine.set_settings(settings);
654 engine.workspace_root = Some(workspace_root.to_path_buf());
655 engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
666 workspace_root,
667 )?);
668 Ok(engine)
679 }
680}
681
682pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
715 let mut router = MemRouterSnapshot::new();
716 for m in mounts {
717 match m.mount.capability {
718 MountCapability::Write => {
719 let dir: Option<PathBuf> = match &m.mount.storage {
720 MountStorage::Folder { path } => Some(path.clone()),
721 MountStorage::GitBranch { .. } => None,
722 MountStorage::Archive { .. } => None,
723 MountStorage::InMemory => None,
727 };
728 router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
729 }
730 MountCapability::ReadOnly => match &m.mount.storage {
731 MountStorage::Archive { path } => {
732 router.add_read_only(m.mount.mem.clone(), path.clone());
733 }
734 MountStorage::Folder { path } => {
735 router.add_read_only(m.mount.mem.clone(), path.clone());
736 }
737 MountStorage::GitBranch { gitdir, .. } => {
738 router.add_read_only(m.mount.mem.clone(), gitdir.clone());
739 }
740 MountStorage::InMemory => {}
746 },
747 }
748 }
749 router
750}
751
752pub fn resolve_builtin_schema_pin_pub(
757 pin: &memstead_schema::SchemaRef,
758 catalogue: &[Arc<memstead_schema::Schema>],
759) -> Option<Arc<memstead_schema::Schema>> {
760 resolve_builtin_schema_pin(pin, catalogue)
761}
762
763fn newest_builtin_version<'a>(
768 name: &str,
769 builtins: &'a [Arc<memstead_schema::Schema>],
770) -> Option<&'a semver::Version> {
771 builtins
772 .iter()
773 .filter(|s| s.manifest.name == name)
774 .map(|s| &s.version)
775 .max()
776}
777
778pub struct SchemaResolver<'a> {
793 catalogue: &'a [Arc<memstead_schema::Schema>],
794}
795
796impl<'a> SchemaResolver<'a> {
797 pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
800 Self { catalogue }
801 }
802
803 pub fn resolve(
807 &self,
808 pin: &memstead_schema::SchemaRef,
809 ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
810 {
811 resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
812 crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
813 &pin.name,
814 &pin.version,
815 self.catalogue,
816 )
817 })
818 }
819}
820
821pub fn load_workspace_schemas(
829 schemas_dir: Option<&Path>,
830) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
831 Ok(load_workspace_schemas_with_failures(schemas_dir).0)
832}
833
834#[derive(Debug, Clone)]
841pub struct FailedSchemaPackage {
842 pub path: PathBuf,
843 pub name: Option<String>,
844 pub version: Option<String>,
845 pub error: String,
847}
848
849pub fn load_workspace_schemas_with_failures(
855 schemas_dir: Option<&Path>,
856) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
857 let Some(dir) = schemas_dir else {
858 return (Vec::new(), Vec::new());
859 };
860 if !dir.is_dir() {
861 return (Vec::new(), Vec::new());
862 }
863 let entries = match std::fs::read_dir(dir) {
864 Ok(e) => e,
865 Err(_) => return (Vec::new(), Vec::new()),
866 };
867 let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
868 let mut failures: Vec<FailedSchemaPackage> = Vec::new();
869 for entry in entries.flatten() {
870 let path = entry.path();
871 if !path.is_dir() {
872 continue;
873 }
874 if !path.join("schema.yaml").is_file() {
875 continue;
876 }
877 match memstead_schema::load_schema_from_dir(&path) {
878 Ok(schema) => schemas.push(Arc::new(schema)),
879 Err(e) => {
880 let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
884 let peek = |k: &str| {
885 header
886 .lines()
887 .find_map(|l| l.strip_prefix(&format!("{k}:")))
888 .map(|v| v.trim().trim_matches('"').to_string())
889 .filter(|v| !v.is_empty())
890 };
891 failures.push(FailedSchemaPackage {
892 path: path.clone(),
893 name: peek("name"),
894 version: peek("version"),
895 error: e.to_string(),
896 });
897 }
898 }
899 }
900 (schemas, failures)
901}
902
903pub(super) fn resolve_builtin_schema_pin(
904 pin: &memstead_schema::SchemaRef,
905 catalogue: &[Arc<memstead_schema::Schema>],
906) -> Option<Arc<memstead_schema::Schema>> {
907 catalogue
908 .iter()
909 .find(|s| {
910 let id = s.id();
911 id.0 == pin.name && id.1 == pin.version
912 })
913 .cloned()
914}
915
916pub(super) fn unbacked_mount_warning(
924 mount: &crate::workspace::Mount,
925 backend: &dyn MemBackend,
926 entity_count: Option<usize>,
927) -> Option<WarningHint> {
928 use crate::ops::MountUnbackedReason;
929 use crate::workspace::MountStorage;
930 let (location, missing_reason) = match &mount.storage {
931 MountStorage::GitBranch { branch, .. } => (branch.clone(), MountUnbackedReason::MissingRef),
932 MountStorage::Folder { path } => {
933 (path.display().to_string(), MountUnbackedReason::MissingPath)
934 }
935 MountStorage::Archive { path } => {
936 (path.display().to_string(), MountUnbackedReason::MissingPath)
937 }
938 MountStorage::InMemory => return None,
939 };
940 if !backend.storage_present().unwrap_or(true) {
941 return Some(WarningHint::MountUnbacked {
942 mem: mount.mem.clone(),
943 reason: missing_reason,
944 location,
945 });
946 }
947 if entity_count == Some(0) {
948 return Some(WarningHint::MountUnbacked {
949 mem: mount.mem.clone(),
950 reason: MountUnbackedReason::Empty,
951 location,
952 });
953 }
954 None
955}
956
957pub(super) fn collect_source_entries(
958 backend: &dyn MemBackend,
959) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
960 let paths = backend.list_entities()?;
961 let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
962 let mut errors: Vec<SourceReadError> = Vec::new();
963 for path in paths {
964 match backend.read_entity(&path) {
965 Ok(Some(bytes)) => match String::from_utf8(bytes) {
966 Ok(content) => entries.push(SourceEntry {
967 relative_path: path.to_string_lossy().into_owned(),
968 source_path: path.clone(),
969 content,
970 }),
971 Err(e) => errors.push(SourceReadError {
972 source_path: path,
973 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
974 }),
975 },
976 Ok(None) => {
977 }
979 Err(e) => errors.push(SourceReadError {
980 source_path: path,
981 error: std::io::Error::other(e.to_string()),
982 }),
983 }
984 }
985 Ok((entries, errors))
986}
987
988#[cfg(test)]
989mod tests {
990
991 use std::path::Path;
992
993 use memstead_schema::SchemaRef;
994 use tempfile::TempDir;
995
996 use crate::backend::MemBackend;
997 use crate::engine::test_helpers::*;
998 use crate::engine::{Engine, EngineError};
999 use crate::ops::WarningHint;
1000 use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1001 use crate::vcs::CommitContext;
1002 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1003
1004 #[test]
1009 fn unbacked_mount_probe_classes_missing_path_empty_and_present() {
1010 use crate::engine::boot::unbacked_mount_warning;
1011 use crate::ops::MountUnbackedReason;
1012 let tmp = TempDir::new().unwrap();
1013 let mount = |path: std::path::PathBuf| Mount {
1014 mem: "probe".into(),
1015 schema: None,
1016 storage: MountStorage::Folder { path },
1017 capability: MountCapability::Write,
1018 lifecycle: MountLifecycle::Eager,
1019 cross_linkable: true,
1020 migration_target: None,
1021 };
1022
1023 let gone = tmp.path().join("gone");
1024 let backend = FilesystemMemWriter::new(gone.clone());
1025 let w = unbacked_mount_warning(&mount(gone.clone()), &backend, Some(0))
1026 .expect("a missing folder is unbacked");
1027 match &w {
1028 WarningHint::MountUnbacked {
1029 mem,
1030 reason,
1031 location,
1032 } => {
1033 assert_eq!(mem, "probe");
1034 assert_eq!(*reason, MountUnbackedReason::MissingPath);
1035 assert_eq!(location, &gone.display().to_string());
1036 }
1037 other => panic!("unexpected variant: {other:?}"),
1038 }
1039 assert_eq!(w.code(), "MOUNT_UNBACKED");
1040 let json = serde_json::to_value(&w).unwrap();
1041 assert_eq!(json["details"]["reason"], "missing_path");
1042 assert!(unbacked_mount_warning(&mount(gone), &backend, None).is_some());
1044
1045 let hollow = tmp.path().join("hollow");
1046 std::fs::create_dir_all(&hollow).unwrap();
1047 let backend = FilesystemMemWriter::new(hollow.clone());
1048 let w = unbacked_mount_warning(&mount(hollow.clone()), &backend, Some(0))
1049 .expect("an entity-less folder is unbacked");
1050 assert_eq!(
1051 serde_json::to_value(&w).unwrap()["details"]["reason"],
1052 "empty"
1053 );
1054 assert!(unbacked_mount_warning(&mount(hollow.clone()), &backend, None).is_none());
1056 assert!(unbacked_mount_warning(&mount(hollow), &backend, Some(1)).is_none());
1058 }
1059
1060 #[test]
1064 fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
1065 let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
1066 let resolver = super::SchemaResolver::new(&catalogue);
1067
1068 let ok: SchemaRef = "default@1.0.0".parse().unwrap();
1069 assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
1070
1071 let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
1072 let sources = resolver.resolve(&miss).unwrap_err();
1073 let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1074 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1075 assert!(sources.iter().all(|s| !s.pinned_version_match));
1076 }
1077
1078 #[test]
1079 fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
1080 let engine = Engine::from_mounts(Vec::new()).unwrap();
1081 assert!(engine.mem_names().is_empty());
1082 match engine.list_entities("missing") {
1083 Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
1084 other => panic!("expected UnknownMem, got {other:?}"),
1085 }
1086 }
1087
1088 #[test]
1089 fn duplicate_mem_names_rejected_at_construction() {
1090 let tmp = TempDir::new().unwrap();
1091 let writer1: Box<dyn MemBackend> =
1092 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1093 let writer2: Box<dyn MemBackend> =
1094 Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1095 let err = Engine::from_mounts(vec![
1096 (folder_mount("specs", tmp.path().to_path_buf()), writer1),
1097 (folder_mount("specs", tmp.path().to_path_buf()), writer2),
1098 ])
1099 .unwrap_err();
1100 assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
1101 }
1102
1103 #[test]
1104 fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
1105 let tmp = TempDir::new().unwrap();
1111 let mem_dir = tmp.path().to_path_buf();
1112 let body =
1113 "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1114 std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1115
1116 let writer = FilesystemMemWriter::new(mem_dir.clone());
1117 let engine = Engine::from_mounts(vec![(
1118 folder_mount("specs", mem_dir),
1119 Box::new(writer) as Box<dyn MemBackend>,
1120 )])
1121 .unwrap();
1122
1123 let warnings = engine.load_warnings();
1124 assert!(
1125 warnings
1126 .iter()
1127 .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1128 "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
1129 );
1130 }
1131
1132 #[test]
1140 fn generation_behind_hint_fires_for_old_builtin_pin_only() {
1141 let tmp = TempDir::new().unwrap();
1143 let mem_dir = tmp.path().to_path_buf();
1144 let writer = FilesystemMemWriter::new(mem_dir.clone());
1145 let mut engine = Engine::from_mounts(vec![(
1146 folder_mount("specs", mem_dir),
1147 Box::new(writer) as Box<dyn MemBackend>,
1148 )])
1149 .unwrap();
1150 let behind: Vec<_> = engine
1151 .load_warnings()
1152 .iter()
1153 .filter_map(|w| match w {
1154 WarningHint::SchemaGenerationsBehind {
1155 mem,
1156 pinned,
1157 newest,
1158 } => Some((mem.clone(), pinned.clone(), newest.clone())),
1159 _ => None,
1160 })
1161 .collect();
1162 assert_eq!(
1163 behind,
1164 vec![(
1165 "specs".to_string(),
1166 "default@1.0.0".to_string(),
1167 "1.3.0".to_string()
1168 )],
1169 "old built-in pin must surface the generation-behind hint"
1170 );
1171 assert!(
1172 engine
1173 .health()
1174 .warnings
1175 .iter()
1176 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1177 "the hint rides health without an include gate"
1178 );
1179 engine
1181 .create_entity_with_ctx(
1182 crate::engine::CreateEntityArgs {
1183 anchors: Vec::new(),
1184 mem: "specs".to_string(),
1185 title: "Still writable".to_string(),
1186 entity_type: "spec".to_string(),
1187 sections: indexmap::IndexMap::from_iter([
1188 ("identity".to_string(), "i".to_string()),
1189 ("purpose".to_string(), "p".to_string()),
1190 ]),
1191 metadata: indexmap::IndexMap::new(),
1192 relations: Vec::new(),
1193 dry_run: false,
1194 },
1195 &crate::vcs::CommitContext::internal(),
1196 )
1197 .expect("generation-behind hint must never block mutations");
1198
1199 let tmp = TempDir::new().unwrap();
1201 let mem_dir = tmp.path().to_path_buf();
1202 let writer = FilesystemMemWriter::new(mem_dir.clone());
1203 let mut mount = folder_mount("specs", mem_dir);
1204 mount.schema = Some("default@1.3.0".parse().unwrap());
1205 let engine =
1206 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1207 assert!(
1208 !engine
1209 .load_warnings()
1210 .iter()
1211 .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1212 "newest built-in pin must stay silent: {:?}",
1213 engine.load_warnings()
1214 );
1215 let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1216 assert!(
1217 !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1218 "newest pin: health output carries no generation hint"
1219 );
1220 }
1221
1222 #[test]
1226 fn newest_builtin_version_orders_by_semver_not_string() {
1227 let manifest = |version: &str| {
1228 format!(
1229 r#"name: gen-test
1230version: {version}
1231description: test
1232when_to_use: test
1233types:
1234 - note
1235relationships:
1236 mode: strict
1237 definitions:
1238 - name: _default
1239 description: default
1240 default_weight: 1.0
1241 - name: PART_OF
1242 description: hier
1243 default_weight: 3.0
1244community:
1245 resolution: 1.0
1246 seed: 42
1247"#
1248 )
1249 };
1250 let type_yaml = r#"name: note
1251description: test
1252when_to_use: test
1253sections:
1254 - key: body
1255 heading: Body
1256 required: true
1257 search_weight: 10.0
1258 catch_all: true
1259metadata_fields: []
1260title_weight: 1.0
1261text_fields: [body]
1262hierarchy_relationship: PART_OF
1263no_self_loop_relationships: []
1264updatable_fields: [title, body]
1265health_required_fields: [body]
1266staleness_threshold_days: 30
1267write_rules: []
1268"#;
1269 let types = vec![("note".to_string(), type_yaml.to_string())];
1270 let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1271 .iter()
1272 .map(|v| {
1273 std::sync::Arc::new(
1274 memstead_schema::load_schema_from_memory(&manifest(v), &types)
1275 .expect("fixture schema loads"),
1276 )
1277 })
1278 .collect();
1279 let newest = super::newest_builtin_version("gen-test", &catalogue)
1280 .expect("name present in catalogue");
1281 assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1282 assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1283 }
1284
1285 #[test]
1291 fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1292 let tmp = TempDir::new().unwrap();
1293 let mem_dir = tmp.path().to_path_buf();
1294 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1297 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1298 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1299 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1300
1301 let writer = FilesystemMemWriter::new(mem_dir.clone());
1302 let engine = Engine::from_mounts(vec![(
1303 folder_mount("specs", mem_dir),
1304 Box::new(writer) as Box<dyn MemBackend>,
1305 )])
1306 .unwrap();
1307
1308 let source_id = crate::entity::EntityId::new("specs", "source");
1309 let target_id = crate::entity::EntityId::new("specs", "target");
1310 let source_entity = engine.get_entity(&source_id).expect("source loaded");
1311 assert!(
1314 source_entity.relationships.is_empty(),
1315 "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1316 source_entity.relationships,
1317 );
1318 let outgoing: Vec<_> = engine
1320 .store()
1321 .outgoing(&source_id)
1322 .iter()
1323 .filter(|e| e.rel_type == "MADE_UP_TYPE")
1324 .collect();
1325 assert!(
1326 outgoing.is_empty(),
1327 "MADE_UP_TYPE edge must be dropped from the store"
1328 );
1329 let parsed_invalid: Vec<_> = engine
1331 .load_warnings()
1332 .iter()
1333 .filter_map(|w| match w {
1334 WarningHint::ParsedRelationInvalid {
1335 entity_id,
1336 rel_type,
1337 target,
1338 reason,
1339 origin,
1340 recovery,
1341 } => Some((
1342 entity_id.clone(),
1343 rel_type.clone(),
1344 target.clone(),
1345 reason.clone(),
1346 origin.clone(),
1347 recovery.clone(),
1348 )),
1349 _ => None,
1350 })
1351 .collect();
1352 assert_eq!(
1353 parsed_invalid.len(),
1354 1,
1355 "expected one warning, got {parsed_invalid:?}"
1356 );
1357 assert_eq!(parsed_invalid[0].0, source_id);
1358 assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1359 assert_eq!(parsed_invalid[0].2, target_id);
1360 assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1361 assert_eq!(parsed_invalid[0].4, "writable");
1362 let recovery = parsed_invalid[0]
1364 .5
1365 .as_ref()
1366 .expect("writable-origin warning must carry recovery");
1367 assert_eq!(
1368 recovery.kind,
1369 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1370 );
1371 assert_eq!(recovery.source_id, parsed_invalid[0].0);
1372 assert_eq!(recovery.target_id, parsed_invalid[0].2);
1373 assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1374 }
1375
1376 #[test]
1384 fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1385 let tmp = TempDir::new().unwrap();
1386 let mem_dir = tmp.path().to_path_buf();
1387 let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1391 let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1392 std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1393 std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1394
1395 let writer = FilesystemMemWriter::new(mem_dir.clone());
1396 let engine = Engine::from_mounts(vec![(
1397 folder_mount("specs", mem_dir),
1398 Box::new(writer) as Box<dyn MemBackend>,
1399 )])
1400 .unwrap();
1401
1402 let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1403 let beta_id = crate::entity::EntityId::new("specs", "beta");
1404
1405 assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1408 assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1409
1410 let surviving: Vec<_> = engine
1413 .store()
1414 .all_entities()
1415 .flat_map(|e| {
1416 engine
1417 .store()
1418 .outgoing(&e.id)
1419 .iter()
1420 .filter(|edge| edge.rel_type == "PART_OF")
1421 .map(|edge| (e.id.clone(), edge.target.clone()))
1422 .collect::<Vec<_>>()
1423 })
1424 .collect();
1425 assert_eq!(
1426 surviving.len(),
1427 1,
1428 "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1429 );
1430
1431 let cycle_drops: Vec<_> = engine
1434 .load_warnings()
1435 .iter()
1436 .filter_map(|w| match w {
1437 WarningHint::ParsedRelationInvalid {
1438 entity_id,
1439 rel_type,
1440 target,
1441 reason,
1442 ..
1443 } if reason == "cycle" => {
1444 Some((entity_id.clone(), rel_type.clone(), target.clone()))
1445 }
1446 _ => None,
1447 })
1448 .collect();
1449 assert_eq!(
1450 cycle_drops.len(),
1451 1,
1452 "exactly one cycle warning must fire, got {cycle_drops:?}",
1453 );
1454 let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1456 assert_eq!(dropped_rel_type, "PART_OF");
1457 let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1458 let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1459 assert!(
1460 is_alpha_to_beta || is_beta_to_alpha,
1461 "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1462 );
1463 assert_ne!(
1465 (&surviving[0].0, &surviving[0].1),
1466 (dropped_from, dropped_to),
1467 "surviving edge must differ from the dropped one",
1468 );
1469 }
1470
1471 #[test]
1479 fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1480 let tmp = TempDir::new().unwrap();
1481 let project_dir = tmp.path().join("project");
1482 let registry_dir = tmp.path().join("registry");
1483 std::fs::create_dir_all(&project_dir).unwrap();
1484 std::fs::create_dir_all(®istry_dir).unwrap();
1485
1486 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1489 std::fs::write(project_dir.join("source.md"), source).unwrap();
1490
1491 let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1494 std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1495
1496 let engine = Engine::from_mounts(vec![
1497 (
1498 folder_mount("project", project_dir.clone()),
1499 Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1500 ),
1501 (
1502 folder_mount("registry", registry_dir.clone()),
1503 Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1504 ),
1505 ])
1506 .unwrap();
1507
1508 let nested: Vec<_> = engine
1509 .load_warnings()
1510 .iter()
1511 .filter_map(|w| match w {
1512 WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1513 Some(resolved_id.to_string())
1514 }
1515 _ => None,
1516 })
1517 .collect();
1518 assert!(
1519 !nested.contains(&"registry--registry-service".to_string()),
1520 "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1521 );
1522 assert!(
1523 nested.contains(&"registry--never-created".to_string()),
1524 "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1525 );
1526 }
1527
1528 #[test]
1538 fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1539 use crate::engine::test_helpers::write_schema_files_with_default_type;
1540
1541 let tmp = TempDir::new().unwrap();
1542 let schemas_dir = tmp.path().join("schemas");
1543 std::fs::create_dir_all(&schemas_dir).unwrap();
1544 let manifest = r#"name: shape-test
1548version: 0.1.0
1549description: shape-constraint schema
1550when_to_use: tests
1551types:
1552 - doc
1553 - actor
1554relationships:
1555 mode: strict
1556 definitions:
1557 - name: OWNS
1558 description: actor owns doc
1559 default_weight: 1.0
1560 source_types: [actor]
1561 target_types: [doc]
1562 - name: _default
1563 description: fallback
1564 default_weight: 1.0
1565community:
1566 resolution: 1.0
1567 seed: 42
1568"#;
1569 write_schema_files_with_default_type(
1570 &schemas_dir,
1571 "shape-test",
1572 manifest,
1573 &["doc", "actor"],
1574 );
1575
1576 let mem_dir = tmp.path().join("mem");
1577 std::fs::create_dir_all(&mem_dir).unwrap();
1578 let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1582 let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1583 std::fs::write(mem_dir.join("target.md"), target).unwrap();
1584 std::fs::write(mem_dir.join("source.md"), source).unwrap();
1585
1586 let writer = FilesystemMemWriter::new(mem_dir.clone());
1587 let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1588 let mount = Mount {
1589 mem: "specs".to_string(),
1590 schema: Some(pin),
1591 storage: MountStorage::Folder { path: mem_dir },
1592 capability: MountCapability::Write,
1593 lifecycle: MountLifecycle::Eager,
1594 cross_linkable: true,
1595 migration_target: None,
1596 };
1597 let engine = Engine::from_mounts_with_schemas_dir(
1598 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1599 Some(&schemas_dir),
1600 )
1601 .unwrap();
1602
1603 let source_id = crate::entity::EntityId::new("specs", "source");
1604 let target_id = crate::entity::EntityId::new("specs", "target");
1605
1606 let shape_drops: Vec<_> = engine
1607 .load_warnings()
1608 .iter()
1609 .filter_map(|w| match w {
1610 WarningHint::ParsedRelationInvalid {
1611 entity_id,
1612 rel_type,
1613 target,
1614 reason,
1615 origin,
1616 recovery,
1617 } if reason == "shape" => Some((
1618 entity_id.clone(),
1619 rel_type.clone(),
1620 target.clone(),
1621 origin.clone(),
1622 recovery.clone(),
1623 )),
1624 _ => None,
1625 })
1626 .collect();
1627 assert_eq!(
1628 shape_drops.len(),
1629 1,
1630 "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1631 engine.load_warnings(),
1632 );
1633 let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1634 shape_drops.into_iter().next().unwrap();
1635 assert_eq!(drop_from, source_id);
1636 assert_eq!(drop_type, "OWNS");
1637 assert_eq!(drop_to, target_id);
1638 assert_eq!(drop_origin, "writable");
1639 let recovery = drop_recovery.expect("writable origin must carry recovery");
1642 assert_eq!(
1643 recovery.kind,
1644 crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1645 );
1646 assert_eq!(recovery.source_id, source_id);
1647 assert_eq!(recovery.target_id, target_id);
1648 assert_eq!(recovery.rel_type, "OWNS");
1649 }
1650
1651 #[test]
1657 fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1658 let tmp = TempDir::new().unwrap();
1659 let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1663 let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1664 let archive_path = build_archive(
1665 tmp.path(),
1666 "ext",
1667 &[
1668 ("target.md", target.as_bytes()),
1669 ("source.md", source.as_bytes()),
1670 ],
1671 );
1672
1673 let engine = Engine::from_mounts(vec![(
1674 archive_mount("external", archive_path.clone()),
1675 Box::new(ArchiveBackend::new(archive_path)),
1676 )])
1677 .unwrap();
1678
1679 let invalid: Vec<_> = engine
1680 .load_warnings()
1681 .iter()
1682 .filter_map(|w| match w {
1683 WarningHint::ParsedRelationInvalid {
1684 rel_type,
1685 reason,
1686 origin,
1687 recovery,
1688 ..
1689 } => Some((
1690 rel_type.clone(),
1691 reason.clone(),
1692 origin.clone(),
1693 recovery.clone(),
1694 )),
1695 _ => None,
1696 })
1697 .collect();
1698 assert_eq!(
1699 invalid.len(),
1700 1,
1701 "expected one parse-time drop on the readonly mount, got {invalid:?}",
1702 );
1703 assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1704 assert_eq!(invalid[0].1, "unknown_rel_type");
1705 assert_eq!(invalid[0].2, "readonly");
1706 assert!(
1707 invalid[0].3.is_none(),
1708 "readonly-origin warning must omit the recovery hint, got {:?}",
1709 invalid[0].3,
1710 );
1711 }
1712
1713 #[test]
1714 fn load_on_init_populates_store_from_folder_mount() {
1715 let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1718
1719 let tmp = TempDir::new().unwrap();
1720 let mem_dir = tmp.path().to_path_buf();
1721 let writer = FilesystemMemWriter::new(mem_dir.clone());
1722 <FilesystemMemWriter as MemWriter>::write_entity(
1723 &writer,
1724 Path::new("hello.md"),
1725 body.as_bytes(),
1726 )
1727 .unwrap();
1728 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1729 .unwrap();
1730
1731 let engine = Engine::from_mounts(vec![(
1732 folder_mount("specs", mem_dir),
1733 Box::new(writer) as Box<dyn MemBackend>,
1734 )])
1735 .unwrap();
1736
1737 assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1739 let id = crate::EntityId::new("specs", "hello");
1740 let entity = engine.get_entity(&id).expect("entity must be present");
1741 assert_eq!(entity.title, "Hello");
1742 assert_eq!(entity.entity_type, "spec");
1743 assert!(engine.load_errors().is_empty());
1744 assert_eq!(engine.schemas().len(), 1);
1746 assert!(engine.schemas().contains_key("specs"));
1747 }
1748
1749 #[test]
1750 fn load_on_init_populates_store_from_archive_mount() {
1751 let body =
1752 "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1753
1754 let tmp = TempDir::new().unwrap();
1755 let archive_path =
1756 build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1757
1758 let engine = Engine::from_mounts(vec![(
1759 archive_mount("external", archive_path.clone()),
1760 Box::new(ArchiveBackend::new(archive_path)),
1761 )])
1762 .unwrap();
1763
1764 let id = crate::EntityId::new("external", "from-archive");
1765 let entity = engine.get_entity(&id).expect("entity must be present");
1766 assert_eq!(entity.title, "From Archive");
1767 assert!(engine.load_errors().is_empty());
1768 }
1769
1770 #[test]
1771 fn load_on_init_populates_store_from_heterogeneous_mounts() {
1772 let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1773 let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1774
1775 let tmp = TempDir::new().unwrap();
1776
1777 let folder_dir = tmp.path().join("folder-mem");
1778 std::fs::create_dir_all(&folder_dir).unwrap();
1779 let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1780 <FilesystemMemWriter as MemWriter>::write_entity(
1781 &folder_writer,
1782 Path::new("local.md"),
1783 folder_body.as_bytes(),
1784 )
1785 .unwrap();
1786 <FilesystemMemWriter as MemWriter>::commit(
1787 &folder_writer,
1788 "seed",
1789 &CommitContext::internal(),
1790 )
1791 .unwrap();
1792
1793 let archive_path = build_archive(
1794 tmp.path(),
1795 "external",
1796 &[("external.md", archive_body.as_bytes())],
1797 );
1798
1799 let engine = Engine::from_mounts(vec![
1800 (
1801 folder_mount("local", folder_dir),
1802 Box::new(folder_writer) as Box<dyn MemBackend>,
1803 ),
1804 (
1805 archive_mount("external", archive_path.clone()),
1806 Box::new(ArchiveBackend::new(archive_path)),
1807 ),
1808 ])
1809 .unwrap();
1810
1811 assert_eq!(engine.store().len(), 2);
1813 assert!(
1814 engine
1815 .get_entity(&crate::EntityId::new("local", "local"))
1816 .is_some()
1817 );
1818 assert!(
1819 engine
1820 .get_entity(&crate::EntityId::new("external", "external"))
1821 .is_some()
1822 );
1823 }
1824
1825 #[test]
1826 fn load_on_init_collects_per_file_parse_errors_without_failing() {
1827 let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1831 let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1832
1833 let tmp = TempDir::new().unwrap();
1834 let mem_dir = tmp.path().to_path_buf();
1835 let writer = FilesystemMemWriter::new(mem_dir.clone());
1836 <FilesystemMemWriter as MemWriter>::write_entity(
1837 &writer,
1838 Path::new("good.md"),
1839 good.as_bytes(),
1840 )
1841 .unwrap();
1842 <FilesystemMemWriter as MemWriter>::write_entity(
1843 &writer,
1844 Path::new("bad.md"),
1845 bad.as_bytes(),
1846 )
1847 .unwrap();
1848 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1849 .unwrap();
1850
1851 let engine = Engine::from_mounts(vec![(
1852 folder_mount("specs", mem_dir),
1853 Box::new(writer) as Box<dyn MemBackend>,
1854 )])
1855 .unwrap();
1856
1857 assert!(
1859 engine
1860 .get_entity(&crate::EntityId::new("specs", "good"))
1861 .is_some(),
1862 "good.md must parse and reach the store"
1863 );
1864 let bad_known_to_engine = engine
1869 .get_entity(&crate::EntityId::new("specs", "bad"))
1870 .is_some()
1871 || !engine.load_errors().is_empty();
1872 assert!(
1873 bad_known_to_engine,
1874 "bad.md must either parse or surface in load_errors"
1875 );
1876 }
1877
1878 #[test]
1879 fn empty_mount_list_yields_empty_store() {
1880 let engine = Engine::from_mounts(Vec::new()).unwrap();
1881 assert!(engine.store().is_empty());
1882 assert!(engine.schemas().is_empty());
1883 assert!(engine.load_errors().is_empty());
1884 }
1885
1886 #[test]
1889 fn from_workspace_root_errors_for_empty_layout() {
1890 let tmp = TempDir::new().unwrap();
1891 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1892 match err {
1893 crate::BootError::NotInitialised(p) => {
1894 assert_eq!(p, tmp.path());
1895 }
1896 other => panic!("expected NotInitialised, got {other:?}"),
1897 }
1898 }
1899
1900 #[test]
1901 fn from_workspace_root_loads_new_two_layer_layout() {
1902 let tmp = TempDir::new().unwrap();
1903 let mem_dir = tmp.path().join("mem");
1904 std::fs::create_dir_all(&mem_dir).unwrap();
1905 std::fs::write(
1906 mem_dir.join("hello.md"),
1907 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1908 )
1909 .unwrap();
1910
1911 let memstead = tmp.path().join(".memstead");
1912 std::fs::create_dir_all(&memstead).unwrap();
1913 std::fs::write(
1914 memstead.join("workspace.toml"),
1915 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1916 )
1917 .unwrap();
1918 use crate::workspace_store::WorkspaceStoreAdapter;
1921 let store = crate::FileWorkspaceStore::new();
1922 store
1923 .save_state(
1924 tmp.path(),
1925 &crate::workspace::Workspace {
1926 mounts: vec![folder_mount("specs", mem_dir)],
1927 settings: crate::workspace::WorkspaceSettings::default(),
1928 },
1929 )
1930 .unwrap();
1931
1932 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1933 assert_eq!(engine.mem_names(), vec!["specs"]);
1934 let entity = engine
1935 .get_entity(&crate::EntityId::new("specs", "hello"))
1936 .expect("seeded entity must load through from_workspace_root");
1937 assert_eq!(entity.title, "Hello");
1938 }
1939
1940 #[test]
1945 fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1946 use crate::pipeline::{MediumType, Projection};
1947 let tmp = TempDir::new().unwrap();
1948 let mem_dir = tmp.path().join("mem");
1949 std::fs::create_dir_all(&mem_dir).unwrap();
1950
1951 let memstead = tmp.path().join(".memstead");
1952 std::fs::create_dir_all(&memstead).unwrap();
1953 std::fs::write(
1954 memstead.join("workspace.toml"),
1955 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1956 )
1957 .unwrap();
1958 use crate::workspace_store::WorkspaceStoreAdapter;
1959 crate::FileWorkspaceStore::new()
1960 .save_state(
1961 tmp.path(),
1962 &crate::workspace::Workspace {
1963 mounts: vec![folder_mount("specs", mem_dir)],
1964 settings: crate::workspace::WorkspaceSettings::default(),
1965 },
1966 )
1967 .unwrap();
1968
1969 crate::pipeline_store::write_binding(
1971 tmp.path(),
1972 "specs",
1973 "graph",
1974 &sample_v2_binding("specs"),
1975 )
1976 .unwrap();
1977
1978 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1979 let pc = engine.pipeline_configs();
1980 assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1981 assert_eq!(pc.bindings[0].mem, "specs");
1982 assert_eq!(pc.bindings[0].name, "graph");
1983 assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1984 assert_eq!(
1985 pc.bindings[0].config.sources[0].medium_type,
1986 MediumType::Codebase
1987 );
1988
1989 crate::pipeline_store::write_projection(
1996 tmp.path(),
1997 "specs",
1998 "legacy",
1999 &Projection {
2000 intent: None,
2001 source_facets: vec!["view".to_string()],
2002 reference_mems: Vec::new(),
2003 destination_mem: "specs".to_string(),
2004 rules: None,
2005 },
2006 )
2007 .unwrap();
2008 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2009 let pc = engine.pipeline_configs();
2010 assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
2011 assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
2012 assert_eq!(pc.quarantined[0].name, "legacy");
2013 assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
2014 assert!(
2015 pc.quarantined[0]
2016 .reason_message
2017 .contains("memstead projection migrate"),
2018 "quarantine reason names the migrate command, got: {}",
2019 pc.quarantined[0].reason_message
2020 );
2021 }
2022
2023 fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
2026 v2_binding_with_pointer(dest, "..")
2027 }
2028
2029 fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
2030 use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
2031 use crate::pipeline::{IngestTrigger, MediumType, Source};
2032 Binding {
2033 version: BINDING_VERSION,
2034 intent: None,
2035 sources: vec![Source {
2036 name: "src".to_string(),
2037 medium_type: MediumType::Codebase,
2038 pointer: pointer.to_string(),
2039 change_detection: None,
2040 scope: Vec::new(),
2041 engagement: None,
2042 preparation: None,
2043 }],
2044 reference_mems: Vec::new(),
2045 destination_mem: dest.to_string(),
2046 deny_paths: Vec::new(),
2047 coverage_semantics: None,
2048 rules: None,
2049 prune: None,
2050 operations: Operations {
2051 build: Some(BuildOperation {
2052 mode: BuildMode::Discovery,
2053 trigger: IngestTrigger::Loop,
2054 batch_size: 10,
2055 post_actions: None,
2056 }),
2057 sync: None,
2058 verify: None,
2059 },
2060 }
2061 }
2062
2063 #[test]
2072 fn url_anchors_adjudicate_from_supplied_observations_and_age() {
2073 use crate::anchor::{
2074 AnchorInput, AnchorState, SuppliedObservationInput, validate_supplied_observations,
2075 };
2076 use crate::vcs::Actor;
2077 use crate::workspace_store::WorkspaceStoreAdapter;
2078 use indexmap::IndexMap;
2079
2080 let tmp = TempDir::new().unwrap();
2081 let mem_dir = tmp.path().join("mem");
2082 std::fs::create_dir_all(&mem_dir).unwrap();
2083 std::fs::write(
2084 mem_dir.join("hello.md"),
2085 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
2086 )
2087 .unwrap();
2088
2089 let memstead = tmp.path().join(".memstead");
2090 std::fs::create_dir_all(&memstead).unwrap();
2091 std::fs::write(
2092 memstead.join("workspace.toml"),
2093 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2094 )
2095 .unwrap();
2096 crate::FileWorkspaceStore::new()
2097 .save_state(
2098 tmp.path(),
2099 &crate::workspace::Workspace {
2100 mounts: vec![folder_mount("specs", mem_dir.clone())],
2101 settings: crate::workspace::WorkspaceSettings::default(),
2102 },
2103 )
2104 .unwrap();
2105 crate::pipeline_store::write_binding(
2108 tmp.path(),
2109 "specs",
2110 "graph",
2111 &v2_binding_with_pointer("specs", "src"),
2112 )
2113 .unwrap();
2114 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2115
2116 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2117 let clock_now = "2026-09-02T12:00:00Z";
2118 let pinned = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_788_350_400);
2119 engine.set_mutation_clock(std::sync::Arc::new(move || pinned));
2120 assert_eq!(
2121 engine.now_iso(),
2122 clock_now,
2123 "clock pin is the date the ages count from"
2124 );
2125
2126 let url = |artifact: &str, stability: &str, content: &str| AnchorInput {
2127 artifact: Some(artifact.to_string()),
2128 grain: Some("url".to_string()),
2129 class: Some("anchored".to_string()),
2130 content: Some(content.to_string()),
2131 hash_stability: Some(stability.to_string()),
2132 ..Default::default()
2133 };
2134 let mut sections = IndexMap::new();
2135 sections.insert("identity".to_string(), "Cites the web.".to_string());
2136 sections.insert("purpose".to_string(), "Four url anchors.".to_string());
2137 let created = engine
2138 .create_entity(
2139 crate::CreateEntityArgs {
2140 mem: "specs".to_string(),
2141 title: "Cites".to_string(),
2142 entity_type: "spec".to_string(),
2143 sections,
2144 metadata: IndexMap::new(),
2145 relations: Vec::new(),
2146 anchors: vec![
2147 url("https://w.test/stable", "stable", "immutable text"),
2148 url("https://w.test/living", "unstable", "page v1"),
2149 url("https://w.test/gone", "unstable", "was here"),
2150 url("https://w.test/never", "unstable", "never observed again"),
2151 ],
2152 dry_run: false,
2153 },
2154 Actor::Agent,
2155 None,
2156 None,
2157 )
2158 .expect("url anchors beside a single path source are legal");
2159 let before = engine.entity_anchors_resolved(&created.id);
2160 assert_eq!(before.len(), 4);
2161 assert!(
2162 before
2163 .iter()
2164 .all(|r| r.state.is_none() && r.observed_at.is_none()),
2165 "no observation yet: every url row unobserved"
2166 );
2167 assert!(
2168 before
2169 .iter()
2170 .all(|r| r.anchor.hash_source == Some(crate::anchor::AnchorHashSource::Author)),
2171 "content supplied at write: author baseline"
2172 );
2173
2174 let rows = vec![
2177 SuppliedObservationInput {
2178 artifact: Some("https://w.test/stable".into()),
2179 hash: Some(crate::anchor::prepared_content_hash(b"immutable text")),
2180 ..Default::default()
2181 },
2182 SuppliedObservationInput {
2183 artifact: Some("https://w.test/living".into()),
2184 content: Some("page v2".into()),
2185 observed_at: Some("2026-08-03T09:00:00Z".into()),
2186 ..Default::default()
2187 },
2188 SuppliedObservationInput {
2189 artifact: Some("https://w.test/gone".into()),
2190 absent: Some(true),
2191 ..Default::default()
2192 },
2193 SuppliedObservationInput {
2194 artifact: Some("https://elsewhere.test/unrelated".into()),
2195 hash: Some("zz".into()),
2196 ..Default::default()
2197 },
2198 ];
2199 let supplied = validate_supplied_observations(&rows, clock_now).unwrap();
2200 let report = engine.verify_mem_anchors_with("specs", &supplied).unwrap();
2201 let state_of = |artifact: &str| {
2202 report
2203 .anchors
2204 .iter()
2205 .find(|a| a.artifact == artifact)
2206 .map(|a| {
2207 (
2208 a.state.clone(),
2209 a.unobserved_for_days,
2210 a.observation_supplied,
2211 )
2212 })
2213 .unwrap()
2214 };
2215 assert_eq!(
2216 state_of("https://w.test/stable"),
2217 ("resolved".into(), Some(0), true)
2218 );
2219 assert_eq!(
2220 state_of("https://w.test/living"),
2221 ("recheck".into(), Some(30), true)
2222 );
2223 assert_eq!(
2224 state_of("https://w.test/gone"),
2225 ("recheck".into(), Some(0), true)
2226 );
2227 assert_eq!(
2228 state_of("https://w.test/never"),
2229 ("unobserved".into(), None, false)
2230 );
2231 assert_eq!(
2232 report.unmatched_observations,
2233 vec!["https://elsewhere.test/unrelated"]
2234 );
2235 assert_eq!(report.recordable_observations.len(), 3);
2236 assert_eq!(
2237 (report.resolved, report.recheck, report.unobserved),
2238 (1, 2, 1)
2239 );
2240
2241 assert!(
2243 engine
2244 .entity_anchors_resolved(&created.id)
2245 .iter()
2246 .all(|r| r.state.is_none()),
2247 "verify writes nothing"
2248 );
2249
2250 let written = engine
2253 .record_anchor_observations("specs", &report.recordable_observations, None)
2254 .unwrap();
2255 assert_eq!(written, 3);
2256 let after = engine.entity_anchors_resolved(&created.id);
2257 let row = |artifact: &str| {
2258 after
2259 .iter()
2260 .find(|r| r.anchor.artifact == artifact)
2261 .unwrap()
2262 };
2263 assert_eq!(
2264 row("https://w.test/stable").state,
2265 Some(AnchorState::Resolves)
2266 );
2267 assert_eq!(
2268 row("https://w.test/living").state,
2269 Some(AnchorState::Recheck)
2270 );
2271 assert_eq!(
2272 row("https://w.test/living").observed_at.as_deref(),
2273 Some("2026-08-03T09:00:00Z")
2274 );
2275 assert_eq!(row("https://w.test/gone").state, Some(AnchorState::Recheck));
2276 assert_eq!(row("https://w.test/never").state, None, "still unobserved");
2277 let sidecar_bytes = std::fs::read(mem_dir.join(".memstead").join("anchors.json")).unwrap();
2278 let sidecar: serde_json::Value = serde_json::from_slice(&sidecar_bytes).unwrap();
2279 assert_eq!(sidecar["version"], 2);
2280 assert_eq!(
2282 engine
2283 .record_anchor_observations("specs", &report.recordable_observations, None)
2284 .unwrap(),
2285 0
2286 );
2287
2288 let report2 = engine.verify_mem_anchors("specs").unwrap();
2293 let aged = report2
2294 .anchors
2295 .iter()
2296 .find(|a| a.artifact == "https://w.test/living")
2297 .unwrap();
2298 assert_eq!(aged.unobserved_for_days, Some(30));
2299 assert!(!aged.observation_supplied);
2300 let axis = crate::ops::health::health_anchors_axis(&engine);
2301 let aging = axis["specs"]["aging"].as_array().unwrap();
2302 assert!(
2303 aging
2304 .iter()
2305 .any(|a| a["artifact"] == "https://w.test/living"
2306 && a["note"] == "unobserved for 30 days"),
2307 "{axis}"
2308 );
2309 let open = crate::ops::health::health_open_questions_axis(&engine, Some("specs"));
2310 assert!(
2311 open["specs"]["anchors_aging"]["items"]
2312 .as_array()
2313 .is_some_and(|items| items.iter().any(|i| i["note"] == "unobserved for 30 days")),
2314 "{open}"
2315 );
2316
2317 let drift_rows = vec![SuppliedObservationInput {
2318 artifact: Some("https://w.test/stable".into()),
2319 content: Some("immutable text, edited after all".into()),
2320 ..Default::default()
2321 }];
2322 let supplied = validate_supplied_observations(&drift_rows, clock_now).unwrap();
2323 let report3 = engine.verify_mem_anchors_with("specs", &supplied).unwrap();
2324 assert_eq!(
2325 state_of_report(&report3, "https://w.test/stable"),
2326 "drifted"
2327 );
2328 assert_eq!(report3.drifted, 1);
2329 }
2330
2331 #[test]
2337 fn sidecar_v1_fixture_loads_and_next_anchor_write_rewrites_v2_while_v3_refuses() {
2338 use crate::anchor::AnchorInput;
2339 use crate::vcs::Actor;
2340 use crate::workspace_store::WorkspaceStoreAdapter;
2341 use indexmap::IndexMap;
2342
2343 let tmp = TempDir::new().unwrap();
2344 let mem_dir = tmp.path().join("mem");
2345 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2346 std::fs::write(
2347 mem_dir.join("hello.md"),
2348 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
2349 )
2350 .unwrap();
2351 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"}]}}"#;
2352 std::fs::write(mem_dir.join(".memstead").join("anchors.json"), v1).unwrap();
2353 let memstead = tmp.path().join(".memstead");
2354 std::fs::create_dir_all(&memstead).unwrap();
2355 std::fs::write(
2356 memstead.join("workspace.toml"),
2357 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2358 )
2359 .unwrap();
2360 crate::FileWorkspaceStore::new()
2361 .save_state(
2362 tmp.path(),
2363 &crate::workspace::Workspace {
2364 mounts: vec![folder_mount("specs", mem_dir.clone())],
2365 settings: crate::workspace::WorkspaceSettings::default(),
2366 },
2367 )
2368 .unwrap();
2369 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2370
2371 let rows = engine.entity_anchors_resolved(&crate::EntityId::canonical("specs--hello"));
2372 assert_eq!(rows.len(), 1, "the version-1 row loads");
2373 assert_eq!(rows[0].anchor.hash.as_deref(), Some("author-pinned"));
2374 assert!(rows[0].anchor.last_observed.is_none());
2375 assert!(rows[0].state.is_none(), "never observed: unobserved");
2376 let on_disk =
2377 std::fs::read_to_string(mem_dir.join(".memstead").join("anchors.json")).unwrap();
2378 assert_eq!(on_disk, v1, "reading rewrites nothing");
2379
2380 let mut sections = IndexMap::new();
2383 sections.insert("identity".to_string(), "Second.".to_string());
2384 sections.insert("purpose".to_string(), "Second.".to_string());
2385 engine
2386 .create_entity(
2387 crate::CreateEntityArgs {
2388 mem: "specs".to_string(),
2389 title: "Second".to_string(),
2390 entity_type: "spec".to_string(),
2391 sections,
2392 metadata: IndexMap::new(),
2393 relations: Vec::new(),
2394 anchors: vec![AnchorInput {
2395 artifact: Some("https://fixture.test/other".to_string()),
2396 grain: Some("url".to_string()),
2397 class: Some("informed-by".to_string()),
2398 ..Default::default()
2399 }],
2400 dry_run: false,
2401 },
2402 Actor::Agent,
2403 None,
2404 None,
2405 )
2406 .unwrap();
2407 let after: serde_json::Value = serde_json::from_slice(
2408 &std::fs::read(mem_dir.join(".memstead").join("anchors.json")).unwrap(),
2409 )
2410 .unwrap();
2411 assert_eq!(after["version"], 2);
2412 assert_eq!(
2413 after["entities"]["specs--hello"][0]["hash"],
2414 "author-pinned"
2415 );
2416 assert!(
2417 after["entities"]["specs--hello"][0]
2418 .get("last_observed")
2419 .is_none()
2420 );
2421
2422 std::fs::write(
2424 mem_dir.join(".memstead").join("anchors.json"),
2425 r#"{"version":3,"entities":{}}"#,
2426 )
2427 .unwrap();
2428 let mut sections = IndexMap::new();
2429 sections.insert("identity".to_string(), "Third.".to_string());
2430 sections.insert("purpose".to_string(), "Third.".to_string());
2431 let err = engine
2432 .create_entity(
2433 crate::CreateEntityArgs {
2434 mem: "specs".to_string(),
2435 title: "Third".to_string(),
2436 entity_type: "spec".to_string(),
2437 sections,
2438 metadata: IndexMap::new(),
2439 relations: Vec::new(),
2440 anchors: vec![AnchorInput {
2441 artifact: Some("https://fixture.test/third".to_string()),
2442 grain: Some("url".to_string()),
2443 class: Some("informed-by".to_string()),
2444 ..Default::default()
2445 }],
2446 dry_run: false,
2447 },
2448 Actor::Agent,
2449 None,
2450 None,
2451 )
2452 .expect_err("a version-3 sidecar refuses");
2453 assert!(
2454 err.to_string()
2455 .contains("unsupported anchors sidecar version 3"),
2456 "{err}"
2457 );
2458 }
2459
2460 fn state_of_report(
2461 report: &crate::engine::query::MemAnchorVerification,
2462 artifact: &str,
2463 ) -> String {
2464 report
2465 .anchors
2466 .iter()
2467 .find(|a| a.artifact == artifact)
2468 .map(|a| a.state.clone())
2469 .unwrap()
2470 }
2471
2472 #[test]
2480 fn entity_anchors_resolve_live_state_for_path_medium() {
2481 use crate::anchor::{AnchorInput, AnchorState};
2482 use crate::vcs::Actor;
2483 use crate::workspace_store::WorkspaceStoreAdapter;
2484 use indexmap::IndexMap;
2485
2486 let tmp = TempDir::new().unwrap();
2487 let mem_dir = tmp.path().join("mem");
2488 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2489 std::fs::write(
2490 mem_dir.join(".memstead").join("config.json"),
2491 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2492 )
2493 .unwrap();
2494
2495 let memstead = tmp.path().join(".memstead");
2496 std::fs::create_dir_all(&memstead).unwrap();
2497 std::fs::write(
2498 memstead.join("workspace.toml"),
2499 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2500 )
2501 .unwrap();
2502 crate::FileWorkspaceStore::new()
2503 .save_state(
2504 tmp.path(),
2505 &crate::workspace::Workspace {
2506 mounts: vec![folder_mount("specs", mem_dir.clone())],
2507 settings: crate::workspace::WorkspaceSettings::default(),
2508 },
2509 )
2510 .unwrap();
2511
2512 crate::pipeline_store::write_binding(
2518 tmp.path(),
2519 "specs",
2520 "graph",
2521 &v2_binding_with_pointer("specs", "src"),
2522 )
2523 .unwrap();
2524 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2525 std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
2526 std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
2529
2530 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2531
2532 let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
2533 artifact: Some(artifact.to_string()),
2534 grain: Some("file".to_string()),
2535 class: Some(class.to_string()),
2536 hash: hash.map(str::to_string),
2537 hash_stability: Some("stable".to_string()),
2538 ..Default::default()
2539 };
2540 let mut sections = IndexMap::new();
2541 sections.insert("identity".to_string(), "Covers src.".to_string());
2542 sections.insert("purpose".to_string(), "Track sources.".to_string());
2543 let present_hash = crate::anchor::prepared_content_hash(
2546 &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
2547 );
2548 let created = engine
2549 .create_entity(
2550 crate::CreateEntityArgs {
2551 mem: "specs".to_string(),
2552 title: "Covers".to_string(),
2553 entity_type: "spec".to_string(),
2554 sections,
2555 metadata: IndexMap::new(),
2556 relations: Vec::new(),
2557 anchors: vec![
2558 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")), ],
2563 dry_run: false,
2564 },
2565 Actor::Agent,
2566 None,
2567 None,
2568 )
2569 .unwrap();
2570
2571 std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
2572 let resolved = engine.entity_anchors_resolved(&created.id);
2573 assert_eq!(resolved.len(), 4);
2574 let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
2575 resolved
2576 .iter()
2577 .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
2578 .and_then(|r| r.state)
2579 };
2580 assert_eq!(
2581 state_of(
2582 "src/present.rs",
2583 crate::anchor::AnchorProvenanceClass::Anchored
2584 ),
2585 Some(AnchorState::Resolves),
2586 "recorded hash matches the observed prepared form → resolves"
2587 );
2588 assert_eq!(
2589 state_of(
2590 "src/present.rs",
2591 crate::anchor::AnchorProvenanceClass::Derived
2592 ),
2593 Some(AnchorState::Drifted),
2594 "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2595 );
2596 assert_eq!(
2597 state_of(
2598 "src/present.rs",
2599 crate::anchor::AnchorProvenanceClass::InformedBy
2600 ),
2601 Some(AnchorState::Resolves),
2602 "present non-hash anchor resolves on existence"
2603 );
2604 assert_eq!(
2605 state_of(
2606 "src/gone.rs",
2607 crate::anchor::AnchorProvenanceClass::Anchored
2608 ),
2609 Some(AnchorState::Orphaned),
2610 "absent artifact is orphaned"
2611 );
2612 }
2613
2614 #[test]
2619 fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2620 use crate::workspace_store::WorkspaceStoreAdapter;
2621
2622 let tmp = TempDir::new().unwrap();
2623 let mem_dir = tmp.path().join("mem");
2624 std::fs::create_dir_all(&mem_dir).unwrap();
2625 let memstead = tmp.path().join(".memstead");
2626 std::fs::create_dir_all(&memstead).unwrap();
2627 std::fs::write(
2628 memstead.join("workspace.toml"),
2629 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2630 )
2631 .unwrap();
2632 crate::FileWorkspaceStore::new()
2633 .save_state(
2634 tmp.path(),
2635 &crate::workspace::Workspace {
2636 mounts: vec![folder_mount("specs", mem_dir)],
2637 settings: crate::workspace::WorkspaceSettings::default(),
2638 },
2639 )
2640 .unwrap();
2641
2642 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2643 assert!(engine.pipeline_configs().bindings.is_empty());
2644
2645 engine
2647 .add_projection_json(
2648 "specs",
2649 "graph",
2650 r#"{
2651 "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2652 "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2653 "destination_mem": "specs"
2654 }"#,
2655 None,
2656 )
2657 .unwrap();
2658 assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2660 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2661 assert_eq!(
2662 engine.pipeline_configs().bindings[0].config.sources[0].name,
2663 "src"
2664 );
2665
2666 let err = engine
2668 .add_projection_json("specs", "bad", "{ not json", None)
2669 .unwrap_err();
2670 assert!(
2671 matches!(
2672 err,
2673 crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2674 ),
2675 "got {err:?}"
2676 );
2677
2678 engine
2680 .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2681 .unwrap();
2682 assert_eq!(
2683 engine.pipeline_configs().bindings[0]
2684 .config
2685 .intent
2686 .as_deref(),
2687 Some("i2")
2688 );
2689
2690 engine
2692 .rename_projection("specs", "graph", "graph2", None)
2693 .unwrap();
2694 assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2695
2696 let json = engine.pipeline_configs_json();
2699 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2700 assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2701 assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2702 let bindings = parsed["bindings"].as_array().unwrap();
2703 assert_eq!(bindings.len(), 1);
2704 assert_eq!(bindings[0]["name"], "graph2");
2705 assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2706
2707 engine.delete_projection("specs", "graph2", None).unwrap();
2708 assert!(engine.pipeline_configs().bindings.is_empty());
2709 }
2710
2711 #[test]
2717 fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2718 use crate::engine::test_helpers::write_schema_files_with_default_type;
2719
2720 let tmp = TempDir::new().unwrap();
2721 let mem_dir = tmp.path().join("mem");
2722 std::fs::create_dir_all(&mem_dir).unwrap();
2723
2724 let authored_dir = tmp.path().join(".memstead").join("schemas");
2726 let manifest = r#"name: authored
2727version: 0.1.0
2728description: an authored-in-workspace test schema
2729when_to_use: tests
2730types:
2731 - doc
2732relationships:
2733 mode: strict
2734 definitions:
2735 - name: _default
2736 description: fallback
2737 default_weight: 1.0
2738community:
2739 resolution: 1.0
2740 seed: 42
2741"#;
2742 write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2743
2744 let memstead = tmp.path().join(".memstead");
2746 std::fs::create_dir_all(&memstead).unwrap();
2747 std::fs::write(
2748 memstead.join("workspace.toml"),
2749 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2750 )
2751 .unwrap();
2752 let mount = Mount {
2753 mem: "specs".to_string(),
2754 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2755 storage: MountStorage::Folder { path: mem_dir },
2756 capability: MountCapability::Write,
2757 lifecycle: MountLifecycle::Eager,
2758 cross_linkable: true,
2759 migration_target: None,
2760 };
2761 use crate::workspace_store::WorkspaceStoreAdapter;
2762 crate::FileWorkspaceStore::new()
2763 .save_state(
2764 tmp.path(),
2765 &crate::workspace::Workspace {
2766 mounts: vec![mount],
2767 settings: crate::workspace::WorkspaceSettings::default(),
2768 },
2769 )
2770 .unwrap();
2771
2772 let engine = Engine::from_workspace_root(tmp.path())
2775 .expect("authored schema at .memstead/schemas/ must resolve at boot");
2776 assert_eq!(engine.mem_names(), vec!["specs"]);
2777 }
2778
2779 #[test]
2785 fn health_reports_authoring_drift_for_stamped_schemas_only() {
2786 use crate::engine::test_helpers::write_schema_files_with_default_type;
2787
2788 let tmp = TempDir::new().unwrap();
2789 let mem_dir = tmp.path().join("mem");
2790 std::fs::create_dir_all(&mem_dir).unwrap();
2791 let manifest = r#"name: authored
2792version: 0.1.0
2793description: an authored-in-workspace test schema
2794when_to_use: tests
2795types:
2796 - doc
2797relationships:
2798 mode: strict
2799 definitions:
2800 - name: _default
2801 description: fallback
2802 default_weight: 1.0
2803community:
2804 resolution: 1.0
2805 seed: 42
2806"#;
2807 let sealed_root = tmp.path().join(".memstead").join("schemas");
2810 write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2811 let author_root = tmp.path().join("author");
2812 write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2813 let authoring_dir = author_root.join("authored@0.1.0");
2814 let sealed_dir = sealed_root.join("authored@0.1.0");
2815 let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2817 std::fs::write(
2818 &stamp_path,
2819 serde_json::to_vec_pretty(&serde_json::json!({
2820 "authoring_path": authoring_dir.display().to_string(),
2821 }))
2822 .unwrap(),
2823 )
2824 .unwrap();
2825
2826 let memstead = tmp.path().join(".memstead");
2827 std::fs::write(
2828 memstead.join("workspace.toml"),
2829 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2830 )
2831 .unwrap();
2832 let mount = Mount {
2833 mem: "specs".to_string(),
2834 schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2835 storage: MountStorage::Folder { path: mem_dir },
2836 capability: MountCapability::Write,
2837 lifecycle: MountLifecycle::Eager,
2838 cross_linkable: true,
2839 migration_target: None,
2840 };
2841 use crate::workspace_store::WorkspaceStoreAdapter;
2842 crate::FileWorkspaceStore::new()
2843 .save_state(
2844 tmp.path(),
2845 &crate::workspace::Workspace {
2846 mounts: vec![mount],
2847 settings: crate::workspace::WorkspaceSettings::default(),
2848 },
2849 )
2850 .unwrap();
2851 let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2852 let drift_codes = |e: &Engine| -> Vec<String> {
2853 e.health()
2854 .warnings
2855 .iter()
2856 .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2857 .map(|w| w.code().to_string())
2858 .collect()
2859 };
2860
2861 let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2864 let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2865 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2866 assert_eq!(
2867 std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2868 sealed_before,
2869 "health must not touch the sealed copy"
2870 );
2871 assert_eq!(
2872 std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2873 author_before,
2874 "health must not touch the authoring copy"
2875 );
2876
2877 std::fs::write(
2881 authoring_dir.join("schema.yaml"),
2882 format!(
2883 "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2884 ),
2885 )
2886 .unwrap();
2887 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2888
2889 std::fs::write(
2892 authoring_dir.join("schema.yaml"),
2893 manifest.replace(
2894 "an authored-in-workspace test schema",
2895 "a semantically different description",
2896 ),
2897 )
2898 .unwrap();
2899 let warnings = engine.health().warnings;
2900 let diverged = warnings
2901 .iter()
2902 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2903 .expect("semantic change must surface as DIVERGED");
2904 let d = serde_json::to_value(diverged).unwrap();
2905 assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2906 assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2907
2908 std::fs::remove_dir_all(&authoring_dir).unwrap();
2910 let warnings = engine.health().warnings;
2911 let missing = warnings
2912 .iter()
2913 .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2914 .expect("vanished authoring package must surface as MISSING");
2915 let m = serde_json::to_value(missing).unwrap();
2916 assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2917 assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2918 assert_eq!(
2919 m["details"]["stamped_path"],
2920 authoring_dir.display().to_string()
2921 );
2922 assert!(
2923 !warnings
2924 .iter()
2925 .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2926 "missing and diverged are distinct findings"
2927 );
2928
2929 std::fs::remove_file(&stamp_path).unwrap();
2931 assert_eq!(drift_codes(&engine), Vec::<String>::new());
2932 }
2933
2934 #[test]
2939 fn full_refresh_is_additive_and_reports_skipped_removals() {
2940 use crate::engine::test_helpers::write_schema_files_with_default_type;
2941 use crate::workspace_store::WorkspaceStoreAdapter;
2942
2943 let tmp = TempDir::new().unwrap();
2944 let root = tmp.path();
2945 let mem_a = root.join("mem-a");
2946 std::fs::create_dir_all(&mem_a).unwrap();
2947 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2948 std::fs::write(
2949 root.join(".memstead").join("workspace.toml"),
2950 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2951 )
2952 .unwrap();
2953 let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2954 mem: mem.to_string(),
2955 schema: Some(SchemaRef::new(schema, version)),
2956 storage: MountStorage::Folder {
2957 path: dir.to_path_buf(),
2958 },
2959 capability: MountCapability::Write,
2960 lifecycle: MountLifecycle::Eager,
2961 cross_linkable: true,
2962 migration_target: None,
2963 };
2964 let save = |mounts: Vec<Mount>| {
2965 crate::FileWorkspaceStore::new()
2966 .save_state(
2967 root,
2968 &crate::workspace::Workspace {
2969 mounts,
2970 settings: crate::workspace::WorkspaceSettings::default(),
2971 },
2972 )
2973 .unwrap();
2974 };
2975 save(vec![mount(
2976 "specs",
2977 &mem_a,
2978 "default",
2979 semver::Version::new(1, 0, 0),
2980 )]);
2981 let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2982 assert_eq!(engine.mem_names(), vec!["specs"]);
2983
2984 let manifest = r#"name: authored
2987version: 0.1.0
2988description: an out-of-band installed schema
2989when_to_use: tests
2990types:
2991 - doc
2992relationships:
2993 mode: strict
2994 definitions:
2995 - name: _default
2996 description: fallback
2997 default_weight: 1.0
2998community:
2999 resolution: 1.0
3000 seed: 42
3001"#;
3002 write_schema_files_with_default_type(
3003 &root.join(".memstead").join("schemas"),
3004 "authored@0.1.0",
3005 manifest,
3006 &["doc"],
3007 );
3008 let mem_b = root.join("mem-b");
3009 std::fs::create_dir_all(&mem_b).unwrap();
3010 save(vec![
3011 mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
3012 mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
3013 ]);
3014
3015 let (actor, client) = cli_actor();
3018 let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
3019 pre.entity_type = "doc".to_string();
3020 pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
3021 let err = engine
3022 .create_entity(pre.clone(), actor, Some(&client), None)
3023 .unwrap_err();
3024 assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
3025 assert!(
3026 !engine
3027 .workspace_schemas()
3028 .iter()
3029 .any(|s| s.id().0 == "authored"),
3030 "schema catalogue is fixed pre-refresh"
3031 );
3032
3033 let report = engine.full_refresh();
3035 assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
3036 assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
3037 assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
3038 assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
3039 assert!(report.failures.is_empty(), "{report:?}");
3040 engine
3041 .create_entity(pre, actor, Some(&client), None)
3042 .expect("newly mounted mem accepts writes after the refresh");
3043
3044 std::fs::remove_dir_all(
3047 root.join(".memstead")
3048 .join("schemas")
3049 .join("authored@0.1.0"),
3050 )
3051 .unwrap();
3052 save(vec![mount(
3053 "notes",
3054 &mem_b,
3055 "authored",
3056 semver::Version::new(0, 1, 0),
3057 )]);
3058 let report = engine.full_refresh();
3059 assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
3060 assert_eq!(
3061 report.schema_removals_skipped,
3062 vec!["authored@0.1.0".to_string()]
3063 );
3064 assert!(report.schemas_added.is_empty());
3065 assert!(report.mems_mounted.is_empty());
3066 engine
3069 .create_entity(
3070 crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
3071 actor,
3072 Some(&client),
3073 None,
3074 )
3075 .expect("skipped-removal mem stays writable");
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.failures.iter().any(|f| f.item == "mount:broken"),
3096 "failed mount must be reported per-item: {report:?}"
3097 );
3098 assert!(
3099 !report.mems_mounted.contains(&"broken".to_string()),
3100 "a failed mount never surfaces as newly available"
3101 );
3102 assert!(
3103 engine
3104 .get_entity(&crate::EntityId::new("broken", "anything"))
3105 .is_none()
3106 && engine.mem_names().contains(&"notes"),
3107 "other mounts unaffected"
3108 );
3109 }
3110
3111 #[test]
3112 fn from_workspace_root_propagates_mem_management_settings() {
3113 let tmp = TempDir::new().unwrap();
3118 let mem_dir = tmp.path().join("mem");
3119 std::fs::create_dir_all(&mem_dir).unwrap();
3120
3121 let memstead = tmp.path().join(".memstead");
3122 std::fs::create_dir_all(&memstead).unwrap();
3123 std::fs::write(
3124 memstead.join("workspace.toml"),
3125 r#"format = "memstead-git-branch-2"
3126
3127[persistence_adapter]
3128name = "file-two-layer"
3129
3130[[mem_management.create]]
3131pattern = "exec-*"
3132schemas = ["default@1.0.0"]
3133
3134[[mem_management.delete]]
3135pattern = "exec-*"
3136"#,
3137 )
3138 .unwrap();
3139 use crate::workspace_store::WorkspaceStoreAdapter;
3140 let store = crate::FileWorkspaceStore::new();
3141 store
3142 .save_state(
3143 tmp.path(),
3144 &crate::workspace::Workspace {
3145 mounts: vec![folder_mount("specs", mem_dir)],
3146 settings: crate::workspace::WorkspaceSettings::default(),
3147 },
3148 )
3149 .unwrap();
3150
3151 let engine = Engine::from_workspace_root(tmp.path()).unwrap();
3152 let s = engine.settings();
3153 assert_eq!(s.mem_create_rules.len(), 1);
3154 assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
3155 assert_eq!(
3156 s.mem_create_rules[0].schemas,
3157 vec!["default@1.0.0".to_string()]
3158 );
3159 assert_eq!(s.mem_delete_rules.len(), 1);
3160 assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
3161 }
3162
3163 #[test]
3171 fn from_mounts_quarantines_unknown_schema_pin() {
3172 let tmp = TempDir::new().unwrap();
3173 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
3174 let mount = Mount {
3175 mem: "specs".to_string(),
3176 schema: Some(SchemaRef::new(
3177 "totally-not-a-schema",
3178 semver::Version::new(1, 0, 0),
3179 )),
3180 storage: MountStorage::Folder {
3181 path: tmp.path().to_path_buf(),
3182 },
3183 capability: MountCapability::Write,
3184 lifecycle: MountLifecycle::Eager,
3185 cross_linkable: true,
3186 migration_target: None,
3187 };
3188 let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3189 .expect("a broken mem quarantines, never fails the workspace");
3190
3191 let roster = engine.quarantined_mems();
3192 assert_eq!(roster.len(), 1);
3193 assert_eq!(roster[0].mount.mem, "specs");
3194 assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
3195 assert!(
3196 roster[0].reason_message.contains("totally-not-a-schema"),
3197 "reason carries the failing pin: {}",
3198 roster[0].reason_message
3199 );
3200 assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
3202 let err = engine.unknown_mem_error("specs");
3205 assert_eq!(err.code(), "MEM_QUARANTINED");
3206 assert!(
3207 err.to_string().contains("SCHEMA_NOT_FOUND"),
3208 "quarantine refusal carries the underlying reason: {err}"
3209 );
3210 let health = engine.health();
3212 assert_eq!(health.quarantined.len(), 1);
3213 assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
3214 }
3215
3216 #[test]
3222 fn reload_returns_repaired_mem_from_quarantine() {
3223 let tmp = TempDir::new().unwrap();
3224 let dir = tmp.path().to_path_buf();
3225 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
3226 let config_path = dir.join(".memstead").join("config.json");
3227 std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
3228 let writer = FilesystemMemWriter::new(dir.clone());
3229 let mount = Mount {
3230 mem: "specs".to_string(),
3231 schema: None,
3232 storage: MountStorage::Folder { path: dir },
3233 capability: MountCapability::Write,
3234 lifecycle: MountLifecycle::Eager,
3235 cross_linkable: true,
3236 migration_target: None,
3237 };
3238 let mut engine =
3239 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3240 assert_eq!(engine.quarantined_mems().len(), 1);
3241 let err = engine.reload_one_mem("specs").unwrap_err();
3244 assert_eq!(err.code(), "MEM_QUARANTINED");
3245 assert_eq!(engine.quarantined_mems().len(), 1);
3246
3247 std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
3250 engine
3251 .reload_one_mem("specs")
3252 .expect("repaired mem re-attaches on reload");
3253 assert!(
3254 engine.quarantined_mems().is_empty(),
3255 "roster entry disappears after re-attach"
3256 );
3257 let mut sections = indexmap::IndexMap::new();
3259 sections.insert("identity".to_string(), "back".to_string());
3260 sections.insert("purpose".to_string(), "post-repair service".to_string());
3261 engine
3262 .create_entity_with_ctx(
3263 crate::engine::CreateEntityArgs {
3264 anchors: Vec::new(),
3265 mem: "specs".to_string(),
3266 title: "Back".to_string(),
3267 entity_type: "spec".to_string(),
3268 sections,
3269 metadata: indexmap::IndexMap::new(),
3270 relations: Vec::new(),
3271 dry_run: false,
3272 },
3273 &crate::vcs::CommitContext::internal(),
3274 )
3275 .expect("reattached mem serves writes");
3276 }
3277
3278 #[test]
3283 fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
3284 let tmp = TempDir::new().unwrap();
3285 let healthy_dir = tmp.path().join("healthy");
3286 std::fs::create_dir_all(&healthy_dir).unwrap();
3287 std::fs::write(
3288 healthy_dir.join("linker.md"),
3289 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
3290 # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
3291 )
3292 .unwrap();
3293 let badpin_dir = tmp.path().join("badpin");
3294 std::fs::create_dir_all(&badpin_dir).unwrap();
3295 let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
3296 (
3297 Mount {
3298 mem: mem.to_string(),
3299 schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
3300 storage: MountStorage::Folder { path: dir.clone() },
3301 capability: MountCapability::Write,
3302 lifecycle: MountLifecycle::Eager,
3303 cross_linkable: true,
3304 migration_target: None,
3305 },
3306 Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
3307 )
3308 };
3309 let engine = Engine::from_mounts(vec![
3310 mount("healthy", healthy_dir, "default"),
3311 mount("badpin", badpin_dir, "ghost"),
3312 ])
3313 .expect("boot survives the cross-mem link into the quarantined mem");
3314 assert_eq!(engine.quarantined_mems().len(), 1);
3315 let linker = engine
3319 .get_entity(&crate::EntityId::new("healthy", "linker"))
3320 .expect("linking entity loads");
3321 assert_eq!(linker.entity_type, "spec");
3322 assert!(
3323 engine
3324 .get_entity(&crate::EntityId::new("badpin", "target"))
3325 .is_none_or(|e| e.stub),
3326 "the quarantined-side target is at most a stub, never real data"
3327 );
3328 }
3329
3330 #[test]
3336 fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
3337 let tmp = TempDir::new().unwrap();
3338 let root = tmp.path();
3339 std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
3341 std::fs::write(
3342 root.join(".memstead").join("workspace.toml"),
3343 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3344 )
3345 .unwrap();
3346 for m in ["healthy", "oldkey"] {
3347 std::fs::create_dir_all(root.join(m)).unwrap();
3348 }
3349 std::fs::write(
3350 root.join(".memstead").join("state").join("mounts.json"),
3351 r#"{ "format": "memstead-mounts-3", "mounts": [
3352 { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
3353 { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
3354 ] }"#,
3355 )
3356 .unwrap();
3357 let pkg = root
3359 .join(".memstead")
3360 .join("schemas")
3361 .join("fieldschema@0.1.0");
3362 std::fs::create_dir_all(pkg.join("types")).unwrap();
3363 std::fs::write(
3364 pkg.join("schema.yaml"),
3365 "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",
3366 )
3367 .unwrap();
3368 std::fs::write(
3369 pkg.join("types").join("thing.yaml"),
3370 "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",
3371 )
3372 .unwrap();
3373
3374 let engine = Engine::from_workspace_root(root)
3375 .expect("the old-key schema quarantines its mem, never the workspace");
3376 assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
3377 let q = engine
3378 .quarantine_reason("oldkey")
3379 .expect("oldkey mem is quarantined");
3380 assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
3381 assert!(
3382 q.reason_message.contains("no_self_loop_relationships"),
3383 "quarantine reason is the rename error naming the new key: {}",
3384 q.reason_message
3385 );
3386 }
3387
3388 #[test]
3393 fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
3394 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";
3395 let boot = |pin: &str| {
3396 let tmp = TempDir::new().unwrap();
3397 let dir = tmp.path().to_path_buf();
3398 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
3399 std::fs::write(
3400 dir.join(".memstead").join("config.json"),
3401 format!("{{ \"schema\": \"{pin}\" }}"),
3402 )
3403 .unwrap();
3404 std::fs::write(dir.join("gap.md"), entry_md).unwrap();
3405 let writer = FilesystemMemWriter::new(dir.clone());
3406 let mount = Mount {
3407 mem: "proc".to_string(),
3408 schema: None,
3409 storage: MountStorage::Folder { path: dir },
3410 capability: MountCapability::Write,
3411 lifecycle: MountLifecycle::Eager,
3412 cross_linkable: true,
3413 migration_target: None,
3414 };
3415 let engine =
3416 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
3417 .unwrap();
3418 (engine.health(), tmp)
3419 };
3420
3421 let (health_new, _t1) = boot("ingest@0.3.0");
3422 assert_eq!(
3423 health_new.orphan_count, 0,
3424 "0.3.0 entry types are leaves — zero false orphans"
3425 );
3426 assert_eq!(
3427 health_new
3428 .leaf_entities_by_type
3429 .get("ingest@0.3.0:coverage_gap"),
3430 Some(&1),
3431 "the population stays visible: {:?}",
3432 health_new.leaf_entities_by_type
3433 );
3434
3435 let (health_old, _t2) = boot("ingest@0.2.0");
3436 assert_eq!(
3437 health_old.orphan_count, 1,
3438 "the prior version's behaviour is unchanged"
3439 );
3440 assert!(health_old.leaf_entities_by_type.is_empty());
3441 }
3442
3443 #[test]
3449 fn broken_mem_quarantines_while_healthy_siblings_serve() {
3450 let tmp = TempDir::new().unwrap();
3451 let make_mount = |mem: &str, pin: Option<SchemaRef>| {
3452 let dir = tmp.path().join(mem);
3453 std::fs::create_dir_all(&dir).unwrap();
3454 let writer = FilesystemMemWriter::new(dir.clone());
3455 (
3456 Mount {
3457 mem: mem.to_string(),
3458 schema: pin,
3459 storage: MountStorage::Folder { path: dir },
3460 capability: MountCapability::Write,
3461 lifecycle: MountLifecycle::Eager,
3462 cross_linkable: true,
3463 migration_target: None,
3464 },
3465 Box::new(writer) as Box<dyn MemBackend>,
3466 )
3467 };
3468 let healthy = make_mount(
3469 "healthy",
3470 Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3471 );
3472 let bad_pin = make_mount(
3473 "badpin",
3474 Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
3475 );
3476 let missing_pin = make_mount("nopin", None);
3477 let bad_io_path = tmp.path().join("badio");
3480 std::fs::write(&bad_io_path, "not a directory").unwrap();
3481 let bad_io = (
3482 Mount {
3483 mem: "badio".to_string(),
3484 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3485 storage: MountStorage::Folder {
3486 path: bad_io_path.clone(),
3487 },
3488 capability: MountCapability::Write,
3489 lifecycle: MountLifecycle::Eager,
3490 cross_linkable: true,
3491 migration_target: None,
3492 },
3493 Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
3494 );
3495 let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
3496 .expect("mixed workspace boots");
3497
3498 let codes: std::collections::HashMap<String, String> = engine
3500 .quarantined_mems()
3501 .iter()
3502 .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
3503 .collect();
3504 assert_eq!(
3505 codes.get("badpin").map(String::as_str),
3506 Some("SCHEMA_NOT_FOUND")
3507 );
3508 assert_eq!(
3509 codes.get("nopin").map(String::as_str),
3510 Some("MEM_CONFIG_INCOMPLETE")
3511 );
3512 assert!(
3513 codes.contains_key("badio"),
3514 "backend read failure quarantines too: {codes:?}"
3515 );
3516
3517 let mut sections = indexmap::IndexMap::new();
3519 sections.insert("identity".to_string(), "alive".to_string());
3520 sections.insert("purpose".to_string(), "proof of service".to_string());
3521 let created = engine
3522 .create_entity_with_ctx(
3523 crate::engine::CreateEntityArgs {
3524 anchors: Vec::new(),
3525 mem: "healthy".to_string(),
3526 title: "Alive".to_string(),
3527 entity_type: "spec".to_string(),
3528 sections,
3529 metadata: indexmap::IndexMap::new(),
3530 relations: Vec::new(),
3531 dry_run: false,
3532 },
3533 &crate::vcs::CommitContext::internal(),
3534 )
3535 .expect("healthy mem serves writes");
3536 assert_eq!(created.id.to_string(), "healthy--alive");
3537
3538 let mut sections = indexmap::IndexMap::new();
3540 sections.insert("identity".to_string(), "x".to_string());
3541 let err = engine
3542 .create_entity_with_ctx(
3543 crate::engine::CreateEntityArgs {
3544 anchors: Vec::new(),
3545 mem: "badpin".to_string(),
3546 title: "Nope".to_string(),
3547 entity_type: "spec".to_string(),
3548 sections,
3549 metadata: indexmap::IndexMap::new(),
3550 relations: Vec::new(),
3551 dry_run: false,
3552 },
3553 &crate::vcs::CommitContext::internal(),
3554 )
3555 .unwrap_err();
3556 assert_eq!(err.code(), "MEM_QUARANTINED");
3557 }
3558
3559 #[test]
3566 fn mem_config_schema_is_authoritative_over_mount_pin() {
3567 let tmp = TempDir::new().unwrap();
3568 let mem_dir = tmp.path().to_path_buf();
3569 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3570 std::fs::write(
3571 mem_dir.join(".memstead").join("config.json"),
3572 r#"{"schema":"software@0.1.0"}"#,
3573 )
3574 .unwrap();
3575 let writer = FilesystemMemWriter::new(mem_dir.clone());
3576 let mount = Mount {
3577 mem: "specs".to_string(),
3578 schema: Some(SchemaRef::new(
3579 "totally-not-a-schema",
3580 semver::Version::new(9, 9, 9),
3581 )),
3582 storage: MountStorage::Folder { path: mem_dir },
3583 capability: MountCapability::Write,
3584 lifecycle: MountLifecycle::Eager,
3585 cross_linkable: true,
3586 migration_target: None,
3587 };
3588 let engine = Engine::from_mounts(vec![(
3589 mount,
3590 Box::new(writer) as Box<dyn MemBackend>,
3591 )])
3592 .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3593
3594 let mismatch = engine
3595 .load_warnings()
3596 .iter()
3597 .find_map(|w| match w {
3598 WarningHint::SchemaPinMismatch {
3599 mem,
3600 config_pin,
3601 mount_pin,
3602 } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3603 _ => None,
3604 })
3605 .expect("SchemaPinMismatch warning must surface naming both pins");
3606 assert_eq!(mismatch.0, "specs");
3607 assert_eq!(mismatch.1, "software@0.1.0");
3608 assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3609 }
3610
3611 #[test]
3612 fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3613 let tmp = TempDir::new().unwrap();
3614 let memstead = tmp.path().join(".memstead");
3615 std::fs::create_dir_all(&memstead).unwrap();
3616 std::fs::write(
3617 memstead.join("workspace.toml"),
3618 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3619 )
3620 .unwrap();
3621 let state_dir = memstead.join("state");
3624 std::fs::create_dir_all(&state_dir).unwrap();
3625 std::fs::write(
3626 state_dir.join("mounts.json"),
3627 r#"{
3628 "format": "memstead-mounts-3",
3629 "mounts": [
3630 {
3631 "mem": "specs",
3632 "schema": "default@1.0.0",
3633 "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3634 "capability": "write",
3635 "lifecycle": "eager",
3636 "cross_linkable": true
3637 }
3638 ]
3639 }"#,
3640 )
3641 .unwrap();
3642 let engine = Engine::from_workspace_root(tmp.path())
3650 .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3651 let roster = engine.quarantined_mems();
3652 assert_eq!(roster.len(), 1);
3653 assert_eq!(roster[0].mount.mem, "specs");
3654 assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3655 assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3656 }
3657
3658 #[test]
3659 fn from_workspace_root_roots_standalone_folder_mem() {
3660 let tmp = TempDir::new().unwrap();
3664 let root = tmp.path();
3665 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3666 std::fs::write(
3667 root.join(".memstead").join("config.json"),
3668 r#"{"schema":"default@1.0.0"}"#,
3669 )
3670 .unwrap();
3671 std::fs::write(
3673 root.join("hello.md"),
3674 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3675 )
3676 .unwrap();
3677
3678 let engine = Engine::from_workspace_root(root)
3679 .expect("a bare folder mem must root as a one-mount workspace");
3680 assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3681 assert!(
3682 engine.status().entity_count >= 1,
3683 "the standalone mem's entity must load"
3684 );
3685 }
3686
3687 #[test]
3688 fn booting_a_workspace_writes_nothing_into_it() {
3689 let tmp = TempDir::new().unwrap();
3698 let root = tmp.path();
3699 std::fs::create_dir_all(root.join(".memstead").join("meta-schemas")).unwrap();
3700 std::fs::write(
3701 root.join(".memstead").join("config.json"),
3702 r#"{"schema":"default@1.0.0"}"#,
3703 )
3704 .unwrap();
3705 let stale = root
3708 .join(".memstead")
3709 .join("meta-schemas")
3710 .join("type-definition.schema.json");
3711 std::fs::write(&stale, "{\"title\":\"stale, from an older binary\"}").unwrap();
3712 std::fs::write(
3713 root.join("hello.md"),
3714 "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3715 )
3716 .unwrap();
3717
3718 fn snapshot(dir: &Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> {
3719 let mut out = std::collections::BTreeMap::new();
3720 let mut stack = vec![dir.to_path_buf()];
3721 while let Some(d) = stack.pop() {
3722 for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
3723 let p = entry.path();
3724 if p.is_dir() {
3725 stack.push(p);
3726 } else if let Ok(bytes) = std::fs::read(&p) {
3727 out.insert(p, bytes);
3728 }
3729 }
3730 }
3731 out
3732 }
3733
3734 let before = snapshot(root);
3735 let engine = Engine::from_workspace_root(root).expect("the fixture must boot");
3736 assert_eq!(engine.status().mem_count, 1, "fixture sanity: one mount");
3737 let after = snapshot(root);
3738
3739 assert_eq!(
3740 before.keys().collect::<Vec<_>>(),
3741 after.keys().collect::<Vec<_>>(),
3742 "boot created or removed a file in the workspace it read"
3743 );
3744 for (path, bytes) in &before {
3745 assert_eq!(
3746 bytes,
3747 after.get(path).unwrap(),
3748 "boot rewrote {} in the workspace it read",
3749 path.display()
3750 );
3751 }
3752 }
3753
3754 #[test]
3755 fn from_workspace_root_still_rejects_truly_empty_dir() {
3756 let tmp = TempDir::new().unwrap();
3760 let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3761 assert!(
3762 matches!(err, crate::BootError::NotInitialised(_)),
3763 "got {err:?}"
3764 );
3765 }
3766
3767 #[test]
3774 fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3775 let tmp = TempDir::new().unwrap();
3776 let schemas_dir = tmp.path().join("schemas");
3777 let pkg = schemas_dir.join("debate");
3778 std::fs::create_dir_all(pkg.join("types")).unwrap();
3779 std::fs::write(
3780 pkg.join("schema.yaml"),
3781 r#"name: debate
3782version: 0.1.0
3783description: sealed-violator fixture
3784when_to_use: tests
3785types:
3786 - question
3787relationships:
3788 mode: strict
3789 definitions:
3790 - name: PART_OF
3791 description: hier
3792 default_weight: 3.0
3793 - name: _default
3794 description: fallback
3795 default_weight: 1.0
3796community:
3797 resolution: 1.0
3798 seed: 42
3799"#,
3800 )
3801 .unwrap();
3802 std::fs::write(
3803 pkg.join("types").join("question.yaml"),
3804 r#"name: question
3805description: t
3806when_to_use: tests
3807sections:
3808 - key: answers
3809 heading: Answers argued
3810 required: true
3811 search_weight: 10.0
3812 write_rules: []
3813 - key: notes
3814 heading: Notes
3815 required: false
3816 search_weight: 3.0
3817 catch_all: true
3818 write_rules: []
3819metadata_fields: []
3820title_weight: 100.0
3821text_fields:
3822 - answers
3823 - notes
3824hierarchy_relationship: PART_OF
3825no_self_loop_relationships: []
3826updatable_fields:
3827 - title
3828 - answers
3829 - notes
3830health_required_fields:
3831 - answers
3832staleness_threshold_days: 90
3833write_rules: []
3834"#,
3835 )
3836 .unwrap();
3837
3838 let mem_dir = tmp.path().join("mem");
3839 std::fs::create_dir_all(&mem_dir).unwrap();
3840 std::fs::write(
3841 mem_dir.join("q.md"),
3842 "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3843 )
3844 .unwrap();
3845
3846 let writer = FilesystemMemWriter::new(mem_dir.clone());
3847 let mount = Mount {
3848 mem: "debate-mem".to_string(),
3849 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3850 storage: MountStorage::Folder { path: mem_dir },
3851 capability: MountCapability::Write,
3852 lifecycle: MountLifecycle::Eager,
3853 cross_linkable: true,
3854 migration_target: None,
3855 };
3856 let engine = Engine::from_mounts_with_schemas_dir(
3857 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3858 Some(&schemas_dir),
3859 )
3860 .expect("a violating sealed schema must keep loading, never refuse boot");
3861
3862 assert!(
3864 engine.status().entity_count >= 1,
3865 "entities load despite the schema violation"
3866 );
3867
3868 let hits: Vec<_> = engine
3870 .load_warnings()
3871 .iter()
3872 .filter_map(|w| match w {
3873 WarningHint::SchemaHeadingRoundtripViolation {
3874 mem,
3875 schema_ref,
3876 violations,
3877 } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3878 _ => None,
3879 })
3880 .collect();
3881 assert_eq!(
3882 hits.len(),
3883 1,
3884 "exactly one schema-level finding; all warnings = {:?}",
3885 engine.load_warnings()
3886 );
3887 let (mem, schema_ref, violations) = &hits[0];
3888 assert_eq!(mem, "debate-mem");
3889 assert_eq!(schema_ref, "debate@0.1.0");
3890 assert_eq!(violations.len(), 1);
3891 assert_eq!(violations[0].type_name, "question");
3892 assert_eq!(violations[0].key, "answers");
3893 assert_eq!(violations[0].heading, "Answers argued");
3894 assert_eq!(violations[0].derived_key, "answers_argued");
3895 }
3896
3897 #[test]
3905 fn sealed_violator_mem_still_serves_writes() {
3906 let tmp = TempDir::new().unwrap();
3908 let schemas_dir = tmp.path().join("schemas");
3909 let pkg = schemas_dir.join("debate");
3910 std::fs::create_dir_all(pkg.join("types")).unwrap();
3911 std::fs::write(
3912 pkg.join("schema.yaml"),
3913 r#"name: debate
3914version: 0.1.0
3915description: sealed-violator fixture
3916when_to_use: tests
3917types:
3918 - question
3919relationships:
3920 mode: strict
3921 definitions:
3922 - name: PART_OF
3923 description: hier
3924 default_weight: 3.0
3925 - name: _default
3926 description: fallback
3927 default_weight: 1.0
3928community:
3929 resolution: 1.0
3930 seed: 42
3931"#,
3932 )
3933 .unwrap();
3934 std::fs::write(
3935 pkg.join("types").join("question.yaml"),
3936 r#"name: question
3937description: t
3938when_to_use: tests
3939sections:
3940 - key: answers
3941 heading: Answers argued
3942 required: true
3943 search_weight: 10.0
3944 write_rules: []
3945 - key: notes
3946 heading: Notes
3947 required: false
3948 search_weight: 3.0
3949 catch_all: true
3950 write_rules: []
3951metadata_fields: []
3952title_weight: 100.0
3953text_fields:
3954 - answers
3955 - notes
3956hierarchy_relationship: PART_OF
3957no_self_loop_relationships: []
3958updatable_fields:
3959 - title
3960 - answers
3961 - notes
3962health_required_fields:
3963 - answers
3964staleness_threshold_days: 90
3965write_rules: []
3966"#,
3967 )
3968 .unwrap();
3969
3970 let mem_dir = tmp.path().join("mem");
3971 std::fs::create_dir_all(&mem_dir).unwrap();
3972 std::fs::write(
3976 mem_dir.join("q.md"),
3977 "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3978 )
3979 .unwrap();
3980
3981 let writer = FilesystemMemWriter::new(mem_dir.clone());
3982 let mount = Mount {
3983 mem: "debate-mem".to_string(),
3984 schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3985 storage: MountStorage::Folder { path: mem_dir },
3986 capability: MountCapability::Write,
3987 lifecycle: MountLifecycle::Eager,
3988 cross_linkable: true,
3989 migration_target: None,
3990 };
3991 let mut engine = Engine::from_mounts_with_schemas_dir(
3992 vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3993 Some(&schemas_dir),
3994 )
3995 .expect("a violating sealed schema must keep loading");
3996
3997 let id = crate::EntityId::new("debate-mem", "q");
3998 let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3999 let mut sections = indexmap::IndexMap::new();
4000 sections.insert("answers".to_string(), "Updated answers body.".to_string());
4001 let outcome = engine
4002 .update_entity(
4003 crate::engine::UpdateEntityArgs {
4004 anchors: Vec::new(),
4005 anchors_unset: Vec::new(),
4006 id: id.clone(),
4007 expected_hash: Some(hash),
4008 sections,
4009 append_sections: indexmap::IndexMap::new(),
4010 patch_sections: indexmap::IndexMap::new(),
4011 sections_unset: Vec::new(),
4012 metadata: indexmap::IndexMap::new(),
4013 metadata_unset: Vec::new(),
4014 declare_relations: Vec::new(),
4015 dry_run: false,
4016 relations_unset: Vec::new(),
4017 },
4018 crate::vcs::Actor::Cli,
4019 None,
4020 None,
4021 )
4022 .expect("a write against a sealed-violator mem must NOT be refused");
4023 assert!(!outcome.write_id.is_empty(), "the write commits");
4024 assert!(
4025 outcome
4026 .warnings
4027 .iter()
4028 .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
4029 "the write-path divergence warning fires where its condition holds: {:?}",
4030 outcome.warnings
4031 );
4032
4033 assert!(
4035 engine.load_warnings().iter().any(|w| matches!(
4036 w,
4037 WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
4038 )),
4039 "the health finding persists across writes"
4040 );
4041 let entity = engine.get_entity(&id).unwrap();
4050 assert!(
4051 entity
4052 .sections
4053 .values()
4054 .any(|s| s.contains("Updated answers body.")),
4055 "written content survives the round-trip (in the catch-all): {:?}",
4056 entity.sections
4057 );
4058 }
4059
4060 #[test]
4067 fn search_mem_filter_gates_against_visible_roster() {
4068 use crate::vcs::Actor;
4069 use crate::workspace_store::WorkspaceStoreAdapter;
4070 use indexmap::IndexMap;
4071
4072 let tmp = TempDir::new().unwrap();
4073 let mem_dir = tmp.path().join("mem");
4074 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4075 std::fs::write(
4076 mem_dir.join(".memstead").join("config.json"),
4077 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4078 )
4079 .unwrap();
4080 let memstead = tmp.path().join(".memstead");
4081 std::fs::create_dir_all(&memstead).unwrap();
4082 std::fs::write(
4083 memstead.join("workspace.toml"),
4084 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4085 )
4086 .unwrap();
4087 crate::FileWorkspaceStore::new()
4088 .save_state(
4089 tmp.path(),
4090 &crate::workspace::Workspace {
4091 mounts: vec![folder_mount("specs", mem_dir.clone())],
4092 settings: crate::workspace::WorkspaceSettings::default(),
4093 },
4094 )
4095 .unwrap();
4096 let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4097 let mut sections = IndexMap::new();
4098 sections.insert(
4099 "identity".to_string(),
4100 "Zebra searching fixture.".to_string(),
4101 );
4102 sections.insert("purpose".to_string(), "Search gate test.".to_string());
4103 engine
4104 .create_entity(
4105 crate::CreateEntityArgs {
4106 mem: "specs".to_string(),
4107 title: "Zebra".to_string(),
4108 entity_type: "spec".to_string(),
4109 sections,
4110 metadata: IndexMap::new(),
4111 relations: Vec::new(),
4112 anchors: Vec::new(),
4113 dry_run: false,
4114 },
4115 Actor::Agent,
4116 None,
4117 None,
4118 )
4119 .unwrap();
4120
4121 let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
4122 query: Some(crate::ops::Query {
4123 any: vec![term.to_string()],
4124 ..Default::default()
4125 }),
4126 mem: mem.map(str::to_string),
4127 ..Default::default()
4128 };
4129
4130 let err = engine
4132 .search(&scope(Some("no-such-mem"), "zebra"))
4133 .expect_err("a nonexistent mem filter must refuse");
4134 assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
4135
4136 let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
4138 assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
4139
4140 let none = engine
4143 .search(&scope(Some("specs"), "quixotic-nonword"))
4144 .unwrap();
4145 assert_eq!(none.total, 0, "{none:?}");
4146 }
4147
4148 fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
4153 Mount {
4154 lifecycle: MountLifecycle::Lazy,
4155 ..folder_mount(mem, path)
4156 }
4157 }
4158
4159 fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
4160 std::fs::write(
4161 dir.join(format!("{slug}.md")),
4162 format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
4163 )
4164 .unwrap();
4165 }
4166
4167 fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
4168 let eager_dir = tmp.path().join("eag");
4169 let lazy_dir = tmp.path().join("laz");
4170 std::fs::create_dir_all(&eager_dir).unwrap();
4171 std::fs::create_dir_all(&lazy_dir).unwrap();
4172 write_spec(&eager_dir, "alpha", "Alpha", "");
4173 write_spec(&lazy_dir, "omega", "Omega", "");
4174 (eager_dir, lazy_dir)
4175 }
4176
4177 fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
4178 Engine::from_mounts(vec![
4179 (
4180 folder_mount("eag", eager_dir.to_path_buf()),
4181 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4182 ),
4183 (
4184 lazy_folder_mount("laz", lazy_dir.to_path_buf()),
4185 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4186 ),
4187 ])
4188 .unwrap()
4189 }
4190
4191 #[test]
4197 fn lazy_mount_defers_and_first_read_loads() {
4198 let tmp = TempDir::new().unwrap();
4199 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4200 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4201
4202 assert!(
4204 engine
4205 .get_entity(&crate::EntityId::new("eag", "alpha"))
4206 .is_some()
4207 );
4208 assert_eq!(engine.deferred_mems(), vec!["laz"]);
4209 assert!(engine.mem_is_deferred("laz"));
4210 assert!(
4211 engine
4212 .get_entity(&crate::EntityId::new("laz", "omega"))
4213 .is_none(),
4214 "deferred mem's entities are not in the store yet"
4215 );
4216 assert!(engine.schema_for("laz").is_some());
4218
4219 engine.reload_if_stale(Some("laz"));
4221 assert!(engine.deferred_mems().is_empty());
4222 let omega = engine
4223 .get_entity(&crate::EntityId::new("laz", "omega"))
4224 .expect("first read loads the mem");
4225 assert_eq!(omega.title, "Omega");
4226
4227 let (actor, client) = cli_actor();
4229 engine
4230 .create_entity(
4231 empty_create_args("laz", "Later"),
4232 actor,
4233 Some(&client),
4234 None,
4235 )
4236 .unwrap();
4237 assert!(
4238 engine
4239 .get_entity(&crate::EntityId::new("laz", "later"))
4240 .is_some()
4241 );
4242 }
4243
4244 #[test]
4248 fn scoped_operation_never_loads_lazy_sibling() {
4249 let tmp = TempDir::new().unwrap();
4250 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4251 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4252
4253 engine.reload_if_stale(Some("eag"));
4254 assert_eq!(
4255 engine.deferred_mems(),
4256 vec!["laz"],
4257 "an eager-scoped operation must not load the lazy sibling"
4258 );
4259
4260 engine.reload_if_stale(None);
4261 assert!(
4262 engine.deferred_mems().is_empty(),
4263 "a workspace-scoped pass loads every deferred mem"
4264 );
4265 }
4266
4267 #[test]
4271 fn lazy_load_failure_quarantines_typed() {
4272 let tmp = TempDir::new().unwrap();
4273 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4274 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4275
4276 std::fs::remove_dir_all(&lazy_dir).unwrap();
4280 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4281 engine.reload_if_stale(Some("laz"));
4282
4283 let q = engine
4284 .quarantine_reason("laz")
4285 .expect("failed deferred load quarantines, never serves empty");
4286 assert!(!q.reason_code.is_empty());
4287 assert!(
4288 !engine.mem_names().contains(&"laz"),
4289 "a quarantined mem leaves the serving roster"
4290 );
4291 assert!(engine.deferred_mems().is_empty());
4292 }
4293
4294 #[test]
4302 fn quarantine_entry_invalidates_both_memos() {
4303 let tmp = TempDir::new().unwrap();
4304 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4305 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4306
4307 let _ = engine.communities();
4309 let _ = engine.search_indexes();
4310 assert!(engine.search_indexes_memo.get().is_some());
4311
4312 std::fs::remove_dir_all(&lazy_dir).unwrap();
4314 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4315 engine.reload_if_stale(Some("laz"));
4316 assert!(engine.quarantine_reason("laz").is_some());
4317 assert!(
4318 engine.search_indexes_memo.get().is_none(),
4319 "quarantine entry bumps the schemas epoch — the search memo must clear"
4320 );
4321 assert!(
4322 engine.community_memo.get().is_none(),
4323 "quarantine entry must clear the community memo too"
4324 );
4325 let _ = engine.search_indexes();
4327
4328 let _ = engine.communities();
4330 let _ = engine.search_indexes();
4331 let _ = engine.reload_one_mem("laz");
4332 assert!(engine.quarantine_reason("laz").is_some());
4333 assert!(
4334 engine.search_indexes_memo.get().is_none(),
4335 "a failed reattach bumps the epoch — the search memo must clear"
4336 );
4337 let _ = engine.search_indexes();
4338 }
4339
4340 #[test]
4345 fn quarantine_reattach_invalidates_both_memos() {
4346 let tmp = TempDir::new().unwrap();
4347 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4348 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4349
4350 std::fs::remove_dir_all(&lazy_dir).unwrap();
4352 std::fs::write(&lazy_dir, b"not a directory").unwrap();
4353 engine.reload_if_stale(Some("laz"));
4354 assert!(engine.quarantine_reason("laz").is_some());
4355
4356 let _ = engine.communities();
4358 let _ = engine.search_indexes();
4359 assert!(engine.community_memo.get().is_some());
4360 assert!(engine.search_indexes_memo.get().is_some());
4361
4362 std::fs::remove_file(&lazy_dir).unwrap();
4364 std::fs::create_dir_all(&lazy_dir).unwrap();
4365 write_spec(&lazy_dir, "omega", "Omega", "");
4366 engine
4367 .reload_one_mem("laz")
4368 .expect("reattach succeeds once the backend is back");
4369 assert!(engine.quarantine_reason("laz").is_none());
4370
4371 assert!(
4374 engine.community_memo.get().is_none(),
4375 "reattach must invalidate the community memo"
4376 );
4377 assert!(
4378 engine.search_indexes_memo.get().is_none(),
4379 "reattach must invalidate the search memo"
4380 );
4381 }
4382
4383 #[test]
4388 fn lazy_load_runs_the_same_validation_gauntlet() {
4389 let tmp = TempDir::new().unwrap();
4390 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4391 std::fs::write(
4393 lazy_dir.join("bad.md"),
4394 "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
4395 )
4396 .unwrap();
4397
4398 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4399 let warned_before = engine
4400 .load_warnings()
4401 .iter()
4402 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
4403 engine.reload_if_stale(Some("laz"));
4404 let warned_after = engine
4405 .load_warnings()
4406 .iter()
4407 .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
4408 assert!(
4409 !warned_before && warned_after,
4410 "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
4411 warnings: {:?}",
4412 engine.load_warnings()
4413 );
4414 let bad = engine
4416 .get_entity(&crate::EntityId::new("laz", "bad"))
4417 .unwrap();
4418 assert!(bad.relationships.is_empty());
4419 }
4420
4421 #[test]
4429 fn delete_guard_sees_referrers_in_deferred_mems() {
4430 use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
4431
4432 let tmp = TempDir::new().unwrap();
4433 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4434 let (actor, client) = cli_actor();
4435
4436 {
4440 let mut authoring = Engine::from_mounts(vec![
4441 (
4442 folder_mount("eag", eager_dir.to_path_buf()),
4443 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
4444 as Box<dyn MemBackend>,
4445 ),
4446 (
4447 folder_mount("laz", lazy_dir.to_path_buf()),
4448 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
4449 as Box<dyn MemBackend>,
4450 ),
4451 ])
4452 .unwrap();
4453 let mut settings = crate::workspace::WorkspaceSettings::default();
4454 settings.cross_mem_links.insert(
4455 "laz".to_string(),
4456 memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
4457 );
4458 authoring.set_settings(settings);
4459 authoring
4460 .relate_entity(
4461 RelateEntityArgs {
4462 source: crate::EntityId::new("laz", "omega"),
4463 expected_hash: None,
4464 rel_type: "USES".to_string(),
4465 target: crate::EntityId::new("eag", "alpha"),
4466 remove: false,
4467 description: None,
4468 dry_run: false,
4469 },
4470 actor,
4471 Some(&client),
4472 None,
4473 )
4474 .expect("cross-mem relate lands under the grant");
4475 }
4476
4477 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4481 assert!(engine.mem_is_deferred("laz"));
4482 let err = engine
4483 .delete_entity(
4484 DeleteEntityArgs {
4485 id: crate::EntityId::new("eag", "alpha"),
4486 expected_hash: None,
4487 },
4488 actor,
4489 Some(&client),
4490 None,
4491 )
4492 .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
4493 assert!(
4494 matches!(err, EngineError::HasIncomingRefs { .. }),
4495 "expected HAS_INCOMING_REFS, got {err:?}"
4496 );
4497 assert!(
4498 engine
4499 .get_entity(&crate::EntityId::new("eag", "alpha"))
4500 .is_some(),
4501 "the entity survives"
4502 );
4503 }
4504
4505 #[test]
4516 fn acyclicity_guard_sees_edges_in_deferred_mems() {
4517 use crate::engine::RelateEntityArgs;
4518 use memstead_schema::workspace_config::CrossLinkValue;
4519
4520 let tmp = TempDir::new().unwrap();
4521 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4522 .iter()
4523 .map(|m| {
4524 let d = tmp.path().join(m);
4525 std::fs::create_dir_all(&d).unwrap();
4526 d
4527 })
4528 .collect();
4529 write_spec(&dirs[0], "node", "Node A", "");
4530 write_spec(&dirs[1], "node", "Node B", "");
4531 write_spec(&dirs[2], "node", "Node C", "");
4532
4533 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4534 ["ma", "mb", "mc"]
4535 .iter()
4536 .zip(dirs.iter())
4537 .map(|(m, d)| {
4538 let mut mount = folder_mount(m, d.clone());
4539 if lazy_mid && *m == "ma" {
4540 mount.lifecycle = MountLifecycle::Lazy;
4541 }
4542 (
4543 mount,
4544 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4545 )
4546 })
4547 .collect()
4548 };
4549 let grants = || {
4550 let mut settings = crate::workspace::WorkspaceSettings::default();
4551 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4552 settings
4553 .cross_mem_links
4554 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4555 }
4556 settings
4557 };
4558 let relate = |engine: &mut Engine, from: &str, to: &str| {
4559 let (actor, client) = cli_actor();
4560 engine.relate_entity(
4561 RelateEntityArgs {
4562 source: crate::EntityId::new(from, "node"),
4563 expected_hash: None,
4564 rel_type: "DEPENDS_ON".to_string(),
4565 target: crate::EntityId::new(to, "node"),
4566 remove: false,
4567 description: None,
4568 dry_run: false,
4569 },
4570 actor,
4571 Some(&client),
4572 None,
4573 )
4574 };
4575
4576 {
4578 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4579 authoring.set_settings(grants());
4580 relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
4581 relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
4582 }
4583
4584 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4587 engine.set_settings(grants());
4588 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4589 let err = relate(&mut engine, "mc", "mb")
4590 .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
4591 assert!(
4592 matches!(err, EngineError::RelationshipCycle { .. }),
4593 "expected RELATIONSHIP_CYCLE, got {err:?}"
4594 );
4595 }
4596
4597 #[test]
4605 fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
4606 use crate::engine::RelateEntityArgs;
4607 use memstead_schema::workspace_config::CrossLinkValue;
4608
4609 let tmp = TempDir::new().unwrap();
4610 let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4611 .iter()
4612 .map(|m| {
4613 let d = tmp.path().join(m);
4614 std::fs::create_dir_all(&d).unwrap();
4615 d
4616 })
4617 .collect();
4618 write_spec(&dirs[0], "node", "Node A", "");
4619 write_spec(&dirs[1], "node", "Node B", "");
4620 write_spec(&dirs[2], "node", "Node C", "");
4621 write_spec(&dirs[2], "node2", "Node C2", "");
4626
4627 let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4628 ["ma", "mb", "mc"]
4629 .iter()
4630 .zip(dirs.iter())
4631 .map(|(m, d)| {
4632 let mut mount = folder_mount(m, d.clone());
4633 if lazy_mid && *m == "ma" {
4634 mount.lifecycle = MountLifecycle::Lazy;
4635 }
4636 (
4637 mount,
4638 Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4639 )
4640 })
4641 .collect()
4642 };
4643 let grants = || {
4644 let mut settings = crate::workspace::WorkspaceSettings::default();
4645 for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4646 settings
4647 .cross_mem_links
4648 .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4649 }
4650 settings
4651 };
4652 let relate_args = |from: &str, to: &str| RelateEntityArgs {
4653 source: crate::EntityId::new(from, "node"),
4654 expected_hash: None,
4655 rel_type: "DEPENDS_ON".to_string(),
4656 target: crate::EntityId::new(to, "node"),
4657 remove: false,
4658 description: None,
4659 dry_run: false,
4660 };
4661
4662 {
4664 let (actor, client) = cli_actor();
4665 let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4666 authoring.set_settings(grants());
4667 authoring
4668 .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4669 .expect("mb→ma lands");
4670 let (actor2, client2) = cli_actor();
4671 authoring
4672 .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4673 .expect("ma→mc lands");
4674 }
4675
4676 let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4680 engine.set_settings(grants());
4681 assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4682 let (actor, client) = cli_actor();
4683 let result = engine
4684 .batch_relate(
4685 vec![
4686 (relate_args("mc", "mb"), None),
4687 (
4693 RelateEntityArgs {
4694 source: crate::EntityId::new("mc", "node2"),
4695 expected_hash: None,
4696 rel_type: "DEPENDS_ON".to_string(),
4697 target: crate::EntityId::new("mc", "node"),
4698 remove: false,
4699 description: None,
4700 dry_run: false,
4701 },
4702 None,
4703 ),
4704 ],
4705 actor,
4706 Some(&client),
4707 false,
4708 )
4709 .expect("the batch call itself returns a report-all envelope");
4710 assert!(
4711 !result.applied,
4712 "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4713 result.succeeded
4714 );
4715 }
4716
4717 #[test]
4725 fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4726 use crate::engine::RelateEntityArgs;
4727 use memstead_schema::workspace_config::CrossLinkValue;
4728
4729 let tmp = TempDir::new().unwrap();
4730 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4731 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4732 let mut settings = crate::workspace::WorkspaceSettings::default();
4733 settings.cross_mem_links.insert(
4734 "eag".to_string(),
4735 CrossLinkValue::List(vec!["laz".to_string()]),
4736 );
4737 engine.set_settings(settings);
4738
4739 let relate = |engine: &mut Engine, to: &str| {
4740 let (actor, client) = cli_actor();
4741 engine.relate_entity(
4742 RelateEntityArgs {
4743 source: crate::EntityId::new("eag", "alpha"),
4744 expected_hash: None,
4745 rel_type: "SUPPORTS".to_string(),
4746 target: crate::EntityId::new("laz", to),
4747 remove: false,
4748 description: None,
4749 dry_run: false,
4750 },
4751 actor,
4752 Some(&client),
4753 None,
4754 )
4755 };
4756
4757 let outcome = relate(&mut engine, "omega").expect("verified target admits");
4759 assert!(
4760 engine.mem_is_deferred("laz"),
4761 "verification never loads the mem"
4762 );
4763 assert!(
4764 !outcome
4765 .warnings
4766 .iter()
4767 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4768 "a storage-verified target is not an auto-stub case: {:?}",
4769 outcome.warnings
4770 );
4771 let stub = engine
4772 .store()
4773 .get(&crate::EntityId::new("laz", "omega"))
4774 .expect("until-load stub present");
4775 assert!(stub.stub);
4776 assert_eq!(
4777 stub.stub_kind,
4778 Some(crate::entity::StubKind::LoadTime),
4779 "verified-in-storage stub carries the load-time kind"
4780 );
4781
4782 let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4784 assert!(engine.mem_is_deferred("laz"), "still no load");
4785 assert!(
4786 outcome
4787 .warnings
4788 .iter()
4789 .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4790 "absent target keeps the auto-stub warning: {:?}",
4791 outcome.warnings
4792 );
4793 let stub = engine
4794 .store()
4795 .get(&crate::EntityId::new("laz", "missing"))
4796 .expect("forward-reference stub present");
4797 assert_eq!(
4798 stub.stub_kind,
4799 Some(crate::entity::StubKind::ForwardReference)
4800 );
4801 }
4802
4803 #[test]
4809 fn readonly_deferred_target_answers_from_storage() {
4810 use crate::engine::RelateEntityArgs;
4811 use memstead_schema::workspace_config::CrossLinkValue;
4812
4813 let tmp = TempDir::new().unwrap();
4814 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4815 let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4816 ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4817 let mut engine = Engine::from_mounts(vec![
4818 (
4819 folder_mount("eag", eager_dir.to_path_buf()),
4820 Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4821 ),
4822 (
4823 ro_mount,
4824 Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4825 ),
4826 ])
4827 .unwrap();
4828 let mut settings = crate::workspace::WorkspaceSettings::default();
4829 settings.cross_mem_links.insert(
4830 "eag".to_string(),
4831 CrossLinkValue::List(vec!["laz".to_string()]),
4832 );
4833 engine.set_settings(settings);
4834
4835 let relate = |engine: &mut Engine, to: &str| {
4836 let (actor, client) = cli_actor();
4837 engine.relate_entity(
4838 RelateEntityArgs {
4839 source: crate::EntityId::new("eag", "alpha"),
4840 expected_hash: None,
4841 rel_type: "SUPPORTS".to_string(),
4842 target: crate::EntityId::new("laz", to),
4843 remove: false,
4844 description: None,
4845 dry_run: false,
4846 },
4847 actor,
4848 Some(&client),
4849 None,
4850 )
4851 };
4852
4853 relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4854 assert!(
4855 engine.mem_is_deferred("laz"),
4856 "the admit never loads the mem"
4857 );
4858
4859 let err =
4860 relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4861 assert!(
4862 matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4863 "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4864 );
4865 assert!(
4866 engine.mem_is_deferred("laz"),
4867 "the refusal never loads the mem either"
4868 );
4869 }
4870
4871 #[test]
4876 fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4877 let tmp = TempDir::new().unwrap();
4878 let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4879 write_spec(
4880 &eager_dir,
4881 "linker",
4882 "Linker",
4883 "\nSee [[laz--omega]] for detail.\n",
4884 );
4885
4886 let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4887 let target = crate::EntityId::new("laz", "omega");
4888 assert!(
4889 engine.get_entity(&target).is_none_or(|e| e.stub),
4890 "before the lazy load the cross-mem target is at most a stub, never real"
4891 );
4892
4893 engine.reload_if_stale(Some("laz"));
4894 let resolved = engine.get_entity(&target).expect("target loaded");
4895 assert!(!resolved.stub, "after the load the target is real");
4896 assert!(
4897 !engine.load_warnings().iter().any(
4898 |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4899 ),
4900 "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4901 engine.load_warnings()
4902 );
4903 }
4904}