1use std::collections::HashMap;
15use std::sync::Arc;
16
17use memstead_schema::{Schema, TypeDefinition, type_by_name};
18
19use super::{
20 DanglingLink, FoldedTag, HealthIssue, HealthReport, HealthSummary, StaleEntity,
21 TagDistribution, TagVariant, UntaggedStats,
22};
23use crate::entity::MetadataValue;
24use crate::graph::query;
25use crate::store::Store;
26
27pub const HEALTH_INCLUDE_KEYS: &[&str] = &[
33 "orphans",
34 "stubs",
35 "most_connected",
36 "missing_fields",
37 "stale",
38 "dangling_links",
39 "tags",
40 "missing_required_outgoing",
41 "constraints",
42 "signals",
43 "labelling",
44 "conformance",
45 "integrity",
46 "config",
47 "anchors",
48 "friction",
49 "open_questions",
50 "stale_derivations",
51 "checks",
52 "ledger",
53 "vital_signs",
54];
55
56pub const VITAL_SIGNS_ITEM_CAP: usize = 20;
58
59pub fn health_vital_signs_axis(
84 engine: &crate::engine::Engine,
85 mem_filter: Option<&str>,
86) -> serde_json::Value {
87 let cap = VITAL_SIGNS_ITEM_CAP;
88 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
89 let count = items.len();
90 let more = count.saturating_sub(cap);
91 items.truncate(cap);
92 let mut o = serde_json::Map::new();
93 o.insert("count".into(), serde_json::json!(count));
94 o.insert("items".into(), serde_json::Value::Array(items));
95 if more > 0 {
96 o.insert("more".into(), serde_json::json!(more));
97 }
98 serde_json::Value::Object(o)
99 };
100
101 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
102 mems.sort();
103 let communities = engine.communities();
104 let mut out = serde_json::Map::new();
105 for mem in &mems {
106 if let Some(f) = mem_filter
107 && f != mem
108 {
109 continue;
110 }
111 let entities: Vec<&crate::entity::Entity> = engine
112 .store()
113 .all_entities()
114 .filter(|e| !e.stub && e.id.mem() == mem)
115 .collect();
116 let schema = engine.schema_for(mem);
117
118 let last_resort: Option<String> = schema.as_ref().and_then(|s| {
120 s.types
121 .values()
122 .find(|t| t.last_resort)
123 .map(|t| t.name.clone())
124 });
125 let type_share = match &last_resort {
126 None => serde_json::json!({ "status": "not_declared" }),
127 Some(lr) => {
128 let mut per: std::collections::BTreeMap<String, (usize, usize)> =
129 std::collections::BTreeMap::new();
130 for e in &entities {
131 let cluster = communities
132 .entity_cluster_map
133 .get(&e.id.0)
134 .cloned()
135 .unwrap_or_else(|| "unplaced".to_string());
136 let slot = per.entry(cluster).or_insert((0, 0));
137 slot.0 += 1;
138 if e.entity_type == *lr {
139 slot.1 += 1;
140 }
141 }
142 let mut rows: Vec<serde_json::Value> = per
143 .into_iter()
144 .map(|(community, (total, on_last_resort))| {
145 serde_json::json!({
146 "community": community,
147 "entities": total,
148 "on_last_resort_type": on_last_resort,
149 })
150 })
151 .collect();
152 rows.sort_by(|a, b| {
155 let share = |v: &serde_json::Value| {
156 let t = v["entities"].as_u64().unwrap_or(1).max(1) as f64;
157 v["on_last_resort_type"].as_u64().unwrap_or(0) as f64 / t
158 };
159 share(b)
160 .partial_cmp(&share(a))
161 .unwrap_or(std::cmp::Ordering::Equal)
162 .then_with(|| a["community"].as_str().cmp(&b["community"].as_str()))
163 });
164 let mut v = capped(rows);
165 v["status"] = serde_json::json!("declared");
166 v["last_resort_type"] = serde_json::json!(lr);
167 v
168 }
169 };
170
171 let mut claims: std::collections::BTreeMap<
174 String,
175 (std::collections::BTreeSet<String>, bool),
176 > = std::collections::BTreeMap::new();
177 for e in &entities {
178 for a in engine.entity_anchors(&e.id) {
179 let slot = claims.entry(a.artifact.clone()).or_default();
180 slot.0.insert(e.id.0.clone());
181 if a.class == crate::anchor::AnchorProvenanceClass::Anchored {
182 slot.1 = true;
183 }
184 }
185 }
186 let roots = engine.anchor_source_roots(mem);
187 let mut unclaimed: Vec<serde_json::Value> = Vec::new();
188 let mut sources_enumerated = 0usize;
189 if let Some(ws) = engine.workspace_root() {
190 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
191 for join in roots.values() {
192 sources_enumerated += 1;
193 for file in crate::ingest::cursor::enumerate_source_artifacts(
194 engine,
195 &join.source,
196 &join.deny_paths,
197 ws,
198 ) {
199 if !seen.insert(file.clone()) || claims.contains_key(&file) {
200 continue;
201 }
202 let size = std::fs::metadata(ws.join(&file))
203 .map(|m| m.len())
204 .unwrap_or(0);
205 unclaimed.push(serde_json::json!({ "artifact": file, "bytes": size }));
206 }
207 }
208 }
209 unclaimed.sort_by(|a, b| {
210 b["bytes"]
211 .as_u64()
212 .cmp(&a["bytes"].as_u64())
213 .then_with(|| a["artifact"].as_str().cmp(&b["artifact"].as_str()))
214 });
215 let unclaimed_v = if sources_enumerated == 0 {
216 serde_json::json!({ "status": "no_bound_source" })
217 } else {
218 let mut v = capped(unclaimed);
219 v["status"] = serde_json::json!("enumerated");
220 v
221 };
222 let contested: Vec<serde_json::Value> = claims
223 .iter()
224 .filter(|(_, (who, owned))| who.len() >= 2 && !owned)
225 .map(|(artifact, (who, _))| {
226 serde_json::json!({
227 "artifact": artifact,
228 "claimed_by": who.iter().cloned().collect::<Vec<_>>(),
229 })
230 })
231 .collect();
232
233 let cluster_size = |c: &str| -> usize {
235 communities
236 .clusters
237 .get(c)
238 .map(|ci| ci.entities.len())
239 .unwrap_or(0)
240 };
241 let mut by_community: std::collections::BTreeMap<String, Vec<String>> =
242 std::collections::BTreeMap::new();
243 for e in &entities {
244 if !engine.store().outgoing(&e.id).is_empty() {
245 continue;
246 }
247 let own = communities.entity_cluster_map.get(&e.id.0).cloned();
248 let community = match own {
249 Some(c) if cluster_size(&c) > 1 => c,
250 _ => engine
251 .store()
252 .incoming(&e.id)
253 .iter()
254 .find_map(|edge| communities.entity_cluster_map.get(&edge.from.0).cloned())
255 .unwrap_or_else(|| "unplaced".to_string()),
256 };
257 by_community
258 .entry(community)
259 .or_default()
260 .push(e.id.0.clone());
261 }
262 let zero_total: usize = by_community.values().map(Vec::len).sum();
263 let zero_rows: Vec<serde_json::Value> = by_community
264 .into_iter()
265 .map(|(community, mut ids)| {
266 ids.sort();
267 let count = ids.len();
268 let more = count.saturating_sub(cap);
269 ids.truncate(cap);
270 let mut o = serde_json::json!({
271 "community": community,
272 "count": count,
273 "items": ids,
274 });
275 if more > 0 {
276 o["more"] = serde_json::json!(more);
277 }
278 o
279 })
280 .collect();
281 let mut zero_v = capped(zero_rows);
282 zero_v["entities"] = serde_json::json!(zero_total);
283
284 let mut empty_sections: Vec<serde_json::Value> = Vec::new();
286 if let Some(s) = &schema {
287 for e in &entities {
288 let Some(td) = s.types.get(&e.entity_type) else {
289 continue;
290 };
291 for sec in &td.sections {
292 if e.sections
293 .get(&sec.key)
294 .is_some_and(|body| body.trim().is_empty())
295 {
296 empty_sections.push(serde_json::json!({
297 "id": e.id.0,
298 "section": sec.key,
299 }));
300 }
301 }
302 }
303 }
304
305 out.insert(
306 mem.clone(),
307 serde_json::json!({
308 "type_share_by_community": type_share,
309 "unclaimed_source_files": unclaimed_v,
310 "contested_unowned_files": capped(contested),
311 "zero_outgoing_entities": zero_v,
312 "empty_declared_sections": capped(empty_sections),
313 }),
314 );
315 }
316 let mut top = serde_json::Map::new();
317 top.insert("_item_cap".into(), serde_json::json!(cap));
318 for (k, v) in out {
319 top.insert(k, v);
320 }
321 serde_json::Value::Object(top)
322}
323
324pub fn health_checks_axis(
350 engine: &crate::engine::Engine,
351 mem_filter: Option<&str>,
352) -> serde_json::Value {
353 let cap = OPEN_QUESTIONS_ITEM_CAP;
354 let capped = |mut items: Vec<String>| -> serde_json::Value {
355 items.sort();
356 let count = items.len();
357 let more = count.saturating_sub(cap);
358 items.truncate(cap);
359 let mut o = serde_json::Map::new();
360 o.insert("count".into(), serde_json::json!(count));
361 o.insert("items".into(), serde_json::json!(items));
362 if more > 0 {
363 o.insert("more".into(), serde_json::json!(more));
364 }
365 serde_json::Value::Object(o)
366 };
367
368 let ledger = engine
369 .workspace_root()
370 .map(crate::check::CheckLedger::for_workspace);
371 let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
375 std::collections::BTreeMap::new();
376 let mut latest_conformance: std::collections::BTreeMap<String, crate::check::CheckRecord> =
377 std::collections::BTreeMap::new();
378 let mut foreign_by_entity: std::collections::BTreeMap<String, Vec<String>> =
382 std::collections::BTreeMap::new();
383 let mut newest_any: std::collections::BTreeMap<String, crate::check::CheckRecord> =
386 std::collections::BTreeMap::new();
387 let mut all_verification: std::collections::BTreeMap<String, Vec<crate::check::CheckRecord>> =
391 std::collections::BTreeMap::new();
392 if let Some(l) = &ledger {
393 for rec in l.all() {
394 newest_any.insert(rec.entity.clone(), rec.clone());
395 match rec.resolved_kind() {
396 Some(crate::check::CheckKind::Verification) => {
397 all_verification
398 .entry(rec.entity.clone())
399 .or_default()
400 .push(rec.clone());
401 latest.insert(rec.entity.clone(), rec);
402 }
403 Some(crate::check::CheckKind::Conformance) => {
404 latest_conformance.insert(rec.entity.clone(), rec);
405 }
406 None => {
407 if let Some(k) = rec.foreign_kind() {
408 foreign_by_entity
409 .entry(rec.entity.clone())
410 .or_default()
411 .push(k.to_string());
412 }
413 }
414 }
415 }
416 }
417
418 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
419 mems.sort();
420 let mut out = serde_json::Map::new();
421 for mem in mems {
422 if let Some(f) = mem_filter
423 && f != mem
424 {
425 continue;
426 }
427 let mut counts = std::collections::BTreeMap::from([
428 ("never_checked", 0usize),
429 ("checked_ok", 0usize),
430 ("check_failed", 0usize),
431 ("check_stale", 0usize),
432 ]);
433 let current_pin = engine
439 .mount(&mem)
440 .and_then(|m| m.schema.as_ref())
441 .map(|s| s.as_display());
442 let mut conformance_counts = std::collections::BTreeMap::from([
443 ("never_checked", 0usize),
444 ("checked_ok", 0usize),
445 ("check_failed", 0usize),
446 ("check_stale", 0usize),
447 ]);
448 let mut self_checked: Vec<String> = Vec::new();
449 let mut confirmed_independent: Vec<String> = Vec::new();
450 let mut unconfirmable: Vec<String> = Vec::new();
451 let mut executors = serde_json::Map::new();
455 let mut readings = serde_json::Map::new();
456 let touches = engine.mem_touches(&mem);
457 let mut foreign_kinds: std::collections::BTreeMap<String, usize> =
458 std::collections::BTreeMap::new();
459 let mut findings = serde_json::Map::new();
460 for e in engine.store().all_entities().filter(|e| e.mem == mem) {
461 let id = e.id.0.clone();
462 if let Some(kinds) = foreign_by_entity.get(&id) {
463 for k in kinds {
464 *foreign_kinds.entry(k.clone()).or_insert(0) += 1;
465 }
466 }
467 if let Some(rec) = newest_any.get(&id)
468 && let Some(f) = &rec.finding
469 {
470 findings.insert(
471 id.clone(),
472 serde_json::json!({
473 "verdict": rec.verdict,
474 "kind": rec.kind.as_deref().unwrap_or("verification"),
475 "ts": rec.ts,
476 "identity": rec.identity,
477 "finding": f,
478 }),
479 );
480 }
481 let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
482 *counts.entry(state.as_str()).or_insert(0) += 1;
483 if let Some(records) = all_verification.get(&id) {
484 let rows: Vec<serde_json::Value> = records
485 .iter()
486 .map(|rec| {
487 let reading = if rec.verdict == "ok" {
488 engine.independence_of(e, rec, &touches).0.as_str()
489 } else {
490 "failed"
491 };
492 serde_json::json!({
493 "ts": rec.ts,
494 "identity": rec.identity,
495 "verdict": rec.verdict,
496 "reading": reading,
497 })
498 })
499 .collect();
500 readings.insert(id.clone(), serde_json::Value::Array(rows));
501 }
502 let cstate = crate::check::derive_state_pinned(
503 latest_conformance.get(&id),
504 &e.content_hash,
505 current_pin.as_deref(),
506 );
507 *conformance_counts.entry(cstate.as_str()).or_insert(0) += 1;
508 if state != crate::check::CheckState::CheckedOk {
509 continue;
510 }
511 let check = latest.get(&id).expect("checked_ok implies a record");
521 let (reading, execs) = engine.independence_of(e, check, &touches);
522 if let Some(execs) = execs {
523 executors.insert(id.clone(), serde_json::json!(execs.identities));
524 }
525 match reading {
526 crate::engine::independence::Independence::SelfChecked => self_checked.push(id),
527 crate::engine::independence::Independence::ConfirmedIndependent => {
528 confirmed_independent.push(id)
529 }
530 crate::engine::independence::Independence::Unconfirmable => unconfirmable.push(id),
531 }
532 }
533 let mut m = serde_json::Map::new();
534 for (k, v) in counts {
535 m.insert(k.to_string(), serde_json::json!(v));
536 }
537 let mut c = serde_json::Map::new();
538 for (k, v) in conformance_counts {
539 c.insert(k.to_string(), serde_json::json!(v));
540 }
541 m.insert("conformance".into(), serde_json::Value::Object(c));
542 m.insert(
546 "foreign_kinds".into(),
547 serde_json::to_value(&foreign_kinds).unwrap_or(serde_json::json!({})),
548 );
549 m.insert("findings".into(), serde_json::Value::Object(findings));
550 m.insert(
551 "independence".into(),
552 serde_json::json!({
553 "self_checked": capped(self_checked),
554 "confirmed_independent": capped(confirmed_independent),
555 "unconfirmable": capped(unconfirmable),
556 "comparator": "every identity that mutated the verified plan, its criteria or its session-log notes since the criterion was written; a non-criterion compares against its own author",
557 "executors": serde_json::Value::Object(executors),
558 "readings": serde_json::Value::Object(readings),
559 }),
560 );
561 out.insert(mem, serde_json::Value::Object(m));
562 }
563 serde_json::Value::Object(out)
564}
565
566#[derive(Debug, Clone, serde::Serialize)]
572pub struct DerivationFinding {
573 pub source: crate::entity::EntityId,
574 pub rel_type: String,
575 pub target: crate::entity::EntityId,
576 pub state: String,
578 #[serde(skip_serializing_if = "Option::is_none")]
580 pub baseline: Option<String>,
581 pub current: String,
583}
584
585pub fn health_stale_derivations_axis(
590 engine: &crate::engine::Engine,
591 mem_filter: Option<&str>,
592) -> serde_json::Value {
593 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
594 mems.sort();
595 let mut out = serde_json::Map::new();
596 for mem in mems {
597 if let Some(f) = mem_filter
598 && f != mem
599 {
600 continue;
601 }
602 let findings = engine.derivation_report(&mem).unwrap_or_default();
603 out.insert(
604 mem,
605 serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
606 );
607 }
608 serde_json::Value::Object(out)
609}
610
611pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
615
616pub fn health_open_questions_axis(
632 engine: &crate::engine::Engine,
633 mem_filter: Option<&str>,
634) -> serde_json::Value {
635 let cap = OPEN_QUESTIONS_ITEM_CAP;
636 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
637 let count = items.len();
638 let more = count.saturating_sub(cap);
639 items.truncate(cap);
640 let mut o = serde_json::Map::new();
641 o.insert("count".into(), serde_json::json!(count));
642 o.insert("items".into(), serde_json::Value::Array(items));
643 if more > 0 {
644 o.insert("more".into(), serde_json::json!(more));
645 }
646 serde_json::Value::Object(o)
647 };
648
649 let bindings: Vec<(String, String)> = engine
653 .workspace_root()
654 .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
655 .map(|c| {
656 c.bindings
657 .iter()
658 .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
659 .collect()
660 })
661 .unwrap_or_default();
662 let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
663
664 let mut mems: Vec<String> = mounted.clone();
665 mems.sort();
666 let mut out = serde_json::Map::new();
667 for mem in &mems {
668 if let Some(f) = mem_filter
669 && f != mem
670 {
671 continue;
672 }
673
674 let stubs = capped(
676 engine
677 .store()
678 .all_entities()
679 .filter(|e| e.stub && e.id.mem() == mem)
680 .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
681 .collect(),
682 );
683
684 let (mut recheck, mut unresolvable, mut unobserved, mut dangling_rows) =
697 (Vec::new(), Vec::new(), Vec::new(), Vec::new());
698 let mut aging = Vec::new();
701 let mut entity_end_unreconciled: Option<String> = None;
702 if let Ok(report) = engine.verify_mem_anchors(mem) {
703 entity_end_unreconciled = report.unreconciled.clone();
704 for a in &report.anchors {
705 if let Some(days) = a.unobserved_for_days
706 && days > 0
707 {
708 aging.push(serde_json::json!({
709 "kind": "anchor_aging",
710 "id": a.entity_id,
711 "artifact": a.artifact,
712 "state": a.state,
713 "observed_at": a.observed_at,
714 "unobserved_for_days": days,
715 "note": format!("unobserved for {days} days"),
716 }));
717 }
718 let item = serde_json::json!({
719 "kind": format!("anchor_{}", a.state),
720 "id": a.entity_id,
721 "artifact": a.artifact,
722 });
723 match a.state.as_str() {
724 "recheck" => recheck.push(item),
725 "unresolvable" => unresolvable.push(item),
726 "unobserved" => unobserved.push(item),
727 "dangling" => dangling_rows.push(item),
728 _ => {}
729 }
730 }
731 }
732
733 let constraints = capped(
736 engine
737 .constraint_findings(Some(mem))
738 .iter()
739 .map(|r| {
740 serde_json::json!({
741 "kind": "unsatisfied_constraint",
742 "id": r.id.to_string(),
743 "violations": r.violations.len(),
744 })
745 })
746 .collect(),
747 );
748
749 let dangling = capped(
755 collect_dangling_links(engine.store(), Some(mem))
756 .iter()
757 .map(|d| {
758 serde_json::json!({
759 "kind": d.kind.code(),
760 "id": d.from.to_string(),
761 "target": d.target_id.to_string(),
762 "repair": d.kind.repair(),
763 })
764 })
765 .collect(),
766 );
767
768 let mut process = Vec::new();
778 let mem_bindings: Vec<&String> = bindings
779 .iter()
780 .filter(|(d, _)| d == mem)
781 .map(|(_, b)| b)
782 .collect();
783 let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
784 Vec::new();
785 if mem_bindings.is_empty() {
786 let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
787 if r.declared {
788 resolutions.push((None, r));
789 }
790 } else {
791 for binding in &mem_bindings {
792 resolutions.push((
793 Some((*binding).clone()),
794 crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
795 ));
796 }
797 }
798 for (binding, r) in resolutions {
799 if r.mounted {
800 let mut open = Vec::new();
801 let mut searched = Vec::new();
802 for e in engine
803 .store()
804 .all_entities()
805 .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
806 {
807 let item = serde_json::json!({
808 "kind": e.entity_type,
809 "id": e.id.to_string(),
810 "title": e.title,
811 });
812 if e.entity_type == "negative_finding" {
813 searched.push(item);
814 } else {
815 open.push(item);
816 }
817 }
818 process.push(serde_json::json!({
819 "binding": binding,
820 "process_mem": r.mem,
821 "declared": r.declared,
822 "resolvable": true,
823 "open_entries": capped(open),
824 "already_searched": capped(searched),
825 }));
826 } else if r.declared {
827 process.push(serde_json::json!({
828 "binding": binding,
829 "process_mem": r.mem,
830 "declared": true,
831 "resolvable": false,
832 "finding": "DECLARED_PROCESS_MEM_MISSING",
833 }));
834 } else {
835 process.push(serde_json::json!({
836 "binding": binding,
837 "resolvable": false,
838 }));
839 }
840 }
841
842 let (mut missing, mut unchecked) = (Vec::new(), Vec::new());
848 if let Some(schema) = engine.schema_for(mem) {
849 let ledger = engine
850 .workspace_root()
851 .map(crate::check::CheckLedger::for_workspace);
852 for entity in engine
853 .store()
854 .all_entities()
855 .filter(|e| !e.stub && e.id.mem() == mem)
856 {
857 let Some(td) = schema.types.get(&entity.entity_type) else {
858 continue;
859 };
860 let Some(res) = &td.resolution else { continue };
861 if let Some(field) = &res.status_field {
862 let open = match entity.metadata.get(field) {
863 Some(crate::entity::MetadataValue::String(s)) => {
864 res.open_values.contains(s)
865 }
866 _ => false,
867 };
868 if !open {
869 continue;
870 }
871 }
872 let has_condition = entity
873 .sections
874 .get(&res.condition_section)
875 .is_some_and(|body| !body.trim().is_empty());
876 if !has_condition {
877 missing.push(serde_json::json!({
878 "kind": "resolution_missing",
879 "id": entity.id.to_string(),
880 "section": res.condition_section,
881 }));
882 continue;
883 }
884 let kind = res.check_kind.as_deref().unwrap_or("verification");
885 let checked = ledger.as_ref().is_some_and(|l| {
886 l.all().into_iter().rev().any(|r| {
887 r.entity == entity.id.to_string()
888 && r.verdict == "ok"
889 && r.entity_hash == entity.content_hash
890 && match crate::check::RecordKind::from_wire(kind) {
891 Some(crate::check::RecordKind::Engine(k)) => {
892 r.resolved_kind() == Some(k)
893 }
894 Some(crate::check::RecordKind::Foreign(name)) => {
895 r.kind.as_deref() == Some(name.as_str())
896 }
897 None => false,
898 }
899 })
900 });
901 if !checked {
902 unchecked.push(serde_json::json!({
903 "kind": "resolution_unchecked",
904 "id": entity.id.to_string(),
905 "section": res.condition_section,
906 "check_kind": kind,
907 }));
908 }
909 }
910 }
911 let resolution_missing = capped(missing);
912 let resolution_unchecked = capped(unchecked);
913
914 let total_open = stubs["count"].as_u64().unwrap_or(0)
915 + resolution_missing["count"].as_u64().unwrap_or(0)
916 + resolution_unchecked["count"].as_u64().unwrap_or(0)
917 + recheck.len() as u64
918 + unresolvable.len() as u64
919 + unobserved.len() as u64
920 + dangling_rows.len() as u64
921 + aging.len() as u64
922 + constraints["count"].as_u64().unwrap_or(0)
923 + dangling["count"].as_u64().unwrap_or(0)
924 + process
925 .iter()
926 .filter_map(|p| p["open_entries"]["count"].as_u64())
927 .sum::<u64>();
928
929 let mut entry = serde_json::Map::new();
930 entry.insert("stubs".into(), stubs);
931 entry.insert("anchors_recheck".into(), capped(recheck));
932 entry.insert("anchors_unresolvable".into(), capped(unresolvable));
933 entry.insert("anchors_unobserved".into(), capped(unobserved));
937 entry.insert("anchors_dangling".into(), capped(dangling_rows));
938 entry.insert("anchors_aging".into(), capped(aging));
939 if let Some(why) = entity_end_unreconciled {
940 entry.insert("entity_end_unreconciled".into(), serde_json::json!(why));
941 }
942 entry.insert("unsatisfied_constraints".into(), constraints);
943 entry.insert("dangling_links".into(), dangling);
944 entry.insert("resolution_missing".into(), resolution_missing);
945 entry.insert("resolution_unchecked".into(), resolution_unchecked);
946 if !process.is_empty() {
947 entry.insert("process".into(), serde_json::Value::Array(process));
948 } else {
949 entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
952 }
953 entry.insert("total_open".into(), serde_json::json!(total_open));
954 out.insert(mem.clone(), serde_json::Value::Object(entry));
955 }
956 let mut top = serde_json::Map::new();
957 top.insert("_item_cap".into(), serde_json::json!(cap));
958 for (k, v) in out {
959 top.insert(k, v);
960 }
961 serde_json::Value::Object(top)
962}
963
964pub fn health_anchors_axis(
965 engine: &crate::engine::Engine,
966 mem_filter: Option<&str>,
967) -> serde_json::Value {
968 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
969 mems.retain(|m| mem_filter.is_none_or(|v| m == v));
970 mems.sort();
971 let mut out = serde_json::Map::new();
972 for mem in mems {
973 let Ok(report) = engine.verify_mem_anchors(&mem) else {
974 continue;
975 };
976 let condition = report.sidecar_error.as_ref().map(|why| {
977 serde_json::json!({
978 "code": "ANCHORS_SIDECAR_UNREADABLE",
979 "mem": mem,
980 "reason": why,
981 })
982 });
983 out.insert(
984 mem,
985 serde_json::json!({
986 "condition": condition,
989 "resolves": report.resolves,
990 "drifted": report.drifted,
991 "recheck": report.recheck,
992 "unresolvable": report.unresolvable,
997 "unobserved": report.unobserved,
998 "dangling": report.dangling,
1003 "entity_end_unreconciled": report.unreconciled,
1004 "population": report.population_statement(),
1007 "fully_adjudicated": report.fully_adjudicated(),
1008 "aging": report
1013 .anchors
1014 .iter()
1015 .filter(|a| a.observed_at.is_some())
1016 .map(|a| {
1017 let days = a.unobserved_for_days.unwrap_or(0);
1018 serde_json::json!({
1019 "id": a.entity_id,
1020 "artifact": a.artifact,
1021 "state": a.state,
1022 "observed_at": a.observed_at,
1023 "unobserved_for_days": days,
1024 "note": format!("unobserved for {days} days"),
1025 })
1026 })
1027 .collect::<Vec<_>>(),
1028 }),
1029 );
1030 }
1031 serde_json::Value::Object(out)
1032}
1033
1034pub fn compute_health(
1047 store: &Store,
1048 default_schema: &TypeDefinition,
1049 mem_schemas: &HashMap<String, Arc<Schema>>,
1050 mem_filter: Option<&str>,
1051) -> HealthSummary {
1052 let mut missing_fields = Vec::new();
1053 let mut stale_entities = Vec::new();
1054
1055 let today_days = days_since_epoch();
1056
1057 let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
1058
1059 for entity in store.all_entities() {
1060 if entity.stub || !in_scope(&entity.mem) {
1061 continue;
1062 }
1063
1064 let resolved = mem_schemas
1073 .get(entity.mem.as_str())
1074 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
1075 .or_else(|| type_by_name(&entity.entity_type));
1076 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
1077 let mut issues = Vec::new();
1078
1079 for field in &schema.health_required_fields {
1081 if schema.section(field).is_some() {
1083 let content = entity.sections.get(field.as_str());
1088 if content.is_none_or(|c| c.trim().is_empty()) {
1089 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1090 issues.push(issue);
1091 } else {
1092 issues.push(HealthIssue {
1093 field: field.clone(),
1094 code: super::HealthIssueCode::Missing,
1095 message: format!("required section '{field}' is empty"),
1096 });
1097 }
1098 }
1099 } else {
1100 let value = entity.metadata.get(field.as_str());
1106 let is_empty = match value {
1107 None => true,
1108 Some(v) => v.to_frontmatter_string().trim().is_empty(),
1109 };
1110 if is_empty {
1111 issues.push(HealthIssue {
1112 field: field.clone(),
1113 code: super::HealthIssueCode::Missing,
1114 message: format!("required field '{field}' is missing"),
1115 });
1116 }
1117 }
1118 }
1119
1120 for s in schema.sections.iter().filter(|s| !s.catch_all) {
1123 if schema.health_required_fields.contains(&s.key) {
1124 continue; }
1126 let content = entity.sections.get(s.key.as_str());
1127 if content.is_none_or(|c| c.trim().is_empty())
1128 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1129 {
1130 issues.push(issue);
1131 }
1132 }
1133
1134 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
1150 let mut seen_unknown = std::collections::HashSet::new();
1151 for rel in &entity.relationships {
1152 if !mem_schema.relationship_known(&rel.rel_type) {
1153 if seen_unknown.insert(rel.rel_type.clone()) {
1154 let suggestion = mem_schema
1155 .suggest_relationship(&rel.rel_type)
1156 .map(|s| format!(" Did you mean '{s}'?"))
1157 .unwrap_or_default();
1158 let (schema_name, schema_version) = mem_schema.id();
1159 issues.push(HealthIssue {
1160 field: "relationships".to_string(),
1161 code: super::HealthIssueCode::UndeclaredRelationship,
1162 message: format!(
1163 "relationship '{}' is not declared in schema \
1164 '{schema_name}@{schema_version}'.{suggestion}",
1165 rel.rel_type
1166 ),
1167 });
1168 }
1169 continue;
1170 }
1171
1172 let target_type = store
1173 .get(&rel.target)
1174 .map(|t| t.entity_type.clone())
1175 .filter(|t| !t.is_empty());
1176 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
1177 rel_type,
1178 from_type,
1179 to_type,
1180 allowed_source_types,
1181 allowed_target_types,
1182 ..
1183 }) = crate::runtime_validator::validate_rel_shape(
1184 &rel.rel_type,
1185 entity.entity_type.as_str(),
1186 target_type.as_deref(),
1187 mem_schema.as_ref(),
1188 ) {
1189 let allowed_src = if allowed_source_types.is_empty() {
1190 "<any>".to_string()
1191 } else {
1192 allowed_source_types.join(", ")
1193 };
1194 let allowed_tgt = if allowed_target_types.is_empty() {
1195 "<any>".to_string()
1196 } else {
1197 allowed_target_types.join(", ")
1198 };
1199 issues.push(HealthIssue {
1200 field: "relationships".to_string(),
1201 code: super::HealthIssueCode::InvalidRelShape,
1202 message: format!(
1203 "INVALID_REL_SHAPE: edge '{rel_type}' from \
1204 '{from_type}' to '{to_type}' (target {target}) \
1205 violates declared shape — allowed_source_types: \
1206 [{allowed_src}], allowed_target_types: \
1207 [{allowed_tgt}]. Remove via \
1208 `memstead_relate from={from_id} to={target} \
1209 type={rel_type} remove=true`.",
1210 target = rel.target,
1211 from_id = entity.id,
1212 ),
1213 });
1214 }
1215 }
1216 }
1217
1218 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
1220
1221 if let Some(ts_field) = auto_ts_field
1222 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
1223 {
1224 let date_str = val.to_frontmatter_string();
1225 if let Some(modified_days) = parse_iso_to_days(&date_str) {
1226 let days_since = today_days.saturating_sub(modified_days);
1227 if days_since > schema.staleness_threshold_days as u64 {
1228 stale_entities.push(StaleEntity {
1229 id: entity.id.clone(),
1230 title: entity.title.clone(),
1231 days_since_modified: days_since,
1232 anchor_state: None,
1233 });
1234 }
1235 }
1236 }
1237
1238 if !issues.is_empty() {
1239 let total = schema.health_required_fields.len();
1245 let score = if total > 0 {
1246 (total.saturating_sub(issues.len()) as f32) / (total as f32)
1247 } else {
1248 1.0
1249 };
1250
1251 missing_fields.push(HealthReport {
1252 id: entity.id.clone(),
1253 title: entity.title.clone(),
1254 score,
1255 issues,
1256 });
1257 }
1258 }
1259
1260 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
1262
1263 let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
1266 .into_iter()
1267 .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
1268 .count();
1269 let leaf_entities_by_type = match mem_filter {
1270 None => query::leaf_population(store, mem_schemas),
1271 Some(v) => {
1272 let scoped: HashMap<String, Arc<Schema>> = mem_schemas
1273 .iter()
1274 .filter(|(mem, _)| mem.as_str() == v)
1275 .map(|(mem, s)| (mem.clone(), s.clone()))
1276 .collect();
1277 query::leaf_population(store, &scoped)
1278 }
1279 };
1280 let stub_count = query::find_stubs(store)
1281 .iter()
1282 .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
1283 .count();
1284
1285 stale_entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1288 missing_fields.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1289
1290 HealthSummary {
1291 stale_entities,
1292 anchor_fresh: Vec::new(),
1293 missing_fields,
1294 orphan_count,
1295 stub_count,
1296 warnings: Vec::new(),
1297 quarantined: Vec::new(),
1298 load_errors: Vec::new(),
1299 boot_diagnosis: None,
1300 leaf_entities_by_type,
1301 dangling_links: None,
1302 findings: None,
1303 tag_distribution: None,
1304 tag_distribution_folded: None,
1305 untagged_entities: None,
1306 }
1307}
1308
1309pub fn collect_tag_distribution(
1323 store: &Store,
1324 mem_filter: Option<&str>,
1325 limit: usize,
1326) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
1327 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
1329 let mut untagged = UntaggedStats {
1330 total: 0,
1331 by_entity_type: HashMap::new(),
1332 };
1333
1334 for entity in store.all_entities() {
1335 if entity.stub {
1336 continue;
1337 }
1338 if let Some(v) = mem_filter
1339 && entity.mem != v
1340 {
1341 continue;
1342 }
1343
1344 let tags_raw = entity
1345 .metadata
1346 .get("tags")
1347 .and_then(|v| match v {
1348 MetadataValue::String(s) => Some(s.as_str()),
1349 _ => None,
1350 })
1351 .unwrap_or("");
1352
1353 let mut any_tag = false;
1354 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
1355 any_tag = true;
1356 let entry = counts
1357 .entry(tag.to_string())
1358 .or_insert_with(|| (0, HashMap::new()));
1359 entry.0 += 1;
1360 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
1361 }
1362 if !any_tag {
1363 untagged.total += 1;
1364 *untagged
1365 .by_entity_type
1366 .entry(entity.entity_type.clone())
1367 .or_insert(0) += 1;
1368 }
1369 }
1370
1371 let mut entries: Vec<TagDistribution> = counts
1373 .iter()
1374 .map(|(tag, (count, by_type))| TagDistribution {
1375 tag: tag.clone(),
1376 count: *count,
1377 by_entity_type: by_type.clone(),
1378 })
1379 .collect();
1380 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
1381 entries.truncate(limit);
1382
1383 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
1388 for (tag, (count, _)) in counts.iter() {
1389 by_canonical
1390 .entry(tag.to_lowercase())
1391 .or_default()
1392 .push((tag.clone(), *count));
1393 }
1394 let mut folded: Vec<FoldedTag> = by_canonical
1395 .into_iter()
1396 .filter(|(_, v)| v.len() > 1)
1397 .map(|(canonical, mut variants)| {
1398 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1399 let total = variants.iter().map(|(_, c)| *c).sum();
1400 FoldedTag {
1401 canonical,
1402 total,
1403 variants: variants
1404 .into_iter()
1405 .map(|(tag, count)| TagVariant { tag, count })
1406 .collect(),
1407 }
1408 })
1409 .collect();
1410 folded.sort_by(|a, b| {
1411 b.total
1412 .cmp(&a.total)
1413 .then_with(|| a.canonical.cmp(&b.canonical))
1414 });
1415
1416 (entries, folded, untagged)
1417}
1418
1419pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
1445 use crate::entity::parser::extract_inline_links_lenient;
1446 use std::collections::HashSet;
1447
1448 let mut out = Vec::new();
1449 for entity in store.all_entities() {
1450 if entity.stub {
1451 continue;
1452 }
1453 if let Some(v) = mem_filter
1454 && entity.mem != v
1455 {
1456 continue;
1457 }
1458 let explicit_targets: HashSet<_> = entity
1459 .relationships
1460 .iter()
1461 .map(|r| r.target.clone())
1462 .collect();
1463 for (section_key, section_body) in &entity.sections {
1464 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
1465 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
1466 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
1467 let kind = if target_missing {
1471 crate::ops::DanglingLinkKind::LinkTargetMissing
1472 } else {
1473 crate::ops::DanglingLinkKind::LinkNotRelated
1474 };
1475 if target_missing || alias_orphan {
1476 out.push(DanglingLink {
1477 kind,
1478 from: entity.id.clone(),
1479 target_id: target_id.clone(),
1480 target_path: target_id.path().to_string(),
1481 section: Some(section_key.clone()),
1482 });
1483 }
1484 }
1485 }
1486 for rel in &entity.relationships {
1503 if store.get(&rel.target).is_some() {
1504 continue;
1505 }
1506 let already_reported = out
1507 .iter()
1508 .any(|d| d.from == entity.id && d.target_id == rel.target);
1509 if already_reported {
1510 continue;
1511 }
1512 out.push(DanglingLink {
1513 kind: crate::ops::DanglingLinkKind::RelationTargetMissing,
1514 from: entity.id.clone(),
1515 target_id: rel.target.clone(),
1516 target_path: rel.target.path().to_string(),
1517 section: None,
1518 });
1519 }
1520 }
1521 out.sort_by(|a, b| {
1526 (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
1527 });
1528 out
1529}
1530
1531pub fn collect_missing_required_outgoing(
1542 store: &Store,
1543 mem_filter: Option<&str>,
1544 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1545) -> Vec<MissingRequiredOutgoingReport> {
1546 let mut out = Vec::new();
1547 for entity in store.all_entities() {
1548 if entity.stub {
1549 continue;
1550 }
1551 if let Some(v) = mem_filter
1552 && entity.mem != v
1553 {
1554 continue;
1555 }
1556 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1557 continue;
1558 };
1559 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1560 continue;
1561 };
1562 if td.required_outgoing.is_empty() {
1563 continue;
1564 }
1565 let unsatisfied = unsatisfied_required_outgoing(entity, td);
1566 if unsatisfied.is_empty() {
1567 continue;
1568 }
1569 out.push(MissingRequiredOutgoingReport {
1570 id: entity.id.clone(),
1571 title: entity.title.clone(),
1572 entity_type: entity.entity_type.clone(),
1573 mem: entity.mem.clone(),
1574 missing: unsatisfied,
1575 });
1576 }
1577 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1578 out
1579}
1580
1581pub fn unsatisfied_required_outgoing(
1589 entity: &crate::entity::Entity,
1590 td: &TypeDefinition,
1591) -> Vec<super::MissingRequiredOutgoingBlock> {
1592 td.required_outgoing
1593 .iter()
1594 .filter(|block| {
1595 if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1600 let armed = entity
1601 .metadata
1602 .get(when_field.as_str())
1603 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1604 if !armed {
1605 return false;
1606 }
1607 }
1608 let count = entity
1609 .relationships
1610 .iter()
1611 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1612 .count();
1613 !block.admits(count)
1614 })
1615 .map(|block| super::MissingRequiredOutgoingBlock {
1616 relationships: block.relationships.clone(),
1617 cardinality: block.cardinality.to_string(),
1618 severity: block.severity,
1619 when_field: block.when_field.clone(),
1620 when_value: block.when_value.clone(),
1621 })
1622 .collect()
1623}
1624
1625#[derive(Debug, Clone, serde::Serialize)]
1633#[serde(tag = "kind", rename_all = "snake_case")]
1634pub enum UnsatisfiedConstraint {
1635 RequiresWhen {
1636 field: String,
1637 when_field: String,
1638 when_value: String,
1639 severity: memstead_schema::ConstraintSeverity,
1640 },
1641 Unique {
1642 fields: Vec<String>,
1643 values: Vec<String>,
1645 colliding: String,
1648 severity: memstead_schema::ConstraintSeverity,
1649 },
1650 EnumFromNeighbour {
1651 field: String,
1652 value: String,
1654 rel_type: String,
1655 section: String,
1656 severity: memstead_schema::ConstraintSeverity,
1657 },
1658 StatusPropagation {
1659 field: String,
1660 value: String,
1662 #[serde(skip_serializing_if = "Option::is_none")]
1666 rel_type: Option<String>,
1667 #[serde(skip_serializing_if = "Option::is_none")]
1669 rel_types: Option<Vec<String>>,
1670 tainted_by: String,
1673 severity: memstead_schema::ConstraintSeverity,
1674 },
1675 TransitionRequiresChecks {
1678 field: String,
1679 to_value: String,
1680 relationships: Vec<String>,
1681 direction: memstead_schema::PropagationDirection,
1682 unchecked: Vec<UncheckedRelated>,
1685 severity: memstead_schema::ConstraintSeverity,
1686 },
1687 MustReach {
1692 relationships: Vec<String>,
1693 direction: memstead_schema::ReachDirection,
1694 terminal_types: Vec<String>,
1695 #[serde(skip_serializing_if = "Option::is_none")]
1696 max_depth: Option<u32>,
1697 severity: memstead_schema::ConstraintSeverity,
1698 },
1699}
1700
1701impl UnsatisfiedConstraint {
1702 pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1703 match self {
1704 Self::RequiresWhen { severity, .. }
1705 | Self::Unique { severity, .. }
1706 | Self::EnumFromNeighbour { severity, .. }
1707 | Self::StatusPropagation { severity, .. }
1708 | Self::MustReach { severity, .. }
1709 | Self::TransitionRequiresChecks { severity, .. } => *severity,
1710 }
1711 }
1712
1713 pub fn describe(&self) -> String {
1715 match self {
1716 Self::RequiresWhen {
1717 field,
1718 when_field,
1719 when_value,
1720 ..
1721 } => format!(
1722 "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1723 ),
1724 Self::Unique {
1725 fields, colliding, ..
1726 } => format!(
1727 "unique: tuple ({}) collides with '{colliding}'",
1728 fields.join(", ")
1729 ),
1730 Self::EnumFromNeighbour {
1731 field,
1732 value,
1733 rel_type,
1734 section,
1735 ..
1736 } => format!(
1737 "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1738 `{section}` section reached via {rel_type}"
1739 ),
1740 Self::StatusPropagation {
1741 field,
1742 value,
1743 tainted_by,
1744 ..
1745 } => {
1746 format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1747 }
1748 Self::MustReach {
1749 relationships,
1750 direction,
1751 terminal_types,
1752 max_depth,
1753 ..
1754 } => {
1755 let depth = match max_depth {
1756 Some(d) => format!(" within {d} hop(s)"),
1757 None => String::new(),
1758 };
1759 format!(
1760 "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1761 relationships.join(", "),
1762 terminal_types.join(", ")
1763 )
1764 }
1765 Self::TransitionRequiresChecks {
1766 field,
1767 to_value,
1768 relationships,
1769 unchecked,
1770 ..
1771 } => {
1772 let listed: Vec<String> = unchecked
1773 .iter()
1774 .map(|u| format!("'{}' ({})", u.id, u.state))
1775 .collect();
1776 format!(
1777 "transition_requires_checks: {field}={to_value} requires a fresh confirming \
1778 check record on every entity related via [{}] — unconfirmed: {}",
1779 relationships.join(", "),
1780 listed.join(", ")
1781 )
1782 }
1783 }
1784 }
1785}
1786
1787#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1791pub struct UncheckedRelated {
1792 pub id: String,
1793 pub state: String,
1794}
1795
1796pub type CheckStateProvider<'a> =
1803 &'a dyn Fn(&crate::entity::Entity) -> crate::engine::independence::CheckStanding;
1804
1805pub fn unsatisfied_constraints(
1829 store: &Store,
1830 entity: &crate::entity::Entity,
1831 td: &TypeDefinition,
1832 exclude: Option<&crate::entity::EntityId>,
1833 checks: Option<CheckStateProvider<'_>>,
1834) -> Vec<UnsatisfiedConstraint> {
1835 use memstead_schema::ConstraintDef;
1836 td.constraints
1837 .iter()
1838 .filter_map(|c| match c {
1839 ConstraintDef::RequiresWhen {
1840 field,
1841 when_field,
1842 when_value,
1843 severity,
1844 } => {
1845 let triggered = entity
1846 .metadata
1847 .get(when_field.as_str())
1848 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1849 if !triggered {
1850 return None;
1851 }
1852 let satisfied = entity
1853 .metadata
1854 .get(field.as_str())
1855 .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1856 || entity
1857 .sections
1858 .get(field.as_str())
1859 .is_some_and(|body| !body.trim().is_empty());
1860 if satisfied {
1861 return None;
1862 }
1863 Some(UnsatisfiedConstraint::RequiresWhen {
1864 field: field.clone(),
1865 when_field: when_field.clone(),
1866 when_value: when_value.clone(),
1867 severity: *severity,
1868 })
1869 }
1870 ConstraintDef::Unique { fields, severity } => {
1871 let tuple = tuple_of(entity, fields)?;
1872 let mut colliding: Vec<&str> = store
1873 .all_entities()
1874 .filter(|other| {
1875 !other.stub
1876 && other.mem == entity.mem
1877 && other.entity_type == entity.entity_type
1878 && Some(&other.id) != exclude
1879 && other.id != entity.id
1880 && tuple_of(other, fields).as_ref() == Some(&tuple)
1881 })
1882 .map(|other| other.id.0.as_str())
1883 .collect();
1884 colliding.sort_unstable();
1885 let first = colliding.first()?;
1886 Some(UnsatisfiedConstraint::Unique {
1887 fields: fields.clone(),
1888 values: tuple,
1889 colliding: first.to_string(),
1890 severity: *severity,
1891 })
1892 }
1893 ConstraintDef::EnumFromNeighbour {
1894 field,
1895 rel_type,
1896 section,
1897 severity,
1898 } => {
1899 let value = entity
1900 .metadata
1901 .get(field.as_str())
1902 .map(|v| v.to_frontmatter_string())
1903 .filter(|v| !v.trim().is_empty())?;
1904 let backed = entity
1905 .relationships
1906 .iter()
1907 .filter(|rel| rel.rel_type == *rel_type)
1908 .filter_map(|rel| store.get(&rel.target))
1909 .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1910 .any(|body| bullet_entries(body).contains(&value));
1911 if backed {
1912 return None;
1913 }
1914 Some(UnsatisfiedConstraint::EnumFromNeighbour {
1915 field: field.clone(),
1916 value,
1917 rel_type: rel_type.clone(),
1918 section: section.clone(),
1919 severity: *severity,
1920 })
1921 }
1922 ConstraintDef::StatusPropagation { .. } => None,
1923 ConstraintDef::TransitionRequiresChecks {
1924 field,
1925 to_value,
1926 relationships,
1927 direction,
1928 severity,
1929 } => {
1930 let triggered = entity
1931 .metadata
1932 .get(field.as_str())
1933 .is_some_and(|v| v.to_frontmatter_string() == *to_value);
1934 if !triggered {
1935 return None;
1936 }
1937 let (_, unchecked) = transition_gate_standing(
1938 store,
1939 entity,
1940 relationships,
1941 *direction,
1942 exclude,
1943 checks,
1944 );
1945 if unchecked.is_empty() {
1946 return None;
1947 }
1948 Some(UnsatisfiedConstraint::TransitionRequiresChecks {
1949 field: field.clone(),
1950 to_value: to_value.clone(),
1951 relationships: relationships.clone(),
1952 direction: *direction,
1953 unchecked,
1954 severity: *severity,
1955 })
1956 }
1957 })
1958 .collect()
1959}
1960
1961pub fn transition_gate_standing(
1972 store: &Store,
1973 entity: &crate::entity::Entity,
1974 relationships: &[String],
1975 direction: memstead_schema::PropagationDirection,
1976 exclude: Option<&crate::entity::EntityId>,
1977 checks: Option<CheckStateProvider<'_>>,
1978) -> (usize, Vec<UncheckedRelated>) {
1979 let related: Vec<&crate::entity::Entity> = match direction {
1980 memstead_schema::PropagationDirection::Outgoing => entity
1981 .relationships
1982 .iter()
1983 .filter(|rel| relationships.contains(&rel.rel_type))
1984 .filter_map(|rel| store.get(&rel.target))
1985 .collect(),
1986 memstead_schema::PropagationDirection::Incoming => store
1987 .all_entities()
1988 .filter(|other| {
1989 other.id != entity.id
1990 && Some(&other.id) != exclude
1991 && other
1992 .relationships
1993 .iter()
1994 .any(|rel| rel.target == entity.id && relationships.contains(&rel.rel_type))
1995 })
1996 .collect(),
1997 };
1998 let total = related.len();
1999 let mut unchecked: Vec<UncheckedRelated> = related
2000 .into_iter()
2001 .filter_map(|rel_entity| {
2002 let standing = match checks {
2006 Some(provider) => provider(rel_entity),
2007 None => crate::engine::independence::CheckStanding {
2008 state: crate::check::CheckState::NeverChecked,
2009 independence: None,
2010 },
2011 };
2012 if standing.confirms() {
2013 None
2014 } else {
2015 Some(UncheckedRelated {
2016 id: rel_entity.id.0.clone(),
2017 state: standing.label().to_string(),
2018 })
2019 }
2020 })
2021 .collect();
2022 unchecked.sort_by(|a, b| a.id.cmp(&b.id));
2023 (total, unchecked)
2024}
2025
2026fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
2030 fields
2031 .iter()
2032 .map(|f| {
2033 entity
2034 .metadata
2035 .get(f.as_str())
2036 .map(|v| v.to_frontmatter_string())
2037 .filter(|v| !v.trim().is_empty())
2038 })
2039 .collect()
2040}
2041
2042fn bullet_entries(body: &str) -> Vec<String> {
2045 let masked = crate::markdown::mask_code_blocks_and_spans(body);
2050 body.lines()
2051 .zip(masked.lines())
2052 .filter_map(|(line, masked_line)| {
2053 let m = masked_line.trim_start();
2054 if m.starts_with("- ") || m.starts_with("* ") {
2055 let t = line.trim_start();
2056 t.strip_prefix("- ")
2057 .or_else(|| t.strip_prefix("* "))
2058 .map(|e| e.trim().to_string())
2059 } else {
2060 None
2061 }
2062 })
2063 .collect()
2064}
2065
2066#[derive(Debug, Clone, serde::Serialize)]
2071pub struct ConstraintFindingReport {
2072 pub id: crate::entity::EntityId,
2073 pub title: String,
2074 pub entity_type: String,
2075 pub mem: String,
2076 pub violations: Vec<UnsatisfiedConstraint>,
2077 #[serde(skip_serializing_if = "Vec::is_empty")]
2081 pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
2082}
2083
2084pub fn collect_constraint_findings(
2094 store: &Store,
2095 mem_filter: Option<&str>,
2096 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2097 checks: Option<CheckStateProvider<'_>>,
2098) -> Vec<ConstraintFindingReport> {
2099 use memstead_schema::ConstraintDef;
2100 type Bucket = (
2101 Vec<UnsatisfiedConstraint>,
2102 Vec<crate::section_format::SectionFormatViolation>,
2103 );
2104 let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
2105
2106 let needs_reverse = mem_schemas.values().any(|s| {
2110 s.types.values().any(|t| {
2111 t.must_reach
2112 .iter()
2113 .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
2114 })
2115 });
2116 let reverse: ReverseIndex = if needs_reverse {
2117 build_reverse_index(store)
2118 } else {
2119 ReverseIndex::default()
2120 };
2121
2122 for entity in store.all_entities() {
2123 if entity.stub {
2124 continue;
2125 }
2126 if let Some(v) = mem_filter
2127 && entity.mem != v
2128 {
2129 continue;
2130 }
2131 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2132 continue;
2133 };
2134 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2135 continue;
2136 };
2137
2138 for def in &td.sections {
2143 if def.compiled_content.is_none() {
2144 continue;
2145 }
2146 let Some(body) = entity.sections.get(def.key.as_str()) else {
2147 continue;
2148 };
2149 let violations = crate::section_format::check_section_format(def, body);
2150 if !violations.is_empty() {
2151 by_entity
2152 .entry(entity.id.0.clone())
2153 .or_default()
2154 .1
2155 .extend(violations);
2156 }
2157 }
2158
2159 for ob in &td.must_reach {
2164 if !reaches_terminal(store, &reverse, &entity.id, ob) {
2165 by_entity.entry(entity.id.0.clone()).or_default().0.push(
2166 UnsatisfiedConstraint::MustReach {
2167 relationships: ob.relationships.clone(),
2168 direction: ob.direction,
2169 terminal_types: ob.terminal_types.clone(),
2170 max_depth: ob.max_depth,
2171 severity: ob.severity,
2172 },
2173 );
2174 }
2175 }
2176
2177 if td.constraints.is_empty() {
2178 continue;
2179 }
2180
2181 let violations = unsatisfied_constraints(store, entity, td, None, checks);
2183 if !violations.is_empty() {
2184 by_entity
2185 .entry(entity.id.0.clone())
2186 .or_default()
2187 .0
2188 .extend(violations);
2189 }
2190
2191 for c in &td.constraints {
2196 let ConstraintDef::StatusPropagation {
2197 field,
2198 value,
2199 rel_type,
2200 rel_types,
2201 direction,
2202 severity,
2203 } = c
2204 else {
2205 continue;
2206 };
2207 let terminal = entity
2208 .metadata
2209 .get(field.as_str())
2210 .is_some_and(|v| v.to_frontmatter_string() == *value);
2211 if !terminal {
2212 continue;
2213 }
2214 let set = c
2215 .propagation_rel_types()
2216 .expect("StatusPropagation always yields a set");
2217 for tainted in reach_transitively(store, &entity.id, &set, *direction) {
2218 if let Some(v) = mem_filter
2219 && tainted.mem() != v
2220 {
2221 continue;
2222 }
2223 by_entity.entry(tainted.0.clone()).or_default().0.push(
2224 UnsatisfiedConstraint::StatusPropagation {
2225 field: field.clone(),
2226 value: value.clone(),
2227 rel_type: rel_type.clone(),
2228 rel_types: rel_types.clone(),
2229 tainted_by: entity.id.to_string(),
2230 severity: *severity,
2231 },
2232 );
2233 }
2234 }
2235 }
2236
2237 let mut out: Vec<ConstraintFindingReport> = by_entity
2238 .into_iter()
2239 .filter_map(|(id, (violations, format_violations))| {
2240 let id = crate::entity::EntityId(id);
2241 let entity = store.get(&id)?;
2242 Some(ConstraintFindingReport {
2243 id,
2244 title: entity.title.clone(),
2245 entity_type: entity.entity_type.clone(),
2246 mem: entity.mem.clone(),
2247 violations,
2248 format_violations,
2249 })
2250 })
2251 .collect();
2252 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2253 out
2254}
2255
2256fn reach_transitively(
2263 store: &Store,
2264 start: &crate::entity::EntityId,
2265 rel_types: &[String],
2266 direction: memstead_schema::PropagationDirection,
2267) -> Vec<crate::entity::EntityId> {
2268 use memstead_schema::PropagationDirection;
2269 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
2270 std::iter::once(start.clone()).collect();
2271 let mut frontier = vec![start.clone()];
2272 let mut reached = Vec::new();
2273 while let Some(current) = frontier.pop() {
2274 let next: Vec<crate::entity::EntityId> = match direction {
2275 PropagationDirection::Incoming => store
2276 .all_entities()
2277 .filter(|e| {
2278 e.relationships
2279 .iter()
2280 .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
2281 })
2282 .map(|e| e.id.clone())
2283 .collect(),
2284 PropagationDirection::Outgoing => store
2285 .get(¤t)
2286 .map(|e| {
2287 e.relationships
2288 .iter()
2289 .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
2290 .map(|r| r.target.clone())
2291 .collect()
2292 })
2293 .unwrap_or_default(),
2294 };
2295 for id in next {
2296 if seen.insert(id.clone()) {
2297 if store.get(&id).is_some_and(|e| !e.stub) {
2298 reached.push(id.clone());
2299 }
2300 frontier.push(id);
2301 }
2302 }
2303 }
2304 reached
2305}
2306
2307type ReverseIndex =
2312 std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
2313
2314fn build_reverse_index(store: &Store) -> ReverseIndex {
2315 let mut idx = ReverseIndex::default();
2316 for entity in store.all_entities() {
2317 for rel in &entity.relationships {
2318 idx.entry(rel.target.clone())
2319 .or_default()
2320 .push((rel.rel_type.clone(), entity.id.clone()));
2321 }
2322 }
2323 idx
2324}
2325
2326fn reaches_terminal(
2335 store: &Store,
2336 reverse: &ReverseIndex,
2337 start: &crate::entity::EntityId,
2338 ob: &memstead_schema::MustReach,
2339) -> bool {
2340 use memstead_schema::ReachDirection;
2341 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
2342 std::iter::once(start.clone()).collect();
2343 let mut frontier = vec![start.clone()];
2344 let mut depth: u32 = 0;
2345 while !frontier.is_empty() {
2346 if let Some(max) = ob.max_depth
2347 && depth >= max
2348 {
2349 return false;
2350 }
2351 depth += 1;
2352 let mut next_frontier = Vec::new();
2353 for current in frontier {
2354 let next: Vec<crate::entity::EntityId> = match ob.direction {
2355 ReachDirection::Out => store
2356 .get(¤t)
2357 .map(|e| {
2358 e.relationships
2359 .iter()
2360 .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
2361 .map(|r| r.target.clone())
2362 .collect()
2363 })
2364 .unwrap_or_default(),
2365 ReachDirection::In => reverse
2366 .get(¤t)
2367 .map(|sources| {
2368 sources
2369 .iter()
2370 .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
2371 .map(|(_, src)| src.clone())
2372 .collect()
2373 })
2374 .unwrap_or_default(),
2375 };
2376 for id in next {
2377 if seen.insert(id.clone()) {
2378 if store.get(&id).is_some_and(|e| {
2379 !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
2380 }) {
2381 return true;
2382 }
2383 next_frontier.push(id);
2384 }
2385 }
2386 }
2387 frontier = next_frontier;
2388 }
2389 false
2390}
2391
2392#[derive(Debug, Clone, serde::Serialize)]
2397pub struct SignalReport {
2398 pub id: crate::entity::EntityId,
2399 pub title: String,
2400 pub entity_type: String,
2401 pub mem: String,
2402 pub signals: Vec<super::signals::ComputedSignal>,
2404}
2405
2406impl SignalReport {
2407 pub fn has_warn(&self) -> bool {
2411 self.signals
2412 .iter()
2413 .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
2414 }
2415}
2416
2417pub fn collect_signal_reports(
2421 store: &Store,
2422 mem_filter: Option<&str>,
2423 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2424) -> Vec<SignalReport> {
2425 let mut out = Vec::new();
2426 for entity in store.all_entities() {
2427 if entity.stub {
2428 continue;
2429 }
2430 if let Some(v) = mem_filter
2431 && entity.mem != v
2432 {
2433 continue;
2434 }
2435 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2436 continue;
2437 };
2438 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2439 continue;
2440 };
2441 if td.signals.is_empty() {
2442 continue;
2443 }
2444 let above: Vec<super::signals::ComputedSignal> =
2445 super::signals::compute_signals(store, td, &entity.id)
2446 .into_iter()
2447 .filter(|s| s.level.is_some())
2448 .collect();
2449 if above.is_empty() {
2450 continue;
2451 }
2452 out.push(SignalReport {
2453 id: entity.id.clone(),
2454 title: entity.title.clone(),
2455 entity_type: entity.entity_type.clone(),
2456 mem: entity.mem.clone(),
2457 signals: above,
2458 });
2459 }
2460 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2461 out
2462}
2463
2464#[derive(Debug, Clone, serde::Serialize)]
2469pub struct SchemaFormatDefect {
2470 pub schema: String,
2471 pub type_name: String,
2472 pub section: String,
2473 pub problems: Vec<String>,
2474}
2475
2476pub fn collect_schema_format_defects(
2480 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2481) -> Vec<SchemaFormatDefect> {
2482 let mut seen: std::collections::BTreeSet<String> = Default::default();
2483 let mut out = Vec::new();
2484 let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
2485 schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
2486 for schema in schemas {
2487 let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
2488 if !seen.insert(schema_ref.clone()) {
2489 continue;
2490 }
2491 for td in schema.types.values() {
2492 for section in &td.sections {
2493 if !section.format_problems.is_empty() {
2494 out.push(SchemaFormatDefect {
2495 schema: schema_ref.clone(),
2496 type_name: td.name.clone(),
2497 section: section.key.clone(),
2498 problems: section.format_problems.clone(),
2499 });
2500 }
2501 }
2502 }
2503 }
2504 out.sort_by(|a, b| {
2505 (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
2506 });
2507 out
2508}
2509
2510#[derive(Debug, Clone, serde::Serialize)]
2518pub struct MissingRequiredOutgoingReport {
2519 pub id: crate::entity::EntityId,
2520 pub title: String,
2521 pub entity_type: String,
2522 pub mem: String,
2523 pub missing: Vec<super::MissingRequiredOutgoingBlock>,
2524}
2525
2526pub fn config_projection(
2539 engine: &crate::Engine,
2540 writable_mems: &[String],
2541 mutations: serde_json::Value,
2542 plugin: serde_json::Value,
2543) -> serde_json::Map<String, serde_json::Value> {
2544 let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
2549 .mounts()
2550 .iter()
2551 .map(|m| {
2552 (
2553 m.mem.as_str(),
2554 (m.storage.backend_id(), m.storage.is_durable()),
2555 )
2556 })
2557 .collect();
2558 let mems_detail: Vec<serde_json::Value> = writable_mems
2559 .iter()
2560 .map(|name| {
2561 let origin = engine
2562 .mem_router()
2563 .origin_for_mem(name)
2564 .map(|o| o.kind())
2565 .unwrap_or("explicit");
2566 let mut entry = serde_json::Map::new();
2567 entry.insert("name".into(), serde_json::json!(name));
2568 entry.insert("origin".into(), serde_json::json!(origin));
2569 if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
2570 entry.insert("storage".into(), serde_json::json!(storage));
2571 entry.insert("durable".into(), serde_json::json!(durable));
2572 }
2573 let mut vcs_obj = serde_json::Map::new();
2574 if let Ok(gitdir) = engine.gitdir_for(name) {
2575 vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
2576 }
2577 if let Ok(worktree) = engine.worktree_for(name) {
2578 vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
2579 }
2580 if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
2581 vcs_obj.insert("head".into(), serde_json::json!(sha));
2582 }
2583 if !vcs_obj.is_empty() {
2584 entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
2585 }
2586 if let Some(cfg) = engine.mem_config_for(name) {
2587 if let Some(title) = &cfg.title {
2591 entry.insert("title".into(), serde_json::json!(title));
2592 }
2593 if let Some(subject) = &cfg.subject {
2594 entry.insert("subject".into(), serde_json::json!(subject));
2595 }
2596 let guidance = serde_json::Map::from_iter(
2597 cfg.write_guidance
2598 .iter()
2599 .map(|(k, v)| (k.clone(), v.clone())),
2600 );
2601 entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
2602 let extra = serde_json::Map::from_iter(
2603 cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
2604 );
2605 entry.insert("extra".into(), serde_json::Value::Object(extra));
2606 }
2607 serde_json::Value::Object(entry)
2608 })
2609 .collect();
2610
2611 let mut out = serde_json::Map::new();
2612 out.insert("mems".into(), serde_json::json!(mems_detail));
2613 out.insert("mutations".into(), mutations);
2614 out.insert("plugin".into(), plugin);
2615 out
2616}
2617
2618pub fn config_projection_from_settings(
2624 settings: &crate::workspace::WorkspaceSettings,
2625) -> (serde_json::Value, serde_json::Value) {
2626 let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
2627 let plugin_map: serde_json::Map<String, serde_json::Value> = settings
2628 .plugin
2629 .iter()
2630 .map(|(k, v)| {
2631 (
2632 k.clone(),
2633 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
2634 )
2635 })
2636 .collect();
2637 (mutations, serde_json::Value::Object(plugin_map))
2638}
2639
2640pub(crate) fn section_heading_mismatch_issue(
2655 entity: &crate::entity::Entity,
2656 schema: &TypeDefinition,
2657 key: &str,
2658) -> Option<HealthIssue> {
2659 let def = schema.section(key)?;
2660 let derived = memstead_schema::derive_section_key(&def.heading);
2661 if derived == key {
2662 return None;
2663 }
2664 if !entity
2665 .raw_section_headings
2666 .iter()
2667 .any(|h| h == &def.heading)
2668 {
2669 return None;
2670 }
2671 let landing = match schema.catch_all_section() {
2672 Some(c) => format!(
2673 "the content was absorbed into catch-all section '{}'",
2674 c.key
2675 ),
2676 None => "the content is unreachable under any declared key".to_string(),
2677 };
2678 Some(HealthIssue {
2679 field: key.to_string(),
2680 code: super::HealthIssueCode::SectionHeadingMismatch,
2681 message: format!(
2682 "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
2683 under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
2684 The schema's declared heading cannot round-trip to its key (expected a heading \
2685 that derives to '{key}'); fix the schema's heading/key pair — new installs of \
2686 such a schema are refused",
2687 found = def.heading,
2688 ),
2689 })
2690}
2691
2692pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
2694 let mut issues = Vec::new();
2695
2696 for field in &schema.health_required_fields {
2697 if schema.section(field).is_some() {
2698 let content = entity.sections.get(field.as_str());
2699 if content.is_none_or(|c| c.trim().is_empty()) {
2700 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
2701 issues.push(issue);
2702 } else {
2703 issues.push(HealthIssue {
2704 field: field.clone(),
2705 code: super::HealthIssueCode::Missing,
2706 message: format!("required section '{field}' is empty"),
2707 });
2708 }
2709 }
2710 } else {
2711 let value = entity.metadata.get(field.as_str());
2712 if value.is_none() {
2713 issues.push(HealthIssue {
2714 field: field.clone(),
2715 code: super::HealthIssueCode::Missing,
2716 message: format!("required field '{field}' is missing"),
2717 });
2718 }
2719 }
2720 }
2721
2722 for s in schema.sections.iter().filter(|s| !s.catch_all) {
2726 if schema.health_required_fields.contains(&s.key) {
2727 continue; }
2729 let content = entity.sections.get(s.key.as_str());
2730 if content.is_none_or(|c| c.trim().is_empty())
2731 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
2732 {
2733 issues.push(issue);
2734 }
2735 }
2736
2737 let total = schema.health_required_fields.len();
2738 let score = if total > 0 {
2739 (total.saturating_sub(issues.len()) as f32) / (total as f32)
2740 } else {
2741 1.0
2742 };
2743
2744 HealthReport {
2745 id: entity.id.clone(),
2746 title: entity.title.clone(),
2747 score,
2748 issues,
2749 }
2750}
2751
2752pub fn days_since_epoch() -> u64 {
2767 if let Some(pinned) = std::env::var("MEMSTEAD_TODAY")
2768 .ok()
2769 .and_then(|s| crate::engine::due::pinned_days_since_epoch(&s))
2770 {
2771 return pinned;
2772 }
2773 #[cfg(target_arch = "wasm32")]
2774 {
2775 (js_sys::Date::now() / 1000.0) as u64 / 86400
2776 }
2777 #[cfg(not(target_arch = "wasm32"))]
2778 {
2779 std::time::SystemTime::now()
2780 .duration_since(std::time::UNIX_EPOCH)
2781 .unwrap_or_default()
2782 .as_secs()
2783 / 86400
2784 }
2785}
2786
2787pub fn parse_iso_to_days(date: &str) -> Option<u64> {
2790 let date_part = date.split('T').next()?;
2791 let parts: Vec<&str> = date_part.split('-').collect();
2792 if parts.len() != 3 {
2793 return None;
2794 }
2795 let year: u64 = parts[0].parse().ok()?;
2796 let month: u64 = parts[1].parse().ok()?;
2797 let day: u64 = parts[2].parse().ok()?;
2798 Some(ymd_to_days(year, month, day))
2799}
2800
2801fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2804 let y = if month <= 2 { year - 1 } else { year };
2806 let m = if month <= 2 { month + 9 } else { month - 3 };
2807 let era = y / 400;
2808 let yoe = y - era * 400;
2809 let doy = (153 * m + 2) / 5 + day - 1;
2810 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2811 let days = era * 146097 + doe;
2812 days - 719468
2813}
2814
2815#[cfg(test)]
2816mod tests {
2817 use super::*;
2818 use crate::entity::{Entity, EntityId, MetadataValue};
2819 use crate::ops::DanglingLinkKind;
2820 use crate::store::Store;
2821 use indexmap::IndexMap;
2822 use memstead_schema::type_by_name;
2823
2824 #[test]
2829 fn bullet_entries_ignores_code() {
2830 let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n - indented-ghost\n\nA `- span-ghost` sample.\n";
2831 let entries = bullet_entries(body);
2832 assert_eq!(
2833 entries,
2834 vec!["real-one".to_string(), "real-two".to_string()],
2835 "only prose bullets are legal values: {entries:?}"
2836 );
2837 }
2838
2839 #[test]
2843 fn bullet_entries_still_reads_prose_bullets_verbatim() {
2844 let entries = bullet_entries("- alpha\n * beta\n* `gamma`\n");
2845 assert_eq!(
2846 entries,
2847 vec![
2848 "alpha".to_string(),
2849 "beta".to_string(),
2850 "`gamma`".to_string()
2851 ]
2852 );
2853 }
2854
2855 #[test]
2861 fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2862 use crate::engine::test_helpers::folder_mount;
2863 let tmp = tempfile::TempDir::new().unwrap();
2864 let dest_dir = tmp.path().join("dest");
2865 let proc_dir = tmp.path().join("oddly-named-process");
2866 std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2867 std::fs::create_dir_all(&proc_dir).unwrap();
2868 std::fs::write(
2871 dest_dir.join(".memstead").join("config.json"),
2872 r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2873 )
2874 .unwrap();
2875 let engine = crate::Engine::from_mounts(vec![
2876 (
2877 folder_mount("dest", dest_dir.clone()),
2878 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2879 as Box<dyn crate::backend::MemBackend>,
2880 ),
2881 (
2882 folder_mount("oddly-named-process", proc_dir.clone()),
2883 Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2884 as Box<dyn crate::backend::MemBackend>,
2885 ),
2886 ])
2887 .unwrap();
2888
2889 let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2891 assert!(r.declared && r.mounted);
2892 assert_eq!(r.mem, "oddly-named-process");
2893 let r =
2896 crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2897 assert!(!r.declared && !r.mounted);
2898 assert_eq!(r.mem, "whatever");
2899
2900 let axis = health_open_questions_axis(&engine, Some("dest"));
2902 let process = &axis["dest"]["process"];
2903 assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2904 assert_eq!(process[0]["declared"], true, "{axis}");
2905 assert_eq!(process[0]["resolvable"], true, "{axis}");
2906
2907 std::fs::write(
2909 dest_dir.join(".memstead").join("config.json"),
2910 r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2911 )
2912 .unwrap();
2913 let engine2 = crate::Engine::from_mounts(vec![(
2914 folder_mount("dest", dest_dir.clone()),
2915 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2916 as Box<dyn crate::backend::MemBackend>,
2917 )])
2918 .unwrap();
2919 let axis = health_open_questions_axis(&engine2, Some("dest"));
2920 let process = &axis["dest"]["process"];
2921 assert_eq!(
2922 process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2923 "{axis}"
2924 );
2925 assert_eq!(process[0]["resolvable"], false, "{axis}");
2926 }
2927
2928 #[test]
2936 fn independence_gate_compares_identities_only() {
2937 use crate::engine::test_helpers::folder_mount;
2938 let tmp = tempfile::TempDir::new().unwrap();
2939 let dir = tmp.path().join("gate");
2940 std::fs::create_dir_all(&dir).unwrap();
2941 let mut engine = crate::Engine::from_mounts(vec![(
2942 folder_mount("gate", dir.clone()),
2943 Box::new(crate::storage::FilesystemMemWriter::new(dir))
2944 as Box<dyn crate::backend::MemBackend>,
2945 )])
2946 .unwrap();
2947 engine.set_workspace_root(tmp.path().to_path_buf());
2948
2949 let create = |engine: &mut crate::Engine, title: &str, identity: Option<&str>| {
2950 engine.set_identity(identity.map(str::to_string));
2951 engine
2952 .create_entity(
2953 crate::CreateEntityArgs {
2954 mem: "gate".to_string(),
2955 title: title.to_string(),
2956 entity_type: "spec".to_string(),
2957 sections: [
2958 ("identity".to_string(), "x".to_string()),
2959 ("purpose".to_string(), "y".to_string()),
2960 ]
2961 .into_iter()
2962 .collect(),
2963 metadata: Default::default(),
2964 relations: Vec::new(),
2965 anchors: Vec::new(),
2966 dry_run: false,
2967 },
2968 crate::vcs::Actor::Cli,
2969 None,
2970 None,
2971 )
2972 .unwrap()
2973 .id
2974 .0
2975 };
2976 let a = create(&mut engine, "Self Checked", Some("alice"));
2977 let b = create(&mut engine, "Independent", Some("alice"));
2978 let c = create(&mut engine, "No Author Identity", None);
2979
2980 let check = |engine: &mut crate::Engine,
2981 id: &str,
2982 identity: Option<&str>,
2983 actor: crate::vcs::Actor,
2984 client: Option<&crate::vcs::ClientId>| {
2985 engine.set_identity(identity.map(str::to_string));
2986 engine
2987 .record_check(
2988 "gate",
2989 id,
2990 crate::check::Verdict::Ok,
2991 crate::check::CheckKind::Verification,
2992 None,
2993 actor,
2994 client,
2995 )
2996 .unwrap();
2997 };
2998 let other_client = crate::vcs::ClientId {
2999 name: "claude-code".into(),
3000 version: "9.9".into(),
3001 };
3002 check(
3005 &mut engine,
3006 &a,
3007 Some("alice"),
3008 crate::vcs::Actor::Agent,
3009 Some(&other_client),
3010 );
3011 check(&mut engine, &b, Some("bob"), crate::vcs::Actor::Cli, None);
3014 check(&mut engine, &c, Some("carol"), crate::vcs::Actor::Cli, None);
3016
3017 let axis = health_checks_axis(&engine, Some("gate"));
3018 let gate = &axis["gate"]["independence"];
3019 assert_eq!(
3020 gate["self_checked"]["items"],
3021 serde_json::json!([a]),
3022 "{axis}"
3023 );
3024 assert_eq!(
3025 gate["confirmed_independent"]["items"],
3026 serde_json::json!([b]),
3027 "{axis}"
3028 );
3029 assert_eq!(
3030 gate["unconfirmable"]["items"],
3031 serde_json::json!([c]),
3032 "{axis}"
3033 );
3034
3035 let prov = engine.entity_provenance("gate", &a).unwrap();
3038 assert_eq!(
3039 prov.created_by.as_ref().and_then(|r| r.identity.as_deref()),
3040 Some("alice"),
3041 "created-by serves the declared identity"
3042 );
3043 assert_eq!(
3044 prov.last_check.as_ref().and_then(|r| r.identity.as_deref()),
3045 Some("alice"),
3046 "the check record serves the declared identity"
3047 );
3048 }
3049
3050 fn make_entity(name: &str, has_required: bool) -> Entity {
3051 let mut metadata = IndexMap::new();
3052 metadata.insert("level".into(), MetadataValue::String("M0".into()));
3053 metadata.insert("type".into(), MetadataValue::String("spec".into()));
3054 metadata.insert(
3055 "created_date".into(),
3056 MetadataValue::String("2026-01-15".into()),
3057 );
3058 metadata.insert(
3059 "last_modified".into(),
3060 MetadataValue::String("2026-04-12".into()),
3061 );
3062
3063 let mut sections = IndexMap::new();
3064 if has_required {
3065 sections.insert("identity".into(), "Has identity.".into());
3066 sections.insert("purpose".into(), "Has purpose.".into());
3067 }
3068
3069 Entity {
3070 id: EntityId::new("specs", name),
3071 title: name.into(),
3072 entity_type: "spec".into(),
3073 mem: "specs".into(),
3074 file_path: format!("{name}.md"),
3075 metadata,
3076 sections,
3077 relationships: Vec::new(),
3078 content_hash: String::new(),
3079 stub: false,
3080 stub_kind: None,
3081 heading_spans: std::collections::HashMap::new(),
3082 raw_section_headings: Vec::new(),
3083 }
3084 }
3085
3086 fn violating_type() -> std::sync::Arc<TypeDefinition> {
3090 let manifest = r#"name: debate
3091version: 0.1.0
3092description: sealed-violator fixture
3093when_to_use: health tests
3094types:
3095 - question
3096relationships:
3097 mode: strict
3098 definitions:
3099 - name: PART_OF
3100 description: hier
3101 default_weight: 3.0
3102 - name: _default
3103 description: fallback
3104 default_weight: 1.0
3105community:
3106 resolution: 1.0
3107 seed: 42
3108"#;
3109 let type_yaml = r#"name: question
3110description: t
3111when_to_use: tests
3112sections:
3113 - key: answers
3114 heading: Answers argued
3115 required: true
3116 search_weight: 10.0
3117 write_rules: []
3118 - key: notes
3119 heading: Notes
3120 required: false
3121 search_weight: 3.0
3122 catch_all: true
3123 write_rules: []
3124metadata_fields: []
3125title_weight: 100.0
3126text_fields:
3127 - answers
3128 - notes
3129hierarchy_relationship: PART_OF
3130no_self_loop_relationships: []
3131updatable_fields:
3132 - title
3133 - answers
3134 - notes
3135health_required_fields:
3136 - answers
3137staleness_threshold_days: 90
3138write_rules: []
3139"#;
3140 memstead_schema::load_schema_from_memory(
3141 manifest,
3142 &[("question".to_string(), type_yaml.to_string())],
3143 )
3144 .expect("violating schema still loads")
3145 .get_type("question")
3146 .expect("question type")
3147 }
3148
3149 #[test]
3155 fn health_distinguishes_heading_mismatch_from_missing_section() {
3156 let schema = violating_type();
3157
3158 let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
3160 let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
3161 .expect("parses")
3162 .entity;
3163 let report = entity_health(&parsed, &schema);
3164 let mismatch: Vec<_> = report
3165 .issues
3166 .iter()
3167 .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
3168 .collect();
3169 assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
3170 let msg = &mismatch[0].message;
3171 assert!(
3172 msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
3173 "names found heading and derived key: {msg}"
3174 );
3175 assert!(
3176 msg.contains("'notes'"),
3177 "names the catch-all landing: {msg}"
3178 );
3179 assert!(
3180 !report.issues.iter().any(|i| i.message.contains("is empty")),
3181 "must not also report the section as missing: {:?}",
3182 report.issues
3183 );
3184
3185 let md_missing = "---\ntype: question\n---\n# Q2\n";
3187 let parsed_missing =
3188 crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
3189 .expect("parses")
3190 .entity;
3191 let report_missing = entity_health(&parsed_missing, &schema);
3192 assert!(
3193 report_missing
3194 .issues
3195 .iter()
3196 .any(|i| i.code == super::super::HealthIssueCode::Missing
3197 && i.message == "required section 'answers' is empty"),
3198 "absent section keeps the missing report (structured MISSING code): {:?}",
3199 report_missing.issues
3200 );
3201 assert!(
3202 !report_missing
3203 .issues
3204 .iter()
3205 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
3206 "no mismatch finding when the heading is not in the file"
3207 );
3208
3209 let ok_type = crate::entity::parser::parse_markdown(
3214 "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
3215 "q3.md",
3216 &schema,
3217 "debate",
3218 )
3219 .expect("parses")
3220 .entity;
3221 let report_ok = entity_health(&ok_type, &schema);
3222 assert!(
3223 !report_ok
3224 .issues
3225 .iter()
3226 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
3227 "mismatch fires only when the declared heading is present: {:?}",
3228 report_ok.issues
3229 );
3230 }
3231
3232 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
3233 let mut metadata = IndexMap::new();
3234 metadata.insert("type".into(), MetadataValue::String("concept".into()));
3235 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
3236 metadata.insert(
3237 "abstraction_level".into(),
3238 MetadataValue::String("concrete".into()),
3239 );
3240 metadata.insert(
3241 "created_date".into(),
3242 MetadataValue::String("2026-01-15".into()),
3243 );
3244 metadata.insert(
3245 "last_modified".into(),
3246 MetadataValue::String("2026-04-12".into()),
3247 );
3248
3249 let mut sections = IndexMap::new();
3250 if with_definition {
3251 sections.insert("definition".into(), "Precise definition.".into());
3252 }
3253 sections.insert("explanation".into(), "How it works.".into());
3254
3255 Entity {
3256 id: EntityId::new("concepts", name),
3257 title: name.into(),
3258 entity_type: "concept".into(),
3259 mem: "concepts".into(),
3260 file_path: format!("{name}.md"),
3261 metadata,
3262 sections,
3263 relationships: Vec::new(),
3264 content_hash: String::new(),
3265 stub: false,
3266 stub_kind: None,
3267 heading_spans: std::collections::HashMap::new(),
3268 raw_section_headings: Vec::new(),
3269 }
3270 }
3271
3272 #[test]
3273 fn health_concept_missing_definition_reports_definition_field() {
3274 let schema = &type_by_name("concept").unwrap();
3275 let entity = make_concept_entity("clarity", false);
3276 let report = entity_health(&entity, schema);
3277
3278 assert!(report.issues.iter().any(|i| i.field == "definition"));
3281 assert!(!report.issues.iter().any(|i| i.field == "identity"));
3282 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
3283 assert!(report.score < 1.0);
3284
3285 let healthy = make_concept_entity("clarity-ok", true);
3287 let healthy_report = entity_health(&healthy, schema);
3288 assert!(
3289 !healthy_report
3290 .issues
3291 .iter()
3292 .any(|i| i.field == "definition")
3293 );
3294 }
3295
3296 #[test]
3297 fn health_detects_missing_sections() {
3298 let schema = &type_by_name("spec").unwrap();
3299 let entity = make_entity("incomplete", false);
3300 let report = entity_health(&entity, schema);
3301 assert!(!report.issues.is_empty());
3302 assert!(report.score < 1.0);
3303 }
3304
3305 #[test]
3306 fn health_clean_entity() {
3307 let schema = &type_by_name("spec").unwrap();
3308 let entity = make_entity("complete", true);
3309 let report = entity_health(&entity, schema);
3310 let section_issues: Vec<_> = report
3312 .issues
3313 .iter()
3314 .filter(|i| i.field == "identity" || i.field == "purpose")
3315 .collect();
3316 assert!(section_issues.is_empty());
3317 }
3318
3319 #[test]
3320 fn health_summary_counts() {
3321 let mut store = Store::new();
3322 let e1 = make_entity("healthy", true);
3323 let e2 = make_entity("unhealthy", false);
3324 store.upsert(e1.id.clone(), e1);
3325 store.upsert(e2.id.clone(), e2);
3326
3327 let schema = &type_by_name("spec").unwrap();
3328 let summary = compute_health(&store, schema, &HashMap::new(), None);
3329 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
3331 }
3332
3333 #[test]
3334 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
3335 use crate::entity::Relationship;
3341 use memstead_schema::SchemaRegistry;
3342
3343 let registry = SchemaRegistry::builtin();
3344 let software = registry
3345 .get("software", &semver::Version::new(0, 2, 0))
3346 .expect("software schema ships as a builtin");
3347
3348 let mut store = Store::new();
3349 let mut bad = make_entity("bad-owns-source", true);
3352 bad.entity_type = "spec".into();
3353 bad.metadata
3354 .insert("level".into(), MetadataValue::String("M0".into()));
3355 bad.metadata
3356 .insert("stability".into(), MetadataValue::String("evolving".into()));
3357 bad.relationships.push(Relationship {
3358 rel_type: "OWNS".into(),
3359 target: EntityId::new("specs", "victim"),
3360 description: None,
3361 });
3362 let mut victim = make_entity("victim", true);
3363 victim.entity_type = "spec".into();
3364 store.upsert(bad.id.clone(), bad);
3365 store.upsert(victim.id.clone(), victim);
3366
3367 let mut mem_schemas = HashMap::new();
3368 mem_schemas.insert("specs".to_string(), software);
3369
3370 let schema = &type_by_name("spec").unwrap();
3371 let summary = compute_health(&store, schema, &mem_schemas, None);
3372 let report = summary
3373 .missing_fields
3374 .iter()
3375 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
3376 .expect("shape-violating entity must surface");
3377 let issue = report
3378 .issues
3379 .iter()
3380 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
3381 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
3382 assert!(
3383 issue.message.contains("OWNS"),
3384 "issue must name the offending rel_type: {}",
3385 issue.message
3386 );
3387 assert!(
3388 issue.message.contains("spec"),
3389 "issue must name the actual source type: {}",
3390 issue.message
3391 );
3392 assert!(
3393 issue.message.contains("actor"),
3394 "issue must name the allowed source type: {}",
3395 issue.message
3396 );
3397 assert!(
3398 issue.message.contains("remove=true"),
3399 "issue must surface the recovery path: {}",
3400 issue.message
3401 );
3402 }
3403
3404 #[test]
3405 fn health_does_not_flag_shape_compliant_edges() {
3406 use crate::entity::Relationship;
3409 use memstead_schema::SchemaRegistry;
3410
3411 let registry = SchemaRegistry::builtin();
3412 let software = registry
3413 .get("software", &semver::Version::new(0, 2, 0))
3414 .expect("software schema ships as a builtin");
3415
3416 let mut store = Store::new();
3417 let mut owner = make_entity("owner", true);
3418 owner.entity_type = "actor".into();
3419 owner
3420 .metadata
3421 .insert("kind".into(), MetadataValue::String("team".into()));
3422 owner
3423 .metadata
3424 .insert("active".into(), MetadataValue::Bool(true));
3425 owner
3426 .metadata
3427 .insert("handle".into(), MetadataValue::String("owner".into()));
3428 owner.relationships.push(Relationship {
3429 rel_type: "OWNS".into(),
3430 target: EntityId::new("specs", "owned"),
3431 description: None,
3432 });
3433 let mut owned = make_entity("owned", true);
3434 owned.entity_type = "spec".into();
3435 store.upsert(owner.id.clone(), owner);
3436 store.upsert(owned.id.clone(), owned);
3437
3438 let mut mem_schemas = HashMap::new();
3439 mem_schemas.insert("specs".to_string(), software);
3440
3441 let schema = &type_by_name("spec").unwrap();
3442 let summary = compute_health(&store, schema, &mem_schemas, None);
3443 let shape_issue = summary
3444 .missing_fields
3445 .iter()
3446 .flat_map(|r| r.issues.iter())
3447 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
3448 assert!(
3449 shape_issue.is_none(),
3450 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
3451 );
3452 }
3453
3454 #[test]
3455 fn health_warns_on_undeclared_relationship_in_existing_entity() {
3456 use crate::entity::Relationship;
3457 use memstead_schema::Schema;
3458
3459 let mut store = Store::new();
3460 let mut entity = make_entity("with-bad-rel", true);
3461 entity.relationships.push(Relationship {
3467 rel_type: "CONJURES".into(),
3468 target: EntityId::new("specs", "unknown"),
3469 description: None,
3470 });
3471 store.upsert(entity.id.clone(), entity);
3472
3473 let mut mem_schemas = HashMap::new();
3474 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
3475
3476 let schema = &type_by_name("spec").unwrap();
3477 let summary = compute_health(&store, schema, &mem_schemas, None);
3478 let report = summary
3479 .missing_fields
3480 .iter()
3481 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
3482 .expect("entity must surface in missing_fields");
3483 let rel_issue = report
3484 .issues
3485 .iter()
3486 .find(|i| i.field == "relationships")
3487 .expect("undeclared relationship must produce an issue");
3488 assert!(
3489 rel_issue.message.contains("CONJURES"),
3490 "issue message must name the offending relationship: {}",
3491 rel_issue.message
3492 );
3493 assert!(
3494 rel_issue.message.contains("default@1.0.0"),
3495 "issue must name the schema pin: {}",
3496 rel_issue.message
3497 );
3498 }
3499
3500 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
3507 let mut entity = make_entity(name, true);
3508 entity.sections.insert(section_key.into(), body.to_string());
3509 entity
3510 }
3511
3512 #[test]
3513 fn dangling_link_detected_after_delete() {
3514 use crate::entity::store_builder::make_stub;
3515
3516 let mut store = Store::new();
3517 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3518 store.upsert(a.id.clone(), a.clone());
3519
3520 let b_id = EntityId::new("specs", "b");
3523 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3524
3525 let dangling = super::collect_dangling_links(&store, None);
3526 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
3527 let d = &dangling[0];
3528 assert_eq!(d.from, a.id);
3529 assert_eq!(d.target_id, b_id);
3530 assert_eq!(d.target_path, "b");
3531 assert_eq!(d.section.as_deref(), Some("purpose"));
3532 assert_eq!(d.kind, DanglingLinkKind::LinkTargetMissing);
3533 }
3534
3535 #[test]
3543 fn the_three_dangling_conditions_are_discriminated() {
3544 use crate::entity::store_builder::make_stub;
3545
3546 let mut store = Store::new();
3547
3548 let gone = make_entity_with_body("gone-link", "purpose", "See [[absent]].");
3550 store.upsert(gone.id.clone(), gone.clone());
3551 let absent = EntityId::new("specs", "absent");
3552 store.upsert(absent.clone(), make_stub(absent.clone()));
3553
3554 let written = make_entity("written", true);
3557 store.upsert(written.id.clone(), written.clone());
3558 let unrelated = make_entity_with_body("unrelated-link", "purpose", "See [[written]].");
3559 store.upsert(unrelated.id.clone(), unrelated.clone());
3560
3561 let mut rel_source = make_entity("rel-source", true);
3565 rel_source.relationships.push(crate::entity::Relationship {
3566 rel_type: "DEPENDS_ON".to_string(),
3567 target: EntityId::new("specs", "vanished"),
3568 description: None,
3569 });
3570 store.upsert(rel_source.id.clone(), rel_source.clone());
3571
3572 let found = super::collect_dangling_links(&store, None);
3573 let kind_of = |from: &str| {
3574 found
3575 .iter()
3576 .find(|d| d.from.path() == from)
3577 .unwrap_or_else(|| panic!("no dangling link from {from}: {found:?}"))
3578 .kind
3579 };
3580 assert_eq!(kind_of("gone-link"), DanglingLinkKind::LinkTargetMissing);
3581 assert_eq!(kind_of("unrelated-link"), DanglingLinkKind::LinkNotRelated);
3582 assert_eq!(
3583 kind_of("rel-source"),
3584 DanglingLinkKind::RelationTargetMissing
3585 );
3586
3587 let codes: std::collections::BTreeSet<_> = found.iter().map(|d| d.kind.code()).collect();
3590 let repairs: std::collections::BTreeSet<_> =
3591 found.iter().map(|d| d.kind.repair()).collect();
3592 assert_eq!(codes.len(), 3, "{found:?}");
3593 assert_eq!(repairs.len(), 3, "{found:?}");
3594 }
3595
3596 #[test]
3603 fn a_relationship_row_pointing_at_a_stub_is_still_not_flagged() {
3604 use crate::entity::store_builder::make_stub;
3605
3606 let mut store = Store::new();
3607 let stub_id = EntityId::new("specs", "forward");
3608 store.upsert(stub_id.clone(), make_stub(stub_id.clone()));
3609
3610 let mut source = make_entity("forward-ref", true);
3611 source.relationships.push(crate::entity::Relationship {
3612 rel_type: "DEPENDS_ON".to_string(),
3613 target: stub_id.clone(),
3614 description: None,
3615 });
3616 store.upsert(source.id.clone(), source.clone());
3617
3618 assert!(
3619 super::collect_dangling_links(&store, None).is_empty(),
3620 "a forward reference through the relationships table stays unflagged"
3621 );
3622
3623 let body = make_entity_with_body("body-ref", "purpose", "See [[forward]].");
3626 store.upsert(body.id.clone(), body.clone());
3627 let found = super::collect_dangling_links(&store, None);
3628 assert_eq!(found.len(), 1, "{found:?}");
3629 assert_eq!(found[0].from, body.id);
3630 assert_eq!(found[0].kind, DanglingLinkKind::LinkTargetMissing);
3631 }
3632
3633 #[test]
3639 fn dangling_links_and_stubs_serve_in_deterministic_order() {
3640 use crate::entity::store_builder::make_stub;
3641
3642 let build = || {
3643 let mut store = Store::new();
3644 for name in ["zeta", "alpha", "mid"] {
3646 let e = make_entity_with_body(
3647 name,
3648 "purpose",
3649 &format!("See [[gone-{name}]] and [[lost-{name}]]."),
3650 );
3651 store.upsert(e.id.clone(), e);
3652 }
3653 for name in ["zeta", "alpha", "mid"] {
3654 for pre in ["gone", "lost"] {
3655 let id = EntityId::new("specs", &format!("{pre}-{name}"));
3656 store.upsert(id.clone(), make_stub(id));
3657 }
3658 }
3659 store
3660 };
3661
3662 let store_a = build();
3663 let store_b = build();
3664
3665 let key =
3666 |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
3667 let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
3668 .iter()
3669 .map(key)
3670 .collect();
3671 let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
3672 .iter()
3673 .map(key)
3674 .collect();
3675 assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
3676 let mut sorted = dangling_a.clone();
3677 sorted.sort();
3678 assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
3679 assert_eq!(dangling_a.len(), 6);
3680
3681 let stub_ids = |s: &Store| -> Vec<String> {
3682 crate::graph::query::find_stubs(s)
3683 .into_iter()
3684 .map(|(id, _)| id.0)
3685 .collect()
3686 };
3687 let stubs_a = stub_ids(&store_a);
3688 assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
3689 let mut sorted = stubs_a.clone();
3690 sorted.sort();
3691 assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
3692 assert_eq!(stubs_a.len(), 6);
3693 }
3694
3695 #[test]
3696 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
3697 use crate::entity::Relationship;
3698 use crate::entity::store_builder::make_stub;
3699
3700 let mut store = Store::new();
3701 let mut a = make_entity("a", true);
3704 let b_id = EntityId::new("specs", "b");
3705 a.relationships.push(Relationship {
3706 rel_type: "REFERENCES".into(),
3707 target: b_id.clone(),
3708 description: None,
3709 });
3710 store.upsert(a.id.clone(), a);
3711 store.upsert(b_id.clone(), make_stub(b_id));
3712
3713 let dangling = super::collect_dangling_links(&store, None);
3714 assert!(
3715 dangling.is_empty(),
3716 "explicit relationships to stubs are valid by design \
3717 (stubs are first-class placeholders); only inline-body \
3718 wiki-links to stubs must surface"
3719 );
3720 }
3721
3722 #[test]
3723 fn dangling_link_does_not_flag_real_reference() {
3724 use crate::entity::Relationship;
3725
3726 let mut store = Store::new();
3727 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3728 a.relationships.push(Relationship {
3730 rel_type: "REFERENCES".into(),
3731 target: EntityId::new("specs", "b"),
3732 description: None,
3733 });
3734 let b = make_entity("b", true);
3735 store.upsert(a.id.clone(), a);
3736 store.upsert(b.id.clone(), b);
3737
3738 let dangling = super::collect_dangling_links(&store, None);
3739 assert!(
3740 dangling.is_empty(),
3741 "real reference backed by relation — not dangling, not alias-orphan"
3742 );
3743 }
3744
3745 #[test]
3750 fn dangling_link_relationship_section_target_absent() {
3751 use crate::entity::Relationship;
3752
3753 let mut store = Store::new();
3754 let mut a = make_entity("a", true);
3755 a.relationships.push(Relationship {
3758 rel_type: "DEPENDS_ON".into(),
3759 target: EntityId::new("specs", "gone"),
3760 description: None,
3761 });
3762 store.upsert(a.id.clone(), a.clone());
3763
3764 let dangling = super::collect_dangling_links(&store, None);
3765 assert_eq!(
3766 dangling.len(),
3767 1,
3768 "exactly one relationship-section dangler"
3769 );
3770 let d = &dangling[0];
3771 assert_eq!(d.from, a.id);
3772 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
3773 assert!(
3774 d.section.is_none(),
3775 "relationship-section danglers ship `section: None`, got {:?}",
3776 d.section
3777 );
3778 }
3779
3780 #[test]
3785 fn dangling_link_relationship_section_stub_target_not_flagged() {
3786 use crate::entity::Relationship;
3787 use crate::entity::store_builder::make_stub;
3788
3789 let mut store = Store::new();
3790 let mut a = make_entity("a", true);
3791 let b_id = EntityId::new("specs", "b");
3792 a.relationships.push(Relationship {
3793 rel_type: "DEPENDS_ON".into(),
3794 target: b_id.clone(),
3795 description: None,
3796 });
3797 store.upsert(a.id.clone(), a);
3798 store.upsert(b_id.clone(), make_stub(b_id));
3799
3800 let dangling = super::collect_dangling_links(&store, None);
3801 assert!(
3802 dangling.is_empty(),
3803 "relationship targets that resolve to stubs are forward-references, not corruption"
3804 );
3805 }
3806
3807 #[test]
3814 fn dangling_link_dedups_across_body_and_relations() {
3815 use crate::entity::Relationship;
3816 use crate::entity::store_builder::make_stub;
3817
3818 let mut store = Store::new();
3819 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3820 let b_id = EntityId::new("specs", "b");
3821 a.relationships.push(Relationship {
3822 rel_type: "REFERENCES".into(),
3823 target: b_id.clone(),
3824 description: None,
3825 });
3826 store.upsert(a.id.clone(), a.clone());
3827 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3828
3829 let dangling = super::collect_dangling_links(&store, None);
3830 assert_eq!(
3831 dangling.len(),
3832 1,
3833 "body + relations both pointing at the same stub should dedup"
3834 );
3835 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
3838 }
3839
3840 #[test]
3841 fn dangling_links_scope_to_mem_filter() {
3842 use crate::entity::store_builder::make_stub;
3843
3844 let mut store = Store::new();
3845
3846 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
3848 store.upsert(a.id.clone(), a);
3849 let gone_specs = EntityId::new("specs", "gone");
3850 store.upsert(gone_specs.clone(), make_stub(gone_specs));
3851
3852 let mut x = make_entity("x", true);
3854 x.id = EntityId::new("web", "x");
3855 x.mem = "web".into();
3856 x.file_path = "x.md".into();
3857 x.sections
3858 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
3859 store.upsert(x.id.clone(), x);
3860 let gone_web = EntityId::new("web", "gone");
3861 store.upsert(gone_web.clone(), make_stub(gone_web));
3862
3863 let all = super::collect_dangling_links(&store, None);
3864 assert_eq!(all.len(), 2);
3865
3866 let specs_only = super::collect_dangling_links(&store, Some("specs"));
3867 assert_eq!(specs_only.len(), 1);
3868 assert_eq!(specs_only[0].from.mem(), "specs");
3869
3870 let web_only = super::collect_dangling_links(&store, Some("web"));
3871 assert_eq!(web_only.len(), 1);
3872 assert_eq!(web_only[0].from.mem(), "web");
3873 }
3874
3875 #[test]
3876 fn parse_iso_date() {
3877 let days = parse_iso_to_days("2026-04-12").unwrap();
3878 assert!(days > 0);
3879
3880 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
3881 assert_eq!(days, days_with_time);
3882 }
3883
3884 #[test]
3885 fn ymd_roundtrip() {
3886 let days = ymd_to_days(2026, 1, 1);
3888 assert!(days > 20000); }
3890
3891 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
3896 let mut e = make_entity(name, true);
3897 e.id = EntityId::new(mem, name);
3898 e.mem = mem.into();
3899 e.entity_type = entity_type.into();
3900 e.metadata
3901 .insert("tags".into(), MetadataValue::String(tags.into()));
3902 e
3903 }
3904
3905 fn make_entity_no_tags(name: &str) -> Entity {
3906 make_entity(name, true)
3907 }
3908
3909 #[test]
3910 fn tag_distribution_aggregates_across_entities() {
3911 let mut store = Store::new();
3912 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
3913 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
3914 let c = make_entity_with_tags("c", "specs", "spec", "plan");
3915 store.upsert(a.id.clone(), a);
3916 store.upsert(b.id.clone(), b);
3917 store.upsert(c.id.clone(), c);
3918
3919 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3920 assert_eq!(dist.len(), 2);
3921 assert_eq!(dist[0].tag, "plan");
3922 assert_eq!(dist[0].count, 3);
3923 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
3924 assert_eq!(dist[1].tag, "decision");
3925 assert_eq!(dist[1].count, 2);
3926 assert_eq!(untagged.total, 0);
3927 }
3928
3929 #[test]
3930 fn tag_distribution_case_sensitive() {
3931 let mut store = Store::new();
3932 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
3933 let b = make_entity_with_tags("b", "specs", "spec", "decision");
3934 store.upsert(a.id.clone(), a);
3935 store.upsert(b.id.clone(), b);
3936
3937 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
3938 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
3939 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
3940 assert!(tags.contains("decision"));
3941 assert!(tags.contains("Decision"));
3942
3943 assert_eq!(folded.len(), 1);
3945 assert_eq!(folded[0].canonical, "decision");
3946 assert_eq!(folded[0].total, 2);
3947 assert_eq!(folded[0].variants.len(), 2);
3948 }
3949
3950 #[test]
3951 fn untagged_entities_counts_missing_and_empty() {
3952 let mut store = Store::new();
3953 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
3955 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
3956 store.upsert(a.id.clone(), a);
3957 store.upsert(b.id.clone(), b);
3958 store.upsert(c.id.clone(), c);
3959
3960 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3961 assert!(dist.is_empty(), "no effective tags → empty distribution");
3962 assert_eq!(untagged.total, 3);
3963 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
3964 }
3965
3966 #[test]
3967 fn tag_distribution_respects_mem_filter() {
3968 let mut store = Store::new();
3969 let a = make_entity_with_tags("a", "specs", "spec", "decision");
3970 let b = make_entity_with_tags("b", "memos", "memo", "observation");
3971 let c = make_entity_no_tags("c");
3972 store.upsert(a.id.clone(), a);
3973 store.upsert(b.id.clone(), b);
3974 store.upsert(c.id.clone(), c);
3975
3976 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
3977 assert_eq!(dist.len(), 1);
3978 assert_eq!(dist[0].tag, "observation");
3979 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3980 }
3981
3982 #[test]
3983 fn tag_distribution_respects_limit() {
3984 let mut store = Store::new();
3985 for (name, tag) in [
3986 ("a", "t-alpha"),
3987 ("b", "t-beta"),
3988 ("c", "t-gamma"),
3989 ("d", "t-delta"),
3990 ("e", "t-epsilon"),
3991 ] {
3992 let e = make_entity_with_tags(name, "specs", "spec", tag);
3993 store.upsert(e.id.clone(), e);
3994 }
3995
3996 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3997 assert_eq!(dist.len(), 3);
3998 assert_eq!(dist[0].tag, "t-alpha");
4001 assert_eq!(dist[1].tag, "t-beta");
4002 assert_eq!(dist[2].tag, "t-delta");
4003 }
4004
4005 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
4012 let manifest = r#"name: tests-ro-health
4013version: 0.1.0
4014description: required_outgoing health test schema
4015when_to_use: tests
4016types:
4017 - decision
4018 - note
4019relationships:
4020 mode: strict
4021 definitions:
4022 - name: PART_OF
4023 description: Hier
4024 default_weight: 3.0
4025 acyclic: true
4026 - name: CHOSEN
4027 description: ch
4028 default_weight: 3.0
4029 - name: REJECTED
4030 description: rj
4031 default_weight: 2.0
4032 - name: REFERENCES
4033 description: ref
4034 default_weight: 0.5
4035 - name: _default
4036 description: Fallback
4037 default_weight: 1.0
4038community:
4039 resolution: 1.0
4040 seed: 42
4041"#;
4042 let body_section = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4043 let decision_yaml = format!(
4044 "name: decision\ndescription: t\nwhen_to_use: Here\n{body_section}required_outgoing:\n - relationships: [CHOSEN]\n cardinality: at_least_one\n - relationships: [REJECTED]\n cardinality: at_least_one\n",
4045 );
4046 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
4047 std::sync::Arc::new(
4048 memstead_schema::load_schema_from_memory(
4049 manifest,
4050 &[
4051 ("decision".to_string(), decision_yaml),
4052 ("note".to_string(), note_yaml),
4053 ],
4054 )
4055 .expect("ro fixture schema must parse"),
4056 )
4057 }
4058
4059 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
4060 use crate::entity::MetadataValue;
4061 let mut metadata = IndexMap::new();
4062 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
4063 let mut sections = IndexMap::new();
4064 sections.insert("body".into(), "Body.".into());
4065 crate::entity::Entity {
4066 id: EntityId::new(mem, slug),
4067 title: slug.to_string(),
4068 entity_type: entity_type.into(),
4069 mem: mem.into(),
4070 file_path: format!("{slug}.md"),
4071 metadata,
4072 sections,
4073 relationships: Vec::new(),
4074 content_hash: String::new(),
4075 stub: false,
4076 stub_kind: None,
4077 heading_spans: std::collections::HashMap::new(),
4078 raw_section_headings: Vec::new(),
4079 }
4080 }
4081
4082 #[test]
4083 fn missing_required_outgoing_collects_violators_only() {
4084 let schema = required_outgoing_fixture_schema();
4085 let mut store = Store::new();
4086 let mut violator = make_typed_entity("plan", "stalled", "decision");
4089 let mut satisfied = make_typed_entity("plan", "wired", "decision");
4090 let opt_a = make_typed_entity("plan", "a", "note");
4091 let opt_b = make_typed_entity("plan", "b", "note");
4092 let happy_note = make_typed_entity("plan", "side", "note");
4093 satisfied.relationships.push(crate::entity::Relationship {
4094 rel_type: "CHOSEN".into(),
4095 target: opt_a.id.clone(),
4096 description: None,
4097 });
4098 satisfied.relationships.push(crate::entity::Relationship {
4099 rel_type: "REJECTED".into(),
4100 target: opt_b.id.clone(),
4101 description: None,
4102 });
4103 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
4104 store.upsert(e.id.clone(), e);
4105 }
4106
4107 let mut mem_schemas = HashMap::new();
4108 mem_schemas.insert("plan".to_string(), schema);
4109
4110 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4111 assert_eq!(
4112 reports.len(),
4113 1,
4114 "exactly one violator (the empty decision); got {reports:?}"
4115 );
4116 let r = &reports[0];
4117 assert_eq!(r.id, violator.id);
4118 assert_eq!(r.entity_type, "decision");
4119 assert_eq!(r.mem, "plan");
4120 assert_eq!(r.missing.len(), 2);
4121 let names: Vec<&str> = r
4122 .missing
4123 .iter()
4124 .flat_map(|b| b.relationships.iter().map(String::as_str))
4125 .collect();
4126 assert!(names.contains(&"CHOSEN"));
4127 assert!(names.contains(&"REJECTED"));
4128
4129 violator.relationships.push(crate::entity::Relationship {
4131 rel_type: "CHOSEN".into(),
4132 target: EntityId::new("plan", "x"),
4133 description: None,
4134 });
4135 }
4136
4137 #[test]
4138 fn missing_required_outgoing_respects_mem_filter() {
4139 let schema = required_outgoing_fixture_schema();
4142 let mut store = Store::new();
4143 let v_a = make_typed_entity("alpha", "stalled", "decision");
4144 let v_b = make_typed_entity("beta", "stalled", "decision");
4145 store.upsert(v_a.id.clone(), v_a);
4146 store.upsert(v_b.id.clone(), v_b.clone());
4147
4148 let mut mem_schemas = HashMap::new();
4149 mem_schemas.insert("alpha".to_string(), schema.clone());
4150 mem_schemas.insert("beta".to_string(), schema);
4151
4152 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
4153 assert_eq!(alpha_only.len(), 1);
4154 assert_eq!(alpha_only[0].mem, "alpha");
4155
4156 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
4157 assert_eq!(both.len(), 2);
4158 }
4159
4160 #[test]
4161 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
4162 let schema = required_outgoing_fixture_schema();
4165 let mut store = Store::new();
4166 let mut stub = make_typed_entity("plan", "ghost", "");
4167 stub.stub = true;
4168 stub.entity_type = String::new();
4169 let other = make_typed_entity("uncharted", "lonely", "decision");
4170 store.upsert(stub.id.clone(), stub);
4171 store.upsert(other.id.clone(), other);
4172
4173 let mut mem_schemas = HashMap::new();
4174 mem_schemas.insert("plan".to_string(), schema);
4175
4176 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4177 assert!(
4178 reports.is_empty(),
4179 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
4180 );
4181 }
4182
4183 #[test]
4188 fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
4189 use crate::entity::MetadataValue;
4190 let manifest = r#"name: tests-ro-cond
4191version: 0.1.0
4192description: conditional required_outgoing health test schema
4193when_to_use: tests
4194types:
4195 - task
4196relationships:
4197 mode: strict
4198 definitions:
4199 - name: PART_OF
4200 description: Hier
4201 default_weight: 3.0
4202 - name: _default
4203 description: Fallback
4204 default_weight: 1.0
4205community:
4206 resolution: 1.0
4207 seed: 42
4208"#;
4209 let task_yaml = "name: task\ndescription: t\nwhen_to_use: Here\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: status\n description: workflow state\n field_type: string\n enum_values: [open, checked]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\n - status\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\nrequired_outgoing:\n - relationships: [PART_OF]\n cardinality: at_least_one\n when_field: status\n when_value: checked\n";
4210 let schema = std::sync::Arc::new(
4211 memstead_schema::load_schema_from_memory(
4212 manifest,
4213 &[("task".to_string(), task_yaml.to_string())],
4214 )
4215 .expect("conditional ro fixture schema must parse"),
4216 );
4217
4218 let mut store = Store::new();
4219 let mut armed = make_typed_entity("plan", "armed", "task");
4220 armed
4221 .metadata
4222 .insert("status".into(), MetadataValue::String("checked".into()));
4223 let mut other_value = make_typed_entity("plan", "quiet", "task");
4224 other_value
4225 .metadata
4226 .insert("status".into(), MetadataValue::String("open".into()));
4227 let unset = make_typed_entity("plan", "blank", "task");
4228 let parent = make_typed_entity("plan", "parent", "task");
4229 let mut satisfied = make_typed_entity("plan", "wired", "task");
4230 satisfied
4231 .metadata
4232 .insert("status".into(), MetadataValue::String("checked".into()));
4233 satisfied.relationships.push(crate::entity::Relationship {
4234 rel_type: "PART_OF".into(),
4235 target: parent.id.clone(),
4236 description: None,
4237 });
4238 for e in [armed.clone(), other_value, unset, parent, satisfied] {
4239 store.upsert(e.id.clone(), e);
4240 }
4241
4242 let mut mem_schemas = HashMap::new();
4243 mem_schemas.insert("plan".to_string(), schema);
4244
4245 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4246 assert_eq!(
4247 reports.len(),
4248 1,
4249 "only the armed edge-less entity is reported; got {reports:?}"
4250 );
4251 let r = &reports[0];
4252 assert_eq!(r.id, armed.id);
4253 assert_eq!(r.missing.len(), 1);
4254 assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
4255 assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
4256 }
4257
4258 fn must_reach_schema(
4266 claim_extra: &str,
4267 inference_extra: &str,
4268 ) -> std::sync::Arc<memstead_schema::Schema> {
4269 let manifest = r#"name: tests-must-reach
4270version: 0.1.0
4271description: must_reach health test schema
4272when_to_use: tests
4273types:
4274 - claim
4275 - inference
4276 - evidence
4277relationships:
4278 mode: strict
4279 definitions:
4280 - name: GROUNDS
4281 description: g
4282 default_weight: 3.0
4283 - name: CONCLUDES
4284 description: c
4285 default_weight: 3.0
4286 - name: PART_OF
4287 description: hier
4288 default_weight: 1.0
4289 - name: _default
4290 description: fallback
4291 default_weight: 1.0
4292community:
4293 resolution: 1.0
4294 seed: 42
4295"#;
4296 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4297 let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
4298 let inference =
4299 format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
4300 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
4301 std::sync::Arc::new(
4302 memstead_schema::load_schema_from_memory(
4303 manifest,
4304 &[
4305 ("claim".to_string(), claim),
4306 ("inference".to_string(), inference),
4307 ("evidence".to_string(), evidence),
4308 ],
4309 )
4310 .expect("must_reach fixture schema must parse"),
4311 )
4312 }
4313
4314 fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
4315 from.relationships.push(crate::entity::Relationship {
4316 rel_type: rel.into(),
4317 target: to.clone(),
4318 description: None,
4319 });
4320 }
4321
4322 fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
4323 r.violations
4324 .iter()
4325 .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
4326 .collect()
4327 }
4328
4329 const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n";
4330
4331 #[test]
4335 fn must_reach_conforming_path_silent_gap_reported() {
4336 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4337 let mut store = Store::new();
4338 let ev = make_typed_entity("arg", "ev", "evidence");
4339 let mut direct = make_typed_entity("arg", "direct", "claim");
4340 link(&mut direct, "GROUNDS", &ev.id);
4341 let mut mid = make_typed_entity("arg", "mid", "claim");
4342 let mut chained = make_typed_entity("arg", "chained", "claim");
4343 link(&mut chained, "GROUNDS", &mid.id);
4344 link(&mut mid, "GROUNDS", &ev.id);
4345 let floating = make_typed_entity("arg", "floating", "claim");
4346 for e in [ev, direct, mid, chained, floating.clone()] {
4347 store.upsert(e.id.clone(), e);
4348 }
4349 let mut mem_schemas = HashMap::new();
4350 mem_schemas.insert("arg".to_string(), schema);
4351
4352 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4353 assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
4354 assert_eq!(reports[0].id, floating.id);
4355 let v = must_reach_violations(&reports[0]);
4356 assert_eq!(v.len(), 1);
4357 let UnsatisfiedConstraint::MustReach {
4358 relationships,
4359 direction,
4360 terminal_types,
4361 max_depth,
4362 ..
4363 } = v[0]
4364 else {
4365 panic!("expected must_reach finding");
4366 };
4367 assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
4368 assert_eq!(*direction, memstead_schema::ReachDirection::Out);
4369 assert_eq!(terminal_types, &vec!["evidence".to_string()]);
4370 assert_eq!(*max_depth, None);
4371 }
4372
4373 #[test]
4378 fn must_reach_one_hop_incoming_floating_leap() {
4379 let schema = must_reach_schema(
4380 "",
4381 "must_reach:\n - relationships: [GROUNDS]\n direction: in\n terminal_types: [claim]\n max_depth: 1\n",
4382 );
4383 let mut store = Store::new();
4384 let leap = make_typed_entity("arg", "leap", "inference");
4385 let grounded = make_typed_entity("arg", "grounded", "inference");
4386 let mut premise = make_typed_entity("arg", "premise", "claim");
4387 link(&mut premise, "GROUNDS", &grounded.id);
4388 for e in [leap.clone(), grounded, premise] {
4389 store.upsert(e.id.clone(), e);
4390 }
4391 let mut mem_schemas = HashMap::new();
4392 mem_schemas.insert("arg".to_string(), schema);
4393
4394 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4395 assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
4396 assert_eq!(reports[0].id, leap.id);
4397 }
4398
4399 #[test]
4402 fn must_reach_stub_and_non_terminal_chains_then_cleared() {
4403 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4404 let mut store = Store::new();
4405 let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
4406 stub_ev.stub = true;
4407 let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
4408 link(&mut to_stub, "GROUNDS", &stub_ev.id);
4409 let dead_end = make_typed_entity("arg", "dead-end", "claim");
4410 let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
4411 link(&mut to_claim, "GROUNDS", &dead_end.id);
4412 for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
4413 store.upsert(e.id.clone(), e);
4414 }
4415 let mut mem_schemas = HashMap::new();
4416 mem_schemas.insert("arg".to_string(), schema.clone());
4417
4418 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4419 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4420 assert!(
4421 ids.contains(&to_stub.id.0.as_str()),
4422 "stub terminates no obligation: {ids:?}"
4423 );
4424 assert!(
4425 ids.contains(&to_claim.id.0.as_str()),
4426 "non-terminal chain is a finding: {ids:?}"
4427 );
4428
4429 let ev = make_typed_entity("arg", "real-ev", "evidence");
4431 let mut repaired = store.get(&to_stub.id).unwrap().clone();
4432 link(&mut repaired, "GROUNDS", &ev.id);
4433 store.upsert(ev.id.clone(), ev);
4434 store.upsert(repaired.id.clone(), repaired);
4435 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4436 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4437 assert!(
4438 !ids.contains(&to_stub.id.0.as_str()),
4439 "conforming path clears the finding: {ids:?}"
4440 );
4441 }
4442
4443 #[test]
4447 fn must_reach_cycles_terminate() {
4448 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4449 let mut store = Store::new();
4450 let mut a = make_typed_entity("arg", "cyc-a", "claim");
4451 let mut b = make_typed_entity("arg", "cyc-b", "claim");
4452 link(&mut a, "GROUNDS", &b.id);
4453 link(&mut b, "GROUNDS", &a.id);
4454 for e in [a, b] {
4455 store.upsert(e.id.clone(), e);
4456 }
4457 let mut mem_schemas = HashMap::new();
4458 mem_schemas.insert("arg".to_string(), schema);
4459
4460 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4461 assert_eq!(reports.len(), 2, "both cycle members lack evidence");
4462 }
4463
4464 #[test]
4468 fn must_reach_depth_bound() {
4469 let two_hop_store = || {
4470 let mut store = Store::new();
4471 let ev = make_typed_entity("arg", "ev", "evidence");
4472 let mut mid = make_typed_entity("arg", "mid", "claim");
4473 let mut start = make_typed_entity("arg", "start", "claim");
4474 link(&mut start, "GROUNDS", &mid.id);
4475 link(&mut mid, "GROUNDS", &ev.id);
4476 for e in [ev, mid, start] {
4477 store.upsert(e.id.clone(), e);
4478 }
4479 store
4480 };
4481 let bounded = |depth: u32| {
4482 must_reach_schema(
4483 &format!(
4484 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n max_depth: {depth}\n"
4485 ),
4486 "",
4487 )
4488 };
4489
4490 let store = two_hop_store();
4491 let mut mem_schemas = HashMap::new();
4492 mem_schemas.insert("arg".to_string(), bounded(1));
4493 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4494 assert_eq!(
4495 reports.len(),
4496 1,
4497 "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
4498 );
4499 assert_eq!(reports[0].id.0, "arg--start");
4500
4501 let mut mem_schemas = HashMap::new();
4502 mem_schemas.insert("arg".to_string(), bounded(2));
4503 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4504 assert!(
4505 reports.is_empty(),
4506 "the same path satisfies depth 2: {reports:?}"
4507 );
4508 }
4509
4510 #[test]
4513 fn must_reach_two_obligations_one_finding() {
4514 let schema = must_reach_schema(
4515 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n - relationships: [CONCLUDES]\n direction: out\n terminal_types: [inference]\n",
4516 "",
4517 );
4518 let mut store = Store::new();
4519 let ev = make_typed_entity("arg", "ev", "evidence");
4520 let mut c = make_typed_entity("arg", "half", "claim");
4521 link(&mut c, "GROUNDS", &ev.id);
4522 for e in [ev, c.clone()] {
4523 store.upsert(e.id.clone(), e);
4524 }
4525 let mut mem_schemas = HashMap::new();
4526 mem_schemas.insert("arg".to_string(), schema);
4527
4528 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4529 assert_eq!(reports.len(), 1);
4530 assert_eq!(reports[0].id, c.id);
4531 let v = must_reach_violations(&reports[0]);
4532 assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
4533 let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
4534 panic!("expected must_reach finding");
4535 };
4536 assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
4537 }
4538
4539 #[test]
4545 fn status_propagation_rel_types_taints_across_type_boundaries() {
4546 use crate::entity::MetadataValue;
4547 let manifest = r#"name: tests-prop-set
4548version: 0.1.0
4549description: propagation relation-set test schema
4550when_to_use: tests
4551types:
4552 - claim
4553relationships:
4554 mode: strict
4555 definitions:
4556 - name: GROUNDS
4557 description: g
4558 default_weight: 3.0
4559 - name: CONCLUDES
4560 description: c
4561 default_weight: 3.0
4562 - name: PART_OF
4563 description: hier
4564 default_weight: 1.0
4565 - name: _default
4566 description: fallback
4567 default_weight: 1.0
4568community:
4569 resolution: 1.0
4570 seed: 42
4571"#;
4572 let claim_yaml = "name: claim\ndescription: t\nwhen_to_use: Here\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: standing\n description: dialectical standing\n field_type: string\n enum_values: [active, withdrawn]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\n - standing\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n - kind: status_propagation\n field: standing\n value: withdrawn\n rel_types: [GROUNDS, CONCLUDES]\n direction: incoming\n";
4573 let schema = std::sync::Arc::new(
4574 memstead_schema::load_schema_from_memory(
4575 manifest,
4576 &[("claim".to_string(), claim_yaml.to_string())],
4577 )
4578 .expect("propagation-set fixture schema must parse"),
4579 );
4580
4581 let mut store = Store::new();
4582 let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
4583 withdrawn
4584 .metadata
4585 .insert("standing".into(), MetadataValue::String("withdrawn".into()));
4586 let mut inference = make_typed_entity("arg", "inference", "claim");
4587 link(&mut inference, "GROUNDS", &withdrawn.id);
4588 let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
4589 link(&mut conclusion, "CONCLUDES", &inference.id);
4590 let bystander = make_typed_entity("arg", "bystander", "claim");
4591 for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
4592 store.upsert(e.id.clone(), e);
4593 }
4594 let mut mem_schemas = HashMap::new();
4595 mem_schemas.insert("arg".to_string(), schema);
4596
4597 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4598 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4599 assert_eq!(
4600 ids,
4601 vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
4602 "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
4603 );
4604 let UnsatisfiedConstraint::StatusPropagation {
4605 rel_type,
4606 rel_types,
4607 tainted_by,
4608 ..
4609 } = &reports[0].violations[0]
4610 else {
4611 panic!("expected status_propagation finding");
4612 };
4613 assert_eq!(*rel_type, None, "set declarations echo no single name");
4614 assert_eq!(
4615 rel_types.as_deref(),
4616 Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
4617 );
4618 assert_eq!(tainted_by, "arg--withdrawn-ev");
4619 }
4620
4621 #[test]
4624 fn must_reach_cross_mem_path_and_mem_filter() {
4625 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4626 let mut store = Store::new();
4627 let far_ev = make_typed_entity("ground", "far-ev", "evidence");
4628 let mut crossing = make_typed_entity("arg", "crossing", "claim");
4629 link(&mut crossing, "GROUNDS", &far_ev.id);
4630 let floating_arg = make_typed_entity("arg", "floating", "claim");
4631 let floating_ground = make_typed_entity("ground", "floating", "claim");
4632 for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
4633 store.upsert(e.id.clone(), e);
4634 }
4635 let mut mem_schemas = HashMap::new();
4636 mem_schemas.insert("arg".to_string(), schema.clone());
4637 mem_schemas.insert("ground".to_string(), schema);
4638
4639 let all = collect_constraint_findings(&store, None, &mem_schemas, None);
4640 assert_eq!(
4641 all.len(),
4642 2,
4643 "the crossing claim is satisfied via the cross-mem edge: {all:?}"
4644 );
4645 let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas, None);
4646 assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
4647 assert_eq!(filtered[0].id, floating_arg.id);
4648 }
4649
4650 fn gated_transition_schema() -> std::sync::Arc<memstead_schema::Schema> {
4657 let manifest = r#"name: tests-gated
4658version: 0.1.0
4659description: transition_requires_checks test schema
4660when_to_use: tests
4661types:
4662 - plan
4663 - criterion
4664relationships:
4665 mode: strict
4666 definitions:
4667 - name: VERIFIES
4668 description: v
4669 default_weight: 3.0
4670 acyclic: true
4671 - name: PART_OF
4672 description: hier
4673 default_weight: 1.0
4674 acyclic: true
4675 - name: _default
4676 description: fallback
4677 default_weight: 1.0
4678community:
4679 resolution: 1.0
4680 seed: 42
4681"#;
4682 let plan_yaml = "name: plan\ndescription: p\nwhen_to_use: t\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: status\n description: s\n field_type: string\n default_value: draft\n enum_values: [draft, complete]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n - kind: transition_requires_checks\n field: status\n to_value: complete\n relationships: [VERIFIES]\n direction: incoming\n severity: block\n";
4683 let criterion_yaml = "name: criterion\ndescription: c\nwhen_to_use: t\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\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4684 std::sync::Arc::new(
4685 memstead_schema::load_schema_from_memory(
4686 manifest,
4687 &[
4688 ("plan".to_string(), plan_yaml.to_string()),
4689 ("criterion".to_string(), criterion_yaml.to_string()),
4690 ],
4691 )
4692 .expect("gated-transition fixture schema loads"),
4693 )
4694 }
4695
4696 #[test]
4702 fn transition_requires_checks_gates_on_derived_state() {
4703 use crate::check::CheckState;
4704 use crate::entity::MetadataValue;
4705 let schema = gated_transition_schema();
4706 let td = schema.types.get("plan").unwrap().clone();
4707 let mut store = Store::default();
4708
4709 let mut plan = make_typed_entity("g", "the-plan", "plan");
4710 plan.metadata
4711 .insert("status".into(), MetadataValue::String("complete".into()));
4712 let mut ok_crit = make_typed_entity("g", "ok-crit", "criterion");
4713 ok_crit.relationships.push(crate::entity::Relationship {
4714 rel_type: "VERIFIES".into(),
4715 target: plan.id.clone(),
4716 description: None,
4717 });
4718 let mut stale_crit = make_typed_entity("g", "stale-crit", "criterion");
4719 stale_crit.relationships.push(crate::entity::Relationship {
4720 rel_type: "VERIFIES".into(),
4721 target: plan.id.clone(),
4722 description: None,
4723 });
4724 for e in [plan.clone(), ok_crit.clone(), stale_crit.clone()] {
4725 store.upsert(e.id.clone(), e);
4726 }
4727
4728 let state_of = |e: &crate::entity::Entity| {
4729 if e.id.0.contains("ok-crit") {
4730 CheckState::CheckedOk
4731 } else {
4732 CheckState::CheckStale
4733 }
4734 };
4735 let provider = |e: &crate::entity::Entity| {
4736 crate::engine::independence::CheckStanding::assumed_independent(state_of(e))
4737 };
4738 let violations = unsatisfied_constraints(&store, &plan, &td, None, Some(&provider));
4739 assert_eq!(violations.len(), 1, "{violations:?}");
4740 match &violations[0] {
4741 UnsatisfiedConstraint::TransitionRequiresChecks {
4742 unchecked,
4743 severity,
4744 ..
4745 } => {
4746 assert_eq!(
4747 unchecked.len(),
4748 1,
4749 "only the unconfirmed criterion is listed"
4750 );
4751 assert_eq!(unchecked[0].id, "g--stale-crit");
4752 assert_eq!(unchecked[0].state, "check_stale");
4753 assert_eq!(*severity, memstead_schema::ConstraintSeverity::Block);
4754 }
4755 other => panic!("expected the gated-transition violation, got {other:?}"),
4756 }
4757 assert!(
4758 violations[0].describe().contains("g--stale-crit")
4759 && violations[0].describe().contains("check_stale"),
4760 "describe names the offender and its state: {}",
4761 violations[0].describe()
4762 );
4763
4764 let all_ok = |_: &crate::entity::Entity| {
4766 crate::engine::independence::CheckStanding::assumed_independent(CheckState::CheckedOk)
4767 };
4768 assert!(
4769 unsatisfied_constraints(&store, &plan, &td, None, Some(&all_ok)).is_empty(),
4770 "all confirmed satisfies the gate"
4771 );
4772
4773 let mut draft = plan.clone();
4775 draft
4776 .metadata
4777 .insert("status".into(), MetadataValue::String("draft".into()));
4778 assert!(
4779 unsatisfied_constraints(&store, &draft, &td, None, Some(&provider)).is_empty(),
4780 "the gate triggers only at to_value"
4781 );
4782
4783 let violations = unsatisfied_constraints(&store, &plan, &td, None, None);
4785 assert_eq!(violations.len(), 1);
4786 match &violations[0] {
4787 UnsatisfiedConstraint::TransitionRequiresChecks { unchecked, .. } => {
4788 assert_eq!(unchecked.len(), 2, "no ledger access confirms nothing");
4789 assert!(unchecked.iter().all(|u| u.state == "never_checked"));
4790 }
4791 other => panic!("expected the gated-transition violation, got {other:?}"),
4792 }
4793
4794 let mut lone = make_typed_entity("g", "lone-plan", "plan");
4796 lone.metadata
4797 .insert("status".into(), MetadataValue::String("complete".into()));
4798 store.upsert(lone.id.clone(), lone.clone());
4799 assert!(
4800 unsatisfied_constraints(&store, &lone, &td, None, Some(&provider)).is_empty(),
4801 "an empty related set satisfies the universal quantification"
4802 );
4803 }
4804}