Skip to main content

memstead_base/ops/
health.rs

1//! Health checks — missing required fields, staleness, scoring.
2//!
3//! Checks each entity against its schema's requirements:
4//! - Required metadata fields present and non-empty
5//! - Required sections present and non-empty
6//! - Staleness: days since last_modified > schema threshold
7//! - Undeclared relationships — existing entities whose
8//!   `relationships:` include a name that is not in the per-mem
9//!   schema's vocabulary surface as soft warnings rather than hard
10//!   load-time failures. Agents can fix either the entity or the
11//!   schema; undeclared *types* on load are decision-3 hard errors
12//!   and covered elsewhere.
13
14use 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
27/// Allowed `include` keys for `memstead_health` — the single source of
28/// truth shared across the lean MCP server, full MCP server, and the
29/// lean CLI's `health` command. Adding a new include key here lights
30/// it up uniformly; agents see the same `UNKNOWN_INCLUDE_KEY` warning
31/// shape whether they reach health via MCP or CLI.
32pub 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    "conformance",
43    "integrity",
44    "config",
45    "anchors",
46    "friction",
47    "open_questions",
48    "stale_derivations",
49    "checks",
50];
51
52/// The `include=["anchors"]` axis — per-mem counts of the four
53/// standalone-verification states, computed through the same
54/// per-anchor mechanism `verify-anchors` and the binding verify use.
55/// Shared by the full composer, the CLI health command, and the lean
56/// MCP server so the axis cannot drift between surfaces.
57/// The `include=["checks"]` axis (agent-trust plan 14): per mem,
58/// counts of the four derived check states plus the author≠checker
59/// independence gate over ok-checked entities. Transport is not
60/// identity: the recorded `(actor, client)` pair names the SURFACE a
61/// record arrived through (Agent|Cli|App plus a client binary), not
62/// who acted — the same actor reaches the engine over several
63/// surfaces, and one surface serves many actors across sessions. So
64/// until a caller-declared identity exists (the caller-identity
65/// follow-up, plan 15), NO author/checker comparison can be
66/// established and every ok-checked entity with recorded provenance
67/// lands in `unconfirmable`. `self_checked` and
68/// `confirmed_independent` remain as categories — their empty lists
69/// are a statement — but stay unreachable until real identity
70/// exists: a same pair does NOT establish the same actor, and
71/// different pairs do NOT establish different actors. Derivation
72/// only: nothing here is stamped, and a workspace without a check
73/// ledger serves all-never-checked. Identity lists are capped at
74/// [`OPEN_QUESTIONS_ITEM_CAP`] with an explicit `more` count.
75pub fn health_checks_axis(
76    engine: &crate::engine::Engine,
77    mem_filter: Option<&str>,
78) -> serde_json::Value {
79    let cap = OPEN_QUESTIONS_ITEM_CAP;
80    let capped = |mut items: Vec<String>| -> serde_json::Value {
81        items.sort();
82        let count = items.len();
83        let more = count.saturating_sub(cap);
84        items.truncate(cap);
85        let mut o = serde_json::Map::new();
86        o.insert("count".into(), serde_json::json!(count));
87        o.insert("items".into(), serde_json::json!(items));
88        if more > 0 {
89            o.insert("more".into(), serde_json::json!(more));
90        }
91        serde_json::Value::Object(o)
92    };
93
94    let ledger = engine
95        .workspace_root()
96        .map(crate::check::CheckLedger::for_workspace);
97    // Newest record per entity, one ledger read for the whole axis.
98    let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
99        std::collections::BTreeMap::new();
100    if let Some(l) = &ledger {
101        for rec in l.all() {
102            latest.insert(rec.entity.clone(), rec);
103        }
104    }
105
106    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
107    mems.sort();
108    let mut out = serde_json::Map::new();
109    for mem in mems {
110        if let Some(f) = mem_filter
111            && f != mem
112        {
113            continue;
114        }
115        let mut counts = std::collections::BTreeMap::from([
116            ("never_checked", 0usize),
117            ("checked_ok", 0usize),
118            ("check_failed", 0usize),
119            ("check_stale", 0usize),
120        ]);
121        // Unreachable until a caller-declared identity exists (the
122        // caller-identity follow-up, plan 15) — kept so the wire shape
123        // states the categories explicitly rather than dropping them.
124        let self_checked: Vec<String> = Vec::new();
125        let confirmed_independent: Vec<String> = Vec::new();
126        let mut unconfirmable: Vec<String> = Vec::new();
127        for e in engine.store().all_entities().filter(|e| e.mem == mem) {
128            let id = e.id.0.clone();
129            let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
130            *counts.entry(state.as_str()).or_insert(0) += 1;
131            if state != crate::check::CheckState::CheckedOk {
132                continue;
133            }
134            // Transport is not identity. The recorded (actor, client)
135            // pair names the surface each record arrived through, not
136            // who acted — a same pair does not establish the same
137            // actor (CLI-authored + CLI-checked across sessions/days
138            // is the norm, not conviction), and different pairs do
139            // not establish different actors (one actor reaches the
140            // engine over several surfaces). Without a
141            // caller-declared identity no author/checker comparison
142            // can be established, so every ok-checked entity lands
143            // here — never a false acquittal via transport.
144            unconfirmable.push(id);
145        }
146        let mut m = serde_json::Map::new();
147        for (k, v) in counts {
148            m.insert(k.to_string(), serde_json::json!(v));
149        }
150        m.insert(
151            "independence".into(),
152            serde_json::json!({
153                "self_checked": capped(self_checked),
154                "confirmed_independent": capped(confirmed_independent),
155                "unconfirmable": capped(unconfirmable),
156            }),
157        );
158        out.insert(mem, serde_json::Value::Object(m));
159    }
160    serde_json::Value::Object(out)
161}
162
163/// One derivation-staleness finding (agent-trust plan 12): an
164/// explicit edge on a derivation-declared rel-type whose baseline
165/// differs from the target's current hash (`stale`), or that has no
166/// recorded baseline at all (`unbaselined`). Fresh edges are never
167/// reported.
168#[derive(Debug, Clone, serde::Serialize)]
169pub struct DerivationFinding {
170    pub source: crate::entity::EntityId,
171    pub rel_type: String,
172    pub target: crate::entity::EntityId,
173    /// `"stale"` or `"unbaselined"` — never fabricated as fresh.
174    pub state: String,
175    /// The recorded baseline hash (`None` for unbaselined edges).
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub baseline: Option<String>,
178    /// The target's current content hash ("" for an absent target).
179    pub current: String,
180}
181
182/// The `include=["stale_derivations"]` axis: per-mem findings from
183/// [`crate::engine::Engine::derivation_report`], shared by the CLI
184/// and both MCP flavours. A mem whose schema declares no derivation
185/// rel-types contributes an empty list — never an error.
186pub fn health_stale_derivations_axis(
187    engine: &crate::engine::Engine,
188    mem_filter: Option<&str>,
189) -> serde_json::Value {
190    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
191    mems.sort();
192    let mut out = serde_json::Map::new();
193    for mem in mems {
194        if let Some(f) = mem_filter
195            && f != mem
196        {
197            continue;
198        }
199        let findings = engine.derivation_report(&mem).unwrap_or_default();
200        out.insert(
201            mem,
202            serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
203        );
204    }
205    serde_json::Value::Object(out)
206}
207
208/// Per-kind item cap for the `open_questions` axis — the axis is an
209/// agent worklist, not a dump. Stated in the output (`_item_cap`);
210/// truncation is always explicit via each list's `more` count.
211pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
212
213/// The `include=["open_questions"]` axis (agent-trust plan 11): per
214/// mem, a composed worklist of what the holding does not know — its
215/// stubs, its never-confirmed (`recheck`) and `unresolvable` anchors,
216/// its unsatisfied constraints, its dangling links, and, when a
217/// paired process mem is resolvable for the destination, that
218/// process mem's open entries. Negative findings ride under the
219/// DISTINCT `already_searched` heading — their operational meaning is
220/// "done, keep off", never todo.
221///
222/// Composition only: every signal is read from the same source its
223/// own axis serves (store stub flags, `verify_mem_anchors`,
224/// `constraint_findings`, `collect_dangling_links`, the pipeline
225/// store), so this axis can never disagree with the per-signal axes.
226/// Best-effort on the process leg: an unreadable pipeline store means
227/// no process sections, never an axis failure.
228pub fn health_open_questions_axis(
229    engine: &crate::engine::Engine,
230    mem_filter: Option<&str>,
231) -> serde_json::Value {
232    let cap = OPEN_QUESTIONS_ITEM_CAP;
233    let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
234        let count = items.len();
235        let more = count.saturating_sub(cap);
236        items.truncate(cap);
237        let mut o = serde_json::Map::new();
238        o.insert("count".into(), serde_json::json!(count));
239        o.insert("items".into(), serde_json::Value::Array(items));
240        if more > 0 {
241            o.insert("more".into(), serde_json::json!(more));
242        }
243        serde_json::Value::Object(o)
244    };
245
246    // Bindings by destination mem — the pairing plan 14 will make
247    // declarative; until then the ingest-name convention (process mem
248    // named after the binding) is the resolution mechanism.
249    let bindings: Vec<(String, String)> = engine
250        .workspace_root()
251        .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
252        .map(|c| {
253            c.bindings
254                .iter()
255                .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
256                .collect()
257        })
258        .unwrap_or_default();
259    let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
260
261    let mut mems: Vec<String> = mounted.clone();
262    mems.sort();
263    let mut out = serde_json::Map::new();
264    for mem in &mems {
265        if let Some(f) = mem_filter
266            && f != mem
267        {
268            continue;
269        }
270
271        // Stubs — same source as the stubs axis (store stub flag).
272        let stubs = capped(
273            engine
274                .store()
275                .all_entities()
276                .filter(|e| e.stub && e.id.mem() == mem)
277                .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
278                .collect(),
279        );
280
281        // Anchors — same per-anchor mechanism as the anchors axis;
282        // only the never-confirmed and unreachable states are holes.
283        let (mut recheck, mut unresolvable) = (Vec::new(), Vec::new());
284        if let Ok(report) = engine.verify_mem_anchors(mem) {
285            for a in &report.anchors {
286                let item = serde_json::json!({
287                    "kind": format!("anchor_{}", a.state),
288                    "id": a.entity_id,
289                    "artifact": a.artifact,
290                });
291                match a.state.as_str() {
292                    "recheck" => recheck.push(item),
293                    "unresolvable" => unresolvable.push(item),
294                    _ => {}
295                }
296            }
297        }
298
299        // Unsatisfied constraints — same collector as the
300        // constraints axis.
301        let constraints = capped(
302            engine
303                .constraint_findings(Some(mem))
304                .iter()
305                .map(|r| {
306                    serde_json::json!({
307                        "kind": "unsatisfied_constraint",
308                        "id": r.id.to_string(),
309                        "violations": r.violations.len(),
310                    })
311                })
312                .collect(),
313        );
314
315        // Dangling links — same collector as the overview include.
316        let dangling = capped(
317            collect_dangling_links(engine.store(), Some(mem))
318                .iter()
319                .map(|d| {
320                    serde_json::json!({
321                        "kind": "dangling_link",
322                        "id": d.from.to_string(),
323                        "target": d.target_id.to_string(),
324                    })
325                })
326                .collect(),
327        );
328
329        // Paired process mems: open entries are work; negative
330        // findings are the opposite — already searched, keep off.
331        // Pairing runs through the ONE resolution function the brief
332        // renderer uses (agent-trust plan 14): a destination's
333        // declaration wins regardless of naming — and pairs even
334        // with no binding at all (the process tier stands without
335        // one); the binding-name convention remains the fallback. A
336        // declaration naming an unmounted mem is a typed finding,
337        // never a silent fallback.
338        let mut process = Vec::new();
339        let mem_bindings: Vec<&String> = bindings
340            .iter()
341            .filter(|(d, _)| d == mem)
342            .map(|(_, b)| b)
343            .collect();
344        let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
345            Vec::new();
346        if mem_bindings.is_empty() {
347            let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
348            if r.declared {
349                resolutions.push((None, r));
350            }
351        } else {
352            for binding in &mem_bindings {
353                resolutions.push((
354                    Some((*binding).clone()),
355                    crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
356                ));
357            }
358        }
359        for (binding, r) in resolutions {
360            if r.mounted {
361                let mut open = Vec::new();
362                let mut searched = Vec::new();
363                for e in engine
364                    .store()
365                    .all_entities()
366                    .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
367                {
368                    let item = serde_json::json!({
369                        "kind": e.entity_type,
370                        "id": e.id.to_string(),
371                        "title": e.title,
372                    });
373                    if e.entity_type == "negative_finding" {
374                        searched.push(item);
375                    } else {
376                        open.push(item);
377                    }
378                }
379                process.push(serde_json::json!({
380                    "binding": binding,
381                    "process_mem": r.mem,
382                    "declared": r.declared,
383                    "resolvable": true,
384                    "open_entries": capped(open),
385                    "already_searched": capped(searched),
386                }));
387            } else if r.declared {
388                process.push(serde_json::json!({
389                    "binding": binding,
390                    "process_mem": r.mem,
391                    "declared": true,
392                    "resolvable": false,
393                    "finding": "DECLARED_PROCESS_MEM_MISSING",
394                }));
395            } else {
396                process.push(serde_json::json!({
397                    "binding": binding,
398                    "resolvable": false,
399                }));
400            }
401        }
402
403        let total_open = stubs["count"].as_u64().unwrap_or(0)
404            + recheck.len() as u64
405            + unresolvable.len() as u64
406            + constraints["count"].as_u64().unwrap_or(0)
407            + dangling["count"].as_u64().unwrap_or(0)
408            + process
409                .iter()
410                .filter_map(|p| p["open_entries"]["count"].as_u64())
411                .sum::<u64>();
412
413        let mut entry = serde_json::Map::new();
414        entry.insert("stubs".into(), stubs);
415        entry.insert("anchors_recheck".into(), capped(recheck));
416        entry.insert("anchors_unresolvable".into(), capped(unresolvable));
417        entry.insert("unsatisfied_constraints".into(), constraints);
418        entry.insert("dangling_links".into(), dangling);
419        if !process.is_empty() {
420            entry.insert("process".into(), serde_json::Value::Array(process));
421        } else {
422            // No binding targets this mem: the absence of a process
423            // section is stated, never silent.
424            entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
425        }
426        entry.insert("total_open".into(), serde_json::json!(total_open));
427        out.insert(mem.clone(), serde_json::Value::Object(entry));
428    }
429    let mut top = serde_json::Map::new();
430    top.insert("_item_cap".into(), serde_json::json!(cap));
431    for (k, v) in out {
432        top.insert(k, v);
433    }
434    serde_json::Value::Object(top)
435}
436
437pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
438    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
439    mems.sort();
440    let mut out = serde_json::Map::new();
441    for mem in mems {
442        let Ok(report) = engine.verify_mem_anchors(&mem) else {
443            continue;
444        };
445        out.insert(
446            mem,
447            serde_json::json!({
448                "resolved": report.resolved,
449                "drifted": report.drifted,
450                "recheck": report.recheck,
451                "unresolvable": report.unresolvable,
452            }),
453        );
454    }
455    serde_json::Value::Object(out)
456}
457
458/// Compute health reports for all entities in the store.
459///
460/// `mem_schemas` maps mem name → `Arc<Schema>`. Entities whose mem
461/// is missing from this map fall back to the builtin `default` schema
462/// relationship vocabulary (keeps legacy fixtures green; real production
463/// paths always register a mem schema).
464pub fn compute_health(
465    store: &Store,
466    default_schema: &TypeDefinition,
467    mem_schemas: &HashMap<String, Arc<Schema>>,
468) -> HealthSummary {
469    let mut missing_fields = Vec::new();
470    let mut stale_entities = Vec::new();
471
472    let today_days = days_since_epoch();
473
474    for entity in store.all_entities() {
475        if entity.stub {
476            continue;
477        }
478
479        // Resolve the entity's `TypeDefinition` against the entity's
480        // own mem's schema first. `type_by_name` only knows the
481        // builtin `default` schema; falling through to it on a mem
482        // pinned to a non-default schema (e.g. `planning@0.1.0`) would
483        // silently use `default_schema` (effectively `spec`) for every
484        // entity and report `spec`'s `health_required_fields` —
485        // `[identity, purpose]` — even on entities of types like
486        // `goal` / `option` / `decision`.
487        let resolved = mem_schemas
488            .get(entity.mem.as_str())
489            .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
490            .or_else(|| type_by_name(&entity.entity_type));
491        let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
492        let mut issues = Vec::new();
493
494        // Check health_required_fields
495        for field in &schema.health_required_fields {
496            // Check if it's a section or metadata field
497            if schema.section(field).is_some() {
498                // It's a section. When the content is present in the
499                // file but sits under a non-deriving heading, report
500                // the distinct mismatch finding instead of "missing" —
501                // the two conditions must never collapse.
502                let content = entity.sections.get(field.as_str());
503                if content.is_none_or(|c| c.trim().is_empty()) {
504                    if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
505                        issues.push(issue);
506                    } else {
507                        issues.push(HealthIssue {
508                            field: field.clone(),
509                            code: super::HealthIssueCode::Missing,
510                            message: format!("required section '{field}' is empty"),
511                        });
512                    }
513                }
514            } else {
515                // It's a metadata field. Treat missing AND empty /
516                // whitespace-only values as gaps so the scan matches
517                // the section branch's `trim().is_empty()` semantics
518                // — an empty `MetadataValue::String("")` is just as
519                // unhelpful to an agent as an absent key.
520                let value = entity.metadata.get(field.as_str());
521                let is_empty = match value {
522                    None => true,
523                    Some(v) => v.to_frontmatter_string().trim().is_empty(),
524                };
525                if is_empty {
526                    issues.push(HealthIssue {
527                        field: field.clone(),
528                        code: super::HealthIssueCode::Missing,
529                        message: format!("required field '{field}' is missing"),
530                    });
531                }
532            }
533        }
534
535        // The heading-mismatch condition is drift worth surfacing on
536        // every declared section, not only the health-required ones.
537        for s in schema.sections.iter().filter(|s| !s.catch_all) {
538            if schema.health_required_fields.contains(&s.key) {
539                continue; // already handled above
540            }
541            let content = entity.sections.get(s.key.as_str());
542            if content.is_none_or(|c| c.trim().is_empty())
543                && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
544            {
545                issues.push(issue);
546            }
547        }
548
549        // Undeclared-relationship warning. Scan the entity's
550        // relationship list against the mem's schema vocabulary; every
551        // unknown name becomes a soft HealthIssue (same severity as a
552        // missing section) so agents running a health sweep after a
553        // schema version bump see drift without a crashed load.
554        //
555        // Shape-violation scan: when the mem's schema declares
556        // `source_types` / `target_types` on a relationship and an
557        // existing edge violates the shape, surface as a soft
558        // HealthIssue. The relate-add path enforces shape going
559        // forward; this scan catches edges authored before the
560        // constraint landed (or via inline `relations:` on
561        // memstead_create, which does not yet shape-check). The
562        // remove-path on `memstead_relate` skips shape validation so the
563        // cleanup is always reachable.
564        if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
565            let mut seen_unknown = std::collections::HashSet::new();
566            for rel in &entity.relationships {
567                if !mem_schema.relationship_known(&rel.rel_type) {
568                    if seen_unknown.insert(rel.rel_type.clone()) {
569                        let suggestion = mem_schema
570                            .suggest_relationship(&rel.rel_type)
571                            .map(|s| format!(" Did you mean '{s}'?"))
572                            .unwrap_or_default();
573                        let (schema_name, schema_version) = mem_schema.id();
574                        issues.push(HealthIssue {
575                            field: "relationships".to_string(),
576                            code: super::HealthIssueCode::UndeclaredRelationship,
577                            message: format!(
578                                "relationship '{}' is not declared in schema \
579                                 '{schema_name}@{schema_version}'.{suggestion}",
580                                rel.rel_type
581                            ),
582                        });
583                    }
584                    continue;
585                }
586
587                let target_type = store
588                    .get(&rel.target)
589                    .map(|t| t.entity_type.clone())
590                    .filter(|t| !t.is_empty());
591                if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
592                    rel_type,
593                    from_type,
594                    to_type,
595                    allowed_source_types,
596                    allowed_target_types,
597                    ..
598                }) = crate::runtime_validator::validate_rel_shape(
599                    &rel.rel_type,
600                    entity.entity_type.as_str(),
601                    target_type.as_deref(),
602                    mem_schema.as_ref(),
603                ) {
604                    let allowed_src = if allowed_source_types.is_empty() {
605                        "<any>".to_string()
606                    } else {
607                        allowed_source_types.join(", ")
608                    };
609                    let allowed_tgt = if allowed_target_types.is_empty() {
610                        "<any>".to_string()
611                    } else {
612                        allowed_target_types.join(", ")
613                    };
614                    issues.push(HealthIssue {
615                        field: "relationships".to_string(),
616                        code: super::HealthIssueCode::InvalidRelShape,
617                        message: format!(
618                            "INVALID_REL_SHAPE: edge '{rel_type}' from \
619                             '{from_type}' to '{to_type}' (target {target}) \
620                             violates declared shape — allowed_source_types: \
621                             [{allowed_src}], allowed_target_types: \
622                             [{allowed_tgt}]. Remove via \
623                             `memstead_relate from={from_id} to={target} \
624                             type={rel_type} remove=true`.",
625                            target = rel.target,
626                            from_id = entity.id,
627                        ),
628                    });
629                }
630            }
631        }
632
633        // Staleness check
634        let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
635
636        if let Some(ts_field) = auto_ts_field
637            && let Some(val) = entity.metadata.get(ts_field.key.as_str())
638        {
639            let date_str = val.to_frontmatter_string();
640            if let Some(modified_days) = parse_iso_to_days(&date_str) {
641                let days_since = today_days.saturating_sub(modified_days);
642                if days_since > schema.staleness_threshold_days as u64 {
643                    stale_entities.push(StaleEntity {
644                        id: entity.id.clone(),
645                        title: entity.title.clone(),
646                        days_since_modified: days_since,
647                    });
648                }
649            }
650        }
651
652        if !issues.is_empty() {
653            // Compute a simple health score: (total_fields - issues) / total_fields.
654            // `issues.len()` can exceed `total_fields` once the
655            // relationship-vocabulary issues are added on top, so saturate
656            // the subtraction rather than underflow. A score of 0.0 is the
657            // natural floor — agents treat it as "maximally broken".
658            let total = schema.health_required_fields.len();
659            let score = if total > 0 {
660                (total.saturating_sub(issues.len()) as f32) / (total as f32)
661            } else {
662                1.0
663            };
664
665            missing_fields.push(HealthReport {
666                id: entity.id.clone(),
667                title: entity.title.clone(),
668                score,
669                issues,
670            });
671        }
672    }
673
674    // Sort stale entities by days_since_modified descending
675    stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
676
677    // Structural counts
678    let orphan_count = query::find_orphans_with_schemas(store, mem_schemas).len();
679    let leaf_entities_by_type = query::leaf_population(store, mem_schemas);
680    let stub_count = query::find_stubs(store).len();
681
682    HealthSummary {
683        stale_entities,
684        missing_fields,
685        orphan_count,
686        stub_count,
687        warnings: Vec::new(),
688        quarantined: Vec::new(),
689        boot_diagnosis: None,
690        leaf_entities_by_type,
691        dangling_links: None,
692        findings: None,
693        tag_distribution: None,
694        tag_distribution_folded: None,
695        untagged_entities: None,
696    }
697}
698
699/// Scan every non-stub entity's `tags` metadata and aggregate (tag → count,
700/// per-entity-type breakdown) plus untagged coverage. Comma-separated parser
701/// with per-segment trim; empty segments drop. Comparison is case-sensitive
702/// on the primary surface — case drift is surfaced separately via
703/// [`TagDistribution`] siblings folded by the caller if desired.
704///
705/// `mem_filter` narrows both aggregation passes to entities in that mem;
706/// `limit` caps the returned `tag_distribution` array after sorting by count
707/// descending (tie-break by tag ascending for deterministic output).
708///
709/// Also returns `FoldedTag` entries for any canonical (lowercase) tag where
710/// two or more authored casings appear — drift-flag only; empty when no
711/// collisions exist.
712pub fn collect_tag_distribution(
713    store: &Store,
714    mem_filter: Option<&str>,
715    limit: usize,
716) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
717    // tag → (count, per_type_count)
718    let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
719    let mut untagged = UntaggedStats {
720        total: 0,
721        by_entity_type: HashMap::new(),
722    };
723
724    for entity in store.all_entities() {
725        if entity.stub {
726            continue;
727        }
728        if let Some(v) = mem_filter
729            && entity.mem != v
730        {
731            continue;
732        }
733
734        let tags_raw = entity
735            .metadata
736            .get("tags")
737            .and_then(|v| match v {
738                MetadataValue::String(s) => Some(s.as_str()),
739                _ => None,
740            })
741            .unwrap_or("");
742
743        let mut any_tag = false;
744        for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
745            any_tag = true;
746            let entry = counts
747                .entry(tag.to_string())
748                .or_insert_with(|| (0, HashMap::new()));
749            entry.0 += 1;
750            *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
751        }
752        if !any_tag {
753            untagged.total += 1;
754            *untagged
755                .by_entity_type
756                .entry(entity.entity_type.clone())
757                .or_insert(0) += 1;
758        }
759    }
760
761    // Primary distribution — case-sensitive.
762    let mut entries: Vec<TagDistribution> = counts
763        .iter()
764        .map(|(tag, (count, by_type))| TagDistribution {
765            tag: tag.clone(),
766            count: *count,
767            by_entity_type: by_type.clone(),
768        })
769        .collect();
770    entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
771    entries.truncate(limit);
772
773    // Case-drift sidecar: group by lowercase canonical; surface only entries
774    // with ≥2 distinct authored casings. Operates on the full counts map, not
775    // the truncated primary surface, so drift hidden below `limit` still
776    // surfaces.
777    let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
778    for (tag, (count, _)) in counts.iter() {
779        by_canonical
780            .entry(tag.to_lowercase())
781            .or_default()
782            .push((tag.clone(), *count));
783    }
784    let mut folded: Vec<FoldedTag> = by_canonical
785        .into_iter()
786        .filter(|(_, v)| v.len() > 1)
787        .map(|(canonical, mut variants)| {
788            variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
789            let total = variants.iter().map(|(_, c)| *c).sum();
790            FoldedTag {
791                canonical,
792                total,
793                variants: variants
794                    .into_iter()
795                    .map(|(tag, count)| TagVariant { tag, count })
796                    .collect(),
797            }
798        })
799        .collect();
800    folded.sort_by(|a, b| {
801        b.total
802            .cmp(&a.total)
803            .then_with(|| a.canonical.cmp(&b.canonical))
804    });
805
806    (entries, folded, untagged)
807}
808
809/// Scan every non-stub entity's section bodies for body wiki-links that
810/// either (a) resolve to a stub target (missing on-disk file) or
811/// (b) lack a backing explicit relation in the referrer (alias-orphan
812/// under the alias model). Both cases surface through the same
813/// `DanglingLink` shape — the existing field set continues to round-trip;
814/// alias-orphans are detectable by the target *not* being a stub while
815/// the referrer's relationships list omits it.
816///
817/// The scan also covers the `## Relationships` table: a typed-relation
818/// target whose entity vanished (out-of-band file edit, historical
819/// cross-mem corruption from the pre-F15 mem-delete path, etc.)
820/// would otherwise stay invisible to the diagnostic surface.
821/// Relationship-section danglers ship the same envelope shape with
822/// `section: None` — the Option marks the source axis without requiring
823/// a magic-string sentinel.
824///
825/// `mem_filter` narrows *scanning* to entities in that mem; resolution
826/// stays global so cross-mem links whose target is a real entity
827/// elsewhere are not flagged as missing.
828pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
829    use crate::entity::parser::extract_inline_links_lenient;
830    use std::collections::HashSet;
831
832    let mut out = Vec::new();
833    for entity in store.all_entities() {
834        if entity.stub {
835            continue;
836        }
837        if let Some(v) = mem_filter
838            && entity.mem != v
839        {
840            continue;
841        }
842        let explicit_targets: HashSet<_> = entity
843            .relationships
844            .iter()
845            .map(|r| r.target.clone())
846            .collect();
847        for (section_key, section_body) in &entity.sections {
848            for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
849                let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
850                let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
851                if target_missing || alias_orphan {
852                    out.push(DanglingLink {
853                        from: entity.id.clone(),
854                        target_id: target_id.clone(),
855                        target_path: target_id.path().to_string(),
856                        section: Some(section_key.clone()),
857                    });
858                }
859            }
860        }
861        // Relationship-table dangler scan. The `## Relationships`
862        // section is structurally distinct from body sections — its
863        // rows materialise from `entity.relationships` rather than a
864        // free-text body — so `section: None` marks the source axis.
865        //
866        // Discrimination differs from the body scan: a relationship
867        // target that resolves to a stub is a legitimate forward
868        // reference (the alias machinery auto-stubs absent targets
869        // by design), not corruption. Only a target that's *fully
870        // absent* from the store — neither stub nor real — flags as
871        // dangling. In practice this only fires for out-of-band file
872        // edits or historical cross-mem-delete corruption that
873        // dropped the stub along with the deleted mem.
874        //
875        // Dedup against the body-scan output so a target that
876        // surfaces from both axes doesn't double-emit.
877        for rel in &entity.relationships {
878            if store.get(&rel.target).is_some() {
879                continue;
880            }
881            let already_reported = out
882                .iter()
883                .any(|d| d.from == entity.id && d.target_id == rel.target);
884            if already_reported {
885                continue;
886            }
887            out.push(DanglingLink {
888                from: entity.id.clone(),
889                target_id: rel.target.clone(),
890                target_path: rel.target.path().to_string(),
891                section: None,
892            });
893        }
894    }
895    out
896}
897
898/// Collect every non-stub entity whose type declares `required_outgoing`
899/// blocks that the entity's current outgoing edges leave unsatisfied.
900/// Results are deterministic — sorted
901/// by `(mem, id)` — so the agent can diff successive sweeps without
902/// the underlying HashMap iteration order leaking through.
903///
904/// `mem_filter` narrows scanning to entities in that mem when set;
905/// `mem_schemas` resolves the entity's type definition against the
906/// mem's pinned schema. Entities whose mem has no schema in the
907/// map are skipped (no schema → no `required_outgoing` to evaluate).
908pub fn collect_missing_required_outgoing(
909    store: &Store,
910    mem_filter: Option<&str>,
911    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
912) -> Vec<MissingRequiredOutgoingReport> {
913    let mut out = Vec::new();
914    for entity in store.all_entities() {
915        if entity.stub {
916            continue;
917        }
918        if let Some(v) = mem_filter
919            && entity.mem != v
920        {
921            continue;
922        }
923        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
924            continue;
925        };
926        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
927            continue;
928        };
929        if td.required_outgoing.is_empty() {
930            continue;
931        }
932        let unsatisfied = unsatisfied_required_outgoing(entity, td);
933        if unsatisfied.is_empty() {
934            continue;
935        }
936        out.push(MissingRequiredOutgoingReport {
937            id: entity.id.clone(),
938            title: entity.title.clone(),
939            entity_type: entity.entity_type.clone(),
940            mem: entity.mem.clone(),
941            missing: unsatisfied,
942        });
943    }
944    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
945    out
946}
947
948/// Evaluate one entity's declared `required_outgoing` blocks against
949/// its current outgoing edges, returning the unsatisfied blocks in
950/// declaration order. THE single evaluation — shared by the health
951/// sweep ([`collect_missing_required_outgoing`]) and the per-mutation
952/// `MISSING_REQUIRED_OUTGOING` warning on create/update. A second
953/// implementation of the block check is a defect: the two surfaces
954/// must never disagree about what counts as unsatisfied.
955pub fn unsatisfied_required_outgoing(
956    entity: &crate::entity::Entity,
957    td: &TypeDefinition,
958) -> Vec<super::MissingRequiredOutgoingBlock> {
959    td.required_outgoing
960        .iter()
961        .filter(|block| {
962            let count = entity
963                .relationships
964                .iter()
965                .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
966                .count();
967            !block.admits(count)
968        })
969        .map(|block| super::MissingRequiredOutgoingBlock {
970            relationships: block.relationships.clone(),
971            cardinality: block.cardinality.to_string(),
972            severity: block.severity,
973        })
974        .collect()
975}
976
977/// One violated declared constraint on one entity — the wire entry
978/// shared by the write-path surface (the `CONSTRAINT_UNSATISFIED`
979/// warning or refusal, tier decided by the declared severity) and the
980/// health `constraints` include. The serde `kind` tag names the form;
981/// the remaining fields restate the declaration (plus the observed
982/// offense — the colliding entity, the unbacked value, the tainting
983/// ancestor) so a consumer can repair without re-fetching the schema.
984#[derive(Debug, Clone, serde::Serialize)]
985#[serde(tag = "kind", rename_all = "snake_case")]
986pub enum UnsatisfiedConstraint {
987    RequiresWhen {
988        field: String,
989        when_field: String,
990        when_value: String,
991        severity: memstead_schema::ConstraintSeverity,
992    },
993    Unique {
994        fields: Vec<String>,
995        /// The entity's values for `fields`, in declaration order.
996        values: Vec<String>,
997        /// The other entity holding the same tuple (lexically smallest
998        /// when several collide).
999        colliding: String,
1000        severity: memstead_schema::ConstraintSeverity,
1001    },
1002    EnumFromNeighbour {
1003        field: String,
1004        /// The set value no reached neighbour's section backs.
1005        value: String,
1006        rel_type: String,
1007        section: String,
1008        severity: memstead_schema::ConstraintSeverity,
1009    },
1010    StatusPropagation {
1011        field: String,
1012        /// The terminal value the ancestor holds.
1013        value: String,
1014        rel_type: String,
1015        /// The tainting ancestor — the entity holding the terminal
1016        /// value that this entity (transitively) reaches.
1017        tainted_by: String,
1018        severity: memstead_schema::ConstraintSeverity,
1019    },
1020}
1021
1022impl UnsatisfiedConstraint {
1023    pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1024        match self {
1025            Self::RequiresWhen { severity, .. }
1026            | Self::Unique { severity, .. }
1027            | Self::EnumFromNeighbour { severity, .. }
1028            | Self::StatusPropagation { severity, .. } => *severity,
1029        }
1030    }
1031
1032    /// One-line human rendering for warning/refusal message text.
1033    pub fn describe(&self) -> String {
1034        match self {
1035            Self::RequiresWhen {
1036                field,
1037                when_field,
1038                when_value,
1039                ..
1040            } => format!(
1041                "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1042            ),
1043            Self::Unique {
1044                fields, colliding, ..
1045            } => format!(
1046                "unique: tuple ({}) collides with '{colliding}'",
1047                fields.join(", ")
1048            ),
1049            Self::EnumFromNeighbour {
1050                field,
1051                value,
1052                rel_type,
1053                section,
1054                ..
1055            } => format!(
1056                "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1057                 `{section}` section reached via {rel_type}"
1058            ),
1059            Self::StatusPropagation {
1060                field,
1061                value,
1062                tainted_by,
1063                ..
1064            } => format!("status_propagation: tainted by '{tainted_by}' ({field}={value})"),
1065        }
1066    }
1067}
1068
1069/// Evaluate one entity's declared per-entity `constraints` against its
1070/// current state (and, for the store-aware forms, against the rest of
1071/// its mem), returning the violated ones in declaration order. THE
1072/// single evaluation — shared by the health sweep
1073/// ([`collect_constraint_findings`]) and the per-mutation
1074/// `CONSTRAINT_UNSATISFIED` surface on create/update/relate; a second
1075/// implementation of any form is a defect.
1076///
1077/// Form semantics:
1078/// - `requires_when` triggers when `when_field`'s frontmatter value
1079///   equals `when_value` exactly; a triggered constraint is satisfied
1080///   when `field` — a metadata field or a section key — is present
1081///   with non-blank content.
1082/// - `unique`: the entity's tuple of `fields` values (skipped when any
1083///   field is unset/blank) must not equal another non-stub entity's
1084///   tuple within the same mem and type. `exclude` names the entity's
1085///   own id so an update does not collide with its stored self.
1086/// - `enum_from_neighbour`: a set `field` value must appear as a
1087///   bullet entry (`- value` / `* value` line) in the `section` body
1088///   of at least one entity reached via an outgoing `rel_type` edge.
1089/// - `status_propagation` is a reachability property of the graph,
1090///   not of one write — it is evaluated only by the health sweep
1091///   ([`collect_constraint_findings`]), never here.
1092pub fn unsatisfied_constraints(
1093    store: &Store,
1094    entity: &crate::entity::Entity,
1095    td: &TypeDefinition,
1096    exclude: Option<&crate::entity::EntityId>,
1097) -> Vec<UnsatisfiedConstraint> {
1098    use memstead_schema::ConstraintDef;
1099    td.constraints
1100        .iter()
1101        .filter_map(|c| match c {
1102            ConstraintDef::RequiresWhen {
1103                field,
1104                when_field,
1105                when_value,
1106                severity,
1107            } => {
1108                let triggered = entity
1109                    .metadata
1110                    .get(when_field.as_str())
1111                    .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1112                if !triggered {
1113                    return None;
1114                }
1115                let satisfied = entity
1116                    .metadata
1117                    .get(field.as_str())
1118                    .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1119                    || entity
1120                        .sections
1121                        .get(field.as_str())
1122                        .is_some_and(|body| !body.trim().is_empty());
1123                if satisfied {
1124                    return None;
1125                }
1126                Some(UnsatisfiedConstraint::RequiresWhen {
1127                    field: field.clone(),
1128                    when_field: when_field.clone(),
1129                    when_value: when_value.clone(),
1130                    severity: *severity,
1131                })
1132            }
1133            ConstraintDef::Unique { fields, severity } => {
1134                let tuple = tuple_of(entity, fields)?;
1135                let mut colliding: Vec<&str> = store
1136                    .all_entities()
1137                    .filter(|other| {
1138                        !other.stub
1139                            && other.mem == entity.mem
1140                            && other.entity_type == entity.entity_type
1141                            && Some(&other.id) != exclude
1142                            && other.id != entity.id
1143                            && tuple_of(other, fields).as_ref() == Some(&tuple)
1144                    })
1145                    .map(|other| other.id.0.as_str())
1146                    .collect();
1147                colliding.sort_unstable();
1148                let first = colliding.first()?;
1149                Some(UnsatisfiedConstraint::Unique {
1150                    fields: fields.clone(),
1151                    values: tuple,
1152                    colliding: first.to_string(),
1153                    severity: *severity,
1154                })
1155            }
1156            ConstraintDef::EnumFromNeighbour {
1157                field,
1158                rel_type,
1159                section,
1160                severity,
1161            } => {
1162                let value = entity
1163                    .metadata
1164                    .get(field.as_str())
1165                    .map(|v| v.to_frontmatter_string())
1166                    .filter(|v| !v.trim().is_empty())?;
1167                let backed = entity
1168                    .relationships
1169                    .iter()
1170                    .filter(|rel| rel.rel_type == *rel_type)
1171                    .filter_map(|rel| store.get(&rel.target))
1172                    .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1173                    .any(|body| bullet_entries(body).any(|entry| entry == value));
1174                if backed {
1175                    return None;
1176                }
1177                Some(UnsatisfiedConstraint::EnumFromNeighbour {
1178                    field: field.clone(),
1179                    value,
1180                    rel_type: rel_type.clone(),
1181                    section: section.clone(),
1182                    severity: *severity,
1183                })
1184            }
1185            ConstraintDef::StatusPropagation { .. } => None,
1186        })
1187        .collect()
1188}
1189
1190/// The entity's tuple of frontmatter values for `fields`, in
1191/// declaration order — `None` when any field is unset or blank (no
1192/// tuple, nothing to compare).
1193fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1194    fields
1195        .iter()
1196        .map(|f| {
1197            entity
1198                .metadata
1199                .get(f.as_str())
1200                .map(|v| v.to_frontmatter_string())
1201                .filter(|v| !v.trim().is_empty())
1202        })
1203        .collect()
1204}
1205
1206/// The bullet entries of a section body — trimmed text of `- item` /
1207/// `* item` lines. The legal-value shape `enum_from_neighbour` reads.
1208fn bullet_entries(body: &str) -> impl Iterator<Item = &str> {
1209    body.lines().filter_map(|line| {
1210        let t = line.trim_start();
1211        t.strip_prefix("- ")
1212            .or_else(|| t.strip_prefix("* "))
1213            .map(str::trim)
1214    })
1215}
1216
1217/// One entity's violated declared constraints, surfaced from the
1218/// health-time scan (`include=["constraints"]`). Mirrors
1219/// [`MissingRequiredOutgoingReport`]'s envelope shape — the two
1220/// includes read the same way.
1221#[derive(Debug, Clone, serde::Serialize)]
1222pub struct ConstraintFindingReport {
1223    pub id: crate::entity::EntityId,
1224    pub title: String,
1225    pub entity_type: String,
1226    pub mem: String,
1227    pub violations: Vec<UnsatisfiedConstraint>,
1228    /// Standing violations of the entity's declared section formats
1229    /// (plan 08) — additive: consumers of the pre-format shape see an
1230    /// absent key, never an empty list.
1231    #[serde(skip_serializing_if = "Vec::is_empty")]
1232    pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
1233}
1234
1235/// Collect every non-stub entity whose declared `constraints` its
1236/// current state violates. Two passes: the per-entity forms
1237/// (`requires_when`, `unique`, `enum_from_neighbour`) through the
1238/// shared [`unsatisfied_constraints`] evaluation, then the
1239/// `status_propagation` graph sweep — for each entity holding a
1240/// declared terminal value, every entity reaching it (transitively)
1241/// via the declared rel-type and direction gains a finding naming that
1242/// tainting ancestor. Deterministic — reports sorted by `(mem, id)`,
1243/// violations in declaration order then by tainting ancestor.
1244pub fn collect_constraint_findings(
1245    store: &Store,
1246    mem_filter: Option<&str>,
1247    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1248) -> Vec<ConstraintFindingReport> {
1249    use memstead_schema::ConstraintDef;
1250    type Bucket = (
1251        Vec<UnsatisfiedConstraint>,
1252        Vec<crate::section_format::SectionFormatViolation>,
1253    );
1254    let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
1255
1256    for entity in store.all_entities() {
1257        if entity.stub {
1258            continue;
1259        }
1260        if let Some(v) = mem_filter
1261            && entity.mem != v
1262        {
1263            continue;
1264        }
1265        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1266            continue;
1267        };
1268        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1269            continue;
1270        };
1271
1272        // Section-format sweep (plan 08) — standing violations of a
1273        // declared markdown shape, every severity (block-tier
1274        // pre-existing violations are health findings too; the next
1275        // write of the section is the sanctioned repair point).
1276        for def in &td.sections {
1277            if def.compiled_content.is_none() {
1278                continue;
1279            }
1280            let Some(body) = entity.sections.get(def.key.as_str()) else {
1281                continue;
1282            };
1283            let violations = crate::section_format::check_section_format(def, body);
1284            if !violations.is_empty() {
1285                by_entity
1286                    .entry(entity.id.0.clone())
1287                    .or_default()
1288                    .1
1289                    .extend(violations);
1290            }
1291        }
1292
1293        if td.constraints.is_empty() {
1294            continue;
1295        }
1296
1297        // Pass 1 — per-entity forms.
1298        let violations = unsatisfied_constraints(store, entity, td, None);
1299        if !violations.is_empty() {
1300            by_entity
1301                .entry(entity.id.0.clone())
1302                .or_default()
1303                .0
1304                .extend(violations);
1305        }
1306
1307        // Pass 2 — this entity as a taint source: it holds a declared
1308        // terminal value, so sweep its dependents.
1309        for c in &td.constraints {
1310            let ConstraintDef::StatusPropagation {
1311                field,
1312                value,
1313                rel_type,
1314                direction,
1315                severity,
1316            } = c
1317            else {
1318                continue;
1319            };
1320            let terminal = entity
1321                .metadata
1322                .get(field.as_str())
1323                .is_some_and(|v| v.to_frontmatter_string() == *value);
1324            if !terminal {
1325                continue;
1326            }
1327            for tainted in reach_transitively(store, &entity.id, rel_type, *direction) {
1328                if let Some(v) = mem_filter
1329                    && tainted.mem() != v
1330                {
1331                    continue;
1332                }
1333                by_entity.entry(tainted.0.clone()).or_default().0.push(
1334                    UnsatisfiedConstraint::StatusPropagation {
1335                        field: field.clone(),
1336                        value: value.clone(),
1337                        rel_type: rel_type.clone(),
1338                        tainted_by: entity.id.to_string(),
1339                        severity: *severity,
1340                    },
1341                );
1342            }
1343        }
1344    }
1345
1346    let mut out: Vec<ConstraintFindingReport> = by_entity
1347        .into_iter()
1348        .filter_map(|(id, (violations, format_violations))| {
1349            let id = crate::entity::EntityId(id);
1350            let entity = store.get(&id)?;
1351            Some(ConstraintFindingReport {
1352                id,
1353                title: entity.title.clone(),
1354                entity_type: entity.entity_type.clone(),
1355                mem: entity.mem.clone(),
1356                violations,
1357                format_violations,
1358            })
1359        })
1360        .collect();
1361    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1362    out
1363}
1364
1365/// Transitive reachability along one rel-type from `start`, excluding
1366/// `start` itself. `Incoming` walks against edge direction (the
1367/// entities whose `rel_type` edges point at the frontier — "what
1368/// stands on this"); `Outgoing` follows the frontier's own edges.
1369/// Stubs are traversed (an edge through a stub still transmits the
1370/// taint) but stubs themselves are not returned.
1371fn reach_transitively(
1372    store: &Store,
1373    start: &crate::entity::EntityId,
1374    rel_type: &str,
1375    direction: memstead_schema::PropagationDirection,
1376) -> Vec<crate::entity::EntityId> {
1377    use memstead_schema::PropagationDirection;
1378    let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1379        std::iter::once(start.clone()).collect();
1380    let mut frontier = vec![start.clone()];
1381    let mut reached = Vec::new();
1382    while let Some(current) = frontier.pop() {
1383        let next: Vec<crate::entity::EntityId> = match direction {
1384            PropagationDirection::Incoming => store
1385                .all_entities()
1386                .filter(|e| {
1387                    e.relationships
1388                        .iter()
1389                        .any(|r| r.rel_type == rel_type && r.target == current)
1390                })
1391                .map(|e| e.id.clone())
1392                .collect(),
1393            PropagationDirection::Outgoing => store
1394                .get(&current)
1395                .map(|e| {
1396                    e.relationships
1397                        .iter()
1398                        .filter(|r| r.rel_type == rel_type)
1399                        .map(|r| r.target.clone())
1400                        .collect()
1401                })
1402                .unwrap_or_default(),
1403        };
1404        for id in next {
1405            if seen.insert(id.clone()) {
1406                if store.get(&id).is_some_and(|e| !e.stub) {
1407                    reached.push(id.clone());
1408                }
1409                frontier.push(id);
1410            }
1411        }
1412    }
1413    reached
1414}
1415
1416/// A defective section-format declaration a loaded schema carries
1417/// (recorded by the lenient boot path; install would have refused).
1418/// Surfaced under the health `constraints` include so a sealed schema
1419/// with a bad declaration is visible without bricking boot.
1420#[derive(Debug, Clone, serde::Serialize)]
1421pub struct SchemaFormatDefect {
1422    pub schema: String,
1423    pub type_name: String,
1424    pub section: String,
1425    pub problems: Vec<String>,
1426}
1427
1428/// Collect the defective section-format declarations across the
1429/// mounted mems' pinned schemas, deduplicated per schema ref,
1430/// deterministic order.
1431pub fn collect_schema_format_defects(
1432    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1433) -> Vec<SchemaFormatDefect> {
1434    let mut seen: std::collections::BTreeSet<String> = Default::default();
1435    let mut out = Vec::new();
1436    let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
1437    schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
1438    for schema in schemas {
1439        let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
1440        if !seen.insert(schema_ref.clone()) {
1441            continue;
1442        }
1443        for td in schema.types.values() {
1444            for section in &td.sections {
1445                if !section.format_problems.is_empty() {
1446                    out.push(SchemaFormatDefect {
1447                        schema: schema_ref.clone(),
1448                        type_name: td.name.clone(),
1449                        section: section.key.clone(),
1450                        problems: section.format_problems.clone(),
1451                    });
1452                }
1453            }
1454        }
1455    }
1456    out.sort_by(|a, b| {
1457        (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
1458    });
1459    out
1460}
1461
1462/// One entity's unsatisfied `required_outgoing` blocks, surfaced from
1463/// the health-time scan. `missing` reuses the per-write warning's wire
1464/// block type — one struct, one serialized shape (`{ relationships,
1465/// cardinality }`) on both surfaces — and adds the `mem` name (the
1466/// warning's `entity_id` already encodes it via the mem prefix, but
1467/// health is multi-mem by default and an explicit field is cheaper for
1468/// downstream filters).
1469#[derive(Debug, Clone, serde::Serialize)]
1470pub struct MissingRequiredOutgoingReport {
1471    pub id: crate::entity::EntityId,
1472    pub title: String,
1473    pub entity_type: String,
1474    pub mem: String,
1475    pub missing: Vec<super::MissingRequiredOutgoingBlock>,
1476}
1477
1478/// Render the workspace-config projection the health surface serves —
1479/// per-writable-mem detail (`origin`, storage/durability, `vcs`
1480/// `gitdir`/`worktree`/`head`, title/subject, `write_guidance`,
1481/// `extra`) plus the `mutations` and `plugin` policy values. One
1482/// implementation, every surface: the MCP composer reaches it through
1483/// `include_config: true` OR the `config` include key; the CLI through
1484/// `--include config`. `mutations` / `plugin` are passed prebuilt so a
1485/// server that owns its own copies inserts them verbatim; callers
1486/// without server state derive them from `Engine::settings()` (see
1487/// [`config_projection_from_settings`]). Returns the three top-level
1488/// entries (`mems`, `mutations`, `plugin`) for the caller to merge —
1489/// callers gate on their own opt-in flag and must render at most once.
1490pub fn config_projection(
1491    engine: &crate::Engine,
1492    writable_mems: &[String],
1493    mutations: serde_json::Value,
1494    plugin: serde_json::Value,
1495) -> serde_json::Map<String, serde_json::Value> {
1496    // Per-mem storage backend → durability marker, derived from the
1497    // mount's `MountStorage` kind. Lives alongside `vcs` so an agent
1498    // reading per-mem config learns whether a `commit_sha` this mem
1499    // returns is durable-on-disk or volatile-in-RAM.
1500    let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
1501        .mounts()
1502        .iter()
1503        .map(|m| {
1504            (
1505                m.mem.as_str(),
1506                (m.storage.backend_id(), m.storage.is_durable()),
1507            )
1508        })
1509        .collect();
1510    let mems_detail: Vec<serde_json::Value> = writable_mems
1511        .iter()
1512        .map(|name| {
1513            let origin = engine
1514                .mem_router()
1515                .origin_for_mem(name)
1516                .map(|o| o.kind())
1517                .unwrap_or("explicit");
1518            let mut entry = serde_json::Map::new();
1519            entry.insert("name".into(), serde_json::json!(name));
1520            entry.insert("origin".into(), serde_json::json!(origin));
1521            if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
1522                entry.insert("storage".into(), serde_json::json!(storage));
1523                entry.insert("durable".into(), serde_json::json!(durable));
1524            }
1525            let mut vcs_obj = serde_json::Map::new();
1526            if let Ok(gitdir) = engine.gitdir_for(name) {
1527                vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
1528            }
1529            if let Ok(worktree) = engine.worktree_for(name) {
1530                vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
1531            }
1532            if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
1533                vcs_obj.insert("head".into(), serde_json::json!(sha));
1534            }
1535            if !vcs_obj.is_empty() {
1536                entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
1537            }
1538            if let Some(cfg) = engine.mem_config_for(name) {
1539                // Display title + subject block, when set — the
1540                // config projection prefers the title wherever a
1541                // mem is printed; the name stays the identity.
1542                if let Some(title) = &cfg.title {
1543                    entry.insert("title".into(), serde_json::json!(title));
1544                }
1545                if let Some(subject) = &cfg.subject {
1546                    entry.insert("subject".into(), serde_json::json!(subject));
1547                }
1548                let guidance = serde_json::Map::from_iter(
1549                    cfg.write_guidance
1550                        .iter()
1551                        .map(|(k, v)| (k.clone(), v.clone())),
1552                );
1553                entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
1554                let extra = serde_json::Map::from_iter(
1555                    cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
1556                );
1557                entry.insert("extra".into(), serde_json::Value::Object(extra));
1558            }
1559            serde_json::Value::Object(entry)
1560        })
1561        .collect();
1562
1563    let mut out = serde_json::Map::new();
1564    out.insert("mems".into(), serde_json::json!(mems_detail));
1565    out.insert("mutations".into(), mutations);
1566    out.insert("plugin".into(), plugin);
1567    out
1568}
1569
1570/// The `(mutations, plugin)` pair for [`config_projection`], derived
1571/// from the engine's own [`crate::workspace::WorkspaceSettings`] — for
1572/// callers (the CLI) that carry no server-owned config copies. Produces
1573/// the same bytes the MCP server passes when both were loaded from the
1574/// same `workspace.toml`.
1575pub fn config_projection_from_settings(
1576    settings: &crate::workspace::WorkspaceSettings,
1577) -> (serde_json::Value, serde_json::Value) {
1578    let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
1579    let plugin_map: serde_json::Map<String, serde_json::Value> = settings
1580        .plugin
1581        .iter()
1582        .map(|(k, v)| {
1583            (
1584                k.clone(),
1585                serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
1586            )
1587        })
1588        .collect();
1589    (mutations, serde_json::Value::Object(plugin_map))
1590}
1591
1592/// Detect the section-fork condition for one declared section: the
1593/// parsed content under `key` is empty, the schema's declared heading
1594/// for the key does not derive back to it
1595/// (`derive_section_key(heading) != key`), and the file carries that
1596/// declared heading — so the content is present in the file but
1597/// unreachable under the key: absorbed into the catch-all when the
1598/// type declares one, dropped from the parsed sections otherwise.
1599///
1600/// Returns the distinct `SECTION_HEADING_MISMATCH` issue naming both
1601/// the found heading and what a deriving heading would look like. The
1602/// caller must NOT also report the section as missing — collapsing the
1603/// two conditions into the missing-section report is exactly the
1604/// misdirection this finding exists to prevent (the operator goes
1605/// hunting for absent content that is in fact present).
1606pub(crate) fn section_heading_mismatch_issue(
1607    entity: &crate::entity::Entity,
1608    schema: &TypeDefinition,
1609    key: &str,
1610) -> Option<HealthIssue> {
1611    let def = schema.section(key)?;
1612    let derived = memstead_schema::derive_section_key(&def.heading);
1613    if derived == key {
1614        return None;
1615    }
1616    if !entity
1617        .raw_section_headings
1618        .iter()
1619        .any(|h| h == &def.heading)
1620    {
1621        return None;
1622    }
1623    let landing = match schema.catch_all_section() {
1624        Some(c) => format!(
1625            "the content was absorbed into catch-all section '{}'",
1626            c.key
1627        ),
1628        None => "the content is unreachable under any declared key".to_string(),
1629    };
1630    Some(HealthIssue {
1631        field: key.to_string(),
1632        code: super::HealthIssueCode::SectionHeadingMismatch,
1633        message: format!(
1634            "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
1635             under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
1636             The schema's declared heading cannot round-trip to its key (expected a heading \
1637             that derives to '{key}'); fix the schema's heading/key pair — new installs of \
1638             such a schema are refused",
1639            found = def.heading,
1640        ),
1641    })
1642}
1643
1644/// Get a single entity's health report.
1645pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
1646    let mut issues = Vec::new();
1647
1648    for field in &schema.health_required_fields {
1649        if schema.section(field).is_some() {
1650            let content = entity.sections.get(field.as_str());
1651            if content.is_none_or(|c| c.trim().is_empty()) {
1652                if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1653                    issues.push(issue);
1654                } else {
1655                    issues.push(HealthIssue {
1656                        field: field.clone(),
1657                        code: super::HealthIssueCode::Missing,
1658                        message: format!("required section '{field}' is empty"),
1659                    });
1660                }
1661            }
1662        } else {
1663            let value = entity.metadata.get(field.as_str());
1664            if value.is_none() {
1665                issues.push(HealthIssue {
1666                    field: field.clone(),
1667                    code: super::HealthIssueCode::Missing,
1668                    message: format!("required field '{field}' is missing"),
1669                });
1670            }
1671        }
1672    }
1673
1674    // The mismatch condition is drift worth surfacing on every declared
1675    // section, not only the health-required ones — an optional section
1676    // whose content forked away is just as invisible to readers.
1677    for s in schema.sections.iter().filter(|s| !s.catch_all) {
1678        if schema.health_required_fields.contains(&s.key) {
1679            continue; // already handled above
1680        }
1681        let content = entity.sections.get(s.key.as_str());
1682        if content.is_none_or(|c| c.trim().is_empty())
1683            && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1684        {
1685            issues.push(issue);
1686        }
1687    }
1688
1689    let total = schema.health_required_fields.len();
1690    let score = if total > 0 {
1691        (total.saturating_sub(issues.len()) as f32) / (total as f32)
1692    } else {
1693        1.0
1694    };
1695
1696    HealthReport {
1697        id: entity.id.clone(),
1698        title: entity.title.clone(),
1699        score,
1700        issues,
1701    }
1702}
1703
1704// ---------------------------------------------------------------------------
1705// Date helpers
1706// ---------------------------------------------------------------------------
1707
1708/// Get current days since Unix epoch.
1709///
1710/// `SystemTime::now()` is unimplemented on `wasm32-unknown-unknown` —
1711/// it traps with `RuntimeError: unreachable` and poisons the wasm
1712/// instance (cold-start F11) — so the wasm build reads the JS-backed
1713/// clock instead. Same value, same summary shape on every target.
1714fn days_since_epoch() -> u64 {
1715    #[cfg(target_arch = "wasm32")]
1716    {
1717        (js_sys::Date::now() / 1000.0) as u64 / 86400
1718    }
1719    #[cfg(not(target_arch = "wasm32"))]
1720    {
1721        std::time::SystemTime::now()
1722            .duration_since(std::time::UNIX_EPOCH)
1723            .unwrap_or_default()
1724            .as_secs()
1725            / 86400
1726    }
1727}
1728
1729/// Parse an ISO 8601 date string to days since epoch.
1730/// Supports `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM:SSZ`.
1731fn parse_iso_to_days(date: &str) -> Option<u64> {
1732    let date_part = date.split('T').next()?;
1733    let parts: Vec<&str> = date_part.split('-').collect();
1734    if parts.len() != 3 {
1735        return None;
1736    }
1737    let year: u64 = parts[0].parse().ok()?;
1738    let month: u64 = parts[1].parse().ok()?;
1739    let day: u64 = parts[2].parse().ok()?;
1740    Some(ymd_to_days(year, month, day))
1741}
1742
1743/// Convert (year, month, day) to days since Unix epoch.
1744/// Inverse of the algorithm in generator.rs.
1745fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
1746    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
1747    let y = if month <= 2 { year - 1 } else { year };
1748    let m = if month <= 2 { month + 9 } else { month - 3 };
1749    let era = y / 400;
1750    let yoe = y - era * 400;
1751    let doy = (153 * m + 2) / 5 + day - 1;
1752    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1753    let days = era * 146097 + doe;
1754    days - 719468
1755}
1756
1757#[cfg(test)]
1758mod tests {
1759    use super::*;
1760    use crate::entity::{Entity, EntityId, MetadataValue};
1761    use crate::store::Store;
1762    use indexmap::IndexMap;
1763    use memstead_schema::type_by_name;
1764
1765    /// Agent-trust plan 14, criterion 4: a destination config
1766    /// declaring its process mem resolves the pairing regardless of
1767    /// naming — with no binding at all — and a declaration naming a
1768    /// missing mem surfaces as the typed finding, never a silent
1769    /// fallback.
1770    #[test]
1771    fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
1772        use crate::engine::test_helpers::folder_mount;
1773        let tmp = tempfile::TempDir::new().unwrap();
1774        let dest_dir = tmp.path().join("dest");
1775        let proc_dir = tmp.path().join("oddly-named-process");
1776        std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
1777        std::fs::create_dir_all(&proc_dir).unwrap();
1778        // Declaration: the destination pairs with a mem whose name no
1779        // convention would derive.
1780        std::fs::write(
1781            dest_dir.join(".memstead").join("config.json"),
1782            r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
1783        )
1784        .unwrap();
1785        let engine = crate::Engine::from_mounts(vec![
1786            (
1787                folder_mount("dest", dest_dir.clone()),
1788                Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
1789                    as Box<dyn crate::backend::MemBackend>,
1790            ),
1791            (
1792                folder_mount("oddly-named-process", proc_dir.clone()),
1793                Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
1794                    as Box<dyn crate::backend::MemBackend>,
1795            ),
1796        ])
1797        .unwrap();
1798
1799        // The one resolution function: declaration wins.
1800        let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
1801        assert!(r.declared && r.mounted);
1802        assert_eq!(r.mem, "oddly-named-process");
1803        // No declaration → derivation fallback, byte-identical to the
1804        // pre-declaration behaviour.
1805        let r =
1806            crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
1807        assert!(!r.declared && !r.mounted);
1808        assert_eq!(r.mem, "whatever");
1809
1810        // The axis pairs the declared mem with no binding present.
1811        let axis = health_open_questions_axis(&engine, Some("dest"));
1812        let process = &axis["dest"]["process"];
1813        assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
1814        assert_eq!(process[0]["declared"], true, "{axis}");
1815        assert_eq!(process[0]["resolvable"], true, "{axis}");
1816
1817        // Declaration naming a missing mem: typed finding.
1818        std::fs::write(
1819            dest_dir.join(".memstead").join("config.json"),
1820            r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
1821        )
1822        .unwrap();
1823        let engine2 = crate::Engine::from_mounts(vec![(
1824            folder_mount("dest", dest_dir.clone()),
1825            Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
1826                as Box<dyn crate::backend::MemBackend>,
1827        )])
1828        .unwrap();
1829        let axis = health_open_questions_axis(&engine2, Some("dest"));
1830        let process = &axis["dest"]["process"];
1831        assert_eq!(
1832            process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
1833            "{axis}"
1834        );
1835        assert_eq!(process[0]["resolvable"], false, "{axis}");
1836    }
1837
1838    fn make_entity(name: &str, has_required: bool) -> Entity {
1839        let mut metadata = IndexMap::new();
1840        metadata.insert("level".into(), MetadataValue::String("M0".into()));
1841        metadata.insert("type".into(), MetadataValue::String("spec".into()));
1842        metadata.insert(
1843            "created_date".into(),
1844            MetadataValue::String("2026-01-15".into()),
1845        );
1846        metadata.insert(
1847            "last_modified".into(),
1848            MetadataValue::String("2026-04-12".into()),
1849        );
1850
1851        let mut sections = IndexMap::new();
1852        if has_required {
1853            sections.insert("identity".into(), "Has identity.".into());
1854            sections.insert("purpose".into(), "Has purpose.".into());
1855        }
1856
1857        Entity {
1858            id: EntityId::new("specs", name),
1859            title: name.into(),
1860            entity_type: "spec".into(),
1861            mem: "specs".into(),
1862            file_path: format!("{name}.md"),
1863            metadata,
1864            sections,
1865            relationships: Vec::new(),
1866            content_hash: String::new(),
1867            stub: false,
1868            stub_kind: None,
1869            heading_spans: std::collections::HashMap::new(),
1870            raw_section_headings: Vec::new(),
1871        }
1872    }
1873
1874    /// A sealed-violator type: section key `answers` with heading
1875    /// `Answers argued` (derives to `answers_argued`) — the plenum
1876    /// finding's exact shape. Loads fine; only new installs refuse.
1877    fn violating_type() -> std::sync::Arc<TypeDefinition> {
1878        let manifest = r#"name: debate
1879version: 0.1.0
1880description: sealed-violator fixture
1881when_to_use: health tests
1882types:
1883  - question
1884relationships:
1885  mode: strict
1886  definitions:
1887    - name: PART_OF
1888      description: hier
1889      default_weight: 3.0
1890    - name: _default
1891      description: fallback
1892      default_weight: 1.0
1893community:
1894  resolution: 1.0
1895  seed: 42
1896"#;
1897        let type_yaml = r#"name: question
1898description: t
1899when_to_use: tests
1900sections:
1901  - key: answers
1902    heading: Answers argued
1903    required: true
1904    search_weight: 10.0
1905    write_rules: []
1906  - key: notes
1907    heading: Notes
1908    required: false
1909    search_weight: 3.0
1910    catch_all: true
1911    write_rules: []
1912metadata_fields: []
1913title_weight: 100.0
1914text_fields:
1915  - answers
1916  - notes
1917hierarchy_relationship: PART_OF
1918no_self_loop_relationships: []
1919updatable_fields:
1920  - title
1921  - answers
1922  - notes
1923health_required_fields:
1924  - answers
1925staleness_threshold_days: 90
1926write_rules: []
1927"#;
1928        memstead_schema::load_schema_from_memory(
1929            manifest,
1930            &[("question".to_string(), type_yaml.to_string())],
1931        )
1932        .expect("violating schema still loads")
1933        .get_type("question")
1934        .expect("question type")
1935    }
1936
1937    /// Health must report the distinct SECTION_HEADING_MISMATCH finding
1938    /// — naming both headings and the catch-all the content landed in —
1939    /// for content sitting under a non-deriving heading, and must NOT
1940    /// report that section as missing. A genuinely absent section keeps
1941    /// the missing report; a conforming entity gets neither.
1942    #[test]
1943    fn health_distinguishes_heading_mismatch_from_missing_section() {
1944        let schema = violating_type();
1945
1946        // Content present under the declared (non-deriving) heading.
1947        let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
1948        let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
1949            .expect("parses")
1950            .entity;
1951        let report = entity_health(&parsed, &schema);
1952        let mismatch: Vec<_> = report
1953            .issues
1954            .iter()
1955            .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
1956            .collect();
1957        assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
1958        let msg = &mismatch[0].message;
1959        assert!(
1960            msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
1961            "names found heading and derived key: {msg}"
1962        );
1963        assert!(
1964            msg.contains("'notes'"),
1965            "names the catch-all landing: {msg}"
1966        );
1967        assert!(
1968            !report.issues.iter().any(|i| i.message.contains("is empty")),
1969            "must not also report the section as missing: {:?}",
1970            report.issues
1971        );
1972
1973        // Genuinely missing section: missing report exactly as today.
1974        let md_missing = "---\ntype: question\n---\n# Q2\n";
1975        let parsed_missing =
1976            crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
1977                .expect("parses")
1978                .entity;
1979        let report_missing = entity_health(&parsed_missing, &schema);
1980        assert!(
1981            report_missing
1982                .issues
1983                .iter()
1984                .any(|i| i.code == super::super::HealthIssueCode::Missing
1985                    && i.message == "required section 'answers' is empty"),
1986            "absent section keeps the missing report (structured MISSING code): {:?}",
1987            report_missing.issues
1988        );
1989        assert!(
1990            !report_missing
1991                .issues
1992                .iter()
1993                .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
1994            "no mismatch finding when the heading is not in the file"
1995        );
1996
1997        // Conforming entity (content under a heading deriving to the
1998        // key would need a deriving heading — for this violating
1999        // schema no heading can reach `answers`, so use the conforming
2000        // catch-all only): neither finding for a section with content.
2001        let ok_type = crate::entity::parser::parse_markdown(
2002            "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
2003            "q3.md",
2004            &schema,
2005            "debate",
2006        )
2007        .expect("parses")
2008        .entity;
2009        let report_ok = entity_health(&ok_type, &schema);
2010        assert!(
2011            !report_ok
2012                .issues
2013                .iter()
2014                .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2015            "mismatch fires only when the declared heading is present: {:?}",
2016            report_ok.issues
2017        );
2018    }
2019
2020    fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
2021        let mut metadata = IndexMap::new();
2022        metadata.insert("type".into(), MetadataValue::String("concept".into()));
2023        metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
2024        metadata.insert(
2025            "abstraction_level".into(),
2026            MetadataValue::String("concrete".into()),
2027        );
2028        metadata.insert(
2029            "created_date".into(),
2030            MetadataValue::String("2026-01-15".into()),
2031        );
2032        metadata.insert(
2033            "last_modified".into(),
2034            MetadataValue::String("2026-04-12".into()),
2035        );
2036
2037        let mut sections = IndexMap::new();
2038        if with_definition {
2039            sections.insert("definition".into(), "Precise definition.".into());
2040        }
2041        sections.insert("explanation".into(), "How it works.".into());
2042
2043        Entity {
2044            id: EntityId::new("concepts", name),
2045            title: name.into(),
2046            entity_type: "concept".into(),
2047            mem: "concepts".into(),
2048            file_path: format!("{name}.md"),
2049            metadata,
2050            sections,
2051            relationships: Vec::new(),
2052            content_hash: String::new(),
2053            stub: false,
2054            stub_kind: None,
2055            heading_spans: std::collections::HashMap::new(),
2056            raw_section_headings: Vec::new(),
2057        }
2058    }
2059
2060    #[test]
2061    fn health_concept_missing_definition_reports_definition_field() {
2062        let schema = &type_by_name("concept").unwrap();
2063        let entity = make_concept_entity("clarity", false);
2064        let report = entity_health(&entity, schema);
2065
2066        // The missing-field issue must name the concept schema's required
2067        // section ("definition"), not spec's "identity".
2068        assert!(report.issues.iter().any(|i| i.field == "definition"));
2069        assert!(!report.issues.iter().any(|i| i.field == "identity"));
2070        assert!(!report.issues.iter().any(|i| i.field == "purpose"));
2071        assert!(report.score < 1.0);
2072
2073        // An entity with the definition filled in has no issue for that field.
2074        let healthy = make_concept_entity("clarity-ok", true);
2075        let healthy_report = entity_health(&healthy, schema);
2076        assert!(
2077            !healthy_report
2078                .issues
2079                .iter()
2080                .any(|i| i.field == "definition")
2081        );
2082    }
2083
2084    #[test]
2085    fn health_detects_missing_sections() {
2086        let schema = &type_by_name("spec").unwrap();
2087        let entity = make_entity("incomplete", false);
2088        let report = entity_health(&entity, schema);
2089        assert!(!report.issues.is_empty());
2090        assert!(report.score < 1.0);
2091    }
2092
2093    #[test]
2094    fn health_clean_entity() {
2095        let schema = &type_by_name("spec").unwrap();
2096        let entity = make_entity("complete", true);
2097        let report = entity_health(&entity, schema);
2098        // May still have issues for other required fields, but identity/purpose are covered
2099        let section_issues: Vec<_> = report
2100            .issues
2101            .iter()
2102            .filter(|i| i.field == "identity" || i.field == "purpose")
2103            .collect();
2104        assert!(section_issues.is_empty());
2105    }
2106
2107    #[test]
2108    fn health_summary_counts() {
2109        let mut store = Store::new();
2110        let e1 = make_entity("healthy", true);
2111        let e2 = make_entity("unhealthy", false);
2112        store.upsert(e1.id.clone(), e1);
2113        store.upsert(e2.id.clone(), e2);
2114
2115        let schema = &type_by_name("spec").unwrap();
2116        let summary = compute_health(&store, schema, &HashMap::new());
2117        assert_eq!(summary.orphan_count, 2); // No edges between them
2118        assert_eq!(summary.stub_count, 0);
2119    }
2120
2121    #[test]
2122    fn health_surfaces_invalid_rel_shape_on_existing_edges() {
2123        // software@0.1.0 declares `source_types: [actor]` on OWNS.
2124        // Seed a non-actor source with an outgoing OWNS edge — the
2125        // health scan must surface `INVALID_REL_SHAPE` in the
2126        // entity's issues so an agent running a sweep can identify
2127        // edges to clean up via `memstead_relate remove=true`.
2128        use crate::entity::Relationship;
2129        use memstead_schema::SchemaRegistry;
2130
2131        let registry = SchemaRegistry::builtin();
2132        let software = registry
2133            .get("software", &semver::Version::new(0, 2, 0))
2134            .expect("software schema ships as a builtin");
2135
2136        let mut store = Store::new();
2137        // Source entity is `spec`, not `actor`. Add an OWNS edge to
2138        // a target whose type doesn't matter for source-side shape.
2139        let mut bad = make_entity("bad-owns-source", true);
2140        bad.entity_type = "spec".into();
2141        bad.metadata
2142            .insert("level".into(), MetadataValue::String("M0".into()));
2143        bad.metadata
2144            .insert("stability".into(), MetadataValue::String("evolving".into()));
2145        bad.relationships.push(Relationship {
2146            rel_type: "OWNS".into(),
2147            target: EntityId::new("specs", "victim"),
2148            description: None,
2149        });
2150        let mut victim = make_entity("victim", true);
2151        victim.entity_type = "spec".into();
2152        store.upsert(bad.id.clone(), bad);
2153        store.upsert(victim.id.clone(), victim);
2154
2155        let mut mem_schemas = HashMap::new();
2156        mem_schemas.insert("specs".to_string(), software);
2157
2158        let schema = &type_by_name("spec").unwrap();
2159        let summary = compute_health(&store, schema, &mem_schemas);
2160        let report = summary
2161            .missing_fields
2162            .iter()
2163            .find(|r| r.id.as_ref() == "specs--bad-owns-source")
2164            .expect("shape-violating entity must surface");
2165        let issue = report
2166            .issues
2167            .iter()
2168            .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
2169            .expect("shape violation must produce an INVALID_REL_SHAPE issue");
2170        assert!(
2171            issue.message.contains("OWNS"),
2172            "issue must name the offending rel_type: {}",
2173            issue.message
2174        );
2175        assert!(
2176            issue.message.contains("spec"),
2177            "issue must name the actual source type: {}",
2178            issue.message
2179        );
2180        assert!(
2181            issue.message.contains("actor"),
2182            "issue must name the allowed source type: {}",
2183            issue.message
2184        );
2185        assert!(
2186            issue.message.contains("remove=true"),
2187            "issue must surface the recovery path: {}",
2188            issue.message
2189        );
2190    }
2191
2192    #[test]
2193    fn health_does_not_flag_shape_compliant_edges() {
2194        // Sanity counterpart: an actor source with OWNS edge satisfies
2195        // `source_types: [actor]` — no INVALID_REL_SHAPE issue surfaces.
2196        use crate::entity::Relationship;
2197        use memstead_schema::SchemaRegistry;
2198
2199        let registry = SchemaRegistry::builtin();
2200        let software = registry
2201            .get("software", &semver::Version::new(0, 2, 0))
2202            .expect("software schema ships as a builtin");
2203
2204        let mut store = Store::new();
2205        let mut owner = make_entity("owner", true);
2206        owner.entity_type = "actor".into();
2207        owner
2208            .metadata
2209            .insert("kind".into(), MetadataValue::String("team".into()));
2210        owner
2211            .metadata
2212            .insert("active".into(), MetadataValue::Bool(true));
2213        owner
2214            .metadata
2215            .insert("handle".into(), MetadataValue::String("owner".into()));
2216        owner.relationships.push(Relationship {
2217            rel_type: "OWNS".into(),
2218            target: EntityId::new("specs", "owned"),
2219            description: None,
2220        });
2221        let mut owned = make_entity("owned", true);
2222        owned.entity_type = "spec".into();
2223        store.upsert(owner.id.clone(), owner);
2224        store.upsert(owned.id.clone(), owned);
2225
2226        let mut mem_schemas = HashMap::new();
2227        mem_schemas.insert("specs".to_string(), software);
2228
2229        let schema = &type_by_name("spec").unwrap();
2230        let summary = compute_health(&store, schema, &mem_schemas);
2231        let shape_issue = summary
2232            .missing_fields
2233            .iter()
2234            .flat_map(|r| r.issues.iter())
2235            .find(|i| i.message.contains("INVALID_REL_SHAPE"));
2236        assert!(
2237            shape_issue.is_none(),
2238            "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
2239        );
2240    }
2241
2242    #[test]
2243    fn health_warns_on_undeclared_relationship_in_existing_entity() {
2244        use crate::entity::Relationship;
2245        use memstead_schema::Schema;
2246
2247        let mut store = Store::new();
2248        let mut entity = make_entity("with-bad-rel", true);
2249        // Author an edge using a name that does not exist in the default
2250        // schema's vocabulary. The load-side contract per decision 3 is
2251        // about unknown *types*; unknown *relationships* on an already-
2252        // loaded entity land in the soft health surface instead so an
2253        // agent running `memstead_health` after a schema edit sees the drift.
2254        entity.relationships.push(Relationship {
2255            rel_type: "CONJURES".into(),
2256            target: EntityId::new("specs", "unknown"),
2257            description: None,
2258        });
2259        store.upsert(entity.id.clone(), entity);
2260
2261        let mut mem_schemas = HashMap::new();
2262        mem_schemas.insert("specs".to_string(), Schema::builtin_default());
2263
2264        let schema = &type_by_name("spec").unwrap();
2265        let summary = compute_health(&store, schema, &mem_schemas);
2266        let report = summary
2267            .missing_fields
2268            .iter()
2269            .find(|r| r.id.as_ref() == "specs--with-bad-rel")
2270            .expect("entity must surface in missing_fields");
2271        let rel_issue = report
2272            .issues
2273            .iter()
2274            .find(|i| i.field == "relationships")
2275            .expect("undeclared relationship must produce an issue");
2276        assert!(
2277            rel_issue.message.contains("CONJURES"),
2278            "issue message must name the offending relationship: {}",
2279            rel_issue.message
2280        );
2281        assert!(
2282            rel_issue.message.contains("default@1.0.0"),
2283            "issue must name the schema pin: {}",
2284            rel_issue.message
2285        );
2286    }
2287
2288    // -------------------------------------------------------------------
2289    // Dangling wiki-link detection
2290    // -------------------------------------------------------------------
2291
2292    /// Build an entity with an arbitrary section body so the test can seed
2293    /// inline wiki-links at will. Mem defaults to `specs`.
2294    fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
2295        let mut entity = make_entity(name, true);
2296        entity.sections.insert(section_key.into(), body.to_string());
2297        entity
2298    }
2299
2300    #[test]
2301    fn dangling_link_detected_after_delete() {
2302        use crate::entity::store_builder::make_stub;
2303
2304        let mut store = Store::new();
2305        let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2306        store.upsert(a.id.clone(), a.clone());
2307
2308        // Seed b as a stub — the signal that its markdown file is gone
2309        // (post-delete, pre-recreate, or never authored).
2310        let b_id = EntityId::new("specs", "b");
2311        store.upsert(b_id.clone(), make_stub(b_id.clone()));
2312
2313        let dangling = super::collect_dangling_links(&store, None);
2314        assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
2315        let d = &dangling[0];
2316        assert_eq!(d.from, a.id);
2317        assert_eq!(d.target_id, b_id);
2318        assert_eq!(d.target_path, "b");
2319        assert_eq!(d.section.as_deref(), Some("purpose"));
2320    }
2321
2322    #[test]
2323    fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
2324        use crate::entity::Relationship;
2325        use crate::entity::store_builder::make_stub;
2326
2327        let mut store = Store::new();
2328        // A has NO inline link in its body — only an explicit relationship
2329        // edge pointing at a stub.
2330        let mut a = make_entity("a", true);
2331        let b_id = EntityId::new("specs", "b");
2332        a.relationships.push(Relationship {
2333            rel_type: "REFERENCES".into(),
2334            target: b_id.clone(),
2335            description: None,
2336        });
2337        store.upsert(a.id.clone(), a);
2338        store.upsert(b_id.clone(), make_stub(b_id));
2339
2340        let dangling = super::collect_dangling_links(&store, None);
2341        assert!(
2342            dangling.is_empty(),
2343            "explicit relationships to stubs are valid by design \
2344             (stubs are first-class placeholders); only inline-body \
2345             wiki-links to stubs must surface"
2346        );
2347    }
2348
2349    #[test]
2350    fn dangling_link_does_not_flag_real_reference() {
2351        use crate::entity::Relationship;
2352
2353        let mut store = Store::new();
2354        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2355        // Backing relation makes the body link a valid alias.
2356        a.relationships.push(Relationship {
2357            rel_type: "REFERENCES".into(),
2358            target: EntityId::new("specs", "b"),
2359            description: None,
2360        });
2361        let b = make_entity("b", true);
2362        store.upsert(a.id.clone(), a);
2363        store.upsert(b.id.clone(), b);
2364
2365        let dangling = super::collect_dangling_links(&store, None);
2366        assert!(
2367            dangling.is_empty(),
2368            "real reference backed by relation — not dangling, not alias-orphan"
2369        );
2370    }
2371
2372    /// F12: a `## Relationships` row pointing at a fully-absent target
2373    /// (out-of-band file edit, mem-delete corruption) must surface.
2374    /// The scan covers both axes; relationship-table danglers ship
2375    /// `section: None` to mark the source axis.
2376    #[test]
2377    fn dangling_link_relationship_section_target_absent() {
2378        use crate::entity::Relationship;
2379
2380        let mut store = Store::new();
2381        let mut a = make_entity("a", true);
2382        // Note: NO stub in the store for `gone` — out-of-band edit
2383        // removed the stub but left the relationship row.
2384        a.relationships.push(Relationship {
2385            rel_type: "DEPENDS_ON".into(),
2386            target: EntityId::new("specs", "gone"),
2387            description: None,
2388        });
2389        store.upsert(a.id.clone(), a.clone());
2390
2391        let dangling = super::collect_dangling_links(&store, None);
2392        assert_eq!(
2393            dangling.len(),
2394            1,
2395            "exactly one relationship-section dangler"
2396        );
2397        let d = &dangling[0];
2398        assert_eq!(d.from, a.id);
2399        assert_eq!(d.target_id, EntityId::new("specs", "gone"));
2400        assert!(
2401            d.section.is_none(),
2402            "relationship-section danglers ship `section: None`, got {:?}",
2403            d.section
2404        );
2405    }
2406
2407    /// Relationship rows pointing at stubs are NOT flagged. Auto-stub
2408    /// is the alias machinery's forward-reference mechanism; flagging
2409    /// stubs would conflate the "engine-managed placeholder" case with
2410    /// corruption.
2411    #[test]
2412    fn dangling_link_relationship_section_stub_target_not_flagged() {
2413        use crate::entity::Relationship;
2414        use crate::entity::store_builder::make_stub;
2415
2416        let mut store = Store::new();
2417        let mut a = make_entity("a", true);
2418        let b_id = EntityId::new("specs", "b");
2419        a.relationships.push(Relationship {
2420            rel_type: "DEPENDS_ON".into(),
2421            target: b_id.clone(),
2422            description: None,
2423        });
2424        store.upsert(a.id.clone(), a);
2425        store.upsert(b_id.clone(), make_stub(b_id));
2426
2427        let dangling = super::collect_dangling_links(&store, None);
2428        assert!(
2429            dangling.is_empty(),
2430            "relationship targets that resolve to stubs are forward-references, not corruption"
2431        );
2432    }
2433
2434    /// When both the body and the relationship section point at the
2435    /// same fully-absent target, the dangler dedupes to a single entry
2436    /// on whichever axis fired first (body-scan runs
2437    /// before relationship-scan in the implementation; the body axis
2438    /// wins). Stub-shaped duplicates are not possible because the
2439    /// relationship-section scan skips stubs.
2440    #[test]
2441    fn dangling_link_dedups_across_body_and_relations() {
2442        use crate::entity::Relationship;
2443        use crate::entity::store_builder::make_stub;
2444
2445        let mut store = Store::new();
2446        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2447        let b_id = EntityId::new("specs", "b");
2448        a.relationships.push(Relationship {
2449            rel_type: "REFERENCES".into(),
2450            target: b_id.clone(),
2451            description: None,
2452        });
2453        store.upsert(a.id.clone(), a.clone());
2454        store.upsert(b_id.clone(), make_stub(b_id.clone()));
2455
2456        let dangling = super::collect_dangling_links(&store, None);
2457        assert_eq!(
2458            dangling.len(),
2459            1,
2460            "body + relations both pointing at the same stub should dedup"
2461        );
2462        // Body scan fires first; the surviving entry carries
2463        // `section: Some(_)`.
2464        assert!(dangling[0].section.is_some(), "body axis wins the dedup");
2465    }
2466
2467    #[test]
2468    fn dangling_links_scope_to_mem_filter() {
2469        use crate::entity::store_builder::make_stub;
2470
2471        let mut store = Store::new();
2472
2473        // specs--a with body [[gone]] → dangling in specs.
2474        let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
2475        store.upsert(a.id.clone(), a);
2476        let gone_specs = EntityId::new("specs", "gone");
2477        store.upsert(gone_specs.clone(), make_stub(gone_specs));
2478
2479        // web--x with body [[gone]] → dangling in web (different stub).
2480        let mut x = make_entity("x", true);
2481        x.id = EntityId::new("web", "x");
2482        x.mem = "web".into();
2483        x.file_path = "x.md".into();
2484        x.sections
2485            .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
2486        store.upsert(x.id.clone(), x);
2487        let gone_web = EntityId::new("web", "gone");
2488        store.upsert(gone_web.clone(), make_stub(gone_web));
2489
2490        let all = super::collect_dangling_links(&store, None);
2491        assert_eq!(all.len(), 2);
2492
2493        let specs_only = super::collect_dangling_links(&store, Some("specs"));
2494        assert_eq!(specs_only.len(), 1);
2495        assert_eq!(specs_only[0].from.mem(), "specs");
2496
2497        let web_only = super::collect_dangling_links(&store, Some("web"));
2498        assert_eq!(web_only.len(), 1);
2499        assert_eq!(web_only[0].from.mem(), "web");
2500    }
2501
2502    #[test]
2503    fn parse_iso_date() {
2504        let days = parse_iso_to_days("2026-04-12").unwrap();
2505        assert!(days > 0);
2506
2507        let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
2508        assert_eq!(days, days_with_time);
2509    }
2510
2511    #[test]
2512    fn ymd_roundtrip() {
2513        // 2026-01-01
2514        let days = ymd_to_days(2026, 1, 1);
2515        assert!(days > 20000); // sanity check
2516    }
2517
2518    // ---------------------------------------------------------------------
2519    // collect_tag_distribution — #18
2520    // ---------------------------------------------------------------------
2521
2522    fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
2523        let mut e = make_entity(name, true);
2524        e.id = EntityId::new(mem, name);
2525        e.mem = mem.into();
2526        e.entity_type = entity_type.into();
2527        e.metadata
2528            .insert("tags".into(), MetadataValue::String(tags.into()));
2529        e
2530    }
2531
2532    fn make_entity_no_tags(name: &str) -> Entity {
2533        make_entity(name, true)
2534    }
2535
2536    #[test]
2537    fn tag_distribution_aggregates_across_entities() {
2538        let mut store = Store::new();
2539        let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
2540        let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
2541        let c = make_entity_with_tags("c", "specs", "spec", "plan");
2542        store.upsert(a.id.clone(), a);
2543        store.upsert(b.id.clone(), b);
2544        store.upsert(c.id.clone(), c);
2545
2546        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2547        assert_eq!(dist.len(), 2);
2548        assert_eq!(dist[0].tag, "plan");
2549        assert_eq!(dist[0].count, 3);
2550        assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
2551        assert_eq!(dist[1].tag, "decision");
2552        assert_eq!(dist[1].count, 2);
2553        assert_eq!(untagged.total, 0);
2554    }
2555
2556    #[test]
2557    fn tag_distribution_case_sensitive() {
2558        let mut store = Store::new();
2559        let a = make_entity_with_tags("a", "specs", "spec", "Decision");
2560        let b = make_entity_with_tags("b", "specs", "spec", "decision");
2561        store.upsert(a.id.clone(), a);
2562        store.upsert(b.id.clone(), b);
2563
2564        let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
2565        assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
2566        let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
2567        assert!(tags.contains("decision"));
2568        assert!(tags.contains("Decision"));
2569
2570        // Drift sidecar surfaces the collision.
2571        assert_eq!(folded.len(), 1);
2572        assert_eq!(folded[0].canonical, "decision");
2573        assert_eq!(folded[0].total, 2);
2574        assert_eq!(folded[0].variants.len(), 2);
2575    }
2576
2577    #[test]
2578    fn untagged_entities_counts_missing_and_empty() {
2579        let mut store = Store::new();
2580        let a = make_entity_no_tags("a"); // no `tags` metadata
2581        let b = make_entity_with_tags("b", "specs", "spec", "");
2582        let c = make_entity_with_tags("c", "specs", "spec", " , , ");
2583        store.upsert(a.id.clone(), a);
2584        store.upsert(b.id.clone(), b);
2585        store.upsert(c.id.clone(), c);
2586
2587        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2588        assert!(dist.is_empty(), "no effective tags → empty distribution");
2589        assert_eq!(untagged.total, 3);
2590        assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
2591    }
2592
2593    #[test]
2594    fn tag_distribution_respects_mem_filter() {
2595        let mut store = Store::new();
2596        let a = make_entity_with_tags("a", "specs", "spec", "decision");
2597        let b = make_entity_with_tags("b", "memos", "memo", "observation");
2598        let c = make_entity_no_tags("c");
2599        store.upsert(a.id.clone(), a);
2600        store.upsert(b.id.clone(), b);
2601        store.upsert(c.id.clone(), c);
2602
2603        let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
2604        assert_eq!(dist.len(), 1);
2605        assert_eq!(dist[0].tag, "observation");
2606        assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
2607    }
2608
2609    #[test]
2610    fn tag_distribution_respects_limit() {
2611        let mut store = Store::new();
2612        for (name, tag) in [
2613            ("a", "t-alpha"),
2614            ("b", "t-beta"),
2615            ("c", "t-gamma"),
2616            ("d", "t-delta"),
2617            ("e", "t-epsilon"),
2618        ] {
2619            let e = make_entity_with_tags(name, "specs", "spec", tag);
2620            store.upsert(e.id.clone(), e);
2621        }
2622
2623        let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
2624        assert_eq!(dist.len(), 3);
2625        // Every tag appears once → ties across all 5; deterministic tie-break is
2626        // lex ascending: alpha, beta, delta (first 3 sorted).
2627        assert_eq!(dist[0].tag, "t-alpha");
2628        assert_eq!(dist[1].tag, "t-beta");
2629        assert_eq!(dist[2].tag, "t-delta");
2630    }
2631
2632    // ----------------------------------------------------------------------
2633    // required_outgoing health collector
2634    // ----------------------------------------------------------------------
2635
2636    /// Build a minimal schema fixture pinning `decision` with two
2637    /// `required_outgoing` blocks (CHOSEN + REJECTED), `note` with none.
2638    fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
2639        let manifest = r#"name: tests-ro-health
2640version: 0.1.0
2641description: required_outgoing health test schema
2642when_to_use: tests
2643types:
2644  - decision
2645  - note
2646relationships:
2647  mode: strict
2648  definitions:
2649    - name: PART_OF
2650      description: Hier
2651      default_weight: 3.0
2652      acyclic: true
2653    - name: CHOSEN
2654      description: ch
2655      default_weight: 3.0
2656    - name: REJECTED
2657      description: rj
2658      default_weight: 2.0
2659    - name: REFERENCES
2660      description: ref
2661      default_weight: 0.5
2662    - name: _default
2663      description: Fallback
2664      default_weight: 1.0
2665community:
2666  resolution: 1.0
2667  seed: 42
2668"#;
2669        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";
2670        let decision_yaml = format!(
2671            "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",
2672        );
2673        let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
2674        std::sync::Arc::new(
2675            memstead_schema::load_schema_from_memory(
2676                manifest,
2677                &[
2678                    ("decision".to_string(), decision_yaml),
2679                    ("note".to_string(), note_yaml),
2680                ],
2681            )
2682            .expect("ro fixture schema must parse"),
2683        )
2684    }
2685
2686    fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
2687        use crate::entity::MetadataValue;
2688        let mut metadata = IndexMap::new();
2689        metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
2690        let mut sections = IndexMap::new();
2691        sections.insert("body".into(), "Body.".into());
2692        crate::entity::Entity {
2693            id: EntityId::new(mem, slug),
2694            title: slug.to_string(),
2695            entity_type: entity_type.into(),
2696            mem: mem.into(),
2697            file_path: format!("{slug}.md"),
2698            metadata,
2699            sections,
2700            relationships: Vec::new(),
2701            content_hash: String::new(),
2702            stub: false,
2703            stub_kind: None,
2704            heading_spans: std::collections::HashMap::new(),
2705            raw_section_headings: Vec::new(),
2706        }
2707    }
2708
2709    #[test]
2710    fn missing_required_outgoing_collects_violators_only() {
2711        let schema = required_outgoing_fixture_schema();
2712        let mut store = Store::new();
2713        // Two decisions: one without any edges (violates 2 blocks), one
2714        // with both edges satisfied. One note (no requirement).
2715        let mut violator = make_typed_entity("plan", "stalled", "decision");
2716        let mut satisfied = make_typed_entity("plan", "wired", "decision");
2717        let opt_a = make_typed_entity("plan", "a", "note");
2718        let opt_b = make_typed_entity("plan", "b", "note");
2719        let happy_note = make_typed_entity("plan", "side", "note");
2720        satisfied.relationships.push(crate::entity::Relationship {
2721            rel_type: "CHOSEN".into(),
2722            target: opt_a.id.clone(),
2723            description: None,
2724        });
2725        satisfied.relationships.push(crate::entity::Relationship {
2726            rel_type: "REJECTED".into(),
2727            target: opt_b.id.clone(),
2728            description: None,
2729        });
2730        for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
2731            store.upsert(e.id.clone(), e);
2732        }
2733
2734        let mut mem_schemas = HashMap::new();
2735        mem_schemas.insert("plan".to_string(), schema);
2736
2737        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
2738        assert_eq!(
2739            reports.len(),
2740            1,
2741            "exactly one violator (the empty decision); got {reports:?}"
2742        );
2743        let r = &reports[0];
2744        assert_eq!(r.id, violator.id);
2745        assert_eq!(r.entity_type, "decision");
2746        assert_eq!(r.mem, "plan");
2747        assert_eq!(r.missing.len(), 2);
2748        let names: Vec<&str> = r
2749            .missing
2750            .iter()
2751            .flat_map(|b| b.relationships.iter().map(String::as_str))
2752            .collect();
2753        assert!(names.contains(&"CHOSEN"));
2754        assert!(names.contains(&"REJECTED"));
2755
2756        // mark warning still doesn't propagate when violator is removed.
2757        violator.relationships.push(crate::entity::Relationship {
2758            rel_type: "CHOSEN".into(),
2759            target: EntityId::new("plan", "x"),
2760            description: None,
2761        });
2762    }
2763
2764    #[test]
2765    fn missing_required_outgoing_respects_mem_filter() {
2766        // Plan: "a write to mem A doesn't surface mem B's violations
2767        // in memstead_health mem=A; mem-scoped aggregation is correct."
2768        let schema = required_outgoing_fixture_schema();
2769        let mut store = Store::new();
2770        let v_a = make_typed_entity("alpha", "stalled", "decision");
2771        let v_b = make_typed_entity("beta", "stalled", "decision");
2772        store.upsert(v_a.id.clone(), v_a);
2773        store.upsert(v_b.id.clone(), v_b.clone());
2774
2775        let mut mem_schemas = HashMap::new();
2776        mem_schemas.insert("alpha".to_string(), schema.clone());
2777        mem_schemas.insert("beta".to_string(), schema);
2778
2779        let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
2780        assert_eq!(alpha_only.len(), 1);
2781        assert_eq!(alpha_only[0].mem, "alpha");
2782
2783        let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
2784        assert_eq!(both.len(), 2);
2785    }
2786
2787    #[test]
2788    fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
2789        // Stubs have no entity_type; unschemaed mems can't be evaluated
2790        // — both must be silently skipped.
2791        let schema = required_outgoing_fixture_schema();
2792        let mut store = Store::new();
2793        let mut stub = make_typed_entity("plan", "ghost", "");
2794        stub.stub = true;
2795        stub.entity_type = String::new();
2796        let other = make_typed_entity("uncharted", "lonely", "decision");
2797        store.upsert(stub.id.clone(), stub);
2798        store.upsert(other.id.clone(), other);
2799
2800        let mut mem_schemas = HashMap::new();
2801        mem_schemas.insert("plan".to_string(), schema);
2802
2803        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
2804        assert!(
2805            reports.is_empty(),
2806            "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
2807        );
2808    }
2809}