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    "signals",
43    "labelling",
44    "conformance",
45    "integrity",
46    "config",
47    "anchors",
48    "friction",
49    "open_questions",
50    "stale_derivations",
51    "checks",
52];
53
54/// The `include=["anchors"]` axis — per-mem counts of the four
55/// standalone-verification states, computed through the same
56/// per-anchor mechanism `verify-anchors` and the binding verify use.
57/// Shared by the full composer, the CLI health command, and the lean
58/// MCP server so the axis cannot drift between surfaces.
59/// The `include=["checks"]` axis (agent-trust plan 14): per mem,
60/// counts of the four derived check states plus the author≠checker
61/// independence gate over ok-checked entities. Transport is not
62/// identity: the recorded `(actor, client)` pair names the SURFACE a
63/// record arrived through (Agent|Cli|App plus a client binary), not
64/// who acted — the same actor reaches the engine over several
65/// surfaces, and one surface serves many actors across sessions. So
66/// until a caller-declared identity exists (the caller-identity
67/// follow-up, plan 15), NO author/checker comparison can be
68/// established and every ok-checked entity with recorded provenance
69/// lands in `unconfirmable`. `self_checked` and
70/// `confirmed_independent` remain as categories — their empty lists
71/// are a statement — but stay unreachable until real identity
72/// exists: a same pair does NOT establish the same actor, and
73/// different pairs do NOT establish different actors. Derivation
74/// only: nothing here is stamped, and a workspace without a check
75/// ledger serves all-never-checked. Identity lists are capped at
76/// [`OPEN_QUESTIONS_ITEM_CAP`] with an explicit `more` count.
77pub fn health_checks_axis(
78    engine: &crate::engine::Engine,
79    mem_filter: Option<&str>,
80) -> serde_json::Value {
81    let cap = OPEN_QUESTIONS_ITEM_CAP;
82    let capped = |mut items: Vec<String>| -> serde_json::Value {
83        items.sort();
84        let count = items.len();
85        let more = count.saturating_sub(cap);
86        items.truncate(cap);
87        let mut o = serde_json::Map::new();
88        o.insert("count".into(), serde_json::json!(count));
89        o.insert("items".into(), serde_json::json!(items));
90        if more > 0 {
91            o.insert("more".into(), serde_json::json!(more));
92        }
93        serde_json::Value::Object(o)
94    };
95
96    let ledger = engine
97        .workspace_root()
98        .map(crate::check::CheckLedger::for_workspace);
99    // Newest record per entity, one ledger read for the whole axis.
100    let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
101        std::collections::BTreeMap::new();
102    if let Some(l) = &ledger {
103        for rec in l.all() {
104            latest.insert(rec.entity.clone(), rec);
105        }
106    }
107
108    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
109    mems.sort();
110    let mut out = serde_json::Map::new();
111    for mem in mems {
112        if let Some(f) = mem_filter
113            && f != mem
114        {
115            continue;
116        }
117        let mut counts = std::collections::BTreeMap::from([
118            ("never_checked", 0usize),
119            ("checked_ok", 0usize),
120            ("check_failed", 0usize),
121            ("check_stale", 0usize),
122        ]);
123        // Unreachable until a caller-declared identity exists (the
124        // caller-identity follow-up, plan 15) — kept so the wire shape
125        // states the categories explicitly rather than dropping them.
126        let self_checked: Vec<String> = Vec::new();
127        let confirmed_independent: Vec<String> = Vec::new();
128        let mut unconfirmable: Vec<String> = Vec::new();
129        for e in engine.store().all_entities().filter(|e| e.mem == mem) {
130            let id = e.id.0.clone();
131            let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
132            *counts.entry(state.as_str()).or_insert(0) += 1;
133            if state != crate::check::CheckState::CheckedOk {
134                continue;
135            }
136            // Transport is not identity. The recorded (actor, client)
137            // pair names the surface each record arrived through, not
138            // who acted — a same pair does not establish the same
139            // actor (CLI-authored + CLI-checked across sessions/days
140            // is the norm, not conviction), and different pairs do
141            // not establish different actors (one actor reaches the
142            // engine over several surfaces). Without a
143            // caller-declared identity no author/checker comparison
144            // can be established, so every ok-checked entity lands
145            // here — never a false acquittal via transport.
146            unconfirmable.push(id);
147        }
148        let mut m = serde_json::Map::new();
149        for (k, v) in counts {
150            m.insert(k.to_string(), serde_json::json!(v));
151        }
152        m.insert(
153            "independence".into(),
154            serde_json::json!({
155                "self_checked": capped(self_checked),
156                "confirmed_independent": capped(confirmed_independent),
157                "unconfirmable": capped(unconfirmable),
158            }),
159        );
160        out.insert(mem, serde_json::Value::Object(m));
161    }
162    serde_json::Value::Object(out)
163}
164
165/// One derivation-staleness finding (agent-trust plan 12): an
166/// explicit edge on a derivation-declared rel-type whose baseline
167/// differs from the target's current hash (`stale`), or that has no
168/// recorded baseline at all (`unbaselined`). Fresh edges are never
169/// reported.
170#[derive(Debug, Clone, serde::Serialize)]
171pub struct DerivationFinding {
172    pub source: crate::entity::EntityId,
173    pub rel_type: String,
174    pub target: crate::entity::EntityId,
175    /// `"stale"` or `"unbaselined"` — never fabricated as fresh.
176    pub state: String,
177    /// The recorded baseline hash (`None` for unbaselined edges).
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub baseline: Option<String>,
180    /// The target's current content hash ("" for an absent target).
181    pub current: String,
182}
183
184/// The `include=["stale_derivations"]` axis: per-mem findings from
185/// [`crate::engine::Engine::derivation_report`], shared by the CLI
186/// and both MCP flavours. A mem whose schema declares no derivation
187/// rel-types contributes an empty list — never an error.
188pub fn health_stale_derivations_axis(
189    engine: &crate::engine::Engine,
190    mem_filter: Option<&str>,
191) -> serde_json::Value {
192    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
193    mems.sort();
194    let mut out = serde_json::Map::new();
195    for mem in mems {
196        if let Some(f) = mem_filter
197            && f != mem
198        {
199            continue;
200        }
201        let findings = engine.derivation_report(&mem).unwrap_or_default();
202        out.insert(
203            mem,
204            serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
205        );
206    }
207    serde_json::Value::Object(out)
208}
209
210/// Per-kind item cap for the `open_questions` axis — the axis is an
211/// agent worklist, not a dump. Stated in the output (`_item_cap`);
212/// truncation is always explicit via each list's `more` count.
213pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
214
215/// The `include=["open_questions"]` axis (agent-trust plan 11): per
216/// mem, a composed worklist of what the holding does not know — its
217/// stubs, its never-confirmed (`recheck`) and `unresolvable` anchors,
218/// its unsatisfied constraints, its dangling links, and, when a
219/// paired process mem is resolvable for the destination, that
220/// process mem's open entries. Negative findings ride under the
221/// DISTINCT `already_searched` heading — their operational meaning is
222/// "done, keep off", never todo.
223///
224/// Composition only: every signal is read from the same source its
225/// own axis serves (store stub flags, `verify_mem_anchors`,
226/// `constraint_findings`, `collect_dangling_links`, the pipeline
227/// store), so this axis can never disagree with the per-signal axes.
228/// Best-effort on the process leg: an unreadable pipeline store means
229/// no process sections, never an axis failure.
230pub fn health_open_questions_axis(
231    engine: &crate::engine::Engine,
232    mem_filter: Option<&str>,
233) -> serde_json::Value {
234    let cap = OPEN_QUESTIONS_ITEM_CAP;
235    let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
236        let count = items.len();
237        let more = count.saturating_sub(cap);
238        items.truncate(cap);
239        let mut o = serde_json::Map::new();
240        o.insert("count".into(), serde_json::json!(count));
241        o.insert("items".into(), serde_json::Value::Array(items));
242        if more > 0 {
243            o.insert("more".into(), serde_json::json!(more));
244        }
245        serde_json::Value::Object(o)
246    };
247
248    // Bindings by destination mem — the pairing plan 14 will make
249    // declarative; until then the ingest-name convention (process mem
250    // named after the binding) is the resolution mechanism.
251    let bindings: Vec<(String, String)> = engine
252        .workspace_root()
253        .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
254        .map(|c| {
255            c.bindings
256                .iter()
257                .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
258                .collect()
259        })
260        .unwrap_or_default();
261    let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
262
263    let mut mems: Vec<String> = mounted.clone();
264    mems.sort();
265    let mut out = serde_json::Map::new();
266    for mem in &mems {
267        if let Some(f) = mem_filter
268            && f != mem
269        {
270            continue;
271        }
272
273        // Stubs — same source as the stubs axis (store stub flag).
274        let stubs = capped(
275            engine
276                .store()
277                .all_entities()
278                .filter(|e| e.stub && e.id.mem() == mem)
279                .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
280                .collect(),
281        );
282
283        // Anchors — same per-anchor mechanism as the anchors axis;
284        // only the never-confirmed and unreachable states are holes.
285        let (mut recheck, mut unresolvable) = (Vec::new(), Vec::new());
286        if let Ok(report) = engine.verify_mem_anchors(mem) {
287            for a in &report.anchors {
288                let item = serde_json::json!({
289                    "kind": format!("anchor_{}", a.state),
290                    "id": a.entity_id,
291                    "artifact": a.artifact,
292                });
293                match a.state.as_str() {
294                    "recheck" => recheck.push(item),
295                    "unresolvable" => unresolvable.push(item),
296                    _ => {}
297                }
298            }
299        }
300
301        // Unsatisfied constraints — same collector as the
302        // constraints axis.
303        let constraints = capped(
304            engine
305                .constraint_findings(Some(mem))
306                .iter()
307                .map(|r| {
308                    serde_json::json!({
309                        "kind": "unsatisfied_constraint",
310                        "id": r.id.to_string(),
311                        "violations": r.violations.len(),
312                    })
313                })
314                .collect(),
315        );
316
317        // Dangling links — same collector as the overview include.
318        let dangling = capped(
319            collect_dangling_links(engine.store(), Some(mem))
320                .iter()
321                .map(|d| {
322                    serde_json::json!({
323                        "kind": "dangling_link",
324                        "id": d.from.to_string(),
325                        "target": d.target_id.to_string(),
326                    })
327                })
328                .collect(),
329        );
330
331        // Paired process mems: open entries are work; negative
332        // findings are the opposite — already searched, keep off.
333        // Pairing runs through the ONE resolution function the brief
334        // renderer uses (agent-trust plan 14): a destination's
335        // declaration wins regardless of naming — and pairs even
336        // with no binding at all (the process tier stands without
337        // one); the binding-name convention remains the fallback. A
338        // declaration naming an unmounted mem is a typed finding,
339        // never a silent fallback.
340        let mut process = Vec::new();
341        let mem_bindings: Vec<&String> = bindings
342            .iter()
343            .filter(|(d, _)| d == mem)
344            .map(|(_, b)| b)
345            .collect();
346        let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
347            Vec::new();
348        if mem_bindings.is_empty() {
349            let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
350            if r.declared {
351                resolutions.push((None, r));
352            }
353        } else {
354            for binding in &mem_bindings {
355                resolutions.push((
356                    Some((*binding).clone()),
357                    crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
358                ));
359            }
360        }
361        for (binding, r) in resolutions {
362            if r.mounted {
363                let mut open = Vec::new();
364                let mut searched = Vec::new();
365                for e in engine
366                    .store()
367                    .all_entities()
368                    .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
369                {
370                    let item = serde_json::json!({
371                        "kind": e.entity_type,
372                        "id": e.id.to_string(),
373                        "title": e.title,
374                    });
375                    if e.entity_type == "negative_finding" {
376                        searched.push(item);
377                    } else {
378                        open.push(item);
379                    }
380                }
381                process.push(serde_json::json!({
382                    "binding": binding,
383                    "process_mem": r.mem,
384                    "declared": r.declared,
385                    "resolvable": true,
386                    "open_entries": capped(open),
387                    "already_searched": capped(searched),
388                }));
389            } else if r.declared {
390                process.push(serde_json::json!({
391                    "binding": binding,
392                    "process_mem": r.mem,
393                    "declared": true,
394                    "resolvable": false,
395                    "finding": "DECLARED_PROCESS_MEM_MISSING",
396                }));
397            } else {
398                process.push(serde_json::json!({
399                    "binding": binding,
400                    "resolvable": false,
401                }));
402            }
403        }
404
405        let total_open = stubs["count"].as_u64().unwrap_or(0)
406            + recheck.len() as u64
407            + unresolvable.len() as u64
408            + constraints["count"].as_u64().unwrap_or(0)
409            + dangling["count"].as_u64().unwrap_or(0)
410            + process
411                .iter()
412                .filter_map(|p| p["open_entries"]["count"].as_u64())
413                .sum::<u64>();
414
415        let mut entry = serde_json::Map::new();
416        entry.insert("stubs".into(), stubs);
417        entry.insert("anchors_recheck".into(), capped(recheck));
418        entry.insert("anchors_unresolvable".into(), capped(unresolvable));
419        entry.insert("unsatisfied_constraints".into(), constraints);
420        entry.insert("dangling_links".into(), dangling);
421        if !process.is_empty() {
422            entry.insert("process".into(), serde_json::Value::Array(process));
423        } else {
424            // No binding targets this mem: the absence of a process
425            // section is stated, never silent.
426            entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
427        }
428        entry.insert("total_open".into(), serde_json::json!(total_open));
429        out.insert(mem.clone(), serde_json::Value::Object(entry));
430    }
431    let mut top = serde_json::Map::new();
432    top.insert("_item_cap".into(), serde_json::json!(cap));
433    for (k, v) in out {
434        top.insert(k, v);
435    }
436    serde_json::Value::Object(top)
437}
438
439pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
440    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
441    mems.sort();
442    let mut out = serde_json::Map::new();
443    for mem in mems {
444        let Ok(report) = engine.verify_mem_anchors(&mem) else {
445            continue;
446        };
447        out.insert(
448            mem,
449            serde_json::json!({
450                "resolved": report.resolved,
451                "drifted": report.drifted,
452                "recheck": report.recheck,
453                "unresolvable": report.unresolvable,
454            }),
455        );
456    }
457    serde_json::Value::Object(out)
458}
459
460/// Compute health reports for all entities in the store.
461///
462/// `mem_schemas` maps mem name → `Arc<Schema>`. Entities whose mem
463/// is missing from this map fall back to the builtin `default` schema
464/// relationship vocabulary (keeps legacy fixtures green; real production
465/// paths always register a mem schema).
466///
467/// `mem_filter` scopes the per-entity scans and the structural counts
468/// (orphans, stubs, leaf population) to one mem; `None` is the classic
469/// engine-wide sweep. Validating that the name exists is the caller's
470/// job ([`crate::Engine::health_scoped`] refuses `UNKNOWN_MEM` before
471/// reaching here) — an unknown name at this level just scans nothing.
472pub fn compute_health(
473    store: &Store,
474    default_schema: &TypeDefinition,
475    mem_schemas: &HashMap<String, Arc<Schema>>,
476    mem_filter: Option<&str>,
477) -> HealthSummary {
478    let mut missing_fields = Vec::new();
479    let mut stale_entities = Vec::new();
480
481    let today_days = days_since_epoch();
482
483    let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
484
485    for entity in store.all_entities() {
486        if entity.stub || !in_scope(&entity.mem) {
487            continue;
488        }
489
490        // Resolve the entity's `TypeDefinition` against the entity's
491        // own mem's schema first. `type_by_name` only knows the
492        // builtin `default` schema; falling through to it on a mem
493        // pinned to a non-default schema (e.g. `planning@0.1.0`) would
494        // silently use `default_schema` (effectively `spec`) for every
495        // entity and report `spec`'s `health_required_fields` —
496        // `[identity, purpose]` — even on entities of types like
497        // `goal` / `option` / `decision`.
498        let resolved = mem_schemas
499            .get(entity.mem.as_str())
500            .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
501            .or_else(|| type_by_name(&entity.entity_type));
502        let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
503        let mut issues = Vec::new();
504
505        // Check health_required_fields
506        for field in &schema.health_required_fields {
507            // Check if it's a section or metadata field
508            if schema.section(field).is_some() {
509                // It's a section. When the content is present in the
510                // file but sits under a non-deriving heading, report
511                // the distinct mismatch finding instead of "missing" —
512                // the two conditions must never collapse.
513                let content = entity.sections.get(field.as_str());
514                if content.is_none_or(|c| c.trim().is_empty()) {
515                    if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
516                        issues.push(issue);
517                    } else {
518                        issues.push(HealthIssue {
519                            field: field.clone(),
520                            code: super::HealthIssueCode::Missing,
521                            message: format!("required section '{field}' is empty"),
522                        });
523                    }
524                }
525            } else {
526                // It's a metadata field. Treat missing AND empty /
527                // whitespace-only values as gaps so the scan matches
528                // the section branch's `trim().is_empty()` semantics
529                // — an empty `MetadataValue::String("")` is just as
530                // unhelpful to an agent as an absent key.
531                let value = entity.metadata.get(field.as_str());
532                let is_empty = match value {
533                    None => true,
534                    Some(v) => v.to_frontmatter_string().trim().is_empty(),
535                };
536                if is_empty {
537                    issues.push(HealthIssue {
538                        field: field.clone(),
539                        code: super::HealthIssueCode::Missing,
540                        message: format!("required field '{field}' is missing"),
541                    });
542                }
543            }
544        }
545
546        // The heading-mismatch condition is drift worth surfacing on
547        // every declared section, not only the health-required ones.
548        for s in schema.sections.iter().filter(|s| !s.catch_all) {
549            if schema.health_required_fields.contains(&s.key) {
550                continue; // already handled above
551            }
552            let content = entity.sections.get(s.key.as_str());
553            if content.is_none_or(|c| c.trim().is_empty())
554                && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
555            {
556                issues.push(issue);
557            }
558        }
559
560        // Undeclared-relationship warning. Scan the entity's
561        // relationship list against the mem's schema vocabulary; every
562        // unknown name becomes a soft HealthIssue (same severity as a
563        // missing section) so agents running a health sweep after a
564        // schema version bump see drift without a crashed load.
565        //
566        // Shape-violation scan: when the mem's schema declares
567        // `source_types` / `target_types` on a relationship and an
568        // existing edge violates the shape, surface as a soft
569        // HealthIssue. The relate-add path enforces shape going
570        // forward; this scan catches edges authored before the
571        // constraint landed (or via inline `relations:` on
572        // memstead_create, which does not yet shape-check). The
573        // remove-path on `memstead_relate` skips shape validation so the
574        // cleanup is always reachable.
575        if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
576            let mut seen_unknown = std::collections::HashSet::new();
577            for rel in &entity.relationships {
578                if !mem_schema.relationship_known(&rel.rel_type) {
579                    if seen_unknown.insert(rel.rel_type.clone()) {
580                        let suggestion = mem_schema
581                            .suggest_relationship(&rel.rel_type)
582                            .map(|s| format!(" Did you mean '{s}'?"))
583                            .unwrap_or_default();
584                        let (schema_name, schema_version) = mem_schema.id();
585                        issues.push(HealthIssue {
586                            field: "relationships".to_string(),
587                            code: super::HealthIssueCode::UndeclaredRelationship,
588                            message: format!(
589                                "relationship '{}' is not declared in schema \
590                                 '{schema_name}@{schema_version}'.{suggestion}",
591                                rel.rel_type
592                            ),
593                        });
594                    }
595                    continue;
596                }
597
598                let target_type = store
599                    .get(&rel.target)
600                    .map(|t| t.entity_type.clone())
601                    .filter(|t| !t.is_empty());
602                if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
603                    rel_type,
604                    from_type,
605                    to_type,
606                    allowed_source_types,
607                    allowed_target_types,
608                    ..
609                }) = crate::runtime_validator::validate_rel_shape(
610                    &rel.rel_type,
611                    entity.entity_type.as_str(),
612                    target_type.as_deref(),
613                    mem_schema.as_ref(),
614                ) {
615                    let allowed_src = if allowed_source_types.is_empty() {
616                        "<any>".to_string()
617                    } else {
618                        allowed_source_types.join(", ")
619                    };
620                    let allowed_tgt = if allowed_target_types.is_empty() {
621                        "<any>".to_string()
622                    } else {
623                        allowed_target_types.join(", ")
624                    };
625                    issues.push(HealthIssue {
626                        field: "relationships".to_string(),
627                        code: super::HealthIssueCode::InvalidRelShape,
628                        message: format!(
629                            "INVALID_REL_SHAPE: edge '{rel_type}' from \
630                             '{from_type}' to '{to_type}' (target {target}) \
631                             violates declared shape — allowed_source_types: \
632                             [{allowed_src}], allowed_target_types: \
633                             [{allowed_tgt}]. Remove via \
634                             `memstead_relate from={from_id} to={target} \
635                             type={rel_type} remove=true`.",
636                            target = rel.target,
637                            from_id = entity.id,
638                        ),
639                    });
640                }
641            }
642        }
643
644        // Staleness check
645        let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
646
647        if let Some(ts_field) = auto_ts_field
648            && let Some(val) = entity.metadata.get(ts_field.key.as_str())
649        {
650            let date_str = val.to_frontmatter_string();
651            if let Some(modified_days) = parse_iso_to_days(&date_str) {
652                let days_since = today_days.saturating_sub(modified_days);
653                if days_since > schema.staleness_threshold_days as u64 {
654                    stale_entities.push(StaleEntity {
655                        id: entity.id.clone(),
656                        title: entity.title.clone(),
657                        days_since_modified: days_since,
658                    });
659                }
660            }
661        }
662
663        if !issues.is_empty() {
664            // Compute a simple health score: (total_fields - issues) / total_fields.
665            // `issues.len()` can exceed `total_fields` once the
666            // relationship-vocabulary issues are added on top, so saturate
667            // the subtraction rather than underflow. A score of 0.0 is the
668            // natural floor — agents treat it as "maximally broken".
669            let total = schema.health_required_fields.len();
670            let score = if total > 0 {
671                (total.saturating_sub(issues.len()) as f32) / (total as f32)
672            } else {
673                1.0
674            };
675
676            missing_fields.push(HealthReport {
677                id: entity.id.clone(),
678                title: entity.title.clone(),
679                score,
680                issues,
681            });
682        }
683    }
684
685    // Sort stale entities by days_since_modified descending
686    stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
687
688    // Structural counts — scoped by the same filter as the entity scans
689    // above so a `mem`-scoped summary is internally consistent.
690    let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
691        .into_iter()
692        .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
693        .count();
694    let leaf_entities_by_type = match mem_filter {
695        None => query::leaf_population(store, mem_schemas),
696        Some(v) => {
697            let scoped: HashMap<String, Arc<Schema>> = mem_schemas
698                .iter()
699                .filter(|(mem, _)| mem.as_str() == v)
700                .map(|(mem, s)| (mem.clone(), s.clone()))
701                .collect();
702            query::leaf_population(store, &scoped)
703        }
704    };
705    let stub_count = query::find_stubs(store)
706        .iter()
707        .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
708        .count();
709
710    HealthSummary {
711        stale_entities,
712        missing_fields,
713        orphan_count,
714        stub_count,
715        warnings: Vec::new(),
716        quarantined: Vec::new(),
717        load_errors: Vec::new(),
718        boot_diagnosis: None,
719        leaf_entities_by_type,
720        dangling_links: None,
721        findings: None,
722        tag_distribution: None,
723        tag_distribution_folded: None,
724        untagged_entities: None,
725    }
726}
727
728/// Scan every non-stub entity's `tags` metadata and aggregate (tag → count,
729/// per-entity-type breakdown) plus untagged coverage. Comma-separated parser
730/// with per-segment trim; empty segments drop. Comparison is case-sensitive
731/// on the primary surface — case drift is surfaced separately via
732/// [`TagDistribution`] siblings folded by the caller if desired.
733///
734/// `mem_filter` narrows both aggregation passes to entities in that mem;
735/// `limit` caps the returned `tag_distribution` array after sorting by count
736/// descending (tie-break by tag ascending for deterministic output).
737///
738/// Also returns `FoldedTag` entries for any canonical (lowercase) tag where
739/// two or more authored casings appear — drift-flag only; empty when no
740/// collisions exist.
741pub fn collect_tag_distribution(
742    store: &Store,
743    mem_filter: Option<&str>,
744    limit: usize,
745) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
746    // tag → (count, per_type_count)
747    let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
748    let mut untagged = UntaggedStats {
749        total: 0,
750        by_entity_type: HashMap::new(),
751    };
752
753    for entity in store.all_entities() {
754        if entity.stub {
755            continue;
756        }
757        if let Some(v) = mem_filter
758            && entity.mem != v
759        {
760            continue;
761        }
762
763        let tags_raw = entity
764            .metadata
765            .get("tags")
766            .and_then(|v| match v {
767                MetadataValue::String(s) => Some(s.as_str()),
768                _ => None,
769            })
770            .unwrap_or("");
771
772        let mut any_tag = false;
773        for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
774            any_tag = true;
775            let entry = counts
776                .entry(tag.to_string())
777                .or_insert_with(|| (0, HashMap::new()));
778            entry.0 += 1;
779            *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
780        }
781        if !any_tag {
782            untagged.total += 1;
783            *untagged
784                .by_entity_type
785                .entry(entity.entity_type.clone())
786                .or_insert(0) += 1;
787        }
788    }
789
790    // Primary distribution — case-sensitive.
791    let mut entries: Vec<TagDistribution> = counts
792        .iter()
793        .map(|(tag, (count, by_type))| TagDistribution {
794            tag: tag.clone(),
795            count: *count,
796            by_entity_type: by_type.clone(),
797        })
798        .collect();
799    entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
800    entries.truncate(limit);
801
802    // Case-drift sidecar: group by lowercase canonical; surface only entries
803    // with ≥2 distinct authored casings. Operates on the full counts map, not
804    // the truncated primary surface, so drift hidden below `limit` still
805    // surfaces.
806    let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
807    for (tag, (count, _)) in counts.iter() {
808        by_canonical
809            .entry(tag.to_lowercase())
810            .or_default()
811            .push((tag.clone(), *count));
812    }
813    let mut folded: Vec<FoldedTag> = by_canonical
814        .into_iter()
815        .filter(|(_, v)| v.len() > 1)
816        .map(|(canonical, mut variants)| {
817            variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
818            let total = variants.iter().map(|(_, c)| *c).sum();
819            FoldedTag {
820                canonical,
821                total,
822                variants: variants
823                    .into_iter()
824                    .map(|(tag, count)| TagVariant { tag, count })
825                    .collect(),
826            }
827        })
828        .collect();
829    folded.sort_by(|a, b| {
830        b.total
831            .cmp(&a.total)
832            .then_with(|| a.canonical.cmp(&b.canonical))
833    });
834
835    (entries, folded, untagged)
836}
837
838/// Scan every non-stub entity's section bodies for body wiki-links that
839/// either (a) resolve to a stub target (missing on-disk file) or
840/// (b) lack a backing explicit relation in the referrer (alias-orphan
841/// under the alias model). Both cases surface through the same
842/// `DanglingLink` shape — the existing field set continues to round-trip;
843/// alias-orphans are detectable by the target *not* being a stub while
844/// the referrer's relationships list omits it.
845///
846/// The scan also covers the `## Relationships` table: a typed-relation
847/// target whose entity vanished (out-of-band file edit, historical
848/// cross-mem corruption from the pre-F15 mem-delete path, etc.)
849/// would otherwise stay invisible to the diagnostic surface.
850/// Relationship-section danglers ship the same envelope shape with
851/// `section: None` — the Option marks the source axis without requiring
852/// a magic-string sentinel.
853///
854/// `mem_filter` narrows *scanning* to entities in that mem; resolution
855/// stays global so cross-mem links whose target is a real entity
856/// elsewhere are not flagged as missing.
857pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
858    use crate::entity::parser::extract_inline_links_lenient;
859    use std::collections::HashSet;
860
861    let mut out = Vec::new();
862    for entity in store.all_entities() {
863        if entity.stub {
864            continue;
865        }
866        if let Some(v) = mem_filter
867            && entity.mem != v
868        {
869            continue;
870        }
871        let explicit_targets: HashSet<_> = entity
872            .relationships
873            .iter()
874            .map(|r| r.target.clone())
875            .collect();
876        for (section_key, section_body) in &entity.sections {
877            for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
878                let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
879                let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
880                if target_missing || alias_orphan {
881                    out.push(DanglingLink {
882                        from: entity.id.clone(),
883                        target_id: target_id.clone(),
884                        target_path: target_id.path().to_string(),
885                        section: Some(section_key.clone()),
886                    });
887                }
888            }
889        }
890        // Relationship-table dangler scan. The `## Relationships`
891        // section is structurally distinct from body sections — its
892        // rows materialise from `entity.relationships` rather than a
893        // free-text body — so `section: None` marks the source axis.
894        //
895        // Discrimination differs from the body scan: a relationship
896        // target that resolves to a stub is a legitimate forward
897        // reference (the alias machinery auto-stubs absent targets
898        // by design), not corruption. Only a target that's *fully
899        // absent* from the store — neither stub nor real — flags as
900        // dangling. In practice this only fires for out-of-band file
901        // edits or historical cross-mem-delete corruption that
902        // dropped the stub along with the deleted mem.
903        //
904        // Dedup against the body-scan output so a target that
905        // surfaces from both axes doesn't double-emit.
906        for rel in &entity.relationships {
907            if store.get(&rel.target).is_some() {
908                continue;
909            }
910            let already_reported = out
911                .iter()
912                .any(|d| d.from == entity.id && d.target_id == rel.target);
913            if already_reported {
914                continue;
915            }
916            out.push(DanglingLink {
917                from: entity.id.clone(),
918                target_id: rel.target.clone(),
919                target_path: rel.target.path().to_string(),
920                section: None,
921            });
922        }
923    }
924    // Deterministic output — the store iterates a HashMap, so without a
925    // sort two identical runs can serve the same findings in different
926    // orders. Sort by (from, target, section) so successive sweeps diff
927    // cleanly.
928    out.sort_by(|a, b| {
929        (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
930    });
931    out
932}
933
934/// Collect every non-stub entity whose type declares `required_outgoing`
935/// blocks that the entity's current outgoing edges leave unsatisfied.
936/// Results are deterministic — sorted
937/// by `(mem, id)` — so the agent can diff successive sweeps without
938/// the underlying HashMap iteration order leaking through.
939///
940/// `mem_filter` narrows scanning to entities in that mem when set;
941/// `mem_schemas` resolves the entity's type definition against the
942/// mem's pinned schema. Entities whose mem has no schema in the
943/// map are skipped (no schema → no `required_outgoing` to evaluate).
944pub fn collect_missing_required_outgoing(
945    store: &Store,
946    mem_filter: Option<&str>,
947    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
948) -> Vec<MissingRequiredOutgoingReport> {
949    let mut out = Vec::new();
950    for entity in store.all_entities() {
951        if entity.stub {
952            continue;
953        }
954        if let Some(v) = mem_filter
955            && entity.mem != v
956        {
957            continue;
958        }
959        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
960            continue;
961        };
962        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
963            continue;
964        };
965        if td.required_outgoing.is_empty() {
966            continue;
967        }
968        let unsatisfied = unsatisfied_required_outgoing(entity, td);
969        if unsatisfied.is_empty() {
970            continue;
971        }
972        out.push(MissingRequiredOutgoingReport {
973            id: entity.id.clone(),
974            title: entity.title.clone(),
975            entity_type: entity.entity_type.clone(),
976            mem: entity.mem.clone(),
977            missing: unsatisfied,
978        });
979    }
980    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
981    out
982}
983
984/// Evaluate one entity's declared `required_outgoing` blocks against
985/// its current outgoing edges, returning the unsatisfied blocks in
986/// declaration order. THE single evaluation — shared by the health
987/// sweep ([`collect_missing_required_outgoing`]) and the per-mutation
988/// `MISSING_REQUIRED_OUTGOING` warning on create/update. A second
989/// implementation of the block check is a defect: the two surfaces
990/// must never disagree about what counts as unsatisfied.
991pub fn unsatisfied_required_outgoing(
992    entity: &crate::entity::Entity,
993    td: &TypeDefinition,
994) -> Vec<super::MissingRequiredOutgoingBlock> {
995    td.required_outgoing
996        .iter()
997        .filter(|block| {
998            // A conditional block applies only while `when_field`
999            // holds `when_value` (same comparison the `requires_when`
1000            // constraint uses). Unset field or any other value = the
1001            // block is unarmed and never unsatisfied.
1002            if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1003                let armed = entity
1004                    .metadata
1005                    .get(when_field.as_str())
1006                    .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1007                if !armed {
1008                    return false;
1009                }
1010            }
1011            let count = entity
1012                .relationships
1013                .iter()
1014                .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1015                .count();
1016            !block.admits(count)
1017        })
1018        .map(|block| super::MissingRequiredOutgoingBlock {
1019            relationships: block.relationships.clone(),
1020            cardinality: block.cardinality.to_string(),
1021            severity: block.severity,
1022            when_field: block.when_field.clone(),
1023            when_value: block.when_value.clone(),
1024        })
1025        .collect()
1026}
1027
1028/// One violated declared constraint on one entity — the wire entry
1029/// shared by the write-path surface (the `CONSTRAINT_UNSATISFIED`
1030/// warning or refusal, tier decided by the declared severity) and the
1031/// health `constraints` include. The serde `kind` tag names the form;
1032/// the remaining fields restate the declaration (plus the observed
1033/// offense — the colliding entity, the unbacked value, the tainting
1034/// ancestor) so a consumer can repair without re-fetching the schema.
1035#[derive(Debug, Clone, serde::Serialize)]
1036#[serde(tag = "kind", rename_all = "snake_case")]
1037pub enum UnsatisfiedConstraint {
1038    RequiresWhen {
1039        field: String,
1040        when_field: String,
1041        when_value: String,
1042        severity: memstead_schema::ConstraintSeverity,
1043    },
1044    Unique {
1045        fields: Vec<String>,
1046        /// The entity's values for `fields`, in declaration order.
1047        values: Vec<String>,
1048        /// The other entity holding the same tuple (lexically smallest
1049        /// when several collide).
1050        colliding: String,
1051        severity: memstead_schema::ConstraintSeverity,
1052    },
1053    EnumFromNeighbour {
1054        field: String,
1055        /// The set value no reached neighbour's section backs.
1056        value: String,
1057        rel_type: String,
1058        section: String,
1059        severity: memstead_schema::ConstraintSeverity,
1060    },
1061    StatusPropagation {
1062        field: String,
1063        /// The terminal value the ancestor holds.
1064        value: String,
1065        /// Echo of a single-rel-type declaration — present exactly
1066        /// when the schema declared `rel_type`, keeping the
1067        /// long-standing payload byte-identical.
1068        #[serde(skip_serializing_if = "Option::is_none")]
1069        rel_type: Option<String>,
1070        /// Echo of a relation-set declaration (`rel_types`).
1071        #[serde(skip_serializing_if = "Option::is_none")]
1072        rel_types: Option<Vec<String>>,
1073        /// The tainting ancestor — the entity holding the terminal
1074        /// value that this entity (transitively) reaches.
1075        tainted_by: String,
1076        severity: memstead_schema::ConstraintSeverity,
1077    },
1078    /// The entity reaches no non-stub entity of a terminal type along
1079    /// the declared relation set — the declaration is echoed whole so
1080    /// the reader sees which obligation went unmet without re-fetching
1081    /// the schema. Health-sweep only, always warn-tier.
1082    MustReach {
1083        relationships: Vec<String>,
1084        direction: memstead_schema::ReachDirection,
1085        terminal_types: Vec<String>,
1086        #[serde(skip_serializing_if = "Option::is_none")]
1087        max_depth: Option<u32>,
1088        severity: memstead_schema::ConstraintSeverity,
1089    },
1090}
1091
1092impl UnsatisfiedConstraint {
1093    pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1094        match self {
1095            Self::RequiresWhen { severity, .. }
1096            | Self::Unique { severity, .. }
1097            | Self::EnumFromNeighbour { severity, .. }
1098            | Self::StatusPropagation { severity, .. }
1099            | Self::MustReach { severity, .. } => *severity,
1100        }
1101    }
1102
1103    /// One-line human rendering for warning/refusal message text.
1104    pub fn describe(&self) -> String {
1105        match self {
1106            Self::RequiresWhen {
1107                field,
1108                when_field,
1109                when_value,
1110                ..
1111            } => format!(
1112                "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1113            ),
1114            Self::Unique {
1115                fields, colliding, ..
1116            } => format!(
1117                "unique: tuple ({}) collides with '{colliding}'",
1118                fields.join(", ")
1119            ),
1120            Self::EnumFromNeighbour {
1121                field,
1122                value,
1123                rel_type,
1124                section,
1125                ..
1126            } => format!(
1127                "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1128                 `{section}` section reached via {rel_type}"
1129            ),
1130            Self::StatusPropagation {
1131                field,
1132                value,
1133                tainted_by,
1134                ..
1135            } => {
1136                format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1137            }
1138            Self::MustReach {
1139                relationships,
1140                direction,
1141                terminal_types,
1142                max_depth,
1143                ..
1144            } => {
1145                let depth = match max_depth {
1146                    Some(d) => format!(" within {d} hop(s)"),
1147                    None => String::new(),
1148                };
1149                format!(
1150                    "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1151                    relationships.join(", "),
1152                    terminal_types.join(", ")
1153                )
1154            }
1155        }
1156    }
1157}
1158
1159/// Evaluate one entity's declared per-entity `constraints` against its
1160/// current state (and, for the store-aware forms, against the rest of
1161/// its mem), returning the violated ones in declaration order. THE
1162/// single evaluation — shared by the health sweep
1163/// ([`collect_constraint_findings`]) and the per-mutation
1164/// `CONSTRAINT_UNSATISFIED` surface on create/update/relate; a second
1165/// implementation of any form is a defect.
1166///
1167/// Form semantics:
1168/// - `requires_when` triggers when `when_field`'s frontmatter value
1169///   equals `when_value` exactly; a triggered constraint is satisfied
1170///   when `field` — a metadata field or a section key — is present
1171///   with non-blank content.
1172/// - `unique`: the entity's tuple of `fields` values (skipped when any
1173///   field is unset/blank) must not equal another non-stub entity's
1174///   tuple within the same mem and type. `exclude` names the entity's
1175///   own id so an update does not collide with its stored self.
1176/// - `enum_from_neighbour`: a set `field` value must appear as a
1177///   bullet entry (`- value` / `* value` line) in the `section` body
1178///   of at least one entity reached via an outgoing `rel_type` edge.
1179/// - `status_propagation` is a reachability property of the graph,
1180///   not of one write — it is evaluated only by the health sweep
1181///   ([`collect_constraint_findings`]), never here.
1182pub fn unsatisfied_constraints(
1183    store: &Store,
1184    entity: &crate::entity::Entity,
1185    td: &TypeDefinition,
1186    exclude: Option<&crate::entity::EntityId>,
1187) -> Vec<UnsatisfiedConstraint> {
1188    use memstead_schema::ConstraintDef;
1189    td.constraints
1190        .iter()
1191        .filter_map(|c| match c {
1192            ConstraintDef::RequiresWhen {
1193                field,
1194                when_field,
1195                when_value,
1196                severity,
1197            } => {
1198                let triggered = entity
1199                    .metadata
1200                    .get(when_field.as_str())
1201                    .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1202                if !triggered {
1203                    return None;
1204                }
1205                let satisfied = entity
1206                    .metadata
1207                    .get(field.as_str())
1208                    .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1209                    || entity
1210                        .sections
1211                        .get(field.as_str())
1212                        .is_some_and(|body| !body.trim().is_empty());
1213                if satisfied {
1214                    return None;
1215                }
1216                Some(UnsatisfiedConstraint::RequiresWhen {
1217                    field: field.clone(),
1218                    when_field: when_field.clone(),
1219                    when_value: when_value.clone(),
1220                    severity: *severity,
1221                })
1222            }
1223            ConstraintDef::Unique { fields, severity } => {
1224                let tuple = tuple_of(entity, fields)?;
1225                let mut colliding: Vec<&str> = store
1226                    .all_entities()
1227                    .filter(|other| {
1228                        !other.stub
1229                            && other.mem == entity.mem
1230                            && other.entity_type == entity.entity_type
1231                            && Some(&other.id) != exclude
1232                            && other.id != entity.id
1233                            && tuple_of(other, fields).as_ref() == Some(&tuple)
1234                    })
1235                    .map(|other| other.id.0.as_str())
1236                    .collect();
1237                colliding.sort_unstable();
1238                let first = colliding.first()?;
1239                Some(UnsatisfiedConstraint::Unique {
1240                    fields: fields.clone(),
1241                    values: tuple,
1242                    colliding: first.to_string(),
1243                    severity: *severity,
1244                })
1245            }
1246            ConstraintDef::EnumFromNeighbour {
1247                field,
1248                rel_type,
1249                section,
1250                severity,
1251            } => {
1252                let value = entity
1253                    .metadata
1254                    .get(field.as_str())
1255                    .map(|v| v.to_frontmatter_string())
1256                    .filter(|v| !v.trim().is_empty())?;
1257                let backed = entity
1258                    .relationships
1259                    .iter()
1260                    .filter(|rel| rel.rel_type == *rel_type)
1261                    .filter_map(|rel| store.get(&rel.target))
1262                    .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1263                    .any(|body| bullet_entries(body).contains(&value));
1264                if backed {
1265                    return None;
1266                }
1267                Some(UnsatisfiedConstraint::EnumFromNeighbour {
1268                    field: field.clone(),
1269                    value,
1270                    rel_type: rel_type.clone(),
1271                    section: section.clone(),
1272                    severity: *severity,
1273                })
1274            }
1275            ConstraintDef::StatusPropagation { .. } => None,
1276        })
1277        .collect()
1278}
1279
1280/// The entity's tuple of frontmatter values for `fields`, in
1281/// declaration order — `None` when any field is unset or blank (no
1282/// tuple, nothing to compare).
1283fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1284    fields
1285        .iter()
1286        .map(|f| {
1287            entity
1288                .metadata
1289                .get(f.as_str())
1290                .map(|v| v.to_frontmatter_string())
1291                .filter(|v| !v.trim().is_empty())
1292        })
1293        .collect()
1294}
1295
1296/// The bullet entries of a section body — trimmed text of `- item` /
1297/// `* item` lines. The legal-value shape `enum_from_neighbour` reads.
1298fn bullet_entries(body: &str) -> Vec<String> {
1299    // A bullet inside a code block is an example of the list, not a
1300    // member of it — the same referee every other content reader uses
1301    // ([`crate::markdown`]). Masking preserves byte offsets and line
1302    // count, so each masked line pairs with its original.
1303    let masked = crate::markdown::mask_code_blocks_and_spans(body);
1304    body.lines()
1305        .zip(masked.lines())
1306        .filter_map(|(line, masked_line)| {
1307            let m = masked_line.trim_start();
1308            if m.starts_with("- ") || m.starts_with("* ") {
1309                let t = line.trim_start();
1310                t.strip_prefix("- ")
1311                    .or_else(|| t.strip_prefix("* "))
1312                    .map(|e| e.trim().to_string())
1313            } else {
1314                None
1315            }
1316        })
1317        .collect()
1318}
1319
1320/// One entity's violated declared constraints, surfaced from the
1321/// health-time scan (`include=["constraints"]`). Mirrors
1322/// [`MissingRequiredOutgoingReport`]'s envelope shape — the two
1323/// includes read the same way.
1324#[derive(Debug, Clone, serde::Serialize)]
1325pub struct ConstraintFindingReport {
1326    pub id: crate::entity::EntityId,
1327    pub title: String,
1328    pub entity_type: String,
1329    pub mem: String,
1330    pub violations: Vec<UnsatisfiedConstraint>,
1331    /// Standing violations of the entity's declared section formats
1332    /// (plan 08) — additive: consumers of the pre-format shape see an
1333    /// absent key, never an empty list.
1334    #[serde(skip_serializing_if = "Vec::is_empty")]
1335    pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
1336}
1337
1338/// Collect every non-stub entity whose declared `constraints` its
1339/// current state violates. Two passes: the per-entity forms
1340/// (`requires_when`, `unique`, `enum_from_neighbour`) through the
1341/// shared [`unsatisfied_constraints`] evaluation, then the
1342/// `status_propagation` graph sweep — for each entity holding a
1343/// declared terminal value, every entity reaching it (transitively)
1344/// via the declared rel-type and direction gains a finding naming that
1345/// tainting ancestor. Deterministic — reports sorted by `(mem, id)`,
1346/// violations in declaration order then by tainting ancestor.
1347pub fn collect_constraint_findings(
1348    store: &Store,
1349    mem_filter: Option<&str>,
1350    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1351) -> Vec<ConstraintFindingReport> {
1352    use memstead_schema::ConstraintDef;
1353    type Bucket = (
1354        Vec<UnsatisfiedConstraint>,
1355        Vec<crate::section_format::SectionFormatViolation>,
1356    );
1357    let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
1358
1359    // Reverse adjacency for `must_reach` incoming walks — built once
1360    // per sweep, and only when some pinned schema declares one (a
1361    // workspace without the form pays nothing).
1362    let needs_reverse = mem_schemas.values().any(|s| {
1363        s.types.values().any(|t| {
1364            t.must_reach
1365                .iter()
1366                .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
1367        })
1368    });
1369    let reverse: ReverseIndex = if needs_reverse {
1370        build_reverse_index(store)
1371    } else {
1372        ReverseIndex::default()
1373    };
1374
1375    for entity in store.all_entities() {
1376        if entity.stub {
1377            continue;
1378        }
1379        if let Some(v) = mem_filter
1380            && entity.mem != v
1381        {
1382            continue;
1383        }
1384        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1385            continue;
1386        };
1387        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1388            continue;
1389        };
1390
1391        // Section-format sweep (plan 08) — standing violations of a
1392        // declared markdown shape, every severity (block-tier
1393        // pre-existing violations are health findings too; the next
1394        // write of the section is the sanctioned repair point).
1395        for def in &td.sections {
1396            if def.compiled_content.is_none() {
1397                continue;
1398            }
1399            let Some(body) = entity.sections.get(def.key.as_str()) else {
1400                continue;
1401            };
1402            let violations = crate::section_format::check_section_format(def, body);
1403            if !violations.is_empty() {
1404                by_entity
1405                    .entry(entity.id.0.clone())
1406                    .or_default()
1407                    .1
1408                    .extend(violations);
1409            }
1410        }
1411
1412        // Reachability obligations — health-sweep only by design (no
1413        // single write completes a transitive absence, so the write
1414        // path never evaluates these). The finding echoes the whole
1415        // declaration.
1416        for ob in &td.must_reach {
1417            if !reaches_terminal(store, &reverse, &entity.id, ob) {
1418                by_entity.entry(entity.id.0.clone()).or_default().0.push(
1419                    UnsatisfiedConstraint::MustReach {
1420                        relationships: ob.relationships.clone(),
1421                        direction: ob.direction,
1422                        terminal_types: ob.terminal_types.clone(),
1423                        max_depth: ob.max_depth,
1424                        severity: ob.severity,
1425                    },
1426                );
1427            }
1428        }
1429
1430        if td.constraints.is_empty() {
1431            continue;
1432        }
1433
1434        // Pass 1 — per-entity forms.
1435        let violations = unsatisfied_constraints(store, entity, td, None);
1436        if !violations.is_empty() {
1437            by_entity
1438                .entry(entity.id.0.clone())
1439                .or_default()
1440                .0
1441                .extend(violations);
1442        }
1443
1444        // Pass 2 — this entity as a taint source: it holds a declared
1445        // terminal value, so sweep its dependents. The taint walks
1446        // the declared relation set's union subgraph — a single
1447        // `rel_type` is a one-element set.
1448        for c in &td.constraints {
1449            let ConstraintDef::StatusPropagation {
1450                field,
1451                value,
1452                rel_type,
1453                rel_types,
1454                direction,
1455                severity,
1456            } = c
1457            else {
1458                continue;
1459            };
1460            let terminal = entity
1461                .metadata
1462                .get(field.as_str())
1463                .is_some_and(|v| v.to_frontmatter_string() == *value);
1464            if !terminal {
1465                continue;
1466            }
1467            let set = c
1468                .propagation_rel_types()
1469                .expect("StatusPropagation always yields a set");
1470            for tainted in reach_transitively(store, &entity.id, &set, *direction) {
1471                if let Some(v) = mem_filter
1472                    && tainted.mem() != v
1473                {
1474                    continue;
1475                }
1476                by_entity.entry(tainted.0.clone()).or_default().0.push(
1477                    UnsatisfiedConstraint::StatusPropagation {
1478                        field: field.clone(),
1479                        value: value.clone(),
1480                        rel_type: rel_type.clone(),
1481                        rel_types: rel_types.clone(),
1482                        tainted_by: entity.id.to_string(),
1483                        severity: *severity,
1484                    },
1485                );
1486            }
1487        }
1488    }
1489
1490    let mut out: Vec<ConstraintFindingReport> = by_entity
1491        .into_iter()
1492        .filter_map(|(id, (violations, format_violations))| {
1493            let id = crate::entity::EntityId(id);
1494            let entity = store.get(&id)?;
1495            Some(ConstraintFindingReport {
1496                id,
1497                title: entity.title.clone(),
1498                entity_type: entity.entity_type.clone(),
1499                mem: entity.mem.clone(),
1500                violations,
1501                format_violations,
1502            })
1503        })
1504        .collect();
1505    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1506    out
1507}
1508
1509/// Transitive reachability along one rel-type from `start`, excluding
1510/// `start` itself. `Incoming` walks against edge direction (the
1511/// entities whose `rel_type` edges point at the frontier — "what
1512/// stands on this"); `Outgoing` follows the frontier's own edges.
1513/// Stubs are traversed (an edge through a stub still transmits the
1514/// taint) but stubs themselves are not returned.
1515fn reach_transitively(
1516    store: &Store,
1517    start: &crate::entity::EntityId,
1518    rel_types: &[String],
1519    direction: memstead_schema::PropagationDirection,
1520) -> Vec<crate::entity::EntityId> {
1521    use memstead_schema::PropagationDirection;
1522    let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1523        std::iter::once(start.clone()).collect();
1524    let mut frontier = vec![start.clone()];
1525    let mut reached = Vec::new();
1526    while let Some(current) = frontier.pop() {
1527        let next: Vec<crate::entity::EntityId> = match direction {
1528            PropagationDirection::Incoming => store
1529                .all_entities()
1530                .filter(|e| {
1531                    e.relationships
1532                        .iter()
1533                        .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
1534                })
1535                .map(|e| e.id.clone())
1536                .collect(),
1537            PropagationDirection::Outgoing => store
1538                .get(&current)
1539                .map(|e| {
1540                    e.relationships
1541                        .iter()
1542                        .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
1543                        .map(|r| r.target.clone())
1544                        .collect()
1545                })
1546                .unwrap_or_default(),
1547        };
1548        for id in next {
1549            if seen.insert(id.clone()) {
1550                if store.get(&id).is_some_and(|e| !e.stub) {
1551                    reached.push(id.clone());
1552                }
1553                frontier.push(id);
1554            }
1555        }
1556    }
1557    reached
1558}
1559
1560/// Reverse adjacency for `must_reach` incoming walks: target id →
1561/// `(rel_type, source id)` pairs. Built once per sweep so the
1562/// incoming direction stays O(edges) instead of re-scanning the store
1563/// per frontier node.
1564type ReverseIndex =
1565    std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
1566
1567fn build_reverse_index(store: &Store) -> ReverseIndex {
1568    let mut idx = ReverseIndex::default();
1569    for entity in store.all_entities() {
1570        for rel in &entity.relationships {
1571            idx.entry(rel.target.clone())
1572                .or_default()
1573                .push((rel.rel_type.clone(), entity.id.clone()));
1574        }
1575    }
1576    idx
1577}
1578
1579/// Whether `start` reaches at least one non-stub entity of a terminal
1580/// type along the obligation's relation set, direction, and depth
1581/// bound. Breadth-first with visited-set discipline (cycles along the
1582/// walked set terminate); stubs terminate no obligation — they carry
1583/// no outgoing edges and never count as reached terminals. Cross-mem
1584/// edges are followed like any edge, matching the propagation walk
1585/// and the cycle check (the engine's established traversal posture);
1586/// the start entity itself never satisfies its own obligation.
1587fn reaches_terminal(
1588    store: &Store,
1589    reverse: &ReverseIndex,
1590    start: &crate::entity::EntityId,
1591    ob: &memstead_schema::MustReach,
1592) -> bool {
1593    use memstead_schema::ReachDirection;
1594    let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1595        std::iter::once(start.clone()).collect();
1596    let mut frontier = vec![start.clone()];
1597    let mut depth: u32 = 0;
1598    while !frontier.is_empty() {
1599        if let Some(max) = ob.max_depth
1600            && depth >= max
1601        {
1602            return false;
1603        }
1604        depth += 1;
1605        let mut next_frontier = Vec::new();
1606        for current in frontier {
1607            let next: Vec<crate::entity::EntityId> = match ob.direction {
1608                ReachDirection::Out => store
1609                    .get(&current)
1610                    .map(|e| {
1611                        e.relationships
1612                            .iter()
1613                            .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
1614                            .map(|r| r.target.clone())
1615                            .collect()
1616                    })
1617                    .unwrap_or_default(),
1618                ReachDirection::In => reverse
1619                    .get(&current)
1620                    .map(|sources| {
1621                        sources
1622                            .iter()
1623                            .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
1624                            .map(|(_, src)| src.clone())
1625                            .collect()
1626                    })
1627                    .unwrap_or_default(),
1628            };
1629            for id in next {
1630                if seen.insert(id.clone()) {
1631                    if store.get(&id).is_some_and(|e| {
1632                        !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
1633                    }) {
1634                        return true;
1635                    }
1636                    next_frontier.push(id);
1637                }
1638            }
1639        }
1640        frontier = next_frontier;
1641    }
1642    false
1643}
1644
1645/// One entity's above-`none` signals, surfaced from the include-gated
1646/// `signals` health axis. Mirrors [`ConstraintFindingReport`]'s
1647/// envelope shape; the `signals` entries carry value, level, and
1648/// contributors (the evidence ships with the number, always).
1649#[derive(Debug, Clone, serde::Serialize)]
1650pub struct SignalReport {
1651    pub id: crate::entity::EntityId,
1652    pub title: String,
1653    pub entity_type: String,
1654    pub mem: String,
1655    /// Only signals whose level is not `none`, in declaration order.
1656    pub signals: Vec<super::signals::ComputedSignal>,
1657}
1658
1659impl SignalReport {
1660    /// Whether any entry is `warn`-level — the `--strict`
1661    /// participation test (a `notice` never participates; that is the
1662    /// whole difference between the two levels).
1663    pub fn has_warn(&self) -> bool {
1664        self.signals
1665            .iter()
1666            .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
1667    }
1668}
1669
1670/// Collect every non-stub entity carrying at least one declared
1671/// signal above `none`. Deterministic — sorted by `(mem, id)`;
1672/// signals in declaration order, contributors sorted.
1673pub fn collect_signal_reports(
1674    store: &Store,
1675    mem_filter: Option<&str>,
1676    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1677) -> Vec<SignalReport> {
1678    let mut out = Vec::new();
1679    for entity in store.all_entities() {
1680        if entity.stub {
1681            continue;
1682        }
1683        if let Some(v) = mem_filter
1684            && entity.mem != v
1685        {
1686            continue;
1687        }
1688        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1689            continue;
1690        };
1691        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1692            continue;
1693        };
1694        if td.signals.is_empty() {
1695            continue;
1696        }
1697        let above: Vec<super::signals::ComputedSignal> =
1698            super::signals::compute_signals(store, td, &entity.id)
1699                .into_iter()
1700                .filter(|s| s.level.is_some())
1701                .collect();
1702        if above.is_empty() {
1703            continue;
1704        }
1705        out.push(SignalReport {
1706            id: entity.id.clone(),
1707            title: entity.title.clone(),
1708            entity_type: entity.entity_type.clone(),
1709            mem: entity.mem.clone(),
1710            signals: above,
1711        });
1712    }
1713    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1714    out
1715}
1716
1717/// A defective section-format declaration a loaded schema carries
1718/// (recorded by the lenient boot path; install would have refused).
1719/// Surfaced under the health `constraints` include so a sealed schema
1720/// with a bad declaration is visible without bricking boot.
1721#[derive(Debug, Clone, serde::Serialize)]
1722pub struct SchemaFormatDefect {
1723    pub schema: String,
1724    pub type_name: String,
1725    pub section: String,
1726    pub problems: Vec<String>,
1727}
1728
1729/// Collect the defective section-format declarations across the
1730/// mounted mems' pinned schemas, deduplicated per schema ref,
1731/// deterministic order.
1732pub fn collect_schema_format_defects(
1733    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1734) -> Vec<SchemaFormatDefect> {
1735    let mut seen: std::collections::BTreeSet<String> = Default::default();
1736    let mut out = Vec::new();
1737    let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
1738    schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
1739    for schema in schemas {
1740        let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
1741        if !seen.insert(schema_ref.clone()) {
1742            continue;
1743        }
1744        for td in schema.types.values() {
1745            for section in &td.sections {
1746                if !section.format_problems.is_empty() {
1747                    out.push(SchemaFormatDefect {
1748                        schema: schema_ref.clone(),
1749                        type_name: td.name.clone(),
1750                        section: section.key.clone(),
1751                        problems: section.format_problems.clone(),
1752                    });
1753                }
1754            }
1755        }
1756    }
1757    out.sort_by(|a, b| {
1758        (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
1759    });
1760    out
1761}
1762
1763/// One entity's unsatisfied `required_outgoing` blocks, surfaced from
1764/// the health-time scan. `missing` reuses the per-write warning's wire
1765/// block type — one struct, one serialized shape (`{ relationships,
1766/// cardinality }`) on both surfaces — and adds the `mem` name (the
1767/// warning's `entity_id` already encodes it via the mem prefix, but
1768/// health is multi-mem by default and an explicit field is cheaper for
1769/// downstream filters).
1770#[derive(Debug, Clone, serde::Serialize)]
1771pub struct MissingRequiredOutgoingReport {
1772    pub id: crate::entity::EntityId,
1773    pub title: String,
1774    pub entity_type: String,
1775    pub mem: String,
1776    pub missing: Vec<super::MissingRequiredOutgoingBlock>,
1777}
1778
1779/// Render the workspace-config projection the health surface serves —
1780/// per-writable-mem detail (`origin`, storage/durability, `vcs`
1781/// `gitdir`/`worktree`/`head`, title/subject, `write_guidance`,
1782/// `extra`) plus the `mutations` and `plugin` policy values. One
1783/// implementation, every surface: the MCP composer reaches it through
1784/// `include_config: true` OR the `config` include key; the CLI through
1785/// `--include config`. `mutations` / `plugin` are passed prebuilt so a
1786/// server that owns its own copies inserts them verbatim; callers
1787/// without server state derive them from `Engine::settings()` (see
1788/// [`config_projection_from_settings`]). Returns the three top-level
1789/// entries (`mems`, `mutations`, `plugin`) for the caller to merge —
1790/// callers gate on their own opt-in flag and must render at most once.
1791pub fn config_projection(
1792    engine: &crate::Engine,
1793    writable_mems: &[String],
1794    mutations: serde_json::Value,
1795    plugin: serde_json::Value,
1796) -> serde_json::Map<String, serde_json::Value> {
1797    // Per-mem storage backend → durability marker, derived from the
1798    // mount's `MountStorage` kind. Lives alongside `vcs` so an agent
1799    // reading per-mem config learns whether a `commit_sha` this mem
1800    // returns is durable-on-disk or volatile-in-RAM.
1801    let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
1802        .mounts()
1803        .iter()
1804        .map(|m| {
1805            (
1806                m.mem.as_str(),
1807                (m.storage.backend_id(), m.storage.is_durable()),
1808            )
1809        })
1810        .collect();
1811    let mems_detail: Vec<serde_json::Value> = writable_mems
1812        .iter()
1813        .map(|name| {
1814            let origin = engine
1815                .mem_router()
1816                .origin_for_mem(name)
1817                .map(|o| o.kind())
1818                .unwrap_or("explicit");
1819            let mut entry = serde_json::Map::new();
1820            entry.insert("name".into(), serde_json::json!(name));
1821            entry.insert("origin".into(), serde_json::json!(origin));
1822            if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
1823                entry.insert("storage".into(), serde_json::json!(storage));
1824                entry.insert("durable".into(), serde_json::json!(durable));
1825            }
1826            let mut vcs_obj = serde_json::Map::new();
1827            if let Ok(gitdir) = engine.gitdir_for(name) {
1828                vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
1829            }
1830            if let Ok(worktree) = engine.worktree_for(name) {
1831                vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
1832            }
1833            if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
1834                vcs_obj.insert("head".into(), serde_json::json!(sha));
1835            }
1836            if !vcs_obj.is_empty() {
1837                entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
1838            }
1839            if let Some(cfg) = engine.mem_config_for(name) {
1840                // Display title + subject block, when set — the
1841                // config projection prefers the title wherever a
1842                // mem is printed; the name stays the identity.
1843                if let Some(title) = &cfg.title {
1844                    entry.insert("title".into(), serde_json::json!(title));
1845                }
1846                if let Some(subject) = &cfg.subject {
1847                    entry.insert("subject".into(), serde_json::json!(subject));
1848                }
1849                let guidance = serde_json::Map::from_iter(
1850                    cfg.write_guidance
1851                        .iter()
1852                        .map(|(k, v)| (k.clone(), v.clone())),
1853                );
1854                entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
1855                let extra = serde_json::Map::from_iter(
1856                    cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
1857                );
1858                entry.insert("extra".into(), serde_json::Value::Object(extra));
1859            }
1860            serde_json::Value::Object(entry)
1861        })
1862        .collect();
1863
1864    let mut out = serde_json::Map::new();
1865    out.insert("mems".into(), serde_json::json!(mems_detail));
1866    out.insert("mutations".into(), mutations);
1867    out.insert("plugin".into(), plugin);
1868    out
1869}
1870
1871/// The `(mutations, plugin)` pair for [`config_projection`], derived
1872/// from the engine's own [`crate::workspace::WorkspaceSettings`] — for
1873/// callers (the CLI) that carry no server-owned config copies. Produces
1874/// the same bytes the MCP server passes when both were loaded from the
1875/// same `workspace.toml`.
1876pub fn config_projection_from_settings(
1877    settings: &crate::workspace::WorkspaceSettings,
1878) -> (serde_json::Value, serde_json::Value) {
1879    let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
1880    let plugin_map: serde_json::Map<String, serde_json::Value> = settings
1881        .plugin
1882        .iter()
1883        .map(|(k, v)| {
1884            (
1885                k.clone(),
1886                serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
1887            )
1888        })
1889        .collect();
1890    (mutations, serde_json::Value::Object(plugin_map))
1891}
1892
1893/// Detect the section-fork condition for one declared section: the
1894/// parsed content under `key` is empty, the schema's declared heading
1895/// for the key does not derive back to it
1896/// (`derive_section_key(heading) != key`), and the file carries that
1897/// declared heading — so the content is present in the file but
1898/// unreachable under the key: absorbed into the catch-all when the
1899/// type declares one, dropped from the parsed sections otherwise.
1900///
1901/// Returns the distinct `SECTION_HEADING_MISMATCH` issue naming both
1902/// the found heading and what a deriving heading would look like. The
1903/// caller must NOT also report the section as missing — collapsing the
1904/// two conditions into the missing-section report is exactly the
1905/// misdirection this finding exists to prevent (the operator goes
1906/// hunting for absent content that is in fact present).
1907pub(crate) fn section_heading_mismatch_issue(
1908    entity: &crate::entity::Entity,
1909    schema: &TypeDefinition,
1910    key: &str,
1911) -> Option<HealthIssue> {
1912    let def = schema.section(key)?;
1913    let derived = memstead_schema::derive_section_key(&def.heading);
1914    if derived == key {
1915        return None;
1916    }
1917    if !entity
1918        .raw_section_headings
1919        .iter()
1920        .any(|h| h == &def.heading)
1921    {
1922        return None;
1923    }
1924    let landing = match schema.catch_all_section() {
1925        Some(c) => format!(
1926            "the content was absorbed into catch-all section '{}'",
1927            c.key
1928        ),
1929        None => "the content is unreachable under any declared key".to_string(),
1930    };
1931    Some(HealthIssue {
1932        field: key.to_string(),
1933        code: super::HealthIssueCode::SectionHeadingMismatch,
1934        message: format!(
1935            "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
1936             under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
1937             The schema's declared heading cannot round-trip to its key (expected a heading \
1938             that derives to '{key}'); fix the schema's heading/key pair — new installs of \
1939             such a schema are refused",
1940            found = def.heading,
1941        ),
1942    })
1943}
1944
1945/// Get a single entity's health report.
1946pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
1947    let mut issues = Vec::new();
1948
1949    for field in &schema.health_required_fields {
1950        if schema.section(field).is_some() {
1951            let content = entity.sections.get(field.as_str());
1952            if content.is_none_or(|c| c.trim().is_empty()) {
1953                if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1954                    issues.push(issue);
1955                } else {
1956                    issues.push(HealthIssue {
1957                        field: field.clone(),
1958                        code: super::HealthIssueCode::Missing,
1959                        message: format!("required section '{field}' is empty"),
1960                    });
1961                }
1962            }
1963        } else {
1964            let value = entity.metadata.get(field.as_str());
1965            if value.is_none() {
1966                issues.push(HealthIssue {
1967                    field: field.clone(),
1968                    code: super::HealthIssueCode::Missing,
1969                    message: format!("required field '{field}' is missing"),
1970                });
1971            }
1972        }
1973    }
1974
1975    // The mismatch condition is drift worth surfacing on every declared
1976    // section, not only the health-required ones — an optional section
1977    // whose content forked away is just as invisible to readers.
1978    for s in schema.sections.iter().filter(|s| !s.catch_all) {
1979        if schema.health_required_fields.contains(&s.key) {
1980            continue; // already handled above
1981        }
1982        let content = entity.sections.get(s.key.as_str());
1983        if content.is_none_or(|c| c.trim().is_empty())
1984            && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1985        {
1986            issues.push(issue);
1987        }
1988    }
1989
1990    let total = schema.health_required_fields.len();
1991    let score = if total > 0 {
1992        (total.saturating_sub(issues.len()) as f32) / (total as f32)
1993    } else {
1994        1.0
1995    };
1996
1997    HealthReport {
1998        id: entity.id.clone(),
1999        title: entity.title.clone(),
2000        score,
2001        issues,
2002    }
2003}
2004
2005// ---------------------------------------------------------------------------
2006// Date helpers
2007// ---------------------------------------------------------------------------
2008
2009/// Get current days since Unix epoch.
2010///
2011/// `SystemTime::now()` is unimplemented on `wasm32-unknown-unknown` —
2012/// it traps with `RuntimeError: unreachable` and poisons the wasm
2013/// instance (cold-start F11) — so the wasm build reads the JS-backed
2014/// clock instead. Same value, same summary shape on every target.
2015fn days_since_epoch() -> u64 {
2016    #[cfg(target_arch = "wasm32")]
2017    {
2018        (js_sys::Date::now() / 1000.0) as u64 / 86400
2019    }
2020    #[cfg(not(target_arch = "wasm32"))]
2021    {
2022        std::time::SystemTime::now()
2023            .duration_since(std::time::UNIX_EPOCH)
2024            .unwrap_or_default()
2025            .as_secs()
2026            / 86400
2027    }
2028}
2029
2030/// Parse an ISO 8601 date string to days since epoch.
2031/// Supports `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM:SSZ`.
2032fn parse_iso_to_days(date: &str) -> Option<u64> {
2033    let date_part = date.split('T').next()?;
2034    let parts: Vec<&str> = date_part.split('-').collect();
2035    if parts.len() != 3 {
2036        return None;
2037    }
2038    let year: u64 = parts[0].parse().ok()?;
2039    let month: u64 = parts[1].parse().ok()?;
2040    let day: u64 = parts[2].parse().ok()?;
2041    Some(ymd_to_days(year, month, day))
2042}
2043
2044/// Convert (year, month, day) to days since Unix epoch.
2045/// Inverse of the algorithm in generator.rs.
2046fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2047    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
2048    let y = if month <= 2 { year - 1 } else { year };
2049    let m = if month <= 2 { month + 9 } else { month - 3 };
2050    let era = y / 400;
2051    let yoe = y - era * 400;
2052    let doy = (153 * m + 2) / 5 + day - 1;
2053    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2054    let days = era * 146097 + doe;
2055    days - 719468
2056}
2057
2058#[cfg(test)]
2059mod tests {
2060    use super::*;
2061    use crate::entity::{Entity, EntityId, MetadataValue};
2062    use crate::store::Store;
2063    use indexmap::IndexMap;
2064    use memstead_schema::type_by_name;
2065
2066    /// A bullet inside a code block is an example of the list, not a
2067    /// member of it. `enum_from_neighbour` harvests legal values from a
2068    /// neighbour's section body, so an unmasked scan would accept any
2069    /// value someone documented in a fenced sample.
2070    #[test]
2071    fn bullet_entries_ignores_code() {
2072        let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n    - indented-ghost\n\nA `- span-ghost` sample.\n";
2073        let entries = bullet_entries(body);
2074        assert_eq!(
2075            entries,
2076            vec!["real-one".to_string(), "real-two".to_string()],
2077            "only prose bullets are legal values: {entries:?}"
2078        );
2079    }
2080
2081    /// Complement: indentation, `*` markers and inline formatting in a
2082    /// prose bullet all still read exactly as before — the entry text
2083    /// comes from the original, not the mask.
2084    #[test]
2085    fn bullet_entries_still_reads_prose_bullets_verbatim() {
2086        let entries = bullet_entries("- alpha\n  * beta\n* `gamma`\n");
2087        assert_eq!(
2088            entries,
2089            vec![
2090                "alpha".to_string(),
2091                "beta".to_string(),
2092                "`gamma`".to_string()
2093            ]
2094        );
2095    }
2096
2097    /// Agent-trust plan 14, criterion 4: a destination config
2098    /// declaring its process mem resolves the pairing regardless of
2099    /// naming — with no binding at all — and a declaration naming a
2100    /// missing mem surfaces as the typed finding, never a silent
2101    /// fallback.
2102    #[test]
2103    fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2104        use crate::engine::test_helpers::folder_mount;
2105        let tmp = tempfile::TempDir::new().unwrap();
2106        let dest_dir = tmp.path().join("dest");
2107        let proc_dir = tmp.path().join("oddly-named-process");
2108        std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2109        std::fs::create_dir_all(&proc_dir).unwrap();
2110        // Declaration: the destination pairs with a mem whose name no
2111        // convention would derive.
2112        std::fs::write(
2113            dest_dir.join(".memstead").join("config.json"),
2114            r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2115        )
2116        .unwrap();
2117        let engine = crate::Engine::from_mounts(vec![
2118            (
2119                folder_mount("dest", dest_dir.clone()),
2120                Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2121                    as Box<dyn crate::backend::MemBackend>,
2122            ),
2123            (
2124                folder_mount("oddly-named-process", proc_dir.clone()),
2125                Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2126                    as Box<dyn crate::backend::MemBackend>,
2127            ),
2128        ])
2129        .unwrap();
2130
2131        // The one resolution function: declaration wins.
2132        let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2133        assert!(r.declared && r.mounted);
2134        assert_eq!(r.mem, "oddly-named-process");
2135        // No declaration → derivation fallback, byte-identical to the
2136        // pre-declaration behaviour.
2137        let r =
2138            crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2139        assert!(!r.declared && !r.mounted);
2140        assert_eq!(r.mem, "whatever");
2141
2142        // The axis pairs the declared mem with no binding present.
2143        let axis = health_open_questions_axis(&engine, Some("dest"));
2144        let process = &axis["dest"]["process"];
2145        assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2146        assert_eq!(process[0]["declared"], true, "{axis}");
2147        assert_eq!(process[0]["resolvable"], true, "{axis}");
2148
2149        // Declaration naming a missing mem: typed finding.
2150        std::fs::write(
2151            dest_dir.join(".memstead").join("config.json"),
2152            r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2153        )
2154        .unwrap();
2155        let engine2 = crate::Engine::from_mounts(vec![(
2156            folder_mount("dest", dest_dir.clone()),
2157            Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2158                as Box<dyn crate::backend::MemBackend>,
2159        )])
2160        .unwrap();
2161        let axis = health_open_questions_axis(&engine2, Some("dest"));
2162        let process = &axis["dest"]["process"];
2163        assert_eq!(
2164            process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2165            "{axis}"
2166        );
2167        assert_eq!(process[0]["resolvable"], false, "{axis}");
2168    }
2169
2170    fn make_entity(name: &str, has_required: bool) -> Entity {
2171        let mut metadata = IndexMap::new();
2172        metadata.insert("level".into(), MetadataValue::String("M0".into()));
2173        metadata.insert("type".into(), MetadataValue::String("spec".into()));
2174        metadata.insert(
2175            "created_date".into(),
2176            MetadataValue::String("2026-01-15".into()),
2177        );
2178        metadata.insert(
2179            "last_modified".into(),
2180            MetadataValue::String("2026-04-12".into()),
2181        );
2182
2183        let mut sections = IndexMap::new();
2184        if has_required {
2185            sections.insert("identity".into(), "Has identity.".into());
2186            sections.insert("purpose".into(), "Has purpose.".into());
2187        }
2188
2189        Entity {
2190            id: EntityId::new("specs", name),
2191            title: name.into(),
2192            entity_type: "spec".into(),
2193            mem: "specs".into(),
2194            file_path: format!("{name}.md"),
2195            metadata,
2196            sections,
2197            relationships: Vec::new(),
2198            content_hash: String::new(),
2199            stub: false,
2200            stub_kind: None,
2201            heading_spans: std::collections::HashMap::new(),
2202            raw_section_headings: Vec::new(),
2203        }
2204    }
2205
2206    /// A sealed-violator type: section key `answers` with heading
2207    /// `Answers argued` (derives to `answers_argued`) — the plenum
2208    /// finding's exact shape. Loads fine; only new installs refuse.
2209    fn violating_type() -> std::sync::Arc<TypeDefinition> {
2210        let manifest = r#"name: debate
2211version: 0.1.0
2212description: sealed-violator fixture
2213when_to_use: health tests
2214types:
2215  - question
2216relationships:
2217  mode: strict
2218  definitions:
2219    - name: PART_OF
2220      description: hier
2221      default_weight: 3.0
2222    - name: _default
2223      description: fallback
2224      default_weight: 1.0
2225community:
2226  resolution: 1.0
2227  seed: 42
2228"#;
2229        let type_yaml = r#"name: question
2230description: t
2231when_to_use: tests
2232sections:
2233  - key: answers
2234    heading: Answers argued
2235    required: true
2236    search_weight: 10.0
2237    write_rules: []
2238  - key: notes
2239    heading: Notes
2240    required: false
2241    search_weight: 3.0
2242    catch_all: true
2243    write_rules: []
2244metadata_fields: []
2245title_weight: 100.0
2246text_fields:
2247  - answers
2248  - notes
2249hierarchy_relationship: PART_OF
2250no_self_loop_relationships: []
2251updatable_fields:
2252  - title
2253  - answers
2254  - notes
2255health_required_fields:
2256  - answers
2257staleness_threshold_days: 90
2258write_rules: []
2259"#;
2260        memstead_schema::load_schema_from_memory(
2261            manifest,
2262            &[("question".to_string(), type_yaml.to_string())],
2263        )
2264        .expect("violating schema still loads")
2265        .get_type("question")
2266        .expect("question type")
2267    }
2268
2269    /// Health must report the distinct SECTION_HEADING_MISMATCH finding
2270    /// — naming both headings and the catch-all the content landed in —
2271    /// for content sitting under a non-deriving heading, and must NOT
2272    /// report that section as missing. A genuinely absent section keeps
2273    /// the missing report; a conforming entity gets neither.
2274    #[test]
2275    fn health_distinguishes_heading_mismatch_from_missing_section() {
2276        let schema = violating_type();
2277
2278        // Content present under the declared (non-deriving) heading.
2279        let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
2280        let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
2281            .expect("parses")
2282            .entity;
2283        let report = entity_health(&parsed, &schema);
2284        let mismatch: Vec<_> = report
2285            .issues
2286            .iter()
2287            .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
2288            .collect();
2289        assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
2290        let msg = &mismatch[0].message;
2291        assert!(
2292            msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
2293            "names found heading and derived key: {msg}"
2294        );
2295        assert!(
2296            msg.contains("'notes'"),
2297            "names the catch-all landing: {msg}"
2298        );
2299        assert!(
2300            !report.issues.iter().any(|i| i.message.contains("is empty")),
2301            "must not also report the section as missing: {:?}",
2302            report.issues
2303        );
2304
2305        // Genuinely missing section: missing report exactly as today.
2306        let md_missing = "---\ntype: question\n---\n# Q2\n";
2307        let parsed_missing =
2308            crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
2309                .expect("parses")
2310                .entity;
2311        let report_missing = entity_health(&parsed_missing, &schema);
2312        assert!(
2313            report_missing
2314                .issues
2315                .iter()
2316                .any(|i| i.code == super::super::HealthIssueCode::Missing
2317                    && i.message == "required section 'answers' is empty"),
2318            "absent section keeps the missing report (structured MISSING code): {:?}",
2319            report_missing.issues
2320        );
2321        assert!(
2322            !report_missing
2323                .issues
2324                .iter()
2325                .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2326            "no mismatch finding when the heading is not in the file"
2327        );
2328
2329        // Conforming entity (content under a heading deriving to the
2330        // key would need a deriving heading — for this violating
2331        // schema no heading can reach `answers`, so use the conforming
2332        // catch-all only): neither finding for a section with content.
2333        let ok_type = crate::entity::parser::parse_markdown(
2334            "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
2335            "q3.md",
2336            &schema,
2337            "debate",
2338        )
2339        .expect("parses")
2340        .entity;
2341        let report_ok = entity_health(&ok_type, &schema);
2342        assert!(
2343            !report_ok
2344                .issues
2345                .iter()
2346                .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2347            "mismatch fires only when the declared heading is present: {:?}",
2348            report_ok.issues
2349        );
2350    }
2351
2352    fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
2353        let mut metadata = IndexMap::new();
2354        metadata.insert("type".into(), MetadataValue::String("concept".into()));
2355        metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
2356        metadata.insert(
2357            "abstraction_level".into(),
2358            MetadataValue::String("concrete".into()),
2359        );
2360        metadata.insert(
2361            "created_date".into(),
2362            MetadataValue::String("2026-01-15".into()),
2363        );
2364        metadata.insert(
2365            "last_modified".into(),
2366            MetadataValue::String("2026-04-12".into()),
2367        );
2368
2369        let mut sections = IndexMap::new();
2370        if with_definition {
2371            sections.insert("definition".into(), "Precise definition.".into());
2372        }
2373        sections.insert("explanation".into(), "How it works.".into());
2374
2375        Entity {
2376            id: EntityId::new("concepts", name),
2377            title: name.into(),
2378            entity_type: "concept".into(),
2379            mem: "concepts".into(),
2380            file_path: format!("{name}.md"),
2381            metadata,
2382            sections,
2383            relationships: Vec::new(),
2384            content_hash: String::new(),
2385            stub: false,
2386            stub_kind: None,
2387            heading_spans: std::collections::HashMap::new(),
2388            raw_section_headings: Vec::new(),
2389        }
2390    }
2391
2392    #[test]
2393    fn health_concept_missing_definition_reports_definition_field() {
2394        let schema = &type_by_name("concept").unwrap();
2395        let entity = make_concept_entity("clarity", false);
2396        let report = entity_health(&entity, schema);
2397
2398        // The missing-field issue must name the concept schema's required
2399        // section ("definition"), not spec's "identity".
2400        assert!(report.issues.iter().any(|i| i.field == "definition"));
2401        assert!(!report.issues.iter().any(|i| i.field == "identity"));
2402        assert!(!report.issues.iter().any(|i| i.field == "purpose"));
2403        assert!(report.score < 1.0);
2404
2405        // An entity with the definition filled in has no issue for that field.
2406        let healthy = make_concept_entity("clarity-ok", true);
2407        let healthy_report = entity_health(&healthy, schema);
2408        assert!(
2409            !healthy_report
2410                .issues
2411                .iter()
2412                .any(|i| i.field == "definition")
2413        );
2414    }
2415
2416    #[test]
2417    fn health_detects_missing_sections() {
2418        let schema = &type_by_name("spec").unwrap();
2419        let entity = make_entity("incomplete", false);
2420        let report = entity_health(&entity, schema);
2421        assert!(!report.issues.is_empty());
2422        assert!(report.score < 1.0);
2423    }
2424
2425    #[test]
2426    fn health_clean_entity() {
2427        let schema = &type_by_name("spec").unwrap();
2428        let entity = make_entity("complete", true);
2429        let report = entity_health(&entity, schema);
2430        // May still have issues for other required fields, but identity/purpose are covered
2431        let section_issues: Vec<_> = report
2432            .issues
2433            .iter()
2434            .filter(|i| i.field == "identity" || i.field == "purpose")
2435            .collect();
2436        assert!(section_issues.is_empty());
2437    }
2438
2439    #[test]
2440    fn health_summary_counts() {
2441        let mut store = Store::new();
2442        let e1 = make_entity("healthy", true);
2443        let e2 = make_entity("unhealthy", false);
2444        store.upsert(e1.id.clone(), e1);
2445        store.upsert(e2.id.clone(), e2);
2446
2447        let schema = &type_by_name("spec").unwrap();
2448        let summary = compute_health(&store, schema, &HashMap::new(), None);
2449        assert_eq!(summary.orphan_count, 2); // No edges between them
2450        assert_eq!(summary.stub_count, 0);
2451    }
2452
2453    #[test]
2454    fn health_surfaces_invalid_rel_shape_on_existing_edges() {
2455        // software@0.1.0 declares `source_types: [actor]` on OWNS.
2456        // Seed a non-actor source with an outgoing OWNS edge — the
2457        // health scan must surface `INVALID_REL_SHAPE` in the
2458        // entity's issues so an agent running a sweep can identify
2459        // edges to clean up via `memstead_relate remove=true`.
2460        use crate::entity::Relationship;
2461        use memstead_schema::SchemaRegistry;
2462
2463        let registry = SchemaRegistry::builtin();
2464        let software = registry
2465            .get("software", &semver::Version::new(0, 2, 0))
2466            .expect("software schema ships as a builtin");
2467
2468        let mut store = Store::new();
2469        // Source entity is `spec`, not `actor`. Add an OWNS edge to
2470        // a target whose type doesn't matter for source-side shape.
2471        let mut bad = make_entity("bad-owns-source", true);
2472        bad.entity_type = "spec".into();
2473        bad.metadata
2474            .insert("level".into(), MetadataValue::String("M0".into()));
2475        bad.metadata
2476            .insert("stability".into(), MetadataValue::String("evolving".into()));
2477        bad.relationships.push(Relationship {
2478            rel_type: "OWNS".into(),
2479            target: EntityId::new("specs", "victim"),
2480            description: None,
2481        });
2482        let mut victim = make_entity("victim", true);
2483        victim.entity_type = "spec".into();
2484        store.upsert(bad.id.clone(), bad);
2485        store.upsert(victim.id.clone(), victim);
2486
2487        let mut mem_schemas = HashMap::new();
2488        mem_schemas.insert("specs".to_string(), software);
2489
2490        let schema = &type_by_name("spec").unwrap();
2491        let summary = compute_health(&store, schema, &mem_schemas, None);
2492        let report = summary
2493            .missing_fields
2494            .iter()
2495            .find(|r| r.id.as_ref() == "specs--bad-owns-source")
2496            .expect("shape-violating entity must surface");
2497        let issue = report
2498            .issues
2499            .iter()
2500            .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
2501            .expect("shape violation must produce an INVALID_REL_SHAPE issue");
2502        assert!(
2503            issue.message.contains("OWNS"),
2504            "issue must name the offending rel_type: {}",
2505            issue.message
2506        );
2507        assert!(
2508            issue.message.contains("spec"),
2509            "issue must name the actual source type: {}",
2510            issue.message
2511        );
2512        assert!(
2513            issue.message.contains("actor"),
2514            "issue must name the allowed source type: {}",
2515            issue.message
2516        );
2517        assert!(
2518            issue.message.contains("remove=true"),
2519            "issue must surface the recovery path: {}",
2520            issue.message
2521        );
2522    }
2523
2524    #[test]
2525    fn health_does_not_flag_shape_compliant_edges() {
2526        // Sanity counterpart: an actor source with OWNS edge satisfies
2527        // `source_types: [actor]` — no INVALID_REL_SHAPE issue surfaces.
2528        use crate::entity::Relationship;
2529        use memstead_schema::SchemaRegistry;
2530
2531        let registry = SchemaRegistry::builtin();
2532        let software = registry
2533            .get("software", &semver::Version::new(0, 2, 0))
2534            .expect("software schema ships as a builtin");
2535
2536        let mut store = Store::new();
2537        let mut owner = make_entity("owner", true);
2538        owner.entity_type = "actor".into();
2539        owner
2540            .metadata
2541            .insert("kind".into(), MetadataValue::String("team".into()));
2542        owner
2543            .metadata
2544            .insert("active".into(), MetadataValue::Bool(true));
2545        owner
2546            .metadata
2547            .insert("handle".into(), MetadataValue::String("owner".into()));
2548        owner.relationships.push(Relationship {
2549            rel_type: "OWNS".into(),
2550            target: EntityId::new("specs", "owned"),
2551            description: None,
2552        });
2553        let mut owned = make_entity("owned", true);
2554        owned.entity_type = "spec".into();
2555        store.upsert(owner.id.clone(), owner);
2556        store.upsert(owned.id.clone(), owned);
2557
2558        let mut mem_schemas = HashMap::new();
2559        mem_schemas.insert("specs".to_string(), software);
2560
2561        let schema = &type_by_name("spec").unwrap();
2562        let summary = compute_health(&store, schema, &mem_schemas, None);
2563        let shape_issue = summary
2564            .missing_fields
2565            .iter()
2566            .flat_map(|r| r.issues.iter())
2567            .find(|i| i.message.contains("INVALID_REL_SHAPE"));
2568        assert!(
2569            shape_issue.is_none(),
2570            "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
2571        );
2572    }
2573
2574    #[test]
2575    fn health_warns_on_undeclared_relationship_in_existing_entity() {
2576        use crate::entity::Relationship;
2577        use memstead_schema::Schema;
2578
2579        let mut store = Store::new();
2580        let mut entity = make_entity("with-bad-rel", true);
2581        // Author an edge using a name that does not exist in the default
2582        // schema's vocabulary. The load-side contract per decision 3 is
2583        // about unknown *types*; unknown *relationships* on an already-
2584        // loaded entity land in the soft health surface instead so an
2585        // agent running `memstead_health` after a schema edit sees the drift.
2586        entity.relationships.push(Relationship {
2587            rel_type: "CONJURES".into(),
2588            target: EntityId::new("specs", "unknown"),
2589            description: None,
2590        });
2591        store.upsert(entity.id.clone(), entity);
2592
2593        let mut mem_schemas = HashMap::new();
2594        mem_schemas.insert("specs".to_string(), Schema::builtin_default());
2595
2596        let schema = &type_by_name("spec").unwrap();
2597        let summary = compute_health(&store, schema, &mem_schemas, None);
2598        let report = summary
2599            .missing_fields
2600            .iter()
2601            .find(|r| r.id.as_ref() == "specs--with-bad-rel")
2602            .expect("entity must surface in missing_fields");
2603        let rel_issue = report
2604            .issues
2605            .iter()
2606            .find(|i| i.field == "relationships")
2607            .expect("undeclared relationship must produce an issue");
2608        assert!(
2609            rel_issue.message.contains("CONJURES"),
2610            "issue message must name the offending relationship: {}",
2611            rel_issue.message
2612        );
2613        assert!(
2614            rel_issue.message.contains("default@1.0.0"),
2615            "issue must name the schema pin: {}",
2616            rel_issue.message
2617        );
2618    }
2619
2620    // -------------------------------------------------------------------
2621    // Dangling wiki-link detection
2622    // -------------------------------------------------------------------
2623
2624    /// Build an entity with an arbitrary section body so the test can seed
2625    /// inline wiki-links at will. Mem defaults to `specs`.
2626    fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
2627        let mut entity = make_entity(name, true);
2628        entity.sections.insert(section_key.into(), body.to_string());
2629        entity
2630    }
2631
2632    #[test]
2633    fn dangling_link_detected_after_delete() {
2634        use crate::entity::store_builder::make_stub;
2635
2636        let mut store = Store::new();
2637        let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2638        store.upsert(a.id.clone(), a.clone());
2639
2640        // Seed b as a stub — the signal that its markdown file is gone
2641        // (post-delete, pre-recreate, or never authored).
2642        let b_id = EntityId::new("specs", "b");
2643        store.upsert(b_id.clone(), make_stub(b_id.clone()));
2644
2645        let dangling = super::collect_dangling_links(&store, None);
2646        assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
2647        let d = &dangling[0];
2648        assert_eq!(d.from, a.id);
2649        assert_eq!(d.target_id, b_id);
2650        assert_eq!(d.target_path, "b");
2651        assert_eq!(d.section.as_deref(), Some("purpose"));
2652    }
2653
2654    /// Decision 18 (backlog-sweep plan 06): dangling-links and stubs
2655    /// output is deterministic — the store iterates a HashMap, so the
2656    /// collectors sort before serving. Two independently built
2657    /// identical stores must produce byte-identical lists, in the
2658    /// documented (from, target, section) / id order.
2659    #[test]
2660    fn dangling_links_and_stubs_serve_in_deterministic_order() {
2661        use crate::entity::store_builder::make_stub;
2662
2663        let build = || {
2664            let mut store = Store::new();
2665            // Insert in an order unrelated to the expected output order.
2666            for name in ["zeta", "alpha", "mid"] {
2667                let e = make_entity_with_body(
2668                    name,
2669                    "purpose",
2670                    &format!("See [[gone-{name}]] and [[lost-{name}]]."),
2671                );
2672                store.upsert(e.id.clone(), e);
2673            }
2674            for name in ["zeta", "alpha", "mid"] {
2675                for pre in ["gone", "lost"] {
2676                    let id = EntityId::new("specs", &format!("{pre}-{name}"));
2677                    store.upsert(id.clone(), make_stub(id));
2678                }
2679            }
2680            store
2681        };
2682
2683        let store_a = build();
2684        let store_b = build();
2685
2686        let key =
2687            |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
2688        let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
2689            .iter()
2690            .map(key)
2691            .collect();
2692        let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
2693            .iter()
2694            .map(key)
2695            .collect();
2696        assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
2697        let mut sorted = dangling_a.clone();
2698        sorted.sort();
2699        assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
2700        assert_eq!(dangling_a.len(), 6);
2701
2702        let stub_ids = |s: &Store| -> Vec<String> {
2703            crate::graph::query::find_stubs(s)
2704                .into_iter()
2705                .map(|(id, _)| id.0)
2706                .collect()
2707        };
2708        let stubs_a = stub_ids(&store_a);
2709        assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
2710        let mut sorted = stubs_a.clone();
2711        sorted.sort();
2712        assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
2713        assert_eq!(stubs_a.len(), 6);
2714    }
2715
2716    #[test]
2717    fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
2718        use crate::entity::Relationship;
2719        use crate::entity::store_builder::make_stub;
2720
2721        let mut store = Store::new();
2722        // A has NO inline link in its body — only an explicit relationship
2723        // edge pointing at a stub.
2724        let mut a = make_entity("a", true);
2725        let b_id = EntityId::new("specs", "b");
2726        a.relationships.push(Relationship {
2727            rel_type: "REFERENCES".into(),
2728            target: b_id.clone(),
2729            description: None,
2730        });
2731        store.upsert(a.id.clone(), a);
2732        store.upsert(b_id.clone(), make_stub(b_id));
2733
2734        let dangling = super::collect_dangling_links(&store, None);
2735        assert!(
2736            dangling.is_empty(),
2737            "explicit relationships to stubs are valid by design \
2738             (stubs are first-class placeholders); only inline-body \
2739             wiki-links to stubs must surface"
2740        );
2741    }
2742
2743    #[test]
2744    fn dangling_link_does_not_flag_real_reference() {
2745        use crate::entity::Relationship;
2746
2747        let mut store = Store::new();
2748        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2749        // Backing relation makes the body link a valid alias.
2750        a.relationships.push(Relationship {
2751            rel_type: "REFERENCES".into(),
2752            target: EntityId::new("specs", "b"),
2753            description: None,
2754        });
2755        let b = make_entity("b", true);
2756        store.upsert(a.id.clone(), a);
2757        store.upsert(b.id.clone(), b);
2758
2759        let dangling = super::collect_dangling_links(&store, None);
2760        assert!(
2761            dangling.is_empty(),
2762            "real reference backed by relation — not dangling, not alias-orphan"
2763        );
2764    }
2765
2766    /// F12: a `## Relationships` row pointing at a fully-absent target
2767    /// (out-of-band file edit, mem-delete corruption) must surface.
2768    /// The scan covers both axes; relationship-table danglers ship
2769    /// `section: None` to mark the source axis.
2770    #[test]
2771    fn dangling_link_relationship_section_target_absent() {
2772        use crate::entity::Relationship;
2773
2774        let mut store = Store::new();
2775        let mut a = make_entity("a", true);
2776        // Note: NO stub in the store for `gone` — out-of-band edit
2777        // removed the stub but left the relationship row.
2778        a.relationships.push(Relationship {
2779            rel_type: "DEPENDS_ON".into(),
2780            target: EntityId::new("specs", "gone"),
2781            description: None,
2782        });
2783        store.upsert(a.id.clone(), a.clone());
2784
2785        let dangling = super::collect_dangling_links(&store, None);
2786        assert_eq!(
2787            dangling.len(),
2788            1,
2789            "exactly one relationship-section dangler"
2790        );
2791        let d = &dangling[0];
2792        assert_eq!(d.from, a.id);
2793        assert_eq!(d.target_id, EntityId::new("specs", "gone"));
2794        assert!(
2795            d.section.is_none(),
2796            "relationship-section danglers ship `section: None`, got {:?}",
2797            d.section
2798        );
2799    }
2800
2801    /// Relationship rows pointing at stubs are NOT flagged. Auto-stub
2802    /// is the alias machinery's forward-reference mechanism; flagging
2803    /// stubs would conflate the "engine-managed placeholder" case with
2804    /// corruption.
2805    #[test]
2806    fn dangling_link_relationship_section_stub_target_not_flagged() {
2807        use crate::entity::Relationship;
2808        use crate::entity::store_builder::make_stub;
2809
2810        let mut store = Store::new();
2811        let mut a = make_entity("a", true);
2812        let b_id = EntityId::new("specs", "b");
2813        a.relationships.push(Relationship {
2814            rel_type: "DEPENDS_ON".into(),
2815            target: b_id.clone(),
2816            description: None,
2817        });
2818        store.upsert(a.id.clone(), a);
2819        store.upsert(b_id.clone(), make_stub(b_id));
2820
2821        let dangling = super::collect_dangling_links(&store, None);
2822        assert!(
2823            dangling.is_empty(),
2824            "relationship targets that resolve to stubs are forward-references, not corruption"
2825        );
2826    }
2827
2828    /// When both the body and the relationship section point at the
2829    /// same fully-absent target, the dangler dedupes to a single entry
2830    /// on whichever axis fired first (body-scan runs
2831    /// before relationship-scan in the implementation; the body axis
2832    /// wins). Stub-shaped duplicates are not possible because the
2833    /// relationship-section scan skips stubs.
2834    #[test]
2835    fn dangling_link_dedups_across_body_and_relations() {
2836        use crate::entity::Relationship;
2837        use crate::entity::store_builder::make_stub;
2838
2839        let mut store = Store::new();
2840        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2841        let b_id = EntityId::new("specs", "b");
2842        a.relationships.push(Relationship {
2843            rel_type: "REFERENCES".into(),
2844            target: b_id.clone(),
2845            description: None,
2846        });
2847        store.upsert(a.id.clone(), a.clone());
2848        store.upsert(b_id.clone(), make_stub(b_id.clone()));
2849
2850        let dangling = super::collect_dangling_links(&store, None);
2851        assert_eq!(
2852            dangling.len(),
2853            1,
2854            "body + relations both pointing at the same stub should dedup"
2855        );
2856        // Body scan fires first; the surviving entry carries
2857        // `section: Some(_)`.
2858        assert!(dangling[0].section.is_some(), "body axis wins the dedup");
2859    }
2860
2861    #[test]
2862    fn dangling_links_scope_to_mem_filter() {
2863        use crate::entity::store_builder::make_stub;
2864
2865        let mut store = Store::new();
2866
2867        // specs--a with body [[gone]] → dangling in specs.
2868        let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
2869        store.upsert(a.id.clone(), a);
2870        let gone_specs = EntityId::new("specs", "gone");
2871        store.upsert(gone_specs.clone(), make_stub(gone_specs));
2872
2873        // web--x with body [[gone]] → dangling in web (different stub).
2874        let mut x = make_entity("x", true);
2875        x.id = EntityId::new("web", "x");
2876        x.mem = "web".into();
2877        x.file_path = "x.md".into();
2878        x.sections
2879            .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
2880        store.upsert(x.id.clone(), x);
2881        let gone_web = EntityId::new("web", "gone");
2882        store.upsert(gone_web.clone(), make_stub(gone_web));
2883
2884        let all = super::collect_dangling_links(&store, None);
2885        assert_eq!(all.len(), 2);
2886
2887        let specs_only = super::collect_dangling_links(&store, Some("specs"));
2888        assert_eq!(specs_only.len(), 1);
2889        assert_eq!(specs_only[0].from.mem(), "specs");
2890
2891        let web_only = super::collect_dangling_links(&store, Some("web"));
2892        assert_eq!(web_only.len(), 1);
2893        assert_eq!(web_only[0].from.mem(), "web");
2894    }
2895
2896    #[test]
2897    fn parse_iso_date() {
2898        let days = parse_iso_to_days("2026-04-12").unwrap();
2899        assert!(days > 0);
2900
2901        let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
2902        assert_eq!(days, days_with_time);
2903    }
2904
2905    #[test]
2906    fn ymd_roundtrip() {
2907        // 2026-01-01
2908        let days = ymd_to_days(2026, 1, 1);
2909        assert!(days > 20000); // sanity check
2910    }
2911
2912    // ---------------------------------------------------------------------
2913    // collect_tag_distribution — #18
2914    // ---------------------------------------------------------------------
2915
2916    fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
2917        let mut e = make_entity(name, true);
2918        e.id = EntityId::new(mem, name);
2919        e.mem = mem.into();
2920        e.entity_type = entity_type.into();
2921        e.metadata
2922            .insert("tags".into(), MetadataValue::String(tags.into()));
2923        e
2924    }
2925
2926    fn make_entity_no_tags(name: &str) -> Entity {
2927        make_entity(name, true)
2928    }
2929
2930    #[test]
2931    fn tag_distribution_aggregates_across_entities() {
2932        let mut store = Store::new();
2933        let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
2934        let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
2935        let c = make_entity_with_tags("c", "specs", "spec", "plan");
2936        store.upsert(a.id.clone(), a);
2937        store.upsert(b.id.clone(), b);
2938        store.upsert(c.id.clone(), c);
2939
2940        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2941        assert_eq!(dist.len(), 2);
2942        assert_eq!(dist[0].tag, "plan");
2943        assert_eq!(dist[0].count, 3);
2944        assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
2945        assert_eq!(dist[1].tag, "decision");
2946        assert_eq!(dist[1].count, 2);
2947        assert_eq!(untagged.total, 0);
2948    }
2949
2950    #[test]
2951    fn tag_distribution_case_sensitive() {
2952        let mut store = Store::new();
2953        let a = make_entity_with_tags("a", "specs", "spec", "Decision");
2954        let b = make_entity_with_tags("b", "specs", "spec", "decision");
2955        store.upsert(a.id.clone(), a);
2956        store.upsert(b.id.clone(), b);
2957
2958        let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
2959        assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
2960        let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
2961        assert!(tags.contains("decision"));
2962        assert!(tags.contains("Decision"));
2963
2964        // Drift sidecar surfaces the collision.
2965        assert_eq!(folded.len(), 1);
2966        assert_eq!(folded[0].canonical, "decision");
2967        assert_eq!(folded[0].total, 2);
2968        assert_eq!(folded[0].variants.len(), 2);
2969    }
2970
2971    #[test]
2972    fn untagged_entities_counts_missing_and_empty() {
2973        let mut store = Store::new();
2974        let a = make_entity_no_tags("a"); // no `tags` metadata
2975        let b = make_entity_with_tags("b", "specs", "spec", "");
2976        let c = make_entity_with_tags("c", "specs", "spec", " , , ");
2977        store.upsert(a.id.clone(), a);
2978        store.upsert(b.id.clone(), b);
2979        store.upsert(c.id.clone(), c);
2980
2981        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2982        assert!(dist.is_empty(), "no effective tags → empty distribution");
2983        assert_eq!(untagged.total, 3);
2984        assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
2985    }
2986
2987    #[test]
2988    fn tag_distribution_respects_mem_filter() {
2989        let mut store = Store::new();
2990        let a = make_entity_with_tags("a", "specs", "spec", "decision");
2991        let b = make_entity_with_tags("b", "memos", "memo", "observation");
2992        let c = make_entity_no_tags("c");
2993        store.upsert(a.id.clone(), a);
2994        store.upsert(b.id.clone(), b);
2995        store.upsert(c.id.clone(), c);
2996
2997        let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
2998        assert_eq!(dist.len(), 1);
2999        assert_eq!(dist[0].tag, "observation");
3000        assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3001    }
3002
3003    #[test]
3004    fn tag_distribution_respects_limit() {
3005        let mut store = Store::new();
3006        for (name, tag) in [
3007            ("a", "t-alpha"),
3008            ("b", "t-beta"),
3009            ("c", "t-gamma"),
3010            ("d", "t-delta"),
3011            ("e", "t-epsilon"),
3012        ] {
3013            let e = make_entity_with_tags(name, "specs", "spec", tag);
3014            store.upsert(e.id.clone(), e);
3015        }
3016
3017        let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3018        assert_eq!(dist.len(), 3);
3019        // Every tag appears once → ties across all 5; deterministic tie-break is
3020        // lex ascending: alpha, beta, delta (first 3 sorted).
3021        assert_eq!(dist[0].tag, "t-alpha");
3022        assert_eq!(dist[1].tag, "t-beta");
3023        assert_eq!(dist[2].tag, "t-delta");
3024    }
3025
3026    // ----------------------------------------------------------------------
3027    // required_outgoing health collector
3028    // ----------------------------------------------------------------------
3029
3030    /// Build a minimal schema fixture pinning `decision` with two
3031    /// `required_outgoing` blocks (CHOSEN + REJECTED), `note` with none.
3032    fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
3033        let manifest = r#"name: tests-ro-health
3034version: 0.1.0
3035description: required_outgoing health test schema
3036when_to_use: tests
3037types:
3038  - decision
3039  - note
3040relationships:
3041  mode: strict
3042  definitions:
3043    - name: PART_OF
3044      description: Hier
3045      default_weight: 3.0
3046      acyclic: true
3047    - name: CHOSEN
3048      description: ch
3049      default_weight: 3.0
3050    - name: REJECTED
3051      description: rj
3052      default_weight: 2.0
3053    - name: REFERENCES
3054      description: ref
3055      default_weight: 0.5
3056    - name: _default
3057      description: Fallback
3058      default_weight: 1.0
3059community:
3060  resolution: 1.0
3061  seed: 42
3062"#;
3063        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";
3064        let decision_yaml = format!(
3065            "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",
3066        );
3067        let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
3068        std::sync::Arc::new(
3069            memstead_schema::load_schema_from_memory(
3070                manifest,
3071                &[
3072                    ("decision".to_string(), decision_yaml),
3073                    ("note".to_string(), note_yaml),
3074                ],
3075            )
3076            .expect("ro fixture schema must parse"),
3077        )
3078    }
3079
3080    fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
3081        use crate::entity::MetadataValue;
3082        let mut metadata = IndexMap::new();
3083        metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
3084        let mut sections = IndexMap::new();
3085        sections.insert("body".into(), "Body.".into());
3086        crate::entity::Entity {
3087            id: EntityId::new(mem, slug),
3088            title: slug.to_string(),
3089            entity_type: entity_type.into(),
3090            mem: mem.into(),
3091            file_path: format!("{slug}.md"),
3092            metadata,
3093            sections,
3094            relationships: Vec::new(),
3095            content_hash: String::new(),
3096            stub: false,
3097            stub_kind: None,
3098            heading_spans: std::collections::HashMap::new(),
3099            raw_section_headings: Vec::new(),
3100        }
3101    }
3102
3103    #[test]
3104    fn missing_required_outgoing_collects_violators_only() {
3105        let schema = required_outgoing_fixture_schema();
3106        let mut store = Store::new();
3107        // Two decisions: one without any edges (violates 2 blocks), one
3108        // with both edges satisfied. One note (no requirement).
3109        let mut violator = make_typed_entity("plan", "stalled", "decision");
3110        let mut satisfied = make_typed_entity("plan", "wired", "decision");
3111        let opt_a = make_typed_entity("plan", "a", "note");
3112        let opt_b = make_typed_entity("plan", "b", "note");
3113        let happy_note = make_typed_entity("plan", "side", "note");
3114        satisfied.relationships.push(crate::entity::Relationship {
3115            rel_type: "CHOSEN".into(),
3116            target: opt_a.id.clone(),
3117            description: None,
3118        });
3119        satisfied.relationships.push(crate::entity::Relationship {
3120            rel_type: "REJECTED".into(),
3121            target: opt_b.id.clone(),
3122            description: None,
3123        });
3124        for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
3125            store.upsert(e.id.clone(), e);
3126        }
3127
3128        let mut mem_schemas = HashMap::new();
3129        mem_schemas.insert("plan".to_string(), schema);
3130
3131        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3132        assert_eq!(
3133            reports.len(),
3134            1,
3135            "exactly one violator (the empty decision); got {reports:?}"
3136        );
3137        let r = &reports[0];
3138        assert_eq!(r.id, violator.id);
3139        assert_eq!(r.entity_type, "decision");
3140        assert_eq!(r.mem, "plan");
3141        assert_eq!(r.missing.len(), 2);
3142        let names: Vec<&str> = r
3143            .missing
3144            .iter()
3145            .flat_map(|b| b.relationships.iter().map(String::as_str))
3146            .collect();
3147        assert!(names.contains(&"CHOSEN"));
3148        assert!(names.contains(&"REJECTED"));
3149
3150        // mark warning still doesn't propagate when violator is removed.
3151        violator.relationships.push(crate::entity::Relationship {
3152            rel_type: "CHOSEN".into(),
3153            target: EntityId::new("plan", "x"),
3154            description: None,
3155        });
3156    }
3157
3158    #[test]
3159    fn missing_required_outgoing_respects_mem_filter() {
3160        // Plan: "a write to mem A doesn't surface mem B's violations
3161        // in memstead_health mem=A; mem-scoped aggregation is correct."
3162        let schema = required_outgoing_fixture_schema();
3163        let mut store = Store::new();
3164        let v_a = make_typed_entity("alpha", "stalled", "decision");
3165        let v_b = make_typed_entity("beta", "stalled", "decision");
3166        store.upsert(v_a.id.clone(), v_a);
3167        store.upsert(v_b.id.clone(), v_b.clone());
3168
3169        let mut mem_schemas = HashMap::new();
3170        mem_schemas.insert("alpha".to_string(), schema.clone());
3171        mem_schemas.insert("beta".to_string(), schema);
3172
3173        let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
3174        assert_eq!(alpha_only.len(), 1);
3175        assert_eq!(alpha_only[0].mem, "alpha");
3176
3177        let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
3178        assert_eq!(both.len(), 2);
3179    }
3180
3181    #[test]
3182    fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
3183        // Stubs have no entity_type; unschemaed mems can't be evaluated
3184        // — both must be silently skipped.
3185        let schema = required_outgoing_fixture_schema();
3186        let mut store = Store::new();
3187        let mut stub = make_typed_entity("plan", "ghost", "");
3188        stub.stub = true;
3189        stub.entity_type = String::new();
3190        let other = make_typed_entity("uncharted", "lonely", "decision");
3191        store.upsert(stub.id.clone(), stub);
3192        store.upsert(other.id.clone(), other);
3193
3194        let mut mem_schemas = HashMap::new();
3195        mem_schemas.insert("plan".to_string(), schema);
3196
3197        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3198        assert!(
3199            reports.is_empty(),
3200            "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
3201        );
3202    }
3203
3204    /// A conditional block arms only on the trigger value: the sweep
3205    /// reports the armed violator (with the trigger named in the
3206    /// block entry), and skips both the other-value and the
3207    /// edge-satisfied entities.
3208    #[test]
3209    fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
3210        use crate::entity::MetadataValue;
3211        let manifest = r#"name: tests-ro-cond
3212version: 0.1.0
3213description: conditional required_outgoing health test schema
3214when_to_use: tests
3215types:
3216  - task
3217relationships:
3218  mode: strict
3219  definitions:
3220    - name: PART_OF
3221      description: Hier
3222      default_weight: 3.0
3223    - name: _default
3224      description: Fallback
3225      default_weight: 1.0
3226community:
3227  resolution: 1.0
3228  seed: 42
3229"#;
3230        let task_yaml = "name: task\ndescription: t\nwhen_to_use: Here\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: status\n    description: workflow state\n    field_type: string\n    enum_values: [open, checked]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\n  - status\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\nrequired_outgoing:\n  - relationships: [PART_OF]\n    cardinality: at_least_one\n    when_field: status\n    when_value: checked\n";
3231        let schema = std::sync::Arc::new(
3232            memstead_schema::load_schema_from_memory(
3233                manifest,
3234                &[("task".to_string(), task_yaml.to_string())],
3235            )
3236            .expect("conditional ro fixture schema must parse"),
3237        );
3238
3239        let mut store = Store::new();
3240        let mut armed = make_typed_entity("plan", "armed", "task");
3241        armed
3242            .metadata
3243            .insert("status".into(), MetadataValue::String("checked".into()));
3244        let mut other_value = make_typed_entity("plan", "quiet", "task");
3245        other_value
3246            .metadata
3247            .insert("status".into(), MetadataValue::String("open".into()));
3248        let unset = make_typed_entity("plan", "blank", "task");
3249        let parent = make_typed_entity("plan", "parent", "task");
3250        let mut satisfied = make_typed_entity("plan", "wired", "task");
3251        satisfied
3252            .metadata
3253            .insert("status".into(), MetadataValue::String("checked".into()));
3254        satisfied.relationships.push(crate::entity::Relationship {
3255            rel_type: "PART_OF".into(),
3256            target: parent.id.clone(),
3257            description: None,
3258        });
3259        for e in [armed.clone(), other_value, unset, parent, satisfied] {
3260            store.upsert(e.id.clone(), e);
3261        }
3262
3263        let mut mem_schemas = HashMap::new();
3264        mem_schemas.insert("plan".to_string(), schema);
3265
3266        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3267        assert_eq!(
3268            reports.len(),
3269            1,
3270            "only the armed edge-less entity is reported; got {reports:?}"
3271        );
3272        let r = &reports[0];
3273        assert_eq!(r.id, armed.id);
3274        assert_eq!(r.missing.len(), 1);
3275        assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
3276        assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
3277    }
3278
3279    // ----------------------------------------------------------------------
3280    // must_reach reachability obligations
3281    // ----------------------------------------------------------------------
3282
3283    /// Three-type argument-shaped fixture: claim / inference /
3284    /// evidence over GROUNDS / CONCLUDES. The per-type `must_reach`
3285    /// blocks are injected by the caller (empty string = none).
3286    fn must_reach_schema(
3287        claim_extra: &str,
3288        inference_extra: &str,
3289    ) -> std::sync::Arc<memstead_schema::Schema> {
3290        let manifest = r#"name: tests-must-reach
3291version: 0.1.0
3292description: must_reach health test schema
3293when_to_use: tests
3294types:
3295  - claim
3296  - inference
3297  - evidence
3298relationships:
3299  mode: strict
3300  definitions:
3301    - name: GROUNDS
3302      description: g
3303      default_weight: 3.0
3304    - name: CONCLUDES
3305      description: c
3306      default_weight: 3.0
3307    - name: PART_OF
3308      description: hier
3309      default_weight: 1.0
3310    - name: _default
3311      description: fallback
3312      default_weight: 1.0
3313community:
3314  resolution: 1.0
3315  seed: 42
3316"#;
3317        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
3318        let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
3319        let inference =
3320            format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
3321        let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
3322        std::sync::Arc::new(
3323            memstead_schema::load_schema_from_memory(
3324                manifest,
3325                &[
3326                    ("claim".to_string(), claim),
3327                    ("inference".to_string(), inference),
3328                    ("evidence".to_string(), evidence),
3329                ],
3330            )
3331            .expect("must_reach fixture schema must parse"),
3332        )
3333    }
3334
3335    fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
3336        from.relationships.push(crate::entity::Relationship {
3337            rel_type: rel.into(),
3338            target: to.clone(),
3339            description: None,
3340        });
3341    }
3342
3343    fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
3344        r.violations
3345            .iter()
3346            .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
3347            .collect()
3348    }
3349
3350    const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n";
3351
3352    /// A conforming path (direct or transitive through a non-terminal)
3353    /// is silent; an entity without one carries a finding echoing the
3354    /// whole declaration.
3355    #[test]
3356    fn must_reach_conforming_path_silent_gap_reported() {
3357        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3358        let mut store = Store::new();
3359        let ev = make_typed_entity("arg", "ev", "evidence");
3360        let mut direct = make_typed_entity("arg", "direct", "claim");
3361        link(&mut direct, "GROUNDS", &ev.id);
3362        let mut mid = make_typed_entity("arg", "mid", "claim");
3363        let mut chained = make_typed_entity("arg", "chained", "claim");
3364        link(&mut chained, "GROUNDS", &mid.id);
3365        link(&mut mid, "GROUNDS", &ev.id);
3366        let floating = make_typed_entity("arg", "floating", "claim");
3367        for e in [ev, direct, mid, chained, floating.clone()] {
3368            store.upsert(e.id.clone(), e);
3369        }
3370        let mut mem_schemas = HashMap::new();
3371        mem_schemas.insert("arg".to_string(), schema);
3372
3373        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3374        assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
3375        assert_eq!(reports[0].id, floating.id);
3376        let v = must_reach_violations(&reports[0]);
3377        assert_eq!(v.len(), 1);
3378        let UnsatisfiedConstraint::MustReach {
3379            relationships,
3380            direction,
3381            terminal_types,
3382            max_depth,
3383            ..
3384        } = v[0]
3385        else {
3386            panic!("expected must_reach finding");
3387        };
3388        assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
3389        assert_eq!(*direction, memstead_schema::ReachDirection::Out);
3390        assert_eq!(terminal_types, &vec!["evidence".to_string()]);
3391        assert_eq!(*max_depth, None);
3392    }
3393
3394    /// The floating leap: an inference no premise reaches (zero
3395    /// incoming edges of the set) is a finding; one incoming premise
3396    /// edge silences it. Incoming direction with depth 1 is the
3397    /// required-incoming-edge case.
3398    #[test]
3399    fn must_reach_one_hop_incoming_floating_leap() {
3400        let schema = must_reach_schema(
3401            "",
3402            "must_reach:\n  - relationships: [GROUNDS]\n    direction: in\n    terminal_types: [claim]\n    max_depth: 1\n",
3403        );
3404        let mut store = Store::new();
3405        let leap = make_typed_entity("arg", "leap", "inference");
3406        let grounded = make_typed_entity("arg", "grounded", "inference");
3407        let mut premise = make_typed_entity("arg", "premise", "claim");
3408        link(&mut premise, "GROUNDS", &grounded.id);
3409        for e in [leap.clone(), grounded, premise] {
3410            store.upsert(e.id.clone(), e);
3411        }
3412        let mut mem_schemas = HashMap::new();
3413        mem_schemas.insert("arg".to_string(), schema);
3414
3415        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3416        assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
3417        assert_eq!(reports[0].id, leap.id);
3418    }
3419
3420    /// A chain ending in a stub or in non-terminal types is a finding;
3421    /// adding one conforming path clears it on the next sweep.
3422    #[test]
3423    fn must_reach_stub_and_non_terminal_chains_then_cleared() {
3424        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3425        let mut store = Store::new();
3426        let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
3427        stub_ev.stub = true;
3428        let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
3429        link(&mut to_stub, "GROUNDS", &stub_ev.id);
3430        let dead_end = make_typed_entity("arg", "dead-end", "claim");
3431        let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
3432        link(&mut to_claim, "GROUNDS", &dead_end.id);
3433        for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
3434            store.upsert(e.id.clone(), e);
3435        }
3436        let mut mem_schemas = HashMap::new();
3437        mem_schemas.insert("arg".to_string(), schema.clone());
3438
3439        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3440        let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3441        assert!(
3442            ids.contains(&to_stub.id.0.as_str()),
3443            "stub terminates no obligation: {ids:?}"
3444        );
3445        assert!(
3446            ids.contains(&to_claim.id.0.as_str()),
3447            "non-terminal chain is a finding: {ids:?}"
3448        );
3449
3450        // One conforming edge clears the finding on the next call.
3451        let ev = make_typed_entity("arg", "real-ev", "evidence");
3452        let mut repaired = store.get(&to_stub.id).unwrap().clone();
3453        link(&mut repaired, "GROUNDS", &ev.id);
3454        store.upsert(ev.id.clone(), ev);
3455        store.upsert(repaired.id.clone(), repaired);
3456        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3457        let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3458        assert!(
3459            !ids.contains(&to_stub.id.0.as_str()),
3460            "conforming path clears the finding: {ids:?}"
3461        );
3462    }
3463
3464    /// A cycle along the walked set terminates (visited-set
3465    /// discipline): the sweep returns findings for both cycle members
3466    /// instead of hanging.
3467    #[test]
3468    fn must_reach_cycles_terminate() {
3469        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3470        let mut store = Store::new();
3471        let mut a = make_typed_entity("arg", "cyc-a", "claim");
3472        let mut b = make_typed_entity("arg", "cyc-b", "claim");
3473        link(&mut a, "GROUNDS", &b.id);
3474        link(&mut b, "GROUNDS", &a.id);
3475        for e in [a, b] {
3476            store.upsert(e.id.clone(), e);
3477        }
3478        let mut mem_schemas = HashMap::new();
3479        mem_schemas.insert("arg".to_string(), schema);
3480
3481        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3482        assert_eq!(reports.len(), 2, "both cycle members lack evidence");
3483    }
3484
3485    /// Depth bound: a conforming path within the bound is silent; a
3486    /// graph whose only conforming path exceeds the bound is a
3487    /// finding.
3488    #[test]
3489    fn must_reach_depth_bound() {
3490        let two_hop_store = || {
3491            let mut store = Store::new();
3492            let ev = make_typed_entity("arg", "ev", "evidence");
3493            let mut mid = make_typed_entity("arg", "mid", "claim");
3494            let mut start = make_typed_entity("arg", "start", "claim");
3495            link(&mut start, "GROUNDS", &mid.id);
3496            link(&mut mid, "GROUNDS", &ev.id);
3497            for e in [ev, mid, start] {
3498                store.upsert(e.id.clone(), e);
3499            }
3500            store
3501        };
3502        let bounded = |depth: u32| {
3503            must_reach_schema(
3504                &format!(
3505                    "must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n    max_depth: {depth}\n"
3506                ),
3507                "",
3508            )
3509        };
3510
3511        let store = two_hop_store();
3512        let mut mem_schemas = HashMap::new();
3513        mem_schemas.insert("arg".to_string(), bounded(1));
3514        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3515        assert_eq!(
3516            reports.len(),
3517            1,
3518            "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
3519        );
3520        assert_eq!(reports[0].id.0, "arg--start");
3521
3522        let mut mem_schemas = HashMap::new();
3523        mem_schemas.insert("arg".to_string(), bounded(2));
3524        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3525        assert!(
3526            reports.is_empty(),
3527            "the same path satisfies depth 2: {reports:?}"
3528        );
3529    }
3530
3531    /// Two obligations on one type: exactly one finding, naming the
3532    /// unsatisfied block.
3533    #[test]
3534    fn must_reach_two_obligations_one_finding() {
3535        let schema = must_reach_schema(
3536            "must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n  - relationships: [CONCLUDES]\n    direction: out\n    terminal_types: [inference]\n",
3537            "",
3538        );
3539        let mut store = Store::new();
3540        let ev = make_typed_entity("arg", "ev", "evidence");
3541        let mut c = make_typed_entity("arg", "half", "claim");
3542        link(&mut c, "GROUNDS", &ev.id);
3543        for e in [ev, c.clone()] {
3544            store.upsert(e.id.clone(), e);
3545        }
3546        let mut mem_schemas = HashMap::new();
3547        mem_schemas.insert("arg".to_string(), schema);
3548
3549        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3550        assert_eq!(reports.len(), 1);
3551        assert_eq!(reports[0].id, c.id);
3552        let v = must_reach_violations(&reports[0]);
3553        assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
3554        let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
3555            panic!("expected must_reach finding");
3556        };
3557        assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
3558    }
3559
3560    /// `status_propagation` with `rel_types`: the taint crosses
3561    /// rel-type boundaries along the union subgraph (the experiment's
3562    /// withdrawn-evidence chain in the two-rel-type modelling), and
3563    /// the finding echoes the set (`rel_types` present, `rel_type`
3564    /// absent).
3565    #[test]
3566    fn status_propagation_rel_types_taints_across_type_boundaries() {
3567        use crate::entity::MetadataValue;
3568        let manifest = r#"name: tests-prop-set
3569version: 0.1.0
3570description: propagation relation-set test schema
3571when_to_use: tests
3572types:
3573  - claim
3574relationships:
3575  mode: strict
3576  definitions:
3577    - name: GROUNDS
3578      description: g
3579      default_weight: 3.0
3580    - name: CONCLUDES
3581      description: c
3582      default_weight: 3.0
3583    - name: PART_OF
3584      description: hier
3585      default_weight: 1.0
3586    - name: _default
3587      description: fallback
3588      default_weight: 1.0
3589community:
3590  resolution: 1.0
3591  seed: 42
3592"#;
3593        let claim_yaml = "name: claim\ndescription: t\nwhen_to_use: Here\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: standing\n    description: dialectical standing\n    field_type: string\n    enum_values: [active, withdrawn]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\n  - standing\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n  - kind: status_propagation\n    field: standing\n    value: withdrawn\n    rel_types: [GROUNDS, CONCLUDES]\n    direction: incoming\n";
3594        let schema = std::sync::Arc::new(
3595            memstead_schema::load_schema_from_memory(
3596                manifest,
3597                &[("claim".to_string(), claim_yaml.to_string())],
3598            )
3599            .expect("propagation-set fixture schema must parse"),
3600        );
3601
3602        let mut store = Store::new();
3603        let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
3604        withdrawn
3605            .metadata
3606            .insert("standing".into(), MetadataValue::String("withdrawn".into()));
3607        let mut inference = make_typed_entity("arg", "inference", "claim");
3608        link(&mut inference, "GROUNDS", &withdrawn.id);
3609        let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
3610        link(&mut conclusion, "CONCLUDES", &inference.id);
3611        let bystander = make_typed_entity("arg", "bystander", "claim");
3612        for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
3613            store.upsert(e.id.clone(), e);
3614        }
3615        let mut mem_schemas = HashMap::new();
3616        mem_schemas.insert("arg".to_string(), schema);
3617
3618        let reports = collect_constraint_findings(&store, None, &mem_schemas);
3619        let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3620        assert_eq!(
3621            ids,
3622            vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
3623            "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
3624        );
3625        let UnsatisfiedConstraint::StatusPropagation {
3626            rel_type,
3627            rel_types,
3628            tainted_by,
3629            ..
3630        } = &reports[0].violations[0]
3631        else {
3632            panic!("expected status_propagation finding");
3633        };
3634        assert_eq!(*rel_type, None, "set declarations echo no single name");
3635        assert_eq!(
3636            rel_types.as_deref(),
3637            Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
3638        );
3639        assert_eq!(tainted_by, "arg--withdrawn-ev");
3640    }
3641
3642    /// Cross-mem edges satisfy an obligation like any edge; a mem
3643    /// filter reports findings only for entities of the filtered mem.
3644    #[test]
3645    fn must_reach_cross_mem_path_and_mem_filter() {
3646        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3647        let mut store = Store::new();
3648        let far_ev = make_typed_entity("ground", "far-ev", "evidence");
3649        let mut crossing = make_typed_entity("arg", "crossing", "claim");
3650        link(&mut crossing, "GROUNDS", &far_ev.id);
3651        let floating_arg = make_typed_entity("arg", "floating", "claim");
3652        let floating_ground = make_typed_entity("ground", "floating", "claim");
3653        for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
3654            store.upsert(e.id.clone(), e);
3655        }
3656        let mut mem_schemas = HashMap::new();
3657        mem_schemas.insert("arg".to_string(), schema.clone());
3658        mem_schemas.insert("ground".to_string(), schema);
3659
3660        let all = collect_constraint_findings(&store, None, &mem_schemas);
3661        assert_eq!(
3662            all.len(),
3663            2,
3664            "the crossing claim is satisfied via the cross-mem edge: {all:?}"
3665        );
3666        let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas);
3667        assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
3668        assert_eq!(filtered[0].id, floating_arg.id);
3669    }
3670}