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    "ledger",
53    "vital_signs",
54];
55
56/// Item cap per vital-signs list.
57pub const VITAL_SIGNS_ITEM_CAP: usize = 20;
58
59/// The `include=["vital_signs"]` axis (A6, 2026-09-02): per mem, the
60/// cheap, engine-countable model-truth signals the remodel campaign
61/// specified — each a count plus a capped list with an explicit `more`
62/// remainder, never a verdict, threshold or recommendation (the engine
63/// names what is there; the `/remodel` skill decides). Five signals:
64///
65/// - `type_share_by_community`: per community, how many of its entities
66///   sit on the schema's declared last-resort type (`last_resort: true`
67///   on exactly one type); `not_declared` when the schema declares none,
68///   never a guess from type names.
69/// - `unclaimed_source_files`: files of the mem's bound sources that no
70///   entity's anchor claims, largest first with their sizes (the size
71///   threshold that makes one "large" stays in the skill).
72/// - `contested_unowned_files`: files two or more entities claim through
73///   anchors while none owns them (no `anchored` class anchor).
74/// - `zero_outgoing_entities`: entities with no outgoing edge, folded into
75///   the community of their subject (their own cluster when it has more
76///   members than themselves, else the cluster of an entity that links to
77///   them, else `unplaced`) rather than ranked as singletons.
78/// - `empty_declared_sections`: sections the type declares that an
79///   entity carries empty.
80///
81/// Reuse, never recompute: the community partition, the anchor sidecar
82/// reads and the source enumeration are the ones the other axes use.
83pub fn health_vital_signs_axis(
84    engine: &crate::engine::Engine,
85    mem_filter: Option<&str>,
86) -> serde_json::Value {
87    let cap = VITAL_SIGNS_ITEM_CAP;
88    let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
89        let count = items.len();
90        let more = count.saturating_sub(cap);
91        items.truncate(cap);
92        let mut o = serde_json::Map::new();
93        o.insert("count".into(), serde_json::json!(count));
94        o.insert("items".into(), serde_json::Value::Array(items));
95        if more > 0 {
96            o.insert("more".into(), serde_json::json!(more));
97        }
98        serde_json::Value::Object(o)
99    };
100
101    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
102    mems.sort();
103    let communities = engine.communities();
104    let mut out = serde_json::Map::new();
105    for mem in &mems {
106        if let Some(f) = mem_filter
107            && f != mem
108        {
109            continue;
110        }
111        let entities: Vec<&crate::entity::Entity> = engine
112            .store()
113            .all_entities()
114            .filter(|e| !e.stub && e.id.mem() == mem)
115            .collect();
116        let schema = engine.schema_for(mem);
117
118        // --- 1. type share per community ---
119        let last_resort: Option<String> = schema.as_ref().and_then(|s| {
120            s.types
121                .values()
122                .find(|t| t.last_resort)
123                .map(|t| t.name.clone())
124        });
125        let type_share = match &last_resort {
126            None => serde_json::json!({ "status": "not_declared" }),
127            Some(lr) => {
128                let mut per: std::collections::BTreeMap<String, (usize, usize)> =
129                    std::collections::BTreeMap::new();
130                for e in &entities {
131                    let cluster = communities
132                        .entity_cluster_map
133                        .get(&e.id.0)
134                        .cloned()
135                        .unwrap_or_else(|| "unplaced".to_string());
136                    let slot = per.entry(cluster).or_insert((0, 0));
137                    slot.0 += 1;
138                    if e.entity_type == *lr {
139                        slot.1 += 1;
140                    }
141                }
142                let mut rows: Vec<serde_json::Value> = per
143                    .into_iter()
144                    .map(|(community, (total, on_last_resort))| {
145                        serde_json::json!({
146                            "community": community,
147                            "entities": total,
148                            "on_last_resort_type": on_last_resort,
149                        })
150                    })
151                    .collect();
152                // Most concentrated first: the reader sees the flattest
153                // cluster without ranking being a judgement.
154                rows.sort_by(|a, b| {
155                    let share = |v: &serde_json::Value| {
156                        let t = v["entities"].as_u64().unwrap_or(1).max(1) as f64;
157                        v["on_last_resort_type"].as_u64().unwrap_or(0) as f64 / t
158                    };
159                    share(b)
160                        .partial_cmp(&share(a))
161                        .unwrap_or(std::cmp::Ordering::Equal)
162                        .then_with(|| a["community"].as_str().cmp(&b["community"].as_str()))
163                });
164                let mut v = capped(rows);
165                v["status"] = serde_json::json!("declared");
166                v["last_resort_type"] = serde_json::json!(lr);
167                v
168            }
169        };
170
171        // --- 2 and 3. the file-to-entity map of the bound sources ---
172        // artifact -> (claiming entities, owned by an `anchored` anchor)
173        let mut claims: std::collections::BTreeMap<
174            String,
175            (std::collections::BTreeSet<String>, bool),
176        > = std::collections::BTreeMap::new();
177        for e in &entities {
178            for a in engine.entity_anchors(&e.id) {
179                let slot = claims.entry(a.artifact.clone()).or_default();
180                slot.0.insert(e.id.0.clone());
181                if a.class == crate::anchor::AnchorProvenanceClass::Anchored {
182                    slot.1 = true;
183                }
184            }
185        }
186        let roots = engine.anchor_source_roots(mem);
187        let mut unclaimed: Vec<serde_json::Value> = Vec::new();
188        let mut sources_enumerated = 0usize;
189        if let Some(ws) = engine.workspace_root() {
190            let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
191            for join in roots.values() {
192                sources_enumerated += 1;
193                for file in crate::ingest::cursor::enumerate_source_artifacts(
194                    engine,
195                    &join.source,
196                    &join.deny_paths,
197                    ws,
198                ) {
199                    if !seen.insert(file.clone()) || claims.contains_key(&file) {
200                        continue;
201                    }
202                    let size = std::fs::metadata(ws.join(&file))
203                        .map(|m| m.len())
204                        .unwrap_or(0);
205                    unclaimed.push(serde_json::json!({ "artifact": file, "bytes": size }));
206                }
207            }
208        }
209        unclaimed.sort_by(|a, b| {
210            b["bytes"]
211                .as_u64()
212                .cmp(&a["bytes"].as_u64())
213                .then_with(|| a["artifact"].as_str().cmp(&b["artifact"].as_str()))
214        });
215        let unclaimed_v = if sources_enumerated == 0 {
216            serde_json::json!({ "status": "no_bound_source" })
217        } else {
218            let mut v = capped(unclaimed);
219            v["status"] = serde_json::json!("enumerated");
220            v
221        };
222        let contested: Vec<serde_json::Value> = claims
223            .iter()
224            .filter(|(_, (who, owned))| who.len() >= 2 && !owned)
225            .map(|(artifact, (who, _))| {
226                serde_json::json!({
227                    "artifact": artifact,
228                    "claimed_by": who.iter().cloned().collect::<Vec<_>>(),
229                })
230            })
231            .collect();
232
233        // --- 4. zero-outgoing entities, folded into their subject ---
234        let cluster_size = |c: &str| -> usize {
235            communities
236                .clusters
237                .get(c)
238                .map(|ci| ci.entities.len())
239                .unwrap_or(0)
240        };
241        let mut by_community: std::collections::BTreeMap<String, Vec<String>> =
242            std::collections::BTreeMap::new();
243        for e in &entities {
244            if !engine.store().outgoing(&e.id).is_empty() {
245                continue;
246            }
247            let own = communities.entity_cluster_map.get(&e.id.0).cloned();
248            let community = match own {
249                Some(c) if cluster_size(&c) > 1 => c,
250                _ => engine
251                    .store()
252                    .incoming(&e.id)
253                    .iter()
254                    .find_map(|edge| communities.entity_cluster_map.get(&edge.from.0).cloned())
255                    .unwrap_or_else(|| "unplaced".to_string()),
256            };
257            by_community
258                .entry(community)
259                .or_default()
260                .push(e.id.0.clone());
261        }
262        let zero_total: usize = by_community.values().map(Vec::len).sum();
263        let zero_rows: Vec<serde_json::Value> = by_community
264            .into_iter()
265            .map(|(community, mut ids)| {
266                ids.sort();
267                let count = ids.len();
268                let more = count.saturating_sub(cap);
269                ids.truncate(cap);
270                let mut o = serde_json::json!({
271                    "community": community,
272                    "count": count,
273                    "items": ids,
274                });
275                if more > 0 {
276                    o["more"] = serde_json::json!(more);
277                }
278                o
279            })
280            .collect();
281        let mut zero_v = capped(zero_rows);
282        zero_v["entities"] = serde_json::json!(zero_total);
283
284        // --- 5. declared sections carried empty ---
285        let mut empty_sections: Vec<serde_json::Value> = Vec::new();
286        if let Some(s) = &schema {
287            for e in &entities {
288                let Some(td) = s.types.get(&e.entity_type) else {
289                    continue;
290                };
291                for sec in &td.sections {
292                    if e.sections
293                        .get(&sec.key)
294                        .is_some_and(|body| body.trim().is_empty())
295                    {
296                        empty_sections.push(serde_json::json!({
297                            "id": e.id.0,
298                            "section": sec.key,
299                        }));
300                    }
301                }
302            }
303        }
304
305        out.insert(
306            mem.clone(),
307            serde_json::json!({
308                "type_share_by_community": type_share,
309                "unclaimed_source_files": unclaimed_v,
310                "contested_unowned_files": capped(contested),
311                "zero_outgoing_entities": zero_v,
312                "empty_declared_sections": capped(empty_sections),
313            }),
314        );
315    }
316    let mut top = serde_json::Map::new();
317    top.insert("_item_cap".into(), serde_json::json!(cap));
318    for (k, v) in out {
319        top.insert(k, v);
320    }
321    serde_json::Value::Object(top)
322}
323
324/// The `include=["anchors"]` axis — per-mem counts of the four
325/// standalone-verification states, computed through the same
326/// per-anchor mechanism `verify-anchors` and the binding verify use.
327/// Shared by the full composer, the CLI health command, and the lean
328/// MCP server so the axis cannot drift between surfaces.
329/// The `include=["checks"]` axis (agent-trust plan 14): per mem,
330/// counts of the four derived check states plus the author≠checker
331/// independence gate over ok-checked entities. The gate compares
332/// caller-declared IDENTITIES and nothing else (agent-trust plan 15):
333/// the entity's created-by record against its newest ok-check record
334/// — both carry an identity and they are equal → `self_checked`
335/// ("twice-asserted, not verified"); both carry one and they differ →
336/// `confirmed_independent`; either side lacks one → `unconfirmable`,
337/// never a guessed category. Transport is not identity: the recorded
338/// `(actor, client)` pair names the SURFACE a record arrived through
339/// and is recorded as context only — it never participates in the
340/// comparison (a same pair does not establish the same actor, and
341/// different pairs do not establish different actors). An identity is
342/// an honesty device, not authentication: caller-declared, unverified,
343/// tamper-evident in append-only history — a consistent multi-agent
344/// setup gets real independence signals, and a lying setup defeats
345/// only itself. Derivation only: nothing here is stamped, and a
346/// workspace without a check ledger serves all-never-checked.
347/// Identity lists are capped at [`OPEN_QUESTIONS_ITEM_CAP`] with an
348/// explicit `more` count.
349pub fn health_checks_axis(
350    engine: &crate::engine::Engine,
351    mem_filter: Option<&str>,
352) -> serde_json::Value {
353    let cap = OPEN_QUESTIONS_ITEM_CAP;
354    let capped = |mut items: Vec<String>| -> serde_json::Value {
355        items.sort();
356        let count = items.len();
357        let more = count.saturating_sub(cap);
358        items.truncate(cap);
359        let mut o = serde_json::Map::new();
360        o.insert("count".into(), serde_json::json!(count));
361        o.insert("items".into(), serde_json::json!(items));
362        if more > 0 {
363            o.insert("more".into(), serde_json::json!(more));
364        }
365        serde_json::Value::Object(o)
366    };
367
368    let ledger = engine
369        .workspace_root()
370        .map(crate::check::CheckLedger::for_workspace);
371    // Newest record per (entity, kind), one ledger read for the whole
372    // axis. State is kind-scoped: a conformance record never
373    // supersedes a verification record, or the reverse.
374    let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
375        std::collections::BTreeMap::new();
376    let mut latest_conformance: std::collections::BTreeMap<String, crate::check::CheckRecord> =
377        std::collections::BTreeMap::new();
378    // Foreign `x-<name>` kinds: recorded verbatim, never aggregated into
379    // a state — listed by count per mem so a reader sees that another
380    // checker has been here. Keyed by entity for the per-mem tally.
381    let mut foreign_by_entity: std::collections::BTreeMap<String, Vec<String>> =
382        std::collections::BTreeMap::new();
383    // The newest record of ANY kind per entity, for the finding it may
384    // carry: a finding is served under the entity's latest verdict.
385    let mut newest_any: std::collections::BTreeMap<String, crate::check::CheckRecord> =
386        std::collections::BTreeMap::new();
387    // Every verification record per entity, oldest first: the per-record
388    // readings (engine::independence) show each check's standing, not
389    // only the newest one's — a superseded self-check stays visible.
390    let mut all_verification: std::collections::BTreeMap<String, Vec<crate::check::CheckRecord>> =
391        std::collections::BTreeMap::new();
392    if let Some(l) = &ledger {
393        for rec in l.all() {
394            newest_any.insert(rec.entity.clone(), rec.clone());
395            match rec.resolved_kind() {
396                Some(crate::check::CheckKind::Verification) => {
397                    all_verification
398                        .entry(rec.entity.clone())
399                        .or_default()
400                        .push(rec.clone());
401                    latest.insert(rec.entity.clone(), rec);
402                }
403                Some(crate::check::CheckKind::Conformance) => {
404                    latest_conformance.insert(rec.entity.clone(), rec);
405                }
406                None => {
407                    if let Some(k) = rec.foreign_kind() {
408                        foreign_by_entity
409                            .entry(rec.entity.clone())
410                            .or_default()
411                            .push(k.to_string());
412                    }
413                }
414            }
415        }
416    }
417
418    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
419    mems.sort();
420    let mut out = serde_json::Map::new();
421    for mem in mems {
422        if let Some(f) = mem_filter
423            && f != mem
424        {
425            continue;
426        }
427        let mut counts = std::collections::BTreeMap::from([
428            ("never_checked", 0usize),
429            ("checked_ok", 0usize),
430            ("check_failed", 0usize),
431            ("check_stale", 0usize),
432        ]);
433        // The `conformance` kind's counts, additively beside the
434        // verification counts. Pin-aware: a schema re-pin stales
435        // every conformance verdict recorded under the old pin. A
436        // workspace with no conformance records serves all
437        // `never_checked` — honestly empty, never absent.
438        let current_pin = engine
439            .mount(&mem)
440            .and_then(|m| m.schema.as_ref())
441            .map(|s| s.as_display());
442        let mut conformance_counts = std::collections::BTreeMap::from([
443            ("never_checked", 0usize),
444            ("checked_ok", 0usize),
445            ("check_failed", 0usize),
446            ("check_stale", 0usize),
447        ]);
448        let mut self_checked: Vec<String> = Vec::new();
449        let mut confirmed_independent: Vec<String> = Vec::new();
450        let mut unconfirmable: Vec<String> = Vec::new();
451        // Which identities each ok-checked criterion was compared against
452        // (engine::independence), rendered so a reader can see who counted
453        // as an executor. One provenance pass per mem.
454        let mut executors = serde_json::Map::new();
455        let mut readings = serde_json::Map::new();
456        let touches = engine.mem_touches(&mem);
457        let mut foreign_kinds: std::collections::BTreeMap<String, usize> =
458            std::collections::BTreeMap::new();
459        let mut findings = serde_json::Map::new();
460        for e in engine.store().all_entities().filter(|e| e.mem == mem) {
461            let id = e.id.0.clone();
462            if let Some(kinds) = foreign_by_entity.get(&id) {
463                for k in kinds {
464                    *foreign_kinds.entry(k.clone()).or_insert(0) += 1;
465                }
466            }
467            if let Some(rec) = newest_any.get(&id)
468                && let Some(f) = &rec.finding
469            {
470                findings.insert(
471                    id.clone(),
472                    serde_json::json!({
473                        "verdict": rec.verdict,
474                        "kind": rec.kind.as_deref().unwrap_or("verification"),
475                        "ts": rec.ts,
476                        "identity": rec.identity,
477                        "finding": f,
478                    }),
479                );
480            }
481            let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
482            *counts.entry(state.as_str()).or_insert(0) += 1;
483            if let Some(records) = all_verification.get(&id) {
484                let rows: Vec<serde_json::Value> = records
485                    .iter()
486                    .map(|rec| {
487                        let reading = if rec.verdict == "ok" {
488                            engine.independence_of(e, rec, &touches).0.as_str()
489                        } else {
490                            "failed"
491                        };
492                        serde_json::json!({
493                            "ts": rec.ts,
494                            "identity": rec.identity,
495                            "verdict": rec.verdict,
496                            "reading": reading,
497                        })
498                    })
499                    .collect();
500                readings.insert(id.clone(), serde_json::Value::Array(rows));
501            }
502            let cstate = crate::check::derive_state_pinned(
503                latest_conformance.get(&id),
504                &e.content_hash,
505                current_pin.as_deref(),
506            );
507            *conformance_counts.entry(cstate.as_str()).or_insert(0) += 1;
508            if state != crate::check::CheckState::CheckedOk {
509                continue;
510            }
511            // Identity-only comparison (plan 15, comparator widened
512            // 2026-09-02): the newest ok-check's identity against every
513            // identity that mutated the verified plan, its criteria or
514            // its session-log notes since this criterion was written
515            // (engine::independence); a non-criterion compares against
516            // its own author. The (actor, client) transport pair is
517            // recorded context and never a comparator. Either side
518            // lacking an identity is unconfirmable, never a guessed
519            // category.
520            let check = latest.get(&id).expect("checked_ok implies a record");
521            let (reading, execs) = engine.independence_of(e, check, &touches);
522            if let Some(execs) = execs {
523                executors.insert(id.clone(), serde_json::json!(execs.identities));
524            }
525            match reading {
526                crate::engine::independence::Independence::SelfChecked => self_checked.push(id),
527                crate::engine::independence::Independence::ConfirmedIndependent => {
528                    confirmed_independent.push(id)
529                }
530                crate::engine::independence::Independence::Unconfirmable => unconfirmable.push(id),
531            }
532        }
533        let mut m = serde_json::Map::new();
534        for (k, v) in counts {
535            m.insert(k.to_string(), serde_json::json!(v));
536        }
537        let mut c = serde_json::Map::new();
538        for (k, v) in conformance_counts {
539            c.insert(k.to_string(), serde_json::json!(v));
540        }
541        m.insert("conformance".into(), serde_json::Value::Object(c));
542        // Foreign kinds by count, and the structured finding each
543        // entity's newest record carries — recorded, rendered, never
544        // interpreted.
545        m.insert(
546            "foreign_kinds".into(),
547            serde_json::to_value(&foreign_kinds).unwrap_or(serde_json::json!({})),
548        );
549        m.insert("findings".into(), serde_json::Value::Object(findings));
550        m.insert(
551            "independence".into(),
552            serde_json::json!({
553                "self_checked": capped(self_checked),
554                "confirmed_independent": capped(confirmed_independent),
555                "unconfirmable": capped(unconfirmable),
556                "comparator": "every identity that mutated the verified plan, its criteria or its session-log notes since the criterion was written; a non-criterion compares against its own author",
557                "executors": serde_json::Value::Object(executors),
558                "readings": serde_json::Value::Object(readings),
559            }),
560        );
561        out.insert(mem, serde_json::Value::Object(m));
562    }
563    serde_json::Value::Object(out)
564}
565
566/// One derivation-staleness finding (agent-trust plan 12): an
567/// explicit edge on a derivation-declared rel-type whose baseline
568/// differs from the target's current hash (`stale`), or that has no
569/// recorded baseline at all (`unbaselined`). Fresh edges are never
570/// reported.
571#[derive(Debug, Clone, serde::Serialize)]
572pub struct DerivationFinding {
573    pub source: crate::entity::EntityId,
574    pub rel_type: String,
575    pub target: crate::entity::EntityId,
576    /// `"stale"` or `"unbaselined"` — never fabricated as fresh.
577    pub state: String,
578    /// The recorded baseline hash (`None` for unbaselined edges).
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub baseline: Option<String>,
581    /// The target's current content hash ("" for an absent target).
582    pub current: String,
583}
584
585/// The `include=["stale_derivations"]` axis: per-mem findings from
586/// [`crate::engine::Engine::derivation_report`], shared by the CLI
587/// and both MCP flavours. A mem whose schema declares no derivation
588/// rel-types contributes an empty list — never an error.
589pub fn health_stale_derivations_axis(
590    engine: &crate::engine::Engine,
591    mem_filter: Option<&str>,
592) -> serde_json::Value {
593    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
594    mems.sort();
595    let mut out = serde_json::Map::new();
596    for mem in mems {
597        if let Some(f) = mem_filter
598            && f != mem
599        {
600            continue;
601        }
602        let findings = engine.derivation_report(&mem).unwrap_or_default();
603        out.insert(
604            mem,
605            serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
606        );
607    }
608    serde_json::Value::Object(out)
609}
610
611/// Per-kind item cap for the `open_questions` axis — the axis is an
612/// agent worklist, not a dump. Stated in the output (`_item_cap`);
613/// truncation is always explicit via each list's `more` count.
614pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
615
616/// The `include=["open_questions"]` axis (agent-trust plan 11): per
617/// mem, a composed worklist of what the holding does not know — its
618/// stubs, its never-confirmed (`recheck`) and `unresolvable` anchors,
619/// its unsatisfied constraints, its dangling links, and, when a
620/// paired process mem is resolvable for the destination, that
621/// process mem's open entries. Negative findings ride under the
622/// DISTINCT `already_searched` heading — their operational meaning is
623/// "done, keep off", never todo.
624///
625/// Composition only: every signal is read from the same source its
626/// own axis serves (store stub flags, `verify_mem_anchors`,
627/// `constraint_findings`, `collect_dangling_links`, the pipeline
628/// store), so this axis can never disagree with the per-signal axes.
629/// Best-effort on the process leg: an unreadable pipeline store means
630/// no process sections, never an axis failure.
631pub fn health_open_questions_axis(
632    engine: &crate::engine::Engine,
633    mem_filter: Option<&str>,
634) -> serde_json::Value {
635    let cap = OPEN_QUESTIONS_ITEM_CAP;
636    let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
637        let count = items.len();
638        let more = count.saturating_sub(cap);
639        items.truncate(cap);
640        let mut o = serde_json::Map::new();
641        o.insert("count".into(), serde_json::json!(count));
642        o.insert("items".into(), serde_json::Value::Array(items));
643        if more > 0 {
644            o.insert("more".into(), serde_json::json!(more));
645        }
646        serde_json::Value::Object(o)
647    };
648
649    // Bindings by destination mem — the pairing plan 14 will make
650    // declarative; until then the ingest-name convention (process mem
651    // named after the binding) is the resolution mechanism.
652    let bindings: Vec<(String, String)> = engine
653        .workspace_root()
654        .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
655        .map(|c| {
656            c.bindings
657                .iter()
658                .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
659                .collect()
660        })
661        .unwrap_or_default();
662    let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
663
664    let mut mems: Vec<String> = mounted.clone();
665    mems.sort();
666    let mut out = serde_json::Map::new();
667    for mem in &mems {
668        if let Some(f) = mem_filter
669            && f != mem
670        {
671            continue;
672        }
673
674        // Stubs — same source as the stubs axis (store stub flag).
675        let stubs = capped(
676            engine
677                .store()
678                .all_entities()
679                .filter(|e| e.stub && e.id.mem() == mem)
680                .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
681                .collect(),
682        );
683
684        // Anchors — same per-anchor mechanism as the anchors axis;
685        // only the never-confirmed and unreachable states are holes.
686        // A dangling sidecar row is a hole too (consistency-sweep 03/02), and
687        // it gets its OWN bucket. `unresolvable` is the wire form of the
688        // orphaned state, whose repair is the opposite one: an orphaned anchor
689        // asks whether its entity should be re-anchored or pruned, a dangling
690        // row asks why the entity went missing. Folding the two together here
691        // would reproduce, on the health axis, exactly the collapse the axis
692        // itself refuses.
693        //
694        // `entity_end_unreconciled` rides along both ways: an empty dangling
695        // bucket means "none found" only when the check could run at all.
696        let (mut recheck, mut unresolvable, mut unobserved, mut dangling_rows) =
697            (Vec::new(), Vec::new(), Vec::new(), Vec::new());
698        // Rows resting on a recorded observation older than today: open
699        // work too (someone has to look again), stated as its age.
700        let mut aging = Vec::new();
701        let mut entity_end_unreconciled: Option<String> = None;
702        if let Ok(report) = engine.verify_mem_anchors(mem) {
703            entity_end_unreconciled = report.unreconciled.clone();
704            for a in &report.anchors {
705                if let Some(days) = a.unobserved_for_days
706                    && days > 0
707                {
708                    aging.push(serde_json::json!({
709                        "kind": "anchor_aging",
710                        "id": a.entity_id,
711                        "artifact": a.artifact,
712                        "state": a.state,
713                        "observed_at": a.observed_at,
714                        "unobserved_for_days": days,
715                        "note": format!("unobserved for {days} days"),
716                    }));
717                }
718                let item = serde_json::json!({
719                    "kind": format!("anchor_{}", a.state),
720                    "id": a.entity_id,
721                    "artifact": a.artifact,
722                });
723                match a.state.as_str() {
724                    "recheck" => recheck.push(item),
725                    "unresolvable" => unresolvable.push(item),
726                    "unobserved" => unobserved.push(item),
727                    "dangling" => dangling_rows.push(item),
728                    _ => {}
729                }
730            }
731        }
732
733        // Unsatisfied constraints — same collector as the
734        // constraints axis.
735        let constraints = capped(
736            engine
737                .constraint_findings(Some(mem))
738                .iter()
739                .map(|r| {
740                    serde_json::json!({
741                        "kind": "unsatisfied_constraint",
742                        "id": r.id.to_string(),
743                        "violations": r.violations.len(),
744                    })
745                })
746                .collect(),
747        );
748
749        // Dangling links — same collector as the overview include, and the
750        // SAME three names rather than a parallel vocabulary of its own
751        // (04/06, criterion 2). This axis emitted one `dangling_link` kind
752        // over all three conditions, which is the fused code by another
753        // spelling; a second vocabulary is the one that drifts first.
754        let dangling = capped(
755            collect_dangling_links(engine.store(), Some(mem))
756                .iter()
757                .map(|d| {
758                    serde_json::json!({
759                        "kind": d.kind.code(),
760                        "id": d.from.to_string(),
761                        "target": d.target_id.to_string(),
762                        "repair": d.kind.repair(),
763                    })
764                })
765                .collect(),
766        );
767
768        // Paired process mems: open entries are work; negative
769        // findings are the opposite — already searched, keep off.
770        // Pairing runs through the ONE resolution function the brief
771        // renderer uses (agent-trust plan 14): a destination's
772        // declaration wins regardless of naming — and pairs even
773        // with no binding at all (the process tier stands without
774        // one); the binding-name convention remains the fallback. A
775        // declaration naming an unmounted mem is a typed finding,
776        // never a silent fallback.
777        let mut process = Vec::new();
778        let mem_bindings: Vec<&String> = bindings
779            .iter()
780            .filter(|(d, _)| d == mem)
781            .map(|(_, b)| b)
782            .collect();
783        let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
784            Vec::new();
785        if mem_bindings.is_empty() {
786            let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
787            if r.declared {
788                resolutions.push((None, r));
789            }
790        } else {
791            for binding in &mem_bindings {
792                resolutions.push((
793                    Some((*binding).clone()),
794                    crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
795                ));
796            }
797        }
798        for (binding, r) in resolutions {
799            if r.mounted {
800                let mut open = Vec::new();
801                let mut searched = Vec::new();
802                for e in engine
803                    .store()
804                    .all_entities()
805                    .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
806                {
807                    let item = serde_json::json!({
808                        "kind": e.entity_type,
809                        "id": e.id.to_string(),
810                        "title": e.title,
811                    });
812                    if e.entity_type == "negative_finding" {
813                        searched.push(item);
814                    } else {
815                        open.push(item);
816                    }
817                }
818                process.push(serde_json::json!({
819                    "binding": binding,
820                    "process_mem": r.mem,
821                    "declared": r.declared,
822                    "resolvable": true,
823                    "open_entries": capped(open),
824                    "already_searched": capped(searched),
825                }));
826            } else if r.declared {
827                process.push(serde_json::json!({
828                    "binding": binding,
829                    "process_mem": r.mem,
830                    "declared": true,
831                    "resolvable": false,
832                    "finding": "DECLARED_PROCESS_MEM_MISSING",
833                }));
834            } else {
835                process.push(serde_json::json!({
836                    "binding": binding,
837                    "resolvable": false,
838                }));
839            }
840        }
841
842        // The resolution readings (plan B5): for every type of this mem
843        // that declares `resolution`, the open entities with no condition
844        // written, and the open entities whose condition nobody has
845        // checked under the declared kind. The ledger is read as it is;
846        // an `x-` kind counts by name, an engine kind by derived state.
847        let (mut missing, mut unchecked) = (Vec::new(), Vec::new());
848        if let Some(schema) = engine.schema_for(mem) {
849            let ledger = engine
850                .workspace_root()
851                .map(crate::check::CheckLedger::for_workspace);
852            for entity in engine
853                .store()
854                .all_entities()
855                .filter(|e| !e.stub && e.id.mem() == mem)
856            {
857                let Some(td) = schema.types.get(&entity.entity_type) else {
858                    continue;
859                };
860                let Some(res) = &td.resolution else { continue };
861                if let Some(field) = &res.status_field {
862                    let open = match entity.metadata.get(field) {
863                        Some(crate::entity::MetadataValue::String(s)) => {
864                            res.open_values.contains(s)
865                        }
866                        _ => false,
867                    };
868                    if !open {
869                        continue;
870                    }
871                }
872                let has_condition = entity
873                    .sections
874                    .get(&res.condition_section)
875                    .is_some_and(|body| !body.trim().is_empty());
876                if !has_condition {
877                    missing.push(serde_json::json!({
878                        "kind": "resolution_missing",
879                        "id": entity.id.to_string(),
880                        "section": res.condition_section,
881                    }));
882                    continue;
883                }
884                let kind = res.check_kind.as_deref().unwrap_or("verification");
885                let checked = ledger.as_ref().is_some_and(|l| {
886                    l.all().into_iter().rev().any(|r| {
887                        r.entity == entity.id.to_string()
888                            && r.verdict == "ok"
889                            && r.entity_hash == entity.content_hash
890                            && match crate::check::RecordKind::from_wire(kind) {
891                                Some(crate::check::RecordKind::Engine(k)) => {
892                                    r.resolved_kind() == Some(k)
893                                }
894                                Some(crate::check::RecordKind::Foreign(name)) => {
895                                    r.kind.as_deref() == Some(name.as_str())
896                                }
897                                None => false,
898                            }
899                    })
900                });
901                if !checked {
902                    unchecked.push(serde_json::json!({
903                        "kind": "resolution_unchecked",
904                        "id": entity.id.to_string(),
905                        "section": res.condition_section,
906                        "check_kind": kind,
907                    }));
908                }
909            }
910        }
911        let resolution_missing = capped(missing);
912        let resolution_unchecked = capped(unchecked);
913
914        let total_open = stubs["count"].as_u64().unwrap_or(0)
915            + resolution_missing["count"].as_u64().unwrap_or(0)
916            + resolution_unchecked["count"].as_u64().unwrap_or(0)
917            + recheck.len() as u64
918            + unresolvable.len() as u64
919            + unobserved.len() as u64
920            + dangling_rows.len() as u64
921            + aging.len() as u64
922            + constraints["count"].as_u64().unwrap_or(0)
923            + dangling["count"].as_u64().unwrap_or(0)
924            + process
925                .iter()
926                .filter_map(|p| p["open_entries"]["count"].as_u64())
927                .sum::<u64>();
928
929        let mut entry = serde_json::Map::new();
930        entry.insert("stubs".into(), stubs);
931        entry.insert("anchors_recheck".into(), capped(recheck));
932        entry.insert("anchors_unresolvable".into(), capped(unresolvable));
933        // Its own bucket here too. The comment above argues that folding two
934        // anchor conditions together reproduces the collapse the axis refuses,
935        // and a first version then did exactly that eight lines further down.
936        entry.insert("anchors_unobserved".into(), capped(unobserved));
937        entry.insert("anchors_dangling".into(), capped(dangling_rows));
938        entry.insert("anchors_aging".into(), capped(aging));
939        if let Some(why) = entity_end_unreconciled {
940            entry.insert("entity_end_unreconciled".into(), serde_json::json!(why));
941        }
942        entry.insert("unsatisfied_constraints".into(), constraints);
943        entry.insert("dangling_links".into(), dangling);
944        entry.insert("resolution_missing".into(), resolution_missing);
945        entry.insert("resolution_unchecked".into(), resolution_unchecked);
946        if !process.is_empty() {
947            entry.insert("process".into(), serde_json::Value::Array(process));
948        } else {
949            // No binding targets this mem: the absence of a process
950            // section is stated, never silent.
951            entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
952        }
953        entry.insert("total_open".into(), serde_json::json!(total_open));
954        out.insert(mem.clone(), serde_json::Value::Object(entry));
955    }
956    let mut top = serde_json::Map::new();
957    top.insert("_item_cap".into(), serde_json::json!(cap));
958    for (k, v) in out {
959        top.insert(k, v);
960    }
961    serde_json::Value::Object(top)
962}
963
964pub fn health_anchors_axis(
965    engine: &crate::engine::Engine,
966    mem_filter: Option<&str>,
967) -> serde_json::Value {
968    let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
969    mems.retain(|m| mem_filter.is_none_or(|v| m == v));
970    mems.sort();
971    let mut out = serde_json::Map::new();
972    for mem in mems {
973        let Ok(report) = engine.verify_mem_anchors(&mem) else {
974            continue;
975        };
976        let condition = report.sidecar_error.as_ref().map(|why| {
977            serde_json::json!({
978                "code": "ANCHORS_SIDECAR_UNREADABLE",
979                "mem": mem,
980                "reason": why,
981            })
982        });
983        out.insert(
984            mem,
985            serde_json::json!({
986                // The one condition that replaces the counts: an unreadable
987                // sidecar. Absent when the sidecar read cleanly.
988                "condition": condition,
989                "resolves": report.resolves,
990                "drifted": report.drifted,
991                "recheck": report.recheck,
992                // Split from `unresolvable` (03/05, criterion 2): the artifact
993                // being gone is a measurement, the pass not reaching it is the
994                // absence of one, and this is the surface a reader arrives at
995                // without a binding in hand.
996                "unresolvable": report.unresolvable,
997                "unobserved": report.unobserved,
998                // The entity end (03/02). Carried here too, both ways: four
999                // counts over a mem whose sidecar has outlived its entities
1000                // read as a healthy axis, and so does a zero over a mem whose
1001                // entity end was never reconciled.
1002                "dangling": report.dangling,
1003                "entity_end_unreconciled": report.unreconciled,
1004                // The figures never travel without what they were computed
1005                // over (03/05, criteria 1 and 3).
1006                "population": report.population_statement(),
1007                "fully_adjudicated": report.fully_adjudicated(),
1008                // Rows whose state rests on a recorded observation (url
1009                // rows), each with how long it has gone unobserved. A state
1010                // observed months ago is not today's state, and the axis
1011                // says so beside the count it contributed to.
1012                "aging": report
1013                    .anchors
1014                    .iter()
1015                    .filter(|a| a.observed_at.is_some())
1016                    .map(|a| {
1017                        let days = a.unobserved_for_days.unwrap_or(0);
1018                        serde_json::json!({
1019                            "id": a.entity_id,
1020                            "artifact": a.artifact,
1021                            "state": a.state,
1022                            "observed_at": a.observed_at,
1023                            "unobserved_for_days": days,
1024                            "note": format!("unobserved for {days} days"),
1025                        })
1026                    })
1027                    .collect::<Vec<_>>(),
1028            }),
1029        );
1030    }
1031    serde_json::Value::Object(out)
1032}
1033
1034/// Compute health reports for all entities in the store.
1035///
1036/// `mem_schemas` maps mem name → `Arc<Schema>`. Entities whose mem
1037/// is missing from this map fall back to the builtin `default` schema
1038/// relationship vocabulary (keeps legacy fixtures green; real production
1039/// paths always register a mem schema).
1040///
1041/// `mem_filter` scopes the per-entity scans and the structural counts
1042/// (orphans, stubs, leaf population) to one mem; `None` is the classic
1043/// engine-wide sweep. Validating that the name exists is the caller's
1044/// job ([`crate::Engine::health_scoped`] refuses `UNKNOWN_MEM` before
1045/// reaching here) — an unknown name at this level just scans nothing.
1046pub fn compute_health(
1047    store: &Store,
1048    default_schema: &TypeDefinition,
1049    mem_schemas: &HashMap<String, Arc<Schema>>,
1050    mem_filter: Option<&str>,
1051) -> HealthSummary {
1052    let mut missing_fields = Vec::new();
1053    let mut stale_entities = Vec::new();
1054
1055    let today_days = days_since_epoch();
1056
1057    let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
1058
1059    for entity in store.all_entities() {
1060        if entity.stub || !in_scope(&entity.mem) {
1061            continue;
1062        }
1063
1064        // Resolve the entity's `TypeDefinition` against the entity's
1065        // own mem's schema first. `type_by_name` only knows the
1066        // builtin `default` schema; falling through to it on a mem
1067        // pinned to a non-default schema (e.g. `planning@0.1.0`) would
1068        // silently use `default_schema` (effectively `spec`) for every
1069        // entity and report `spec`'s `health_required_fields` —
1070        // `[identity, purpose]` — even on entities of types like
1071        // `goal` / `option` / `decision`.
1072        let resolved = mem_schemas
1073            .get(entity.mem.as_str())
1074            .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
1075            .or_else(|| type_by_name(&entity.entity_type));
1076        let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
1077        let mut issues = Vec::new();
1078
1079        // Check health_required_fields
1080        for field in &schema.health_required_fields {
1081            // Check if it's a section or metadata field
1082            if schema.section(field).is_some() {
1083                // It's a section. When the content is present in the
1084                // file but sits under a non-deriving heading, report
1085                // the distinct mismatch finding instead of "missing" —
1086                // the two conditions must never collapse.
1087                let content = entity.sections.get(field.as_str());
1088                if content.is_none_or(|c| c.trim().is_empty()) {
1089                    if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1090                        issues.push(issue);
1091                    } else {
1092                        issues.push(HealthIssue {
1093                            field: field.clone(),
1094                            code: super::HealthIssueCode::Missing,
1095                            message: format!("required section '{field}' is empty"),
1096                        });
1097                    }
1098                }
1099            } else {
1100                // It's a metadata field. Treat missing AND empty /
1101                // whitespace-only values as gaps so the scan matches
1102                // the section branch's `trim().is_empty()` semantics
1103                // — an empty `MetadataValue::String("")` is just as
1104                // unhelpful to an agent as an absent key.
1105                let value = entity.metadata.get(field.as_str());
1106                let is_empty = match value {
1107                    None => true,
1108                    Some(v) => v.to_frontmatter_string().trim().is_empty(),
1109                };
1110                if is_empty {
1111                    issues.push(HealthIssue {
1112                        field: field.clone(),
1113                        code: super::HealthIssueCode::Missing,
1114                        message: format!("required field '{field}' is missing"),
1115                    });
1116                }
1117            }
1118        }
1119
1120        // The heading-mismatch condition is drift worth surfacing on
1121        // every declared section, not only the health-required ones.
1122        for s in schema.sections.iter().filter(|s| !s.catch_all) {
1123            if schema.health_required_fields.contains(&s.key) {
1124                continue; // already handled above
1125            }
1126            let content = entity.sections.get(s.key.as_str());
1127            if content.is_none_or(|c| c.trim().is_empty())
1128                && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1129            {
1130                issues.push(issue);
1131            }
1132        }
1133
1134        // Undeclared-relationship warning. Scan the entity's
1135        // relationship list against the mem's schema vocabulary; every
1136        // unknown name becomes a soft HealthIssue (same severity as a
1137        // missing section) so agents running a health sweep after a
1138        // schema version bump see drift without a crashed load.
1139        //
1140        // Shape-violation scan: when the mem's schema declares
1141        // `source_types` / `target_types` on a relationship and an
1142        // existing edge violates the shape, surface as a soft
1143        // HealthIssue. The relate-add path enforces shape going
1144        // forward; this scan catches edges authored before the
1145        // constraint landed (or via inline `relations:` on
1146        // memstead_create, which does not yet shape-check). The
1147        // remove-path on `memstead_relate` skips shape validation so the
1148        // cleanup is always reachable.
1149        if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
1150            let mut seen_unknown = std::collections::HashSet::new();
1151            for rel in &entity.relationships {
1152                if !mem_schema.relationship_known(&rel.rel_type) {
1153                    if seen_unknown.insert(rel.rel_type.clone()) {
1154                        let suggestion = mem_schema
1155                            .suggest_relationship(&rel.rel_type)
1156                            .map(|s| format!(" Did you mean '{s}'?"))
1157                            .unwrap_or_default();
1158                        let (schema_name, schema_version) = mem_schema.id();
1159                        issues.push(HealthIssue {
1160                            field: "relationships".to_string(),
1161                            code: super::HealthIssueCode::UndeclaredRelationship,
1162                            message: format!(
1163                                "relationship '{}' is not declared in schema \
1164                                 '{schema_name}@{schema_version}'.{suggestion}",
1165                                rel.rel_type
1166                            ),
1167                        });
1168                    }
1169                    continue;
1170                }
1171
1172                let target_type = store
1173                    .get(&rel.target)
1174                    .map(|t| t.entity_type.clone())
1175                    .filter(|t| !t.is_empty());
1176                if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
1177                    rel_type,
1178                    from_type,
1179                    to_type,
1180                    allowed_source_types,
1181                    allowed_target_types,
1182                    ..
1183                }) = crate::runtime_validator::validate_rel_shape(
1184                    &rel.rel_type,
1185                    entity.entity_type.as_str(),
1186                    target_type.as_deref(),
1187                    mem_schema.as_ref(),
1188                ) {
1189                    let allowed_src = if allowed_source_types.is_empty() {
1190                        "<any>".to_string()
1191                    } else {
1192                        allowed_source_types.join(", ")
1193                    };
1194                    let allowed_tgt = if allowed_target_types.is_empty() {
1195                        "<any>".to_string()
1196                    } else {
1197                        allowed_target_types.join(", ")
1198                    };
1199                    issues.push(HealthIssue {
1200                        field: "relationships".to_string(),
1201                        code: super::HealthIssueCode::InvalidRelShape,
1202                        message: format!(
1203                            "INVALID_REL_SHAPE: edge '{rel_type}' from \
1204                             '{from_type}' to '{to_type}' (target {target}) \
1205                             violates declared shape — allowed_source_types: \
1206                             [{allowed_src}], allowed_target_types: \
1207                             [{allowed_tgt}]. Remove via \
1208                             `memstead_relate from={from_id} to={target} \
1209                             type={rel_type} remove=true`.",
1210                            target = rel.target,
1211                            from_id = entity.id,
1212                        ),
1213                    });
1214                }
1215            }
1216        }
1217
1218        // Staleness check
1219        let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
1220
1221        if let Some(ts_field) = auto_ts_field
1222            && let Some(val) = entity.metadata.get(ts_field.key.as_str())
1223        {
1224            let date_str = val.to_frontmatter_string();
1225            if let Some(modified_days) = parse_iso_to_days(&date_str) {
1226                let days_since = today_days.saturating_sub(modified_days);
1227                if days_since > schema.staleness_threshold_days as u64 {
1228                    stale_entities.push(StaleEntity {
1229                        id: entity.id.clone(),
1230                        title: entity.title.clone(),
1231                        days_since_modified: days_since,
1232                        anchor_state: None,
1233                    });
1234                }
1235            }
1236        }
1237
1238        if !issues.is_empty() {
1239            // Compute a simple health score: (total_fields - issues) / total_fields.
1240            // `issues.len()` can exceed `total_fields` once the
1241            // relationship-vocabulary issues are added on top, so saturate
1242            // the subtraction rather than underflow. A score of 0.0 is the
1243            // natural floor — agents treat it as "maximally broken".
1244            let total = schema.health_required_fields.len();
1245            let score = if total > 0 {
1246                (total.saturating_sub(issues.len()) as f32) / (total as f32)
1247            } else {
1248                1.0
1249            };
1250
1251            missing_fields.push(HealthReport {
1252                id: entity.id.clone(),
1253                title: entity.title.clone(),
1254                score,
1255                issues,
1256            });
1257        }
1258    }
1259
1260    // Sort stale entities by days_since_modified descending
1261    stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
1262
1263    // Structural counts — scoped by the same filter as the entity scans
1264    // above so a `mem`-scoped summary is internally consistent.
1265    let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
1266        .into_iter()
1267        .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
1268        .count();
1269    let leaf_entities_by_type = match mem_filter {
1270        None => query::leaf_population(store, mem_schemas),
1271        Some(v) => {
1272            let scoped: HashMap<String, Arc<Schema>> = mem_schemas
1273                .iter()
1274                .filter(|(mem, _)| mem.as_str() == v)
1275                .map(|(mem, s)| (mem.clone(), s.clone()))
1276                .collect();
1277            query::leaf_population(store, &scoped)
1278        }
1279    };
1280    let stub_count = query::find_stubs(store)
1281        .iter()
1282        .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
1283        .count();
1284
1285    // The store iterates a hash map: order the per-entity lists by id so
1286    // two processes (the CLI and the MCP server) render the same bytes.
1287    stale_entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1288    missing_fields.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1289
1290    HealthSummary {
1291        stale_entities,
1292        anchor_fresh: Vec::new(),
1293        missing_fields,
1294        orphan_count,
1295        stub_count,
1296        warnings: Vec::new(),
1297        quarantined: Vec::new(),
1298        load_errors: Vec::new(),
1299        boot_diagnosis: None,
1300        leaf_entities_by_type,
1301        dangling_links: None,
1302        findings: None,
1303        tag_distribution: None,
1304        tag_distribution_folded: None,
1305        untagged_entities: None,
1306    }
1307}
1308
1309/// Scan every non-stub entity's `tags` metadata and aggregate (tag → count,
1310/// per-entity-type breakdown) plus untagged coverage. Comma-separated parser
1311/// with per-segment trim; empty segments drop. Comparison is case-sensitive
1312/// on the primary surface — case drift is surfaced separately via
1313/// [`TagDistribution`] siblings folded by the caller if desired.
1314///
1315/// `mem_filter` narrows both aggregation passes to entities in that mem;
1316/// `limit` caps the returned `tag_distribution` array after sorting by count
1317/// descending (tie-break by tag ascending for deterministic output).
1318///
1319/// Also returns `FoldedTag` entries for any canonical (lowercase) tag where
1320/// two or more authored casings appear — drift-flag only; empty when no
1321/// collisions exist.
1322pub fn collect_tag_distribution(
1323    store: &Store,
1324    mem_filter: Option<&str>,
1325    limit: usize,
1326) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
1327    // tag → (count, per_type_count)
1328    let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
1329    let mut untagged = UntaggedStats {
1330        total: 0,
1331        by_entity_type: HashMap::new(),
1332    };
1333
1334    for entity in store.all_entities() {
1335        if entity.stub {
1336            continue;
1337        }
1338        if let Some(v) = mem_filter
1339            && entity.mem != v
1340        {
1341            continue;
1342        }
1343
1344        let tags_raw = entity
1345            .metadata
1346            .get("tags")
1347            .and_then(|v| match v {
1348                MetadataValue::String(s) => Some(s.as_str()),
1349                _ => None,
1350            })
1351            .unwrap_or("");
1352
1353        let mut any_tag = false;
1354        for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
1355            any_tag = true;
1356            let entry = counts
1357                .entry(tag.to_string())
1358                .or_insert_with(|| (0, HashMap::new()));
1359            entry.0 += 1;
1360            *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
1361        }
1362        if !any_tag {
1363            untagged.total += 1;
1364            *untagged
1365                .by_entity_type
1366                .entry(entity.entity_type.clone())
1367                .or_insert(0) += 1;
1368        }
1369    }
1370
1371    // Primary distribution — case-sensitive.
1372    let mut entries: Vec<TagDistribution> = counts
1373        .iter()
1374        .map(|(tag, (count, by_type))| TagDistribution {
1375            tag: tag.clone(),
1376            count: *count,
1377            by_entity_type: by_type.clone(),
1378        })
1379        .collect();
1380    entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
1381    entries.truncate(limit);
1382
1383    // Case-drift sidecar: group by lowercase canonical; surface only entries
1384    // with ≥2 distinct authored casings. Operates on the full counts map, not
1385    // the truncated primary surface, so drift hidden below `limit` still
1386    // surfaces.
1387    let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
1388    for (tag, (count, _)) in counts.iter() {
1389        by_canonical
1390            .entry(tag.to_lowercase())
1391            .or_default()
1392            .push((tag.clone(), *count));
1393    }
1394    let mut folded: Vec<FoldedTag> = by_canonical
1395        .into_iter()
1396        .filter(|(_, v)| v.len() > 1)
1397        .map(|(canonical, mut variants)| {
1398            variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1399            let total = variants.iter().map(|(_, c)| *c).sum();
1400            FoldedTag {
1401                canonical,
1402                total,
1403                variants: variants
1404                    .into_iter()
1405                    .map(|(tag, count)| TagVariant { tag, count })
1406                    .collect(),
1407            }
1408        })
1409        .collect();
1410    folded.sort_by(|a, b| {
1411        b.total
1412            .cmp(&a.total)
1413            .then_with(|| a.canonical.cmp(&b.canonical))
1414    });
1415
1416    (entries, folded, untagged)
1417}
1418
1419/// Collect the three separately-repaired conditions the diagnostic
1420/// covers, each tagged with its own [`DanglingLinkKind`]:
1421///
1422/// - `LinkTargetMissing` — a body wiki-link whose target has no markdown
1423///   file: either absent from the store entirely, or present only as a
1424///   stub. Repair: write the entity, or drop the link.
1425/// - `LinkNotRelated` — a body wiki-link to a fully written entity that
1426///   the referrer's relationships list omits (an alias orphan under the
1427///   alias model). The target is fine; the row is missing. Repair: a
1428///   write of the referrer re-synthesises the row.
1429/// - `RelationTargetMissing` — a `## Relationships` row naming an entity
1430///   absent from the store entirely, not even as a stub. Auto-stubbing
1431///   means this only arises from out-of-band file edits or historical
1432///   cross-mem corruption (the pre-F15 mem-delete path). Repair: restore
1433///   the target or remove the edge.
1434///
1435/// The three used to share one code, and only the first was identifiable
1436/// from the payload; the other two were told apart, if at all, by
1437/// whether `section` happened to be null. `section` still marks the
1438/// source axis (`None` for the relationship table), but the condition is
1439/// named, not inferred.
1440///
1441/// `mem_filter` narrows *scanning* to entities in that mem; resolution
1442/// stays global so cross-mem links whose target is a real entity
1443/// elsewhere are not flagged as missing.
1444pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
1445    use crate::entity::parser::extract_inline_links_lenient;
1446    use std::collections::HashSet;
1447
1448    let mut out = Vec::new();
1449    for entity in store.all_entities() {
1450        if entity.stub {
1451            continue;
1452        }
1453        if let Some(v) = mem_filter
1454            && entity.mem != v
1455        {
1456            continue;
1457        }
1458        let explicit_targets: HashSet<_> = entity
1459            .relationships
1460            .iter()
1461            .map(|r| r.target.clone())
1462            .collect();
1463        for (section_key, section_body) in &entity.sections {
1464            for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
1465                let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
1466                let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
1467                // The one place the three conditions are distinguished, so the
1468                // one place the discriminator is set. Consumers read `kind`;
1469                // none re-derives it against the store (04/06).
1470                let kind = if target_missing {
1471                    crate::ops::DanglingLinkKind::LinkTargetMissing
1472                } else {
1473                    crate::ops::DanglingLinkKind::LinkNotRelated
1474                };
1475                if target_missing || alias_orphan {
1476                    out.push(DanglingLink {
1477                        kind,
1478                        from: entity.id.clone(),
1479                        target_id: target_id.clone(),
1480                        target_path: target_id.path().to_string(),
1481                        section: Some(section_key.clone()),
1482                    });
1483                }
1484            }
1485        }
1486        // Relationship-table dangler scan. The `## Relationships`
1487        // section is structurally distinct from body sections — its
1488        // rows materialise from `entity.relationships` rather than a
1489        // free-text body — so `section: None` marks the source axis.
1490        //
1491        // Discrimination differs from the body scan: a relationship
1492        // target that resolves to a stub is a legitimate forward
1493        // reference (the alias machinery auto-stubs absent targets
1494        // by design), not corruption. Only a target that's *fully
1495        // absent* from the store — neither stub nor real — flags as
1496        // dangling. In practice this only fires for out-of-band file
1497        // edits or historical cross-mem-delete corruption that
1498        // dropped the stub along with the deleted mem.
1499        //
1500        // Dedup against the body-scan output so a target that
1501        // surfaces from both axes doesn't double-emit.
1502        for rel in &entity.relationships {
1503            if store.get(&rel.target).is_some() {
1504                continue;
1505            }
1506            let already_reported = out
1507                .iter()
1508                .any(|d| d.from == entity.id && d.target_id == rel.target);
1509            if already_reported {
1510                continue;
1511            }
1512            out.push(DanglingLink {
1513                kind: crate::ops::DanglingLinkKind::RelationTargetMissing,
1514                from: entity.id.clone(),
1515                target_id: rel.target.clone(),
1516                target_path: rel.target.path().to_string(),
1517                section: None,
1518            });
1519        }
1520    }
1521    // Deterministic output — the store iterates a HashMap, so without a
1522    // sort two identical runs can serve the same findings in different
1523    // orders. Sort by (from, target, section) so successive sweeps diff
1524    // cleanly.
1525    out.sort_by(|a, b| {
1526        (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
1527    });
1528    out
1529}
1530
1531/// Collect every non-stub entity whose type declares `required_outgoing`
1532/// blocks that the entity's current outgoing edges leave unsatisfied.
1533/// Results are deterministic — sorted
1534/// by `(mem, id)` — so the agent can diff successive sweeps without
1535/// the underlying HashMap iteration order leaking through.
1536///
1537/// `mem_filter` narrows scanning to entities in that mem when set;
1538/// `mem_schemas` resolves the entity's type definition against the
1539/// mem's pinned schema. Entities whose mem has no schema in the
1540/// map are skipped (no schema → no `required_outgoing` to evaluate).
1541pub fn collect_missing_required_outgoing(
1542    store: &Store,
1543    mem_filter: Option<&str>,
1544    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1545) -> Vec<MissingRequiredOutgoingReport> {
1546    let mut out = Vec::new();
1547    for entity in store.all_entities() {
1548        if entity.stub {
1549            continue;
1550        }
1551        if let Some(v) = mem_filter
1552            && entity.mem != v
1553        {
1554            continue;
1555        }
1556        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1557            continue;
1558        };
1559        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1560            continue;
1561        };
1562        if td.required_outgoing.is_empty() {
1563            continue;
1564        }
1565        let unsatisfied = unsatisfied_required_outgoing(entity, td);
1566        if unsatisfied.is_empty() {
1567            continue;
1568        }
1569        out.push(MissingRequiredOutgoingReport {
1570            id: entity.id.clone(),
1571            title: entity.title.clone(),
1572            entity_type: entity.entity_type.clone(),
1573            mem: entity.mem.clone(),
1574            missing: unsatisfied,
1575        });
1576    }
1577    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1578    out
1579}
1580
1581/// Evaluate one entity's declared `required_outgoing` blocks against
1582/// its current outgoing edges, returning the unsatisfied blocks in
1583/// declaration order. THE single evaluation — shared by the health
1584/// sweep ([`collect_missing_required_outgoing`]) and the per-mutation
1585/// `MISSING_REQUIRED_OUTGOING` warning on create/update. A second
1586/// implementation of the block check is a defect: the two surfaces
1587/// must never disagree about what counts as unsatisfied.
1588pub fn unsatisfied_required_outgoing(
1589    entity: &crate::entity::Entity,
1590    td: &TypeDefinition,
1591) -> Vec<super::MissingRequiredOutgoingBlock> {
1592    td.required_outgoing
1593        .iter()
1594        .filter(|block| {
1595            // A conditional block applies only while `when_field`
1596            // holds `when_value` (same comparison the `requires_when`
1597            // constraint uses). Unset field or any other value = the
1598            // block is unarmed and never unsatisfied.
1599            if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1600                let armed = entity
1601                    .metadata
1602                    .get(when_field.as_str())
1603                    .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1604                if !armed {
1605                    return false;
1606                }
1607            }
1608            let count = entity
1609                .relationships
1610                .iter()
1611                .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1612                .count();
1613            !block.admits(count)
1614        })
1615        .map(|block| super::MissingRequiredOutgoingBlock {
1616            relationships: block.relationships.clone(),
1617            cardinality: block.cardinality.to_string(),
1618            severity: block.severity,
1619            when_field: block.when_field.clone(),
1620            when_value: block.when_value.clone(),
1621        })
1622        .collect()
1623}
1624
1625/// One violated declared constraint on one entity — the wire entry
1626/// shared by the write-path surface (the `CONSTRAINT_UNSATISFIED`
1627/// warning or refusal, tier decided by the declared severity) and the
1628/// health `constraints` include. The serde `kind` tag names the form;
1629/// the remaining fields restate the declaration (plus the observed
1630/// offense — the colliding entity, the unbacked value, the tainting
1631/// ancestor) so a consumer can repair without re-fetching the schema.
1632#[derive(Debug, Clone, serde::Serialize)]
1633#[serde(tag = "kind", rename_all = "snake_case")]
1634pub enum UnsatisfiedConstraint {
1635    RequiresWhen {
1636        field: String,
1637        when_field: String,
1638        when_value: String,
1639        severity: memstead_schema::ConstraintSeverity,
1640    },
1641    Unique {
1642        fields: Vec<String>,
1643        /// The entity's values for `fields`, in declaration order.
1644        values: Vec<String>,
1645        /// The other entity holding the same tuple (lexically smallest
1646        /// when several collide).
1647        colliding: String,
1648        severity: memstead_schema::ConstraintSeverity,
1649    },
1650    EnumFromNeighbour {
1651        field: String,
1652        /// The set value no reached neighbour's section backs.
1653        value: String,
1654        rel_type: String,
1655        section: String,
1656        severity: memstead_schema::ConstraintSeverity,
1657    },
1658    StatusPropagation {
1659        field: String,
1660        /// The terminal value the ancestor holds.
1661        value: String,
1662        /// Echo of a single-rel-type declaration — present exactly
1663        /// when the schema declared `rel_type`, keeping the
1664        /// long-standing payload byte-identical.
1665        #[serde(skip_serializing_if = "Option::is_none")]
1666        rel_type: Option<String>,
1667        /// Echo of a relation-set declaration (`rel_types`).
1668        #[serde(skip_serializing_if = "Option::is_none")]
1669        rel_types: Option<Vec<String>>,
1670        /// The tainting ancestor — the entity holding the terminal
1671        /// value that this entity (transitively) reaches.
1672        tainted_by: String,
1673        severity: memstead_schema::ConstraintSeverity,
1674    },
1675    /// Form 6 — the entity holds the gated `to_value` while related
1676    /// entities lack a fresh confirming check record.
1677    TransitionRequiresChecks {
1678        field: String,
1679        to_value: String,
1680        relationships: Vec<String>,
1681        direction: memstead_schema::PropagationDirection,
1682        /// Every related entity NOT at derived state `checked_ok`,
1683        /// each with the state it derived, sorted by id.
1684        unchecked: Vec<UncheckedRelated>,
1685        severity: memstead_schema::ConstraintSeverity,
1686    },
1687    /// The entity reaches no non-stub entity of a terminal type along
1688    /// the declared relation set — the declaration is echoed whole so
1689    /// the reader sees which obligation went unmet without re-fetching
1690    /// the schema. Health-sweep only, always warn-tier.
1691    MustReach {
1692        relationships: Vec<String>,
1693        direction: memstead_schema::ReachDirection,
1694        terminal_types: Vec<String>,
1695        #[serde(skip_serializing_if = "Option::is_none")]
1696        max_depth: Option<u32>,
1697        severity: memstead_schema::ConstraintSeverity,
1698    },
1699}
1700
1701impl UnsatisfiedConstraint {
1702    pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1703        match self {
1704            Self::RequiresWhen { severity, .. }
1705            | Self::Unique { severity, .. }
1706            | Self::EnumFromNeighbour { severity, .. }
1707            | Self::StatusPropagation { severity, .. }
1708            | Self::MustReach { severity, .. }
1709            | Self::TransitionRequiresChecks { severity, .. } => *severity,
1710        }
1711    }
1712
1713    /// One-line human rendering for warning/refusal message text.
1714    pub fn describe(&self) -> String {
1715        match self {
1716            Self::RequiresWhen {
1717                field,
1718                when_field,
1719                when_value,
1720                ..
1721            } => format!(
1722                "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1723            ),
1724            Self::Unique {
1725                fields, colliding, ..
1726            } => format!(
1727                "unique: tuple ({}) collides with '{colliding}'",
1728                fields.join(", ")
1729            ),
1730            Self::EnumFromNeighbour {
1731                field,
1732                value,
1733                rel_type,
1734                section,
1735                ..
1736            } => format!(
1737                "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1738                 `{section}` section reached via {rel_type}"
1739            ),
1740            Self::StatusPropagation {
1741                field,
1742                value,
1743                tainted_by,
1744                ..
1745            } => {
1746                format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1747            }
1748            Self::MustReach {
1749                relationships,
1750                direction,
1751                terminal_types,
1752                max_depth,
1753                ..
1754            } => {
1755                let depth = match max_depth {
1756                    Some(d) => format!(" within {d} hop(s)"),
1757                    None => String::new(),
1758                };
1759                format!(
1760                    "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1761                    relationships.join(", "),
1762                    terminal_types.join(", ")
1763                )
1764            }
1765            Self::TransitionRequiresChecks {
1766                field,
1767                to_value,
1768                relationships,
1769                unchecked,
1770                ..
1771            } => {
1772                let listed: Vec<String> = unchecked
1773                    .iter()
1774                    .map(|u| format!("'{}' ({})", u.id, u.state))
1775                    .collect();
1776                format!(
1777                    "transition_requires_checks: {field}={to_value} requires a fresh confirming \
1778                     check record on every entity related via [{}] — unconfirmed: {}",
1779                    relationships.join(", "),
1780                    listed.join(", ")
1781                )
1782            }
1783        }
1784    }
1785}
1786
1787/// One related entity blocking a `transition_requires_checks` gate:
1788/// its id and the derived verification state it reads instead of the
1789/// required `checked_ok`.
1790#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1791pub struct UncheckedRelated {
1792    pub id: String,
1793    pub state: String,
1794}
1795
1796/// The `transition_requires_checks` evaluator's window into the check
1797/// ledger: derived verification state for one entity. Callers with an
1798/// engine build it from the workspace check ledger; `None` (no ledger
1799/// access — a workspace-less engine, or a call path that cannot reach
1800/// one) derives every related entity as `never_checked`, so a
1801/// declared gate refuses honestly rather than passing unverified.
1802pub type CheckStateProvider<'a> =
1803    &'a dyn Fn(&crate::entity::Entity) -> crate::engine::independence::CheckStanding;
1804
1805/// Evaluate one entity's declared per-entity `constraints` against its
1806/// current state (and, for the store-aware forms, against the rest of
1807/// its mem), returning the violated ones in declaration order. THE
1808/// single evaluation — shared by the health sweep
1809/// ([`collect_constraint_findings`]) and the per-mutation
1810/// `CONSTRAINT_UNSATISFIED` surface on create/update/relate; a second
1811/// implementation of any form is a defect.
1812///
1813/// Form semantics:
1814/// - `requires_when` triggers when `when_field`'s frontmatter value
1815///   equals `when_value` exactly; a triggered constraint is satisfied
1816///   when `field` — a metadata field or a section key — is present
1817///   with non-blank content.
1818/// - `unique`: the entity's tuple of `fields` values (skipped when any
1819///   field is unset/blank) must not equal another non-stub entity's
1820///   tuple within the same mem and type. `exclude` names the entity's
1821///   own id so an update does not collide with its stored self.
1822/// - `enum_from_neighbour`: a set `field` value must appear as a
1823///   bullet entry (`- value` / `* value` line) in the `section` body
1824///   of at least one entity reached via an outgoing `rel_type` edge.
1825/// - `status_propagation` is a reachability property of the graph,
1826///   not of one write — it is evaluated only by the health sweep
1827///   ([`collect_constraint_findings`]), never here.
1828pub fn unsatisfied_constraints(
1829    store: &Store,
1830    entity: &crate::entity::Entity,
1831    td: &TypeDefinition,
1832    exclude: Option<&crate::entity::EntityId>,
1833    checks: Option<CheckStateProvider<'_>>,
1834) -> Vec<UnsatisfiedConstraint> {
1835    use memstead_schema::ConstraintDef;
1836    td.constraints
1837        .iter()
1838        .filter_map(|c| match c {
1839            ConstraintDef::RequiresWhen {
1840                field,
1841                when_field,
1842                when_value,
1843                severity,
1844            } => {
1845                let triggered = entity
1846                    .metadata
1847                    .get(when_field.as_str())
1848                    .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1849                if !triggered {
1850                    return None;
1851                }
1852                let satisfied = entity
1853                    .metadata
1854                    .get(field.as_str())
1855                    .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1856                    || entity
1857                        .sections
1858                        .get(field.as_str())
1859                        .is_some_and(|body| !body.trim().is_empty());
1860                if satisfied {
1861                    return None;
1862                }
1863                Some(UnsatisfiedConstraint::RequiresWhen {
1864                    field: field.clone(),
1865                    when_field: when_field.clone(),
1866                    when_value: when_value.clone(),
1867                    severity: *severity,
1868                })
1869            }
1870            ConstraintDef::Unique { fields, severity } => {
1871                let tuple = tuple_of(entity, fields)?;
1872                let mut colliding: Vec<&str> = store
1873                    .all_entities()
1874                    .filter(|other| {
1875                        !other.stub
1876                            && other.mem == entity.mem
1877                            && other.entity_type == entity.entity_type
1878                            && Some(&other.id) != exclude
1879                            && other.id != entity.id
1880                            && tuple_of(other, fields).as_ref() == Some(&tuple)
1881                    })
1882                    .map(|other| other.id.0.as_str())
1883                    .collect();
1884                colliding.sort_unstable();
1885                let first = colliding.first()?;
1886                Some(UnsatisfiedConstraint::Unique {
1887                    fields: fields.clone(),
1888                    values: tuple,
1889                    colliding: first.to_string(),
1890                    severity: *severity,
1891                })
1892            }
1893            ConstraintDef::EnumFromNeighbour {
1894                field,
1895                rel_type,
1896                section,
1897                severity,
1898            } => {
1899                let value = entity
1900                    .metadata
1901                    .get(field.as_str())
1902                    .map(|v| v.to_frontmatter_string())
1903                    .filter(|v| !v.trim().is_empty())?;
1904                let backed = entity
1905                    .relationships
1906                    .iter()
1907                    .filter(|rel| rel.rel_type == *rel_type)
1908                    .filter_map(|rel| store.get(&rel.target))
1909                    .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1910                    .any(|body| bullet_entries(body).contains(&value));
1911                if backed {
1912                    return None;
1913                }
1914                Some(UnsatisfiedConstraint::EnumFromNeighbour {
1915                    field: field.clone(),
1916                    value,
1917                    rel_type: rel_type.clone(),
1918                    section: section.clone(),
1919                    severity: *severity,
1920                })
1921            }
1922            ConstraintDef::StatusPropagation { .. } => None,
1923            ConstraintDef::TransitionRequiresChecks {
1924                field,
1925                to_value,
1926                relationships,
1927                direction,
1928                severity,
1929            } => {
1930                let triggered = entity
1931                    .metadata
1932                    .get(field.as_str())
1933                    .is_some_and(|v| v.to_frontmatter_string() == *to_value);
1934                if !triggered {
1935                    return None;
1936                }
1937                let (_, unchecked) = transition_gate_standing(
1938                    store,
1939                    entity,
1940                    relationships,
1941                    *direction,
1942                    exclude,
1943                    checks,
1944                );
1945                if unchecked.is_empty() {
1946                    return None;
1947                }
1948                Some(UnsatisfiedConstraint::TransitionRequiresChecks {
1949                    field: field.clone(),
1950                    to_value: to_value.clone(),
1951                    relationships: relationships.clone(),
1952                    direction: *direction,
1953                    unchecked,
1954                    severity: *severity,
1955                })
1956            }
1957        })
1958        .collect()
1959}
1960
1961/// The standing of one gated-transition constraint's related set
1962/// against one entity, independent of whether the entity currently
1963/// holds the gated value: `(total related, those lacking a fresh
1964/// confirming check record)`, the unconfirmed sorted by id. THE single
1965/// related-set enumeration — shared by the write-time evaluator arm
1966/// above and the gates-brief renderer, so the brief can never disagree
1967/// with the refusal. Outgoing reads the entity's own edges (its
1968/// written state); incoming scans the store for edge sources pointing
1969/// at it, excluding the entity's own stored copy (`exclude`) so an
1970/// update never gates on its pre-write self.
1971pub fn transition_gate_standing(
1972    store: &Store,
1973    entity: &crate::entity::Entity,
1974    relationships: &[String],
1975    direction: memstead_schema::PropagationDirection,
1976    exclude: Option<&crate::entity::EntityId>,
1977    checks: Option<CheckStateProvider<'_>>,
1978) -> (usize, Vec<UncheckedRelated>) {
1979    let related: Vec<&crate::entity::Entity> = match direction {
1980        memstead_schema::PropagationDirection::Outgoing => entity
1981            .relationships
1982            .iter()
1983            .filter(|rel| relationships.contains(&rel.rel_type))
1984            .filter_map(|rel| store.get(&rel.target))
1985            .collect(),
1986        memstead_schema::PropagationDirection::Incoming => store
1987            .all_entities()
1988            .filter(|other| {
1989                other.id != entity.id
1990                    && Some(&other.id) != exclude
1991                    && other
1992                        .relationships
1993                        .iter()
1994                        .any(|rel| rel.target == entity.id && relationships.contains(&rel.rel_type))
1995            })
1996            .collect(),
1997    };
1998    let total = related.len();
1999    let mut unchecked: Vec<UncheckedRelated> = related
2000        .into_iter()
2001        .filter_map(|rel_entity| {
2002            // An ok check confirms only when it is independent of the
2003            // executors (engine::independence): the executor's own ok
2004            // reads `self_checked` here and does not close the gate.
2005            let standing = match checks {
2006                Some(provider) => provider(rel_entity),
2007                None => crate::engine::independence::CheckStanding {
2008                    state: crate::check::CheckState::NeverChecked,
2009                    independence: None,
2010                },
2011            };
2012            if standing.confirms() {
2013                None
2014            } else {
2015                Some(UncheckedRelated {
2016                    id: rel_entity.id.0.clone(),
2017                    state: standing.label().to_string(),
2018                })
2019            }
2020        })
2021        .collect();
2022    unchecked.sort_by(|a, b| a.id.cmp(&b.id));
2023    (total, unchecked)
2024}
2025
2026/// The entity's tuple of frontmatter values for `fields`, in
2027/// declaration order — `None` when any field is unset or blank (no
2028/// tuple, nothing to compare).
2029fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
2030    fields
2031        .iter()
2032        .map(|f| {
2033            entity
2034                .metadata
2035                .get(f.as_str())
2036                .map(|v| v.to_frontmatter_string())
2037                .filter(|v| !v.trim().is_empty())
2038        })
2039        .collect()
2040}
2041
2042/// The bullet entries of a section body — trimmed text of `- item` /
2043/// `* item` lines. The legal-value shape `enum_from_neighbour` reads.
2044fn bullet_entries(body: &str) -> Vec<String> {
2045    // A bullet inside a code block is an example of the list, not a
2046    // member of it — the same referee every other content reader uses
2047    // ([`crate::markdown`]). Masking preserves byte offsets and line
2048    // count, so each masked line pairs with its original.
2049    let masked = crate::markdown::mask_code_blocks_and_spans(body);
2050    body.lines()
2051        .zip(masked.lines())
2052        .filter_map(|(line, masked_line)| {
2053            let m = masked_line.trim_start();
2054            if m.starts_with("- ") || m.starts_with("* ") {
2055                let t = line.trim_start();
2056                t.strip_prefix("- ")
2057                    .or_else(|| t.strip_prefix("* "))
2058                    .map(|e| e.trim().to_string())
2059            } else {
2060                None
2061            }
2062        })
2063        .collect()
2064}
2065
2066/// One entity's violated declared constraints, surfaced from the
2067/// health-time scan (`include=["constraints"]`). Mirrors
2068/// [`MissingRequiredOutgoingReport`]'s envelope shape — the two
2069/// includes read the same way.
2070#[derive(Debug, Clone, serde::Serialize)]
2071pub struct ConstraintFindingReport {
2072    pub id: crate::entity::EntityId,
2073    pub title: String,
2074    pub entity_type: String,
2075    pub mem: String,
2076    pub violations: Vec<UnsatisfiedConstraint>,
2077    /// Standing violations of the entity's declared section formats
2078    /// (plan 08) — additive: consumers of the pre-format shape see an
2079    /// absent key, never an empty list.
2080    #[serde(skip_serializing_if = "Vec::is_empty")]
2081    pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
2082}
2083
2084/// Collect every non-stub entity whose declared `constraints` its
2085/// current state violates. Two passes: the per-entity forms
2086/// (`requires_when`, `unique`, `enum_from_neighbour`) through the
2087/// shared [`unsatisfied_constraints`] evaluation, then the
2088/// `status_propagation` graph sweep — for each entity holding a
2089/// declared terminal value, every entity reaching it (transitively)
2090/// via the declared rel-type and direction gains a finding naming that
2091/// tainting ancestor. Deterministic — reports sorted by `(mem, id)`,
2092/// violations in declaration order then by tainting ancestor.
2093pub fn collect_constraint_findings(
2094    store: &Store,
2095    mem_filter: Option<&str>,
2096    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2097    checks: Option<CheckStateProvider<'_>>,
2098) -> Vec<ConstraintFindingReport> {
2099    use memstead_schema::ConstraintDef;
2100    type Bucket = (
2101        Vec<UnsatisfiedConstraint>,
2102        Vec<crate::section_format::SectionFormatViolation>,
2103    );
2104    let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
2105
2106    // Reverse adjacency for `must_reach` incoming walks — built once
2107    // per sweep, and only when some pinned schema declares one (a
2108    // workspace without the form pays nothing).
2109    let needs_reverse = mem_schemas.values().any(|s| {
2110        s.types.values().any(|t| {
2111            t.must_reach
2112                .iter()
2113                .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
2114        })
2115    });
2116    let reverse: ReverseIndex = if needs_reverse {
2117        build_reverse_index(store)
2118    } else {
2119        ReverseIndex::default()
2120    };
2121
2122    for entity in store.all_entities() {
2123        if entity.stub {
2124            continue;
2125        }
2126        if let Some(v) = mem_filter
2127            && entity.mem != v
2128        {
2129            continue;
2130        }
2131        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2132            continue;
2133        };
2134        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2135            continue;
2136        };
2137
2138        // Section-format sweep (plan 08) — standing violations of a
2139        // declared markdown shape, every severity (block-tier
2140        // pre-existing violations are health findings too; the next
2141        // write of the section is the sanctioned repair point).
2142        for def in &td.sections {
2143            if def.compiled_content.is_none() {
2144                continue;
2145            }
2146            let Some(body) = entity.sections.get(def.key.as_str()) else {
2147                continue;
2148            };
2149            let violations = crate::section_format::check_section_format(def, body);
2150            if !violations.is_empty() {
2151                by_entity
2152                    .entry(entity.id.0.clone())
2153                    .or_default()
2154                    .1
2155                    .extend(violations);
2156            }
2157        }
2158
2159        // Reachability obligations — health-sweep only by design (no
2160        // single write completes a transitive absence, so the write
2161        // path never evaluates these). The finding echoes the whole
2162        // declaration.
2163        for ob in &td.must_reach {
2164            if !reaches_terminal(store, &reverse, &entity.id, ob) {
2165                by_entity.entry(entity.id.0.clone()).or_default().0.push(
2166                    UnsatisfiedConstraint::MustReach {
2167                        relationships: ob.relationships.clone(),
2168                        direction: ob.direction,
2169                        terminal_types: ob.terminal_types.clone(),
2170                        max_depth: ob.max_depth,
2171                        severity: ob.severity,
2172                    },
2173                );
2174            }
2175        }
2176
2177        if td.constraints.is_empty() {
2178            continue;
2179        }
2180
2181        // Pass 1 — per-entity forms.
2182        let violations = unsatisfied_constraints(store, entity, td, None, checks);
2183        if !violations.is_empty() {
2184            by_entity
2185                .entry(entity.id.0.clone())
2186                .or_default()
2187                .0
2188                .extend(violations);
2189        }
2190
2191        // Pass 2 — this entity as a taint source: it holds a declared
2192        // terminal value, so sweep its dependents. The taint walks
2193        // the declared relation set's union subgraph — a single
2194        // `rel_type` is a one-element set.
2195        for c in &td.constraints {
2196            let ConstraintDef::StatusPropagation {
2197                field,
2198                value,
2199                rel_type,
2200                rel_types,
2201                direction,
2202                severity,
2203            } = c
2204            else {
2205                continue;
2206            };
2207            let terminal = entity
2208                .metadata
2209                .get(field.as_str())
2210                .is_some_and(|v| v.to_frontmatter_string() == *value);
2211            if !terminal {
2212                continue;
2213            }
2214            let set = c
2215                .propagation_rel_types()
2216                .expect("StatusPropagation always yields a set");
2217            for tainted in reach_transitively(store, &entity.id, &set, *direction) {
2218                if let Some(v) = mem_filter
2219                    && tainted.mem() != v
2220                {
2221                    continue;
2222                }
2223                by_entity.entry(tainted.0.clone()).or_default().0.push(
2224                    UnsatisfiedConstraint::StatusPropagation {
2225                        field: field.clone(),
2226                        value: value.clone(),
2227                        rel_type: rel_type.clone(),
2228                        rel_types: rel_types.clone(),
2229                        tainted_by: entity.id.to_string(),
2230                        severity: *severity,
2231                    },
2232                );
2233            }
2234        }
2235    }
2236
2237    let mut out: Vec<ConstraintFindingReport> = by_entity
2238        .into_iter()
2239        .filter_map(|(id, (violations, format_violations))| {
2240            let id = crate::entity::EntityId(id);
2241            let entity = store.get(&id)?;
2242            Some(ConstraintFindingReport {
2243                id,
2244                title: entity.title.clone(),
2245                entity_type: entity.entity_type.clone(),
2246                mem: entity.mem.clone(),
2247                violations,
2248                format_violations,
2249            })
2250        })
2251        .collect();
2252    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2253    out
2254}
2255
2256/// Transitive reachability along one rel-type from `start`, excluding
2257/// `start` itself. `Incoming` walks against edge direction (the
2258/// entities whose `rel_type` edges point at the frontier — "what
2259/// stands on this"); `Outgoing` follows the frontier's own edges.
2260/// Stubs are traversed (an edge through a stub still transmits the
2261/// taint) but stubs themselves are not returned.
2262fn reach_transitively(
2263    store: &Store,
2264    start: &crate::entity::EntityId,
2265    rel_types: &[String],
2266    direction: memstead_schema::PropagationDirection,
2267) -> Vec<crate::entity::EntityId> {
2268    use memstead_schema::PropagationDirection;
2269    let mut seen: std::collections::HashSet<crate::entity::EntityId> =
2270        std::iter::once(start.clone()).collect();
2271    let mut frontier = vec![start.clone()];
2272    let mut reached = Vec::new();
2273    while let Some(current) = frontier.pop() {
2274        let next: Vec<crate::entity::EntityId> = match direction {
2275            PropagationDirection::Incoming => store
2276                .all_entities()
2277                .filter(|e| {
2278                    e.relationships
2279                        .iter()
2280                        .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
2281                })
2282                .map(|e| e.id.clone())
2283                .collect(),
2284            PropagationDirection::Outgoing => store
2285                .get(&current)
2286                .map(|e| {
2287                    e.relationships
2288                        .iter()
2289                        .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
2290                        .map(|r| r.target.clone())
2291                        .collect()
2292                })
2293                .unwrap_or_default(),
2294        };
2295        for id in next {
2296            if seen.insert(id.clone()) {
2297                if store.get(&id).is_some_and(|e| !e.stub) {
2298                    reached.push(id.clone());
2299                }
2300                frontier.push(id);
2301            }
2302        }
2303    }
2304    reached
2305}
2306
2307/// Reverse adjacency for `must_reach` incoming walks: target id →
2308/// `(rel_type, source id)` pairs. Built once per sweep so the
2309/// incoming direction stays O(edges) instead of re-scanning the store
2310/// per frontier node.
2311type ReverseIndex =
2312    std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
2313
2314fn build_reverse_index(store: &Store) -> ReverseIndex {
2315    let mut idx = ReverseIndex::default();
2316    for entity in store.all_entities() {
2317        for rel in &entity.relationships {
2318            idx.entry(rel.target.clone())
2319                .or_default()
2320                .push((rel.rel_type.clone(), entity.id.clone()));
2321        }
2322    }
2323    idx
2324}
2325
2326/// Whether `start` reaches at least one non-stub entity of a terminal
2327/// type along the obligation's relation set, direction, and depth
2328/// bound. Breadth-first with visited-set discipline (cycles along the
2329/// walked set terminate); stubs terminate no obligation — they carry
2330/// no outgoing edges and never count as reached terminals. Cross-mem
2331/// edges are followed like any edge, matching the propagation walk
2332/// and the cycle check (the engine's established traversal posture);
2333/// the start entity itself never satisfies its own obligation.
2334fn reaches_terminal(
2335    store: &Store,
2336    reverse: &ReverseIndex,
2337    start: &crate::entity::EntityId,
2338    ob: &memstead_schema::MustReach,
2339) -> bool {
2340    use memstead_schema::ReachDirection;
2341    let mut seen: std::collections::HashSet<crate::entity::EntityId> =
2342        std::iter::once(start.clone()).collect();
2343    let mut frontier = vec![start.clone()];
2344    let mut depth: u32 = 0;
2345    while !frontier.is_empty() {
2346        if let Some(max) = ob.max_depth
2347            && depth >= max
2348        {
2349            return false;
2350        }
2351        depth += 1;
2352        let mut next_frontier = Vec::new();
2353        for current in frontier {
2354            let next: Vec<crate::entity::EntityId> = match ob.direction {
2355                ReachDirection::Out => store
2356                    .get(&current)
2357                    .map(|e| {
2358                        e.relationships
2359                            .iter()
2360                            .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
2361                            .map(|r| r.target.clone())
2362                            .collect()
2363                    })
2364                    .unwrap_or_default(),
2365                ReachDirection::In => reverse
2366                    .get(&current)
2367                    .map(|sources| {
2368                        sources
2369                            .iter()
2370                            .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
2371                            .map(|(_, src)| src.clone())
2372                            .collect()
2373                    })
2374                    .unwrap_or_default(),
2375            };
2376            for id in next {
2377                if seen.insert(id.clone()) {
2378                    if store.get(&id).is_some_and(|e| {
2379                        !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
2380                    }) {
2381                        return true;
2382                    }
2383                    next_frontier.push(id);
2384                }
2385            }
2386        }
2387        frontier = next_frontier;
2388    }
2389    false
2390}
2391
2392/// One entity's above-`none` signals, surfaced from the include-gated
2393/// `signals` health axis. Mirrors [`ConstraintFindingReport`]'s
2394/// envelope shape; the `signals` entries carry value, level, and
2395/// contributors (the evidence ships with the number, always).
2396#[derive(Debug, Clone, serde::Serialize)]
2397pub struct SignalReport {
2398    pub id: crate::entity::EntityId,
2399    pub title: String,
2400    pub entity_type: String,
2401    pub mem: String,
2402    /// Only signals whose level is not `none`, in declaration order.
2403    pub signals: Vec<super::signals::ComputedSignal>,
2404}
2405
2406impl SignalReport {
2407    /// Whether any entry is `warn`-level — the `--strict`
2408    /// participation test (a `notice` never participates; that is the
2409    /// whole difference between the two levels).
2410    pub fn has_warn(&self) -> bool {
2411        self.signals
2412            .iter()
2413            .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
2414    }
2415}
2416
2417/// Collect every non-stub entity carrying at least one declared
2418/// signal above `none`. Deterministic — sorted by `(mem, id)`;
2419/// signals in declaration order, contributors sorted.
2420pub fn collect_signal_reports(
2421    store: &Store,
2422    mem_filter: Option<&str>,
2423    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2424) -> Vec<SignalReport> {
2425    let mut out = Vec::new();
2426    for entity in store.all_entities() {
2427        if entity.stub {
2428            continue;
2429        }
2430        if let Some(v) = mem_filter
2431            && entity.mem != v
2432        {
2433            continue;
2434        }
2435        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2436            continue;
2437        };
2438        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2439            continue;
2440        };
2441        if td.signals.is_empty() {
2442            continue;
2443        }
2444        let above: Vec<super::signals::ComputedSignal> =
2445            super::signals::compute_signals(store, td, &entity.id)
2446                .into_iter()
2447                .filter(|s| s.level.is_some())
2448                .collect();
2449        if above.is_empty() {
2450            continue;
2451        }
2452        out.push(SignalReport {
2453            id: entity.id.clone(),
2454            title: entity.title.clone(),
2455            entity_type: entity.entity_type.clone(),
2456            mem: entity.mem.clone(),
2457            signals: above,
2458        });
2459    }
2460    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2461    out
2462}
2463
2464/// A defective section-format declaration a loaded schema carries
2465/// (recorded by the lenient boot path; install would have refused).
2466/// Surfaced under the health `constraints` include so a sealed schema
2467/// with a bad declaration is visible without bricking boot.
2468#[derive(Debug, Clone, serde::Serialize)]
2469pub struct SchemaFormatDefect {
2470    pub schema: String,
2471    pub type_name: String,
2472    pub section: String,
2473    pub problems: Vec<String>,
2474}
2475
2476/// Collect the defective section-format declarations across the
2477/// mounted mems' pinned schemas, deduplicated per schema ref,
2478/// deterministic order.
2479pub fn collect_schema_format_defects(
2480    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2481) -> Vec<SchemaFormatDefect> {
2482    let mut seen: std::collections::BTreeSet<String> = Default::default();
2483    let mut out = Vec::new();
2484    let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
2485    schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
2486    for schema in schemas {
2487        let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
2488        if !seen.insert(schema_ref.clone()) {
2489            continue;
2490        }
2491        for td in schema.types.values() {
2492            for section in &td.sections {
2493                if !section.format_problems.is_empty() {
2494                    out.push(SchemaFormatDefect {
2495                        schema: schema_ref.clone(),
2496                        type_name: td.name.clone(),
2497                        section: section.key.clone(),
2498                        problems: section.format_problems.clone(),
2499                    });
2500                }
2501            }
2502        }
2503    }
2504    out.sort_by(|a, b| {
2505        (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
2506    });
2507    out
2508}
2509
2510/// One entity's unsatisfied `required_outgoing` blocks, surfaced from
2511/// the health-time scan. `missing` reuses the per-write warning's wire
2512/// block type — one struct, one serialized shape (`{ relationships,
2513/// cardinality }`) on both surfaces — and adds the `mem` name (the
2514/// warning's `entity_id` already encodes it via the mem prefix, but
2515/// health is multi-mem by default and an explicit field is cheaper for
2516/// downstream filters).
2517#[derive(Debug, Clone, serde::Serialize)]
2518pub struct MissingRequiredOutgoingReport {
2519    pub id: crate::entity::EntityId,
2520    pub title: String,
2521    pub entity_type: String,
2522    pub mem: String,
2523    pub missing: Vec<super::MissingRequiredOutgoingBlock>,
2524}
2525
2526/// Render the workspace-config projection the health surface serves —
2527/// per-writable-mem detail (`origin`, storage/durability, `vcs`
2528/// `gitdir`/`worktree`/`head`, title/subject, `write_guidance`,
2529/// `extra`) plus the `mutations` and `plugin` policy values. One
2530/// implementation, every surface: the MCP composer reaches it through
2531/// `include_config: true` OR the `config` include key; the CLI through
2532/// `--include config`. `mutations` / `plugin` are passed prebuilt so a
2533/// server that owns its own copies inserts them verbatim; callers
2534/// without server state derive them from `Engine::settings()` (see
2535/// [`config_projection_from_settings`]). Returns the three top-level
2536/// entries (`mems`, `mutations`, `plugin`) for the caller to merge —
2537/// callers gate on their own opt-in flag and must render at most once.
2538pub fn config_projection(
2539    engine: &crate::Engine,
2540    writable_mems: &[String],
2541    mutations: serde_json::Value,
2542    plugin: serde_json::Value,
2543) -> serde_json::Map<String, serde_json::Value> {
2544    // Per-mem storage backend → durability marker, derived from the
2545    // mount's `MountStorage` kind. Lives alongside `vcs` so an agent
2546    // reading per-mem config learns whether a `write_id` this mem
2547    // returns is durable-on-disk or volatile-in-RAM.
2548    let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
2549        .mounts()
2550        .iter()
2551        .map(|m| {
2552            (
2553                m.mem.as_str(),
2554                (m.storage.backend_id(), m.storage.is_durable()),
2555            )
2556        })
2557        .collect();
2558    let mems_detail: Vec<serde_json::Value> = writable_mems
2559        .iter()
2560        .map(|name| {
2561            let origin = engine
2562                .mem_router()
2563                .origin_for_mem(name)
2564                .map(|o| o.kind())
2565                .unwrap_or("explicit");
2566            let mut entry = serde_json::Map::new();
2567            entry.insert("name".into(), serde_json::json!(name));
2568            entry.insert("origin".into(), serde_json::json!(origin));
2569            if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
2570                entry.insert("storage".into(), serde_json::json!(storage));
2571                entry.insert("durable".into(), serde_json::json!(durable));
2572            }
2573            let mut vcs_obj = serde_json::Map::new();
2574            if let Ok(gitdir) = engine.gitdir_for(name) {
2575                vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
2576            }
2577            if let Ok(worktree) = engine.worktree_for(name) {
2578                vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
2579            }
2580            if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
2581                vcs_obj.insert("head".into(), serde_json::json!(sha));
2582            }
2583            if !vcs_obj.is_empty() {
2584                entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
2585            }
2586            if let Some(cfg) = engine.mem_config_for(name) {
2587                // Display title + subject block, when set — the
2588                // config projection prefers the title wherever a
2589                // mem is printed; the name stays the identity.
2590                if let Some(title) = &cfg.title {
2591                    entry.insert("title".into(), serde_json::json!(title));
2592                }
2593                if let Some(subject) = &cfg.subject {
2594                    entry.insert("subject".into(), serde_json::json!(subject));
2595                }
2596                let guidance = serde_json::Map::from_iter(
2597                    cfg.write_guidance
2598                        .iter()
2599                        .map(|(k, v)| (k.clone(), v.clone())),
2600                );
2601                entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
2602                let extra = serde_json::Map::from_iter(
2603                    cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
2604                );
2605                entry.insert("extra".into(), serde_json::Value::Object(extra));
2606            }
2607            serde_json::Value::Object(entry)
2608        })
2609        .collect();
2610
2611    let mut out = serde_json::Map::new();
2612    out.insert("mems".into(), serde_json::json!(mems_detail));
2613    out.insert("mutations".into(), mutations);
2614    out.insert("plugin".into(), plugin);
2615    out
2616}
2617
2618/// The `(mutations, plugin)` pair for [`config_projection`], derived
2619/// from the engine's own [`crate::workspace::WorkspaceSettings`] — for
2620/// callers (the CLI) that carry no server-owned config copies. Produces
2621/// the same bytes the MCP server passes when both were loaded from the
2622/// same `workspace.toml`.
2623pub fn config_projection_from_settings(
2624    settings: &crate::workspace::WorkspaceSettings,
2625) -> (serde_json::Value, serde_json::Value) {
2626    let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
2627    let plugin_map: serde_json::Map<String, serde_json::Value> = settings
2628        .plugin
2629        .iter()
2630        .map(|(k, v)| {
2631            (
2632                k.clone(),
2633                serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
2634            )
2635        })
2636        .collect();
2637    (mutations, serde_json::Value::Object(plugin_map))
2638}
2639
2640/// Detect the section-fork condition for one declared section: the
2641/// parsed content under `key` is empty, the schema's declared heading
2642/// for the key does not derive back to it
2643/// (`derive_section_key(heading) != key`), and the file carries that
2644/// declared heading — so the content is present in the file but
2645/// unreachable under the key: absorbed into the catch-all when the
2646/// type declares one, dropped from the parsed sections otherwise.
2647///
2648/// Returns the distinct `SECTION_HEADING_MISMATCH` issue naming both
2649/// the found heading and what a deriving heading would look like. The
2650/// caller must NOT also report the section as missing — collapsing the
2651/// two conditions into the missing-section report is exactly the
2652/// misdirection this finding exists to prevent (the operator goes
2653/// hunting for absent content that is in fact present).
2654pub(crate) fn section_heading_mismatch_issue(
2655    entity: &crate::entity::Entity,
2656    schema: &TypeDefinition,
2657    key: &str,
2658) -> Option<HealthIssue> {
2659    let def = schema.section(key)?;
2660    let derived = memstead_schema::derive_section_key(&def.heading);
2661    if derived == key {
2662        return None;
2663    }
2664    if !entity
2665        .raw_section_headings
2666        .iter()
2667        .any(|h| h == &def.heading)
2668    {
2669        return None;
2670    }
2671    let landing = match schema.catch_all_section() {
2672        Some(c) => format!(
2673            "the content was absorbed into catch-all section '{}'",
2674            c.key
2675        ),
2676        None => "the content is unreachable under any declared key".to_string(),
2677    };
2678    Some(HealthIssue {
2679        field: key.to_string(),
2680        code: super::HealthIssueCode::SectionHeadingMismatch,
2681        message: format!(
2682            "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
2683             under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
2684             The schema's declared heading cannot round-trip to its key (expected a heading \
2685             that derives to '{key}'); fix the schema's heading/key pair — new installs of \
2686             such a schema are refused",
2687            found = def.heading,
2688        ),
2689    })
2690}
2691
2692/// Get a single entity's health report.
2693pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
2694    let mut issues = Vec::new();
2695
2696    for field in &schema.health_required_fields {
2697        if schema.section(field).is_some() {
2698            let content = entity.sections.get(field.as_str());
2699            if content.is_none_or(|c| c.trim().is_empty()) {
2700                if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
2701                    issues.push(issue);
2702                } else {
2703                    issues.push(HealthIssue {
2704                        field: field.clone(),
2705                        code: super::HealthIssueCode::Missing,
2706                        message: format!("required section '{field}' is empty"),
2707                    });
2708                }
2709            }
2710        } else {
2711            let value = entity.metadata.get(field.as_str());
2712            if value.is_none() {
2713                issues.push(HealthIssue {
2714                    field: field.clone(),
2715                    code: super::HealthIssueCode::Missing,
2716                    message: format!("required field '{field}' is missing"),
2717                });
2718            }
2719        }
2720    }
2721
2722    // The mismatch condition is drift worth surfacing on every declared
2723    // section, not only the health-required ones — an optional section
2724    // whose content forked away is just as invisible to readers.
2725    for s in schema.sections.iter().filter(|s| !s.catch_all) {
2726        if schema.health_required_fields.contains(&s.key) {
2727            continue; // already handled above
2728        }
2729        let content = entity.sections.get(s.key.as_str());
2730        if content.is_none_or(|c| c.trim().is_empty())
2731            && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
2732        {
2733            issues.push(issue);
2734        }
2735    }
2736
2737    let total = schema.health_required_fields.len();
2738    let score = if total > 0 {
2739        (total.saturating_sub(issues.len()) as f32) / (total as f32)
2740    } else {
2741        1.0
2742    };
2743
2744    HealthReport {
2745        id: entity.id.clone(),
2746        title: entity.title.clone(),
2747        score,
2748        issues,
2749    }
2750}
2751
2752// ---------------------------------------------------------------------------
2753// Date helpers
2754// ---------------------------------------------------------------------------
2755
2756/// Get current days since Unix epoch.
2757///
2758/// `SystemTime::now()` is unimplemented on `wasm32-unknown-unknown` —
2759/// it traps with `RuntimeError: unreachable` and poisons the wasm
2760/// instance (cold-start F11) — so the wasm build reads the JS-backed
2761/// clock instead. Same value, same summary shape on every target.
2762/// Today as whole days since the Unix epoch, the clock every day-threshold
2763/// reading in health uses. `MEMSTEAD_TODAY=YYYY-MM-DD` pins it (the
2764/// injection a fixture needs to age entities without waiting), honoured
2765/// by every binary alike so the CLI and the MCP server read the same day.
2766pub fn days_since_epoch() -> u64 {
2767    if let Some(pinned) = std::env::var("MEMSTEAD_TODAY")
2768        .ok()
2769        .and_then(|s| crate::engine::due::pinned_days_since_epoch(&s))
2770    {
2771        return pinned;
2772    }
2773    #[cfg(target_arch = "wasm32")]
2774    {
2775        (js_sys::Date::now() / 1000.0) as u64 / 86400
2776    }
2777    #[cfg(not(target_arch = "wasm32"))]
2778    {
2779        std::time::SystemTime::now()
2780            .duration_since(std::time::UNIX_EPOCH)
2781            .unwrap_or_default()
2782            .as_secs()
2783            / 86400
2784    }
2785}
2786
2787/// Parse an ISO 8601 date string to days since epoch.
2788/// Supports `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM:SSZ`.
2789pub fn parse_iso_to_days(date: &str) -> Option<u64> {
2790    let date_part = date.split('T').next()?;
2791    let parts: Vec<&str> = date_part.split('-').collect();
2792    if parts.len() != 3 {
2793        return None;
2794    }
2795    let year: u64 = parts[0].parse().ok()?;
2796    let month: u64 = parts[1].parse().ok()?;
2797    let day: u64 = parts[2].parse().ok()?;
2798    Some(ymd_to_days(year, month, day))
2799}
2800
2801/// Convert (year, month, day) to days since Unix epoch.
2802/// Inverse of the algorithm in generator.rs.
2803fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2804    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
2805    let y = if month <= 2 { year - 1 } else { year };
2806    let m = if month <= 2 { month + 9 } else { month - 3 };
2807    let era = y / 400;
2808    let yoe = y - era * 400;
2809    let doy = (153 * m + 2) / 5 + day - 1;
2810    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2811    let days = era * 146097 + doe;
2812    days - 719468
2813}
2814
2815#[cfg(test)]
2816mod tests {
2817    use super::*;
2818    use crate::entity::{Entity, EntityId, MetadataValue};
2819    use crate::ops::DanglingLinkKind;
2820    use crate::store::Store;
2821    use indexmap::IndexMap;
2822    use memstead_schema::type_by_name;
2823
2824    /// A bullet inside a code block is an example of the list, not a
2825    /// member of it. `enum_from_neighbour` harvests legal values from a
2826    /// neighbour's section body, so an unmasked scan would accept any
2827    /// value someone documented in a fenced sample.
2828    #[test]
2829    fn bullet_entries_ignores_code() {
2830        let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n    - indented-ghost\n\nA `- span-ghost` sample.\n";
2831        let entries = bullet_entries(body);
2832        assert_eq!(
2833            entries,
2834            vec!["real-one".to_string(), "real-two".to_string()],
2835            "only prose bullets are legal values: {entries:?}"
2836        );
2837    }
2838
2839    /// Complement: indentation, `*` markers and inline formatting in a
2840    /// prose bullet all still read exactly as before — the entry text
2841    /// comes from the original, not the mask.
2842    #[test]
2843    fn bullet_entries_still_reads_prose_bullets_verbatim() {
2844        let entries = bullet_entries("- alpha\n  * beta\n* `gamma`\n");
2845        assert_eq!(
2846            entries,
2847            vec![
2848                "alpha".to_string(),
2849                "beta".to_string(),
2850                "`gamma`".to_string()
2851            ]
2852        );
2853    }
2854
2855    /// Agent-trust plan 14, criterion 4: a destination config
2856    /// declaring its process mem resolves the pairing regardless of
2857    /// naming — with no binding at all — and a declaration naming a
2858    /// missing mem surfaces as the typed finding, never a silent
2859    /// fallback.
2860    #[test]
2861    fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2862        use crate::engine::test_helpers::folder_mount;
2863        let tmp = tempfile::TempDir::new().unwrap();
2864        let dest_dir = tmp.path().join("dest");
2865        let proc_dir = tmp.path().join("oddly-named-process");
2866        std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2867        std::fs::create_dir_all(&proc_dir).unwrap();
2868        // Declaration: the destination pairs with a mem whose name no
2869        // convention would derive.
2870        std::fs::write(
2871            dest_dir.join(".memstead").join("config.json"),
2872            r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2873        )
2874        .unwrap();
2875        let engine = crate::Engine::from_mounts(vec![
2876            (
2877                folder_mount("dest", dest_dir.clone()),
2878                Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2879                    as Box<dyn crate::backend::MemBackend>,
2880            ),
2881            (
2882                folder_mount("oddly-named-process", proc_dir.clone()),
2883                Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2884                    as Box<dyn crate::backend::MemBackend>,
2885            ),
2886        ])
2887        .unwrap();
2888
2889        // The one resolution function: declaration wins.
2890        let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2891        assert!(r.declared && r.mounted);
2892        assert_eq!(r.mem, "oddly-named-process");
2893        // No declaration → derivation fallback, byte-identical to the
2894        // pre-declaration behaviour.
2895        let r =
2896            crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2897        assert!(!r.declared && !r.mounted);
2898        assert_eq!(r.mem, "whatever");
2899
2900        // The axis pairs the declared mem with no binding present.
2901        let axis = health_open_questions_axis(&engine, Some("dest"));
2902        let process = &axis["dest"]["process"];
2903        assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2904        assert_eq!(process[0]["declared"], true, "{axis}");
2905        assert_eq!(process[0]["resolvable"], true, "{axis}");
2906
2907        // Declaration naming a missing mem: typed finding.
2908        std::fs::write(
2909            dest_dir.join(".memstead").join("config.json"),
2910            r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2911        )
2912        .unwrap();
2913        let engine2 = crate::Engine::from_mounts(vec![(
2914            folder_mount("dest", dest_dir.clone()),
2915            Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2916                as Box<dyn crate::backend::MemBackend>,
2917        )])
2918        .unwrap();
2919        let axis = health_open_questions_axis(&engine2, Some("dest"));
2920        let process = &axis["dest"]["process"];
2921        assert_eq!(
2922            process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2923            "{axis}"
2924        );
2925        assert_eq!(process[0]["resolvable"], false, "{axis}");
2926    }
2927
2928    /// Agent-trust plan 15: the independence gate compares
2929    /// caller-declared identities and nothing else (criterion 2 with
2930    /// the transport complement): equal identities read
2931    /// `self_checked` even across DIFFERING `(actor, client)` pairs,
2932    /// differing identities read `confirmed_independent` even on the
2933    /// SAME pair, a missing identity on either side reads
2934    /// `unconfirmable` — the pair provably does not participate.
2935    #[test]
2936    fn independence_gate_compares_identities_only() {
2937        use crate::engine::test_helpers::folder_mount;
2938        let tmp = tempfile::TempDir::new().unwrap();
2939        let dir = tmp.path().join("gate");
2940        std::fs::create_dir_all(&dir).unwrap();
2941        let mut engine = crate::Engine::from_mounts(vec![(
2942            folder_mount("gate", dir.clone()),
2943            Box::new(crate::storage::FilesystemMemWriter::new(dir))
2944                as Box<dyn crate::backend::MemBackend>,
2945        )])
2946        .unwrap();
2947        engine.set_workspace_root(tmp.path().to_path_buf());
2948
2949        let create = |engine: &mut crate::Engine, title: &str, identity: Option<&str>| {
2950            engine.set_identity(identity.map(str::to_string));
2951            engine
2952                .create_entity(
2953                    crate::CreateEntityArgs {
2954                        mem: "gate".to_string(),
2955                        title: title.to_string(),
2956                        entity_type: "spec".to_string(),
2957                        sections: [
2958                            ("identity".to_string(), "x".to_string()),
2959                            ("purpose".to_string(), "y".to_string()),
2960                        ]
2961                        .into_iter()
2962                        .collect(),
2963                        metadata: Default::default(),
2964                        relations: Vec::new(),
2965                        anchors: Vec::new(),
2966                        dry_run: false,
2967                    },
2968                    crate::vcs::Actor::Cli,
2969                    None,
2970                    None,
2971                )
2972                .unwrap()
2973                .id
2974                .0
2975        };
2976        let a = create(&mut engine, "Self Checked", Some("alice"));
2977        let b = create(&mut engine, "Independent", Some("alice"));
2978        let c = create(&mut engine, "No Author Identity", None);
2979
2980        let check = |engine: &mut crate::Engine,
2981                     id: &str,
2982                     identity: Option<&str>,
2983                     actor: crate::vcs::Actor,
2984                     client: Option<&crate::vcs::ClientId>| {
2985            engine.set_identity(identity.map(str::to_string));
2986            engine
2987                .record_check(
2988                    "gate",
2989                    id,
2990                    crate::check::Verdict::Ok,
2991                    crate::check::CheckKind::Verification,
2992                    None,
2993                    actor,
2994                    client,
2995                )
2996                .unwrap();
2997        };
2998        let other_client = crate::vcs::ClientId {
2999            name: "claude-code".into(),
3000            version: "9.9".into(),
3001        };
3002        // a: authored (cli, no client), checked (agent, claude-code) —
3003        // DIFFERING pairs, equal identities → self_checked.
3004        check(
3005            &mut engine,
3006            &a,
3007            Some("alice"),
3008            crate::vcs::Actor::Agent,
3009            Some(&other_client),
3010        );
3011        // b: SAME pair as its author record, differing identities →
3012        // confirmed_independent.
3013        check(&mut engine, &b, Some("bob"), crate::vcs::Actor::Cli, None);
3014        // c: checker declared, author never did → unconfirmable.
3015        check(&mut engine, &c, Some("carol"), crate::vcs::Actor::Cli, None);
3016
3017        let axis = health_checks_axis(&engine, Some("gate"));
3018        let gate = &axis["gate"]["independence"];
3019        assert_eq!(
3020            gate["self_checked"]["items"],
3021            serde_json::json!([a]),
3022            "{axis}"
3023        );
3024        assert_eq!(
3025            gate["confirmed_independent"]["items"],
3026            serde_json::json!([b]),
3027            "{axis}"
3028        );
3029        assert_eq!(
3030            gate["unconfirmable"]["items"],
3031            serde_json::json!([c]),
3032            "{axis}"
3033        );
3034
3035        // The recorded identity is served by the provenance block and
3036        // the check record (criterion 1's engine half).
3037        let prov = engine.entity_provenance("gate", &a).unwrap();
3038        assert_eq!(
3039            prov.created_by.as_ref().and_then(|r| r.identity.as_deref()),
3040            Some("alice"),
3041            "created-by serves the declared identity"
3042        );
3043        assert_eq!(
3044            prov.last_check.as_ref().and_then(|r| r.identity.as_deref()),
3045            Some("alice"),
3046            "the check record serves the declared identity"
3047        );
3048    }
3049
3050    fn make_entity(name: &str, has_required: bool) -> Entity {
3051        let mut metadata = IndexMap::new();
3052        metadata.insert("level".into(), MetadataValue::String("M0".into()));
3053        metadata.insert("type".into(), MetadataValue::String("spec".into()));
3054        metadata.insert(
3055            "created_date".into(),
3056            MetadataValue::String("2026-01-15".into()),
3057        );
3058        metadata.insert(
3059            "last_modified".into(),
3060            MetadataValue::String("2026-04-12".into()),
3061        );
3062
3063        let mut sections = IndexMap::new();
3064        if has_required {
3065            sections.insert("identity".into(), "Has identity.".into());
3066            sections.insert("purpose".into(), "Has purpose.".into());
3067        }
3068
3069        Entity {
3070            id: EntityId::new("specs", name),
3071            title: name.into(),
3072            entity_type: "spec".into(),
3073            mem: "specs".into(),
3074            file_path: format!("{name}.md"),
3075            metadata,
3076            sections,
3077            relationships: Vec::new(),
3078            content_hash: String::new(),
3079            stub: false,
3080            stub_kind: None,
3081            heading_spans: std::collections::HashMap::new(),
3082            raw_section_headings: Vec::new(),
3083        }
3084    }
3085
3086    /// A sealed-violator type: section key `answers` with heading
3087    /// `Answers argued` (derives to `answers_argued`) — the plenum
3088    /// finding's exact shape. Loads fine; only new installs refuse.
3089    fn violating_type() -> std::sync::Arc<TypeDefinition> {
3090        let manifest = r#"name: debate
3091version: 0.1.0
3092description: sealed-violator fixture
3093when_to_use: health tests
3094types:
3095  - question
3096relationships:
3097  mode: strict
3098  definitions:
3099    - name: PART_OF
3100      description: hier
3101      default_weight: 3.0
3102    - name: _default
3103      description: fallback
3104      default_weight: 1.0
3105community:
3106  resolution: 1.0
3107  seed: 42
3108"#;
3109        let type_yaml = r#"name: question
3110description: t
3111when_to_use: tests
3112sections:
3113  - key: answers
3114    heading: Answers argued
3115    required: true
3116    search_weight: 10.0
3117    write_rules: []
3118  - key: notes
3119    heading: Notes
3120    required: false
3121    search_weight: 3.0
3122    catch_all: true
3123    write_rules: []
3124metadata_fields: []
3125title_weight: 100.0
3126text_fields:
3127  - answers
3128  - notes
3129hierarchy_relationship: PART_OF
3130no_self_loop_relationships: []
3131updatable_fields:
3132  - title
3133  - answers
3134  - notes
3135health_required_fields:
3136  - answers
3137staleness_threshold_days: 90
3138write_rules: []
3139"#;
3140        memstead_schema::load_schema_from_memory(
3141            manifest,
3142            &[("question".to_string(), type_yaml.to_string())],
3143        )
3144        .expect("violating schema still loads")
3145        .get_type("question")
3146        .expect("question type")
3147    }
3148
3149    /// Health must report the distinct SECTION_HEADING_MISMATCH finding
3150    /// — naming both headings and the catch-all the content landed in —
3151    /// for content sitting under a non-deriving heading, and must NOT
3152    /// report that section as missing. A genuinely absent section keeps
3153    /// the missing report; a conforming entity gets neither.
3154    #[test]
3155    fn health_distinguishes_heading_mismatch_from_missing_section() {
3156        let schema = violating_type();
3157
3158        // Content present under the declared (non-deriving) heading.
3159        let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
3160        let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
3161            .expect("parses")
3162            .entity;
3163        let report = entity_health(&parsed, &schema);
3164        let mismatch: Vec<_> = report
3165            .issues
3166            .iter()
3167            .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
3168            .collect();
3169        assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
3170        let msg = &mismatch[0].message;
3171        assert!(
3172            msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
3173            "names found heading and derived key: {msg}"
3174        );
3175        assert!(
3176            msg.contains("'notes'"),
3177            "names the catch-all landing: {msg}"
3178        );
3179        assert!(
3180            !report.issues.iter().any(|i| i.message.contains("is empty")),
3181            "must not also report the section as missing: {:?}",
3182            report.issues
3183        );
3184
3185        // Genuinely missing section: missing report exactly as today.
3186        let md_missing = "---\ntype: question\n---\n# Q2\n";
3187        let parsed_missing =
3188            crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
3189                .expect("parses")
3190                .entity;
3191        let report_missing = entity_health(&parsed_missing, &schema);
3192        assert!(
3193            report_missing
3194                .issues
3195                .iter()
3196                .any(|i| i.code == super::super::HealthIssueCode::Missing
3197                    && i.message == "required section 'answers' is empty"),
3198            "absent section keeps the missing report (structured MISSING code): {:?}",
3199            report_missing.issues
3200        );
3201        assert!(
3202            !report_missing
3203                .issues
3204                .iter()
3205                .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
3206            "no mismatch finding when the heading is not in the file"
3207        );
3208
3209        // Conforming entity (content under a heading deriving to the
3210        // key would need a deriving heading — for this violating
3211        // schema no heading can reach `answers`, so use the conforming
3212        // catch-all only): neither finding for a section with content.
3213        let ok_type = crate::entity::parser::parse_markdown(
3214            "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
3215            "q3.md",
3216            &schema,
3217            "debate",
3218        )
3219        .expect("parses")
3220        .entity;
3221        let report_ok = entity_health(&ok_type, &schema);
3222        assert!(
3223            !report_ok
3224                .issues
3225                .iter()
3226                .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
3227            "mismatch fires only when the declared heading is present: {:?}",
3228            report_ok.issues
3229        );
3230    }
3231
3232    fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
3233        let mut metadata = IndexMap::new();
3234        metadata.insert("type".into(), MetadataValue::String("concept".into()));
3235        metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
3236        metadata.insert(
3237            "abstraction_level".into(),
3238            MetadataValue::String("concrete".into()),
3239        );
3240        metadata.insert(
3241            "created_date".into(),
3242            MetadataValue::String("2026-01-15".into()),
3243        );
3244        metadata.insert(
3245            "last_modified".into(),
3246            MetadataValue::String("2026-04-12".into()),
3247        );
3248
3249        let mut sections = IndexMap::new();
3250        if with_definition {
3251            sections.insert("definition".into(), "Precise definition.".into());
3252        }
3253        sections.insert("explanation".into(), "How it works.".into());
3254
3255        Entity {
3256            id: EntityId::new("concepts", name),
3257            title: name.into(),
3258            entity_type: "concept".into(),
3259            mem: "concepts".into(),
3260            file_path: format!("{name}.md"),
3261            metadata,
3262            sections,
3263            relationships: Vec::new(),
3264            content_hash: String::new(),
3265            stub: false,
3266            stub_kind: None,
3267            heading_spans: std::collections::HashMap::new(),
3268            raw_section_headings: Vec::new(),
3269        }
3270    }
3271
3272    #[test]
3273    fn health_concept_missing_definition_reports_definition_field() {
3274        let schema = &type_by_name("concept").unwrap();
3275        let entity = make_concept_entity("clarity", false);
3276        let report = entity_health(&entity, schema);
3277
3278        // The missing-field issue must name the concept schema's required
3279        // section ("definition"), not spec's "identity".
3280        assert!(report.issues.iter().any(|i| i.field == "definition"));
3281        assert!(!report.issues.iter().any(|i| i.field == "identity"));
3282        assert!(!report.issues.iter().any(|i| i.field == "purpose"));
3283        assert!(report.score < 1.0);
3284
3285        // An entity with the definition filled in has no issue for that field.
3286        let healthy = make_concept_entity("clarity-ok", true);
3287        let healthy_report = entity_health(&healthy, schema);
3288        assert!(
3289            !healthy_report
3290                .issues
3291                .iter()
3292                .any(|i| i.field == "definition")
3293        );
3294    }
3295
3296    #[test]
3297    fn health_detects_missing_sections() {
3298        let schema = &type_by_name("spec").unwrap();
3299        let entity = make_entity("incomplete", false);
3300        let report = entity_health(&entity, schema);
3301        assert!(!report.issues.is_empty());
3302        assert!(report.score < 1.0);
3303    }
3304
3305    #[test]
3306    fn health_clean_entity() {
3307        let schema = &type_by_name("spec").unwrap();
3308        let entity = make_entity("complete", true);
3309        let report = entity_health(&entity, schema);
3310        // May still have issues for other required fields, but identity/purpose are covered
3311        let section_issues: Vec<_> = report
3312            .issues
3313            .iter()
3314            .filter(|i| i.field == "identity" || i.field == "purpose")
3315            .collect();
3316        assert!(section_issues.is_empty());
3317    }
3318
3319    #[test]
3320    fn health_summary_counts() {
3321        let mut store = Store::new();
3322        let e1 = make_entity("healthy", true);
3323        let e2 = make_entity("unhealthy", false);
3324        store.upsert(e1.id.clone(), e1);
3325        store.upsert(e2.id.clone(), e2);
3326
3327        let schema = &type_by_name("spec").unwrap();
3328        let summary = compute_health(&store, schema, &HashMap::new(), None);
3329        assert_eq!(summary.orphan_count, 2); // No edges between them
3330        assert_eq!(summary.stub_count, 0);
3331    }
3332
3333    #[test]
3334    fn health_surfaces_invalid_rel_shape_on_existing_edges() {
3335        // software@0.1.0 declares `source_types: [actor]` on OWNS.
3336        // Seed a non-actor source with an outgoing OWNS edge — the
3337        // health scan must surface `INVALID_REL_SHAPE` in the
3338        // entity's issues so an agent running a sweep can identify
3339        // edges to clean up via `memstead_relate remove=true`.
3340        use crate::entity::Relationship;
3341        use memstead_schema::SchemaRegistry;
3342
3343        let registry = SchemaRegistry::builtin();
3344        let software = registry
3345            .get("software", &semver::Version::new(0, 2, 0))
3346            .expect("software schema ships as a builtin");
3347
3348        let mut store = Store::new();
3349        // Source entity is `spec`, not `actor`. Add an OWNS edge to
3350        // a target whose type doesn't matter for source-side shape.
3351        let mut bad = make_entity("bad-owns-source", true);
3352        bad.entity_type = "spec".into();
3353        bad.metadata
3354            .insert("level".into(), MetadataValue::String("M0".into()));
3355        bad.metadata
3356            .insert("stability".into(), MetadataValue::String("evolving".into()));
3357        bad.relationships.push(Relationship {
3358            rel_type: "OWNS".into(),
3359            target: EntityId::new("specs", "victim"),
3360            description: None,
3361        });
3362        let mut victim = make_entity("victim", true);
3363        victim.entity_type = "spec".into();
3364        store.upsert(bad.id.clone(), bad);
3365        store.upsert(victim.id.clone(), victim);
3366
3367        let mut mem_schemas = HashMap::new();
3368        mem_schemas.insert("specs".to_string(), software);
3369
3370        let schema = &type_by_name("spec").unwrap();
3371        let summary = compute_health(&store, schema, &mem_schemas, None);
3372        let report = summary
3373            .missing_fields
3374            .iter()
3375            .find(|r| r.id.as_ref() == "specs--bad-owns-source")
3376            .expect("shape-violating entity must surface");
3377        let issue = report
3378            .issues
3379            .iter()
3380            .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
3381            .expect("shape violation must produce an INVALID_REL_SHAPE issue");
3382        assert!(
3383            issue.message.contains("OWNS"),
3384            "issue must name the offending rel_type: {}",
3385            issue.message
3386        );
3387        assert!(
3388            issue.message.contains("spec"),
3389            "issue must name the actual source type: {}",
3390            issue.message
3391        );
3392        assert!(
3393            issue.message.contains("actor"),
3394            "issue must name the allowed source type: {}",
3395            issue.message
3396        );
3397        assert!(
3398            issue.message.contains("remove=true"),
3399            "issue must surface the recovery path: {}",
3400            issue.message
3401        );
3402    }
3403
3404    #[test]
3405    fn health_does_not_flag_shape_compliant_edges() {
3406        // Sanity counterpart: an actor source with OWNS edge satisfies
3407        // `source_types: [actor]` — no INVALID_REL_SHAPE issue surfaces.
3408        use crate::entity::Relationship;
3409        use memstead_schema::SchemaRegistry;
3410
3411        let registry = SchemaRegistry::builtin();
3412        let software = registry
3413            .get("software", &semver::Version::new(0, 2, 0))
3414            .expect("software schema ships as a builtin");
3415
3416        let mut store = Store::new();
3417        let mut owner = make_entity("owner", true);
3418        owner.entity_type = "actor".into();
3419        owner
3420            .metadata
3421            .insert("kind".into(), MetadataValue::String("team".into()));
3422        owner
3423            .metadata
3424            .insert("active".into(), MetadataValue::Bool(true));
3425        owner
3426            .metadata
3427            .insert("handle".into(), MetadataValue::String("owner".into()));
3428        owner.relationships.push(Relationship {
3429            rel_type: "OWNS".into(),
3430            target: EntityId::new("specs", "owned"),
3431            description: None,
3432        });
3433        let mut owned = make_entity("owned", true);
3434        owned.entity_type = "spec".into();
3435        store.upsert(owner.id.clone(), owner);
3436        store.upsert(owned.id.clone(), owned);
3437
3438        let mut mem_schemas = HashMap::new();
3439        mem_schemas.insert("specs".to_string(), software);
3440
3441        let schema = &type_by_name("spec").unwrap();
3442        let summary = compute_health(&store, schema, &mem_schemas, None);
3443        let shape_issue = summary
3444            .missing_fields
3445            .iter()
3446            .flat_map(|r| r.issues.iter())
3447            .find(|i| i.message.contains("INVALID_REL_SHAPE"));
3448        assert!(
3449            shape_issue.is_none(),
3450            "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
3451        );
3452    }
3453
3454    #[test]
3455    fn health_warns_on_undeclared_relationship_in_existing_entity() {
3456        use crate::entity::Relationship;
3457        use memstead_schema::Schema;
3458
3459        let mut store = Store::new();
3460        let mut entity = make_entity("with-bad-rel", true);
3461        // Author an edge using a name that does not exist in the default
3462        // schema's vocabulary. The load-side contract per decision 3 is
3463        // about unknown *types*; unknown *relationships* on an already-
3464        // loaded entity land in the soft health surface instead so an
3465        // agent running `memstead_health` after a schema edit sees the drift.
3466        entity.relationships.push(Relationship {
3467            rel_type: "CONJURES".into(),
3468            target: EntityId::new("specs", "unknown"),
3469            description: None,
3470        });
3471        store.upsert(entity.id.clone(), entity);
3472
3473        let mut mem_schemas = HashMap::new();
3474        mem_schemas.insert("specs".to_string(), Schema::builtin_default());
3475
3476        let schema = &type_by_name("spec").unwrap();
3477        let summary = compute_health(&store, schema, &mem_schemas, None);
3478        let report = summary
3479            .missing_fields
3480            .iter()
3481            .find(|r| r.id.as_ref() == "specs--with-bad-rel")
3482            .expect("entity must surface in missing_fields");
3483        let rel_issue = report
3484            .issues
3485            .iter()
3486            .find(|i| i.field == "relationships")
3487            .expect("undeclared relationship must produce an issue");
3488        assert!(
3489            rel_issue.message.contains("CONJURES"),
3490            "issue message must name the offending relationship: {}",
3491            rel_issue.message
3492        );
3493        assert!(
3494            rel_issue.message.contains("default@1.0.0"),
3495            "issue must name the schema pin: {}",
3496            rel_issue.message
3497        );
3498    }
3499
3500    // -------------------------------------------------------------------
3501    // Dangling wiki-link detection
3502    // -------------------------------------------------------------------
3503
3504    /// Build an entity with an arbitrary section body so the test can seed
3505    /// inline wiki-links at will. Mem defaults to `specs`.
3506    fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
3507        let mut entity = make_entity(name, true);
3508        entity.sections.insert(section_key.into(), body.to_string());
3509        entity
3510    }
3511
3512    #[test]
3513    fn dangling_link_detected_after_delete() {
3514        use crate::entity::store_builder::make_stub;
3515
3516        let mut store = Store::new();
3517        let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3518        store.upsert(a.id.clone(), a.clone());
3519
3520        // Seed b as a stub — the signal that its markdown file is gone
3521        // (post-delete, pre-recreate, or never authored).
3522        let b_id = EntityId::new("specs", "b");
3523        store.upsert(b_id.clone(), make_stub(b_id.clone()));
3524
3525        let dangling = super::collect_dangling_links(&store, None);
3526        assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
3527        let d = &dangling[0];
3528        assert_eq!(d.from, a.id);
3529        assert_eq!(d.target_id, b_id);
3530        assert_eq!(d.target_path, "b");
3531        assert_eq!(d.section.as_deref(), Some("purpose"));
3532        assert_eq!(d.kind, DanglingLinkKind::LinkTargetMissing);
3533    }
3534
3535    /// 04/06: the three conditions the fused `DANGLING_LINK` name used
3536    /// to cover are discriminated at the producer, and each carries its
3537    /// own repair. Two of them (a body link to a written entity with no
3538    /// relationship, and a relationship row whose target is absent from
3539    /// the store) were not separable at all before — the payload
3540    /// distinguished them only by whether `section` happened to be
3541    /// null, which is a rendering accident, not a condition.
3542    #[test]
3543    fn the_three_dangling_conditions_are_discriminated() {
3544        use crate::entity::store_builder::make_stub;
3545
3546        let mut store = Store::new();
3547
3548        // (1) Body link whose target has no markdown file at all.
3549        let gone = make_entity_with_body("gone-link", "purpose", "See [[absent]].");
3550        store.upsert(gone.id.clone(), gone.clone());
3551        let absent = EntityId::new("specs", "absent");
3552        store.upsert(absent.clone(), make_stub(absent.clone()));
3553
3554        // (2) Body link to a fully written entity that the source does
3555        // not relate to. The target exists; the edge does not.
3556        let written = make_entity("written", true);
3557        store.upsert(written.id.clone(), written.clone());
3558        let unrelated = make_entity_with_body("unrelated-link", "purpose", "See [[written]].");
3559        store.upsert(unrelated.id.clone(), unrelated.clone());
3560
3561        // (3) Relationship row naming an entity that is not in the
3562        // store at all — not even as a stub. Auto-stubbing means this
3563        // only arises from out-of-band edits or historical corruption.
3564        let mut rel_source = make_entity("rel-source", true);
3565        rel_source.relationships.push(crate::entity::Relationship {
3566            rel_type: "DEPENDS_ON".to_string(),
3567            target: EntityId::new("specs", "vanished"),
3568            description: None,
3569        });
3570        store.upsert(rel_source.id.clone(), rel_source.clone());
3571
3572        let found = super::collect_dangling_links(&store, None);
3573        let kind_of = |from: &str| {
3574            found
3575                .iter()
3576                .find(|d| d.from.path() == from)
3577                .unwrap_or_else(|| panic!("no dangling link from {from}: {found:?}"))
3578                .kind
3579        };
3580        assert_eq!(kind_of("gone-link"), DanglingLinkKind::LinkTargetMissing);
3581        assert_eq!(kind_of("unrelated-link"), DanglingLinkKind::LinkNotRelated);
3582        assert_eq!(
3583            kind_of("rel-source"),
3584            DanglingLinkKind::RelationTargetMissing
3585        );
3586
3587        // Three conditions, three codes, three repairs: no two of them
3588        // collapse onto the same string.
3589        let codes: std::collections::BTreeSet<_> = found.iter().map(|d| d.kind.code()).collect();
3590        let repairs: std::collections::BTreeSet<_> =
3591            found.iter().map(|d| d.kind.repair()).collect();
3592        assert_eq!(codes.len(), 3, "{found:?}");
3593        assert_eq!(repairs.len(), 3, "{found:?}");
3594    }
3595
3596    /// 04/06, criterion 5: the relationships row keeps its tolerance.
3597    /// A row whose target is a stub is a legitimate forward reference —
3598    /// the alias machinery auto-stubs absent targets by design — and the
3599    /// split must not start reporting them. The body scan treats the
3600    /// same stub as missing, which is the whole reason the two axes
3601    /// needed separate codes rather than one.
3602    #[test]
3603    fn a_relationship_row_pointing_at_a_stub_is_still_not_flagged() {
3604        use crate::entity::store_builder::make_stub;
3605
3606        let mut store = Store::new();
3607        let stub_id = EntityId::new("specs", "forward");
3608        store.upsert(stub_id.clone(), make_stub(stub_id.clone()));
3609
3610        let mut source = make_entity("forward-ref", true);
3611        source.relationships.push(crate::entity::Relationship {
3612            rel_type: "DEPENDS_ON".to_string(),
3613            target: stub_id.clone(),
3614            description: None,
3615        });
3616        store.upsert(source.id.clone(), source.clone());
3617
3618        assert!(
3619            super::collect_dangling_links(&store, None).is_empty(),
3620            "a forward reference through the relationships table stays unflagged"
3621        );
3622
3623        // The complement of the complement: the SAME stub reached from a
3624        // body wiki-link is the target-missing condition.
3625        let body = make_entity_with_body("body-ref", "purpose", "See [[forward]].");
3626        store.upsert(body.id.clone(), body.clone());
3627        let found = super::collect_dangling_links(&store, None);
3628        assert_eq!(found.len(), 1, "{found:?}");
3629        assert_eq!(found[0].from, body.id);
3630        assert_eq!(found[0].kind, DanglingLinkKind::LinkTargetMissing);
3631    }
3632
3633    /// Decision 18 (backlog-sweep plan 06): dangling-links and stubs
3634    /// output is deterministic — the store iterates a HashMap, so the
3635    /// collectors sort before serving. Two independently built
3636    /// identical stores must produce byte-identical lists, in the
3637    /// documented (from, target, section) / id order.
3638    #[test]
3639    fn dangling_links_and_stubs_serve_in_deterministic_order() {
3640        use crate::entity::store_builder::make_stub;
3641
3642        let build = || {
3643            let mut store = Store::new();
3644            // Insert in an order unrelated to the expected output order.
3645            for name in ["zeta", "alpha", "mid"] {
3646                let e = make_entity_with_body(
3647                    name,
3648                    "purpose",
3649                    &format!("See [[gone-{name}]] and [[lost-{name}]]."),
3650                );
3651                store.upsert(e.id.clone(), e);
3652            }
3653            for name in ["zeta", "alpha", "mid"] {
3654                for pre in ["gone", "lost"] {
3655                    let id = EntityId::new("specs", &format!("{pre}-{name}"));
3656                    store.upsert(id.clone(), make_stub(id));
3657                }
3658            }
3659            store
3660        };
3661
3662        let store_a = build();
3663        let store_b = build();
3664
3665        let key =
3666            |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
3667        let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
3668            .iter()
3669            .map(key)
3670            .collect();
3671        let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
3672            .iter()
3673            .map(key)
3674            .collect();
3675        assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
3676        let mut sorted = dangling_a.clone();
3677        sorted.sort();
3678        assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
3679        assert_eq!(dangling_a.len(), 6);
3680
3681        let stub_ids = |s: &Store| -> Vec<String> {
3682            crate::graph::query::find_stubs(s)
3683                .into_iter()
3684                .map(|(id, _)| id.0)
3685                .collect()
3686        };
3687        let stubs_a = stub_ids(&store_a);
3688        assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
3689        let mut sorted = stubs_a.clone();
3690        sorted.sort();
3691        assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
3692        assert_eq!(stubs_a.len(), 6);
3693    }
3694
3695    #[test]
3696    fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
3697        use crate::entity::Relationship;
3698        use crate::entity::store_builder::make_stub;
3699
3700        let mut store = Store::new();
3701        // A has NO inline link in its body — only an explicit relationship
3702        // edge pointing at a stub.
3703        let mut a = make_entity("a", true);
3704        let b_id = EntityId::new("specs", "b");
3705        a.relationships.push(Relationship {
3706            rel_type: "REFERENCES".into(),
3707            target: b_id.clone(),
3708            description: None,
3709        });
3710        store.upsert(a.id.clone(), a);
3711        store.upsert(b_id.clone(), make_stub(b_id));
3712
3713        let dangling = super::collect_dangling_links(&store, None);
3714        assert!(
3715            dangling.is_empty(),
3716            "explicit relationships to stubs are valid by design \
3717             (stubs are first-class placeholders); only inline-body \
3718             wiki-links to stubs must surface"
3719        );
3720    }
3721
3722    #[test]
3723    fn dangling_link_does_not_flag_real_reference() {
3724        use crate::entity::Relationship;
3725
3726        let mut store = Store::new();
3727        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3728        // Backing relation makes the body link a valid alias.
3729        a.relationships.push(Relationship {
3730            rel_type: "REFERENCES".into(),
3731            target: EntityId::new("specs", "b"),
3732            description: None,
3733        });
3734        let b = make_entity("b", true);
3735        store.upsert(a.id.clone(), a);
3736        store.upsert(b.id.clone(), b);
3737
3738        let dangling = super::collect_dangling_links(&store, None);
3739        assert!(
3740            dangling.is_empty(),
3741            "real reference backed by relation — not dangling, not alias-orphan"
3742        );
3743    }
3744
3745    /// F12: a `## Relationships` row pointing at a fully-absent target
3746    /// (out-of-band file edit, mem-delete corruption) must surface.
3747    /// The scan covers both axes; relationship-table danglers ship
3748    /// `section: None` to mark the source axis.
3749    #[test]
3750    fn dangling_link_relationship_section_target_absent() {
3751        use crate::entity::Relationship;
3752
3753        let mut store = Store::new();
3754        let mut a = make_entity("a", true);
3755        // Note: NO stub in the store for `gone` — out-of-band edit
3756        // removed the stub but left the relationship row.
3757        a.relationships.push(Relationship {
3758            rel_type: "DEPENDS_ON".into(),
3759            target: EntityId::new("specs", "gone"),
3760            description: None,
3761        });
3762        store.upsert(a.id.clone(), a.clone());
3763
3764        let dangling = super::collect_dangling_links(&store, None);
3765        assert_eq!(
3766            dangling.len(),
3767            1,
3768            "exactly one relationship-section dangler"
3769        );
3770        let d = &dangling[0];
3771        assert_eq!(d.from, a.id);
3772        assert_eq!(d.target_id, EntityId::new("specs", "gone"));
3773        assert!(
3774            d.section.is_none(),
3775            "relationship-section danglers ship `section: None`, got {:?}",
3776            d.section
3777        );
3778    }
3779
3780    /// Relationship rows pointing at stubs are NOT flagged. Auto-stub
3781    /// is the alias machinery's forward-reference mechanism; flagging
3782    /// stubs would conflate the "engine-managed placeholder" case with
3783    /// corruption.
3784    #[test]
3785    fn dangling_link_relationship_section_stub_target_not_flagged() {
3786        use crate::entity::Relationship;
3787        use crate::entity::store_builder::make_stub;
3788
3789        let mut store = Store::new();
3790        let mut a = make_entity("a", true);
3791        let b_id = EntityId::new("specs", "b");
3792        a.relationships.push(Relationship {
3793            rel_type: "DEPENDS_ON".into(),
3794            target: b_id.clone(),
3795            description: None,
3796        });
3797        store.upsert(a.id.clone(), a);
3798        store.upsert(b_id.clone(), make_stub(b_id));
3799
3800        let dangling = super::collect_dangling_links(&store, None);
3801        assert!(
3802            dangling.is_empty(),
3803            "relationship targets that resolve to stubs are forward-references, not corruption"
3804        );
3805    }
3806
3807    /// When both the body and the relationship section point at the
3808    /// same fully-absent target, the dangler dedupes to a single entry
3809    /// on whichever axis fired first (body-scan runs
3810    /// before relationship-scan in the implementation; the body axis
3811    /// wins). Stub-shaped duplicates are not possible because the
3812    /// relationship-section scan skips stubs.
3813    #[test]
3814    fn dangling_link_dedups_across_body_and_relations() {
3815        use crate::entity::Relationship;
3816        use crate::entity::store_builder::make_stub;
3817
3818        let mut store = Store::new();
3819        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3820        let b_id = EntityId::new("specs", "b");
3821        a.relationships.push(Relationship {
3822            rel_type: "REFERENCES".into(),
3823            target: b_id.clone(),
3824            description: None,
3825        });
3826        store.upsert(a.id.clone(), a.clone());
3827        store.upsert(b_id.clone(), make_stub(b_id.clone()));
3828
3829        let dangling = super::collect_dangling_links(&store, None);
3830        assert_eq!(
3831            dangling.len(),
3832            1,
3833            "body + relations both pointing at the same stub should dedup"
3834        );
3835        // Body scan fires first; the surviving entry carries
3836        // `section: Some(_)`.
3837        assert!(dangling[0].section.is_some(), "body axis wins the dedup");
3838    }
3839
3840    #[test]
3841    fn dangling_links_scope_to_mem_filter() {
3842        use crate::entity::store_builder::make_stub;
3843
3844        let mut store = Store::new();
3845
3846        // specs--a with body [[gone]] → dangling in specs.
3847        let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
3848        store.upsert(a.id.clone(), a);
3849        let gone_specs = EntityId::new("specs", "gone");
3850        store.upsert(gone_specs.clone(), make_stub(gone_specs));
3851
3852        // web--x with body [[gone]] → dangling in web (different stub).
3853        let mut x = make_entity("x", true);
3854        x.id = EntityId::new("web", "x");
3855        x.mem = "web".into();
3856        x.file_path = "x.md".into();
3857        x.sections
3858            .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
3859        store.upsert(x.id.clone(), x);
3860        let gone_web = EntityId::new("web", "gone");
3861        store.upsert(gone_web.clone(), make_stub(gone_web));
3862
3863        let all = super::collect_dangling_links(&store, None);
3864        assert_eq!(all.len(), 2);
3865
3866        let specs_only = super::collect_dangling_links(&store, Some("specs"));
3867        assert_eq!(specs_only.len(), 1);
3868        assert_eq!(specs_only[0].from.mem(), "specs");
3869
3870        let web_only = super::collect_dangling_links(&store, Some("web"));
3871        assert_eq!(web_only.len(), 1);
3872        assert_eq!(web_only[0].from.mem(), "web");
3873    }
3874
3875    #[test]
3876    fn parse_iso_date() {
3877        let days = parse_iso_to_days("2026-04-12").unwrap();
3878        assert!(days > 0);
3879
3880        let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
3881        assert_eq!(days, days_with_time);
3882    }
3883
3884    #[test]
3885    fn ymd_roundtrip() {
3886        // 2026-01-01
3887        let days = ymd_to_days(2026, 1, 1);
3888        assert!(days > 20000); // sanity check
3889    }
3890
3891    // ---------------------------------------------------------------------
3892    // collect_tag_distribution — #18
3893    // ---------------------------------------------------------------------
3894
3895    fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
3896        let mut e = make_entity(name, true);
3897        e.id = EntityId::new(mem, name);
3898        e.mem = mem.into();
3899        e.entity_type = entity_type.into();
3900        e.metadata
3901            .insert("tags".into(), MetadataValue::String(tags.into()));
3902        e
3903    }
3904
3905    fn make_entity_no_tags(name: &str) -> Entity {
3906        make_entity(name, true)
3907    }
3908
3909    #[test]
3910    fn tag_distribution_aggregates_across_entities() {
3911        let mut store = Store::new();
3912        let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
3913        let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
3914        let c = make_entity_with_tags("c", "specs", "spec", "plan");
3915        store.upsert(a.id.clone(), a);
3916        store.upsert(b.id.clone(), b);
3917        store.upsert(c.id.clone(), c);
3918
3919        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3920        assert_eq!(dist.len(), 2);
3921        assert_eq!(dist[0].tag, "plan");
3922        assert_eq!(dist[0].count, 3);
3923        assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
3924        assert_eq!(dist[1].tag, "decision");
3925        assert_eq!(dist[1].count, 2);
3926        assert_eq!(untagged.total, 0);
3927    }
3928
3929    #[test]
3930    fn tag_distribution_case_sensitive() {
3931        let mut store = Store::new();
3932        let a = make_entity_with_tags("a", "specs", "spec", "Decision");
3933        let b = make_entity_with_tags("b", "specs", "spec", "decision");
3934        store.upsert(a.id.clone(), a);
3935        store.upsert(b.id.clone(), b);
3936
3937        let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
3938        assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
3939        let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
3940        assert!(tags.contains("decision"));
3941        assert!(tags.contains("Decision"));
3942
3943        // Drift sidecar surfaces the collision.
3944        assert_eq!(folded.len(), 1);
3945        assert_eq!(folded[0].canonical, "decision");
3946        assert_eq!(folded[0].total, 2);
3947        assert_eq!(folded[0].variants.len(), 2);
3948    }
3949
3950    #[test]
3951    fn untagged_entities_counts_missing_and_empty() {
3952        let mut store = Store::new();
3953        let a = make_entity_no_tags("a"); // no `tags` metadata
3954        let b = make_entity_with_tags("b", "specs", "spec", "");
3955        let c = make_entity_with_tags("c", "specs", "spec", " , , ");
3956        store.upsert(a.id.clone(), a);
3957        store.upsert(b.id.clone(), b);
3958        store.upsert(c.id.clone(), c);
3959
3960        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3961        assert!(dist.is_empty(), "no effective tags → empty distribution");
3962        assert_eq!(untagged.total, 3);
3963        assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
3964    }
3965
3966    #[test]
3967    fn tag_distribution_respects_mem_filter() {
3968        let mut store = Store::new();
3969        let a = make_entity_with_tags("a", "specs", "spec", "decision");
3970        let b = make_entity_with_tags("b", "memos", "memo", "observation");
3971        let c = make_entity_no_tags("c");
3972        store.upsert(a.id.clone(), a);
3973        store.upsert(b.id.clone(), b);
3974        store.upsert(c.id.clone(), c);
3975
3976        let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
3977        assert_eq!(dist.len(), 1);
3978        assert_eq!(dist[0].tag, "observation");
3979        assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3980    }
3981
3982    #[test]
3983    fn tag_distribution_respects_limit() {
3984        let mut store = Store::new();
3985        for (name, tag) in [
3986            ("a", "t-alpha"),
3987            ("b", "t-beta"),
3988            ("c", "t-gamma"),
3989            ("d", "t-delta"),
3990            ("e", "t-epsilon"),
3991        ] {
3992            let e = make_entity_with_tags(name, "specs", "spec", tag);
3993            store.upsert(e.id.clone(), e);
3994        }
3995
3996        let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3997        assert_eq!(dist.len(), 3);
3998        // Every tag appears once → ties across all 5; deterministic tie-break is
3999        // lex ascending: alpha, beta, delta (first 3 sorted).
4000        assert_eq!(dist[0].tag, "t-alpha");
4001        assert_eq!(dist[1].tag, "t-beta");
4002        assert_eq!(dist[2].tag, "t-delta");
4003    }
4004
4005    // ----------------------------------------------------------------------
4006    // required_outgoing health collector
4007    // ----------------------------------------------------------------------
4008
4009    /// Build a minimal schema fixture pinning `decision` with two
4010    /// `required_outgoing` blocks (CHOSEN + REJECTED), `note` with none.
4011    fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
4012        let manifest = r#"name: tests-ro-health
4013version: 0.1.0
4014description: required_outgoing health test schema
4015when_to_use: tests
4016types:
4017  - decision
4018  - note
4019relationships:
4020  mode: strict
4021  definitions:
4022    - name: PART_OF
4023      description: Hier
4024      default_weight: 3.0
4025      acyclic: true
4026    - name: CHOSEN
4027      description: ch
4028      default_weight: 3.0
4029    - name: REJECTED
4030      description: rj
4031      default_weight: 2.0
4032    - name: REFERENCES
4033      description: ref
4034      default_weight: 0.5
4035    - name: _default
4036      description: Fallback
4037      default_weight: 1.0
4038community:
4039  resolution: 1.0
4040  seed: 42
4041"#;
4042        let body_section = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4043        let decision_yaml = format!(
4044            "name: decision\ndescription: t\nwhen_to_use: Here\n{body_section}required_outgoing:\n  - relationships: [CHOSEN]\n    cardinality: at_least_one\n  - relationships: [REJECTED]\n    cardinality: at_least_one\n",
4045        );
4046        let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
4047        std::sync::Arc::new(
4048            memstead_schema::load_schema_from_memory(
4049                manifest,
4050                &[
4051                    ("decision".to_string(), decision_yaml),
4052                    ("note".to_string(), note_yaml),
4053                ],
4054            )
4055            .expect("ro fixture schema must parse"),
4056        )
4057    }
4058
4059    fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
4060        use crate::entity::MetadataValue;
4061        let mut metadata = IndexMap::new();
4062        metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
4063        let mut sections = IndexMap::new();
4064        sections.insert("body".into(), "Body.".into());
4065        crate::entity::Entity {
4066            id: EntityId::new(mem, slug),
4067            title: slug.to_string(),
4068            entity_type: entity_type.into(),
4069            mem: mem.into(),
4070            file_path: format!("{slug}.md"),
4071            metadata,
4072            sections,
4073            relationships: Vec::new(),
4074            content_hash: String::new(),
4075            stub: false,
4076            stub_kind: None,
4077            heading_spans: std::collections::HashMap::new(),
4078            raw_section_headings: Vec::new(),
4079        }
4080    }
4081
4082    #[test]
4083    fn missing_required_outgoing_collects_violators_only() {
4084        let schema = required_outgoing_fixture_schema();
4085        let mut store = Store::new();
4086        // Two decisions: one without any edges (violates 2 blocks), one
4087        // with both edges satisfied. One note (no requirement).
4088        let mut violator = make_typed_entity("plan", "stalled", "decision");
4089        let mut satisfied = make_typed_entity("plan", "wired", "decision");
4090        let opt_a = make_typed_entity("plan", "a", "note");
4091        let opt_b = make_typed_entity("plan", "b", "note");
4092        let happy_note = make_typed_entity("plan", "side", "note");
4093        satisfied.relationships.push(crate::entity::Relationship {
4094            rel_type: "CHOSEN".into(),
4095            target: opt_a.id.clone(),
4096            description: None,
4097        });
4098        satisfied.relationships.push(crate::entity::Relationship {
4099            rel_type: "REJECTED".into(),
4100            target: opt_b.id.clone(),
4101            description: None,
4102        });
4103        for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
4104            store.upsert(e.id.clone(), e);
4105        }
4106
4107        let mut mem_schemas = HashMap::new();
4108        mem_schemas.insert("plan".to_string(), schema);
4109
4110        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4111        assert_eq!(
4112            reports.len(),
4113            1,
4114            "exactly one violator (the empty decision); got {reports:?}"
4115        );
4116        let r = &reports[0];
4117        assert_eq!(r.id, violator.id);
4118        assert_eq!(r.entity_type, "decision");
4119        assert_eq!(r.mem, "plan");
4120        assert_eq!(r.missing.len(), 2);
4121        let names: Vec<&str> = r
4122            .missing
4123            .iter()
4124            .flat_map(|b| b.relationships.iter().map(String::as_str))
4125            .collect();
4126        assert!(names.contains(&"CHOSEN"));
4127        assert!(names.contains(&"REJECTED"));
4128
4129        // mark warning still doesn't propagate when violator is removed.
4130        violator.relationships.push(crate::entity::Relationship {
4131            rel_type: "CHOSEN".into(),
4132            target: EntityId::new("plan", "x"),
4133            description: None,
4134        });
4135    }
4136
4137    #[test]
4138    fn missing_required_outgoing_respects_mem_filter() {
4139        // Plan: "a write to mem A doesn't surface mem B's violations
4140        // in memstead_health mem=A; mem-scoped aggregation is correct."
4141        let schema = required_outgoing_fixture_schema();
4142        let mut store = Store::new();
4143        let v_a = make_typed_entity("alpha", "stalled", "decision");
4144        let v_b = make_typed_entity("beta", "stalled", "decision");
4145        store.upsert(v_a.id.clone(), v_a);
4146        store.upsert(v_b.id.clone(), v_b.clone());
4147
4148        let mut mem_schemas = HashMap::new();
4149        mem_schemas.insert("alpha".to_string(), schema.clone());
4150        mem_schemas.insert("beta".to_string(), schema);
4151
4152        let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
4153        assert_eq!(alpha_only.len(), 1);
4154        assert_eq!(alpha_only[0].mem, "alpha");
4155
4156        let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
4157        assert_eq!(both.len(), 2);
4158    }
4159
4160    #[test]
4161    fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
4162        // Stubs have no entity_type; unschemaed mems can't be evaluated
4163        // — both must be silently skipped.
4164        let schema = required_outgoing_fixture_schema();
4165        let mut store = Store::new();
4166        let mut stub = make_typed_entity("plan", "ghost", "");
4167        stub.stub = true;
4168        stub.entity_type = String::new();
4169        let other = make_typed_entity("uncharted", "lonely", "decision");
4170        store.upsert(stub.id.clone(), stub);
4171        store.upsert(other.id.clone(), other);
4172
4173        let mut mem_schemas = HashMap::new();
4174        mem_schemas.insert("plan".to_string(), schema);
4175
4176        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4177        assert!(
4178            reports.is_empty(),
4179            "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
4180        );
4181    }
4182
4183    /// A conditional block arms only on the trigger value: the sweep
4184    /// reports the armed violator (with the trigger named in the
4185    /// block entry), and skips both the other-value and the
4186    /// edge-satisfied entities.
4187    #[test]
4188    fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
4189        use crate::entity::MetadataValue;
4190        let manifest = r#"name: tests-ro-cond
4191version: 0.1.0
4192description: conditional required_outgoing health test schema
4193when_to_use: tests
4194types:
4195  - task
4196relationships:
4197  mode: strict
4198  definitions:
4199    - name: PART_OF
4200      description: Hier
4201      default_weight: 3.0
4202    - name: _default
4203      description: Fallback
4204      default_weight: 1.0
4205community:
4206  resolution: 1.0
4207  seed: 42
4208"#;
4209        let task_yaml = "name: task\ndescription: t\nwhen_to_use: Here\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: status\n    description: workflow state\n    field_type: string\n    enum_values: [open, checked]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\n  - status\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\nrequired_outgoing:\n  - relationships: [PART_OF]\n    cardinality: at_least_one\n    when_field: status\n    when_value: checked\n";
4210        let schema = std::sync::Arc::new(
4211            memstead_schema::load_schema_from_memory(
4212                manifest,
4213                &[("task".to_string(), task_yaml.to_string())],
4214            )
4215            .expect("conditional ro fixture schema must parse"),
4216        );
4217
4218        let mut store = Store::new();
4219        let mut armed = make_typed_entity("plan", "armed", "task");
4220        armed
4221            .metadata
4222            .insert("status".into(), MetadataValue::String("checked".into()));
4223        let mut other_value = make_typed_entity("plan", "quiet", "task");
4224        other_value
4225            .metadata
4226            .insert("status".into(), MetadataValue::String("open".into()));
4227        let unset = make_typed_entity("plan", "blank", "task");
4228        let parent = make_typed_entity("plan", "parent", "task");
4229        let mut satisfied = make_typed_entity("plan", "wired", "task");
4230        satisfied
4231            .metadata
4232            .insert("status".into(), MetadataValue::String("checked".into()));
4233        satisfied.relationships.push(crate::entity::Relationship {
4234            rel_type: "PART_OF".into(),
4235            target: parent.id.clone(),
4236            description: None,
4237        });
4238        for e in [armed.clone(), other_value, unset, parent, satisfied] {
4239            store.upsert(e.id.clone(), e);
4240        }
4241
4242        let mut mem_schemas = HashMap::new();
4243        mem_schemas.insert("plan".to_string(), schema);
4244
4245        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4246        assert_eq!(
4247            reports.len(),
4248            1,
4249            "only the armed edge-less entity is reported; got {reports:?}"
4250        );
4251        let r = &reports[0];
4252        assert_eq!(r.id, armed.id);
4253        assert_eq!(r.missing.len(), 1);
4254        assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
4255        assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
4256    }
4257
4258    // ----------------------------------------------------------------------
4259    // must_reach reachability obligations
4260    // ----------------------------------------------------------------------
4261
4262    /// Three-type argument-shaped fixture: claim / inference /
4263    /// evidence over GROUNDS / CONCLUDES. The per-type `must_reach`
4264    /// blocks are injected by the caller (empty string = none).
4265    fn must_reach_schema(
4266        claim_extra: &str,
4267        inference_extra: &str,
4268    ) -> std::sync::Arc<memstead_schema::Schema> {
4269        let manifest = r#"name: tests-must-reach
4270version: 0.1.0
4271description: must_reach health test schema
4272when_to_use: tests
4273types:
4274  - claim
4275  - inference
4276  - evidence
4277relationships:
4278  mode: strict
4279  definitions:
4280    - name: GROUNDS
4281      description: g
4282      default_weight: 3.0
4283    - name: CONCLUDES
4284      description: c
4285      default_weight: 3.0
4286    - name: PART_OF
4287      description: hier
4288      default_weight: 1.0
4289    - name: _default
4290      description: fallback
4291      default_weight: 1.0
4292community:
4293  resolution: 1.0
4294  seed: 42
4295"#;
4296        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4297        let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
4298        let inference =
4299            format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
4300        let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
4301        std::sync::Arc::new(
4302            memstead_schema::load_schema_from_memory(
4303                manifest,
4304                &[
4305                    ("claim".to_string(), claim),
4306                    ("inference".to_string(), inference),
4307                    ("evidence".to_string(), evidence),
4308                ],
4309            )
4310            .expect("must_reach fixture schema must parse"),
4311        )
4312    }
4313
4314    fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
4315        from.relationships.push(crate::entity::Relationship {
4316            rel_type: rel.into(),
4317            target: to.clone(),
4318            description: None,
4319        });
4320    }
4321
4322    fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
4323        r.violations
4324            .iter()
4325            .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
4326            .collect()
4327    }
4328
4329    const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n";
4330
4331    /// A conforming path (direct or transitive through a non-terminal)
4332    /// is silent; an entity without one carries a finding echoing the
4333    /// whole declaration.
4334    #[test]
4335    fn must_reach_conforming_path_silent_gap_reported() {
4336        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4337        let mut store = Store::new();
4338        let ev = make_typed_entity("arg", "ev", "evidence");
4339        let mut direct = make_typed_entity("arg", "direct", "claim");
4340        link(&mut direct, "GROUNDS", &ev.id);
4341        let mut mid = make_typed_entity("arg", "mid", "claim");
4342        let mut chained = make_typed_entity("arg", "chained", "claim");
4343        link(&mut chained, "GROUNDS", &mid.id);
4344        link(&mut mid, "GROUNDS", &ev.id);
4345        let floating = make_typed_entity("arg", "floating", "claim");
4346        for e in [ev, direct, mid, chained, floating.clone()] {
4347            store.upsert(e.id.clone(), e);
4348        }
4349        let mut mem_schemas = HashMap::new();
4350        mem_schemas.insert("arg".to_string(), schema);
4351
4352        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4353        assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
4354        assert_eq!(reports[0].id, floating.id);
4355        let v = must_reach_violations(&reports[0]);
4356        assert_eq!(v.len(), 1);
4357        let UnsatisfiedConstraint::MustReach {
4358            relationships,
4359            direction,
4360            terminal_types,
4361            max_depth,
4362            ..
4363        } = v[0]
4364        else {
4365            panic!("expected must_reach finding");
4366        };
4367        assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
4368        assert_eq!(*direction, memstead_schema::ReachDirection::Out);
4369        assert_eq!(terminal_types, &vec!["evidence".to_string()]);
4370        assert_eq!(*max_depth, None);
4371    }
4372
4373    /// The floating leap: an inference no premise reaches (zero
4374    /// incoming edges of the set) is a finding; one incoming premise
4375    /// edge silences it. Incoming direction with depth 1 is the
4376    /// required-incoming-edge case.
4377    #[test]
4378    fn must_reach_one_hop_incoming_floating_leap() {
4379        let schema = must_reach_schema(
4380            "",
4381            "must_reach:\n  - relationships: [GROUNDS]\n    direction: in\n    terminal_types: [claim]\n    max_depth: 1\n",
4382        );
4383        let mut store = Store::new();
4384        let leap = make_typed_entity("arg", "leap", "inference");
4385        let grounded = make_typed_entity("arg", "grounded", "inference");
4386        let mut premise = make_typed_entity("arg", "premise", "claim");
4387        link(&mut premise, "GROUNDS", &grounded.id);
4388        for e in [leap.clone(), grounded, premise] {
4389            store.upsert(e.id.clone(), e);
4390        }
4391        let mut mem_schemas = HashMap::new();
4392        mem_schemas.insert("arg".to_string(), schema);
4393
4394        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4395        assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
4396        assert_eq!(reports[0].id, leap.id);
4397    }
4398
4399    /// A chain ending in a stub or in non-terminal types is a finding;
4400    /// adding one conforming path clears it on the next sweep.
4401    #[test]
4402    fn must_reach_stub_and_non_terminal_chains_then_cleared() {
4403        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4404        let mut store = Store::new();
4405        let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
4406        stub_ev.stub = true;
4407        let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
4408        link(&mut to_stub, "GROUNDS", &stub_ev.id);
4409        let dead_end = make_typed_entity("arg", "dead-end", "claim");
4410        let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
4411        link(&mut to_claim, "GROUNDS", &dead_end.id);
4412        for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
4413            store.upsert(e.id.clone(), e);
4414        }
4415        let mut mem_schemas = HashMap::new();
4416        mem_schemas.insert("arg".to_string(), schema.clone());
4417
4418        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4419        let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4420        assert!(
4421            ids.contains(&to_stub.id.0.as_str()),
4422            "stub terminates no obligation: {ids:?}"
4423        );
4424        assert!(
4425            ids.contains(&to_claim.id.0.as_str()),
4426            "non-terminal chain is a finding: {ids:?}"
4427        );
4428
4429        // One conforming edge clears the finding on the next call.
4430        let ev = make_typed_entity("arg", "real-ev", "evidence");
4431        let mut repaired = store.get(&to_stub.id).unwrap().clone();
4432        link(&mut repaired, "GROUNDS", &ev.id);
4433        store.upsert(ev.id.clone(), ev);
4434        store.upsert(repaired.id.clone(), repaired);
4435        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4436        let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4437        assert!(
4438            !ids.contains(&to_stub.id.0.as_str()),
4439            "conforming path clears the finding: {ids:?}"
4440        );
4441    }
4442
4443    /// A cycle along the walked set terminates (visited-set
4444    /// discipline): the sweep returns findings for both cycle members
4445    /// instead of hanging.
4446    #[test]
4447    fn must_reach_cycles_terminate() {
4448        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4449        let mut store = Store::new();
4450        let mut a = make_typed_entity("arg", "cyc-a", "claim");
4451        let mut b = make_typed_entity("arg", "cyc-b", "claim");
4452        link(&mut a, "GROUNDS", &b.id);
4453        link(&mut b, "GROUNDS", &a.id);
4454        for e in [a, b] {
4455            store.upsert(e.id.clone(), e);
4456        }
4457        let mut mem_schemas = HashMap::new();
4458        mem_schemas.insert("arg".to_string(), schema);
4459
4460        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4461        assert_eq!(reports.len(), 2, "both cycle members lack evidence");
4462    }
4463
4464    /// Depth bound: a conforming path within the bound is silent; a
4465    /// graph whose only conforming path exceeds the bound is a
4466    /// finding.
4467    #[test]
4468    fn must_reach_depth_bound() {
4469        let two_hop_store = || {
4470            let mut store = Store::new();
4471            let ev = make_typed_entity("arg", "ev", "evidence");
4472            let mut mid = make_typed_entity("arg", "mid", "claim");
4473            let mut start = make_typed_entity("arg", "start", "claim");
4474            link(&mut start, "GROUNDS", &mid.id);
4475            link(&mut mid, "GROUNDS", &ev.id);
4476            for e in [ev, mid, start] {
4477                store.upsert(e.id.clone(), e);
4478            }
4479            store
4480        };
4481        let bounded = |depth: u32| {
4482            must_reach_schema(
4483                &format!(
4484                    "must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n    max_depth: {depth}\n"
4485                ),
4486                "",
4487            )
4488        };
4489
4490        let store = two_hop_store();
4491        let mut mem_schemas = HashMap::new();
4492        mem_schemas.insert("arg".to_string(), bounded(1));
4493        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4494        assert_eq!(
4495            reports.len(),
4496            1,
4497            "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
4498        );
4499        assert_eq!(reports[0].id.0, "arg--start");
4500
4501        let mut mem_schemas = HashMap::new();
4502        mem_schemas.insert("arg".to_string(), bounded(2));
4503        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4504        assert!(
4505            reports.is_empty(),
4506            "the same path satisfies depth 2: {reports:?}"
4507        );
4508    }
4509
4510    /// Two obligations on one type: exactly one finding, naming the
4511    /// unsatisfied block.
4512    #[test]
4513    fn must_reach_two_obligations_one_finding() {
4514        let schema = must_reach_schema(
4515            "must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n  - relationships: [CONCLUDES]\n    direction: out\n    terminal_types: [inference]\n",
4516            "",
4517        );
4518        let mut store = Store::new();
4519        let ev = make_typed_entity("arg", "ev", "evidence");
4520        let mut c = make_typed_entity("arg", "half", "claim");
4521        link(&mut c, "GROUNDS", &ev.id);
4522        for e in [ev, c.clone()] {
4523            store.upsert(e.id.clone(), e);
4524        }
4525        let mut mem_schemas = HashMap::new();
4526        mem_schemas.insert("arg".to_string(), schema);
4527
4528        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4529        assert_eq!(reports.len(), 1);
4530        assert_eq!(reports[0].id, c.id);
4531        let v = must_reach_violations(&reports[0]);
4532        assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
4533        let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
4534            panic!("expected must_reach finding");
4535        };
4536        assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
4537    }
4538
4539    /// `status_propagation` with `rel_types`: the taint crosses
4540    /// rel-type boundaries along the union subgraph (the experiment's
4541    /// withdrawn-evidence chain in the two-rel-type modelling), and
4542    /// the finding echoes the set (`rel_types` present, `rel_type`
4543    /// absent).
4544    #[test]
4545    fn status_propagation_rel_types_taints_across_type_boundaries() {
4546        use crate::entity::MetadataValue;
4547        let manifest = r#"name: tests-prop-set
4548version: 0.1.0
4549description: propagation relation-set test schema
4550when_to_use: tests
4551types:
4552  - claim
4553relationships:
4554  mode: strict
4555  definitions:
4556    - name: GROUNDS
4557      description: g
4558      default_weight: 3.0
4559    - name: CONCLUDES
4560      description: c
4561      default_weight: 3.0
4562    - name: PART_OF
4563      description: hier
4564      default_weight: 1.0
4565    - name: _default
4566      description: fallback
4567      default_weight: 1.0
4568community:
4569  resolution: 1.0
4570  seed: 42
4571"#;
4572        let claim_yaml = "name: claim\ndescription: t\nwhen_to_use: Here\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: standing\n    description: dialectical standing\n    field_type: string\n    enum_values: [active, withdrawn]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\n  - standing\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n  - kind: status_propagation\n    field: standing\n    value: withdrawn\n    rel_types: [GROUNDS, CONCLUDES]\n    direction: incoming\n";
4573        let schema = std::sync::Arc::new(
4574            memstead_schema::load_schema_from_memory(
4575                manifest,
4576                &[("claim".to_string(), claim_yaml.to_string())],
4577            )
4578            .expect("propagation-set fixture schema must parse"),
4579        );
4580
4581        let mut store = Store::new();
4582        let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
4583        withdrawn
4584            .metadata
4585            .insert("standing".into(), MetadataValue::String("withdrawn".into()));
4586        let mut inference = make_typed_entity("arg", "inference", "claim");
4587        link(&mut inference, "GROUNDS", &withdrawn.id);
4588        let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
4589        link(&mut conclusion, "CONCLUDES", &inference.id);
4590        let bystander = make_typed_entity("arg", "bystander", "claim");
4591        for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
4592            store.upsert(e.id.clone(), e);
4593        }
4594        let mut mem_schemas = HashMap::new();
4595        mem_schemas.insert("arg".to_string(), schema);
4596
4597        let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4598        let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4599        assert_eq!(
4600            ids,
4601            vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
4602            "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
4603        );
4604        let UnsatisfiedConstraint::StatusPropagation {
4605            rel_type,
4606            rel_types,
4607            tainted_by,
4608            ..
4609        } = &reports[0].violations[0]
4610        else {
4611            panic!("expected status_propagation finding");
4612        };
4613        assert_eq!(*rel_type, None, "set declarations echo no single name");
4614        assert_eq!(
4615            rel_types.as_deref(),
4616            Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
4617        );
4618        assert_eq!(tainted_by, "arg--withdrawn-ev");
4619    }
4620
4621    /// Cross-mem edges satisfy an obligation like any edge; a mem
4622    /// filter reports findings only for entities of the filtered mem.
4623    #[test]
4624    fn must_reach_cross_mem_path_and_mem_filter() {
4625        let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4626        let mut store = Store::new();
4627        let far_ev = make_typed_entity("ground", "far-ev", "evidence");
4628        let mut crossing = make_typed_entity("arg", "crossing", "claim");
4629        link(&mut crossing, "GROUNDS", &far_ev.id);
4630        let floating_arg = make_typed_entity("arg", "floating", "claim");
4631        let floating_ground = make_typed_entity("ground", "floating", "claim");
4632        for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
4633            store.upsert(e.id.clone(), e);
4634        }
4635        let mut mem_schemas = HashMap::new();
4636        mem_schemas.insert("arg".to_string(), schema.clone());
4637        mem_schemas.insert("ground".to_string(), schema);
4638
4639        let all = collect_constraint_findings(&store, None, &mem_schemas, None);
4640        assert_eq!(
4641            all.len(),
4642            2,
4643            "the crossing claim is satisfied via the cross-mem edge: {all:?}"
4644        );
4645        let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas, None);
4646        assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
4647        assert_eq!(filtered[0].id, floating_arg.id);
4648    }
4649
4650    // ----------------------------------------------------------------------
4651    // transition_requires_checks (form 6)
4652    // ----------------------------------------------------------------------
4653
4654    /// Two-type gated-transition fixture: criterion --VERIFIES--> plan,
4655    /// plan gates `status: complete` on incoming VERIFIES checks.
4656    fn gated_transition_schema() -> std::sync::Arc<memstead_schema::Schema> {
4657        let manifest = r#"name: tests-gated
4658version: 0.1.0
4659description: transition_requires_checks test schema
4660when_to_use: tests
4661types:
4662  - plan
4663  - criterion
4664relationships:
4665  mode: strict
4666  definitions:
4667    - name: VERIFIES
4668      description: v
4669      default_weight: 3.0
4670      acyclic: true
4671    - name: PART_OF
4672      description: hier
4673      default_weight: 1.0
4674      acyclic: true
4675    - name: _default
4676      description: fallback
4677      default_weight: 1.0
4678community:
4679  resolution: 1.0
4680  seed: 42
4681"#;
4682        let plan_yaml = "name: plan\ndescription: p\nwhen_to_use: t\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: status\n    description: s\n    field_type: string\n    default_value: draft\n    enum_values: [draft, complete]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nupdatable_fields:\n  - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n  - kind: transition_requires_checks\n    field: status\n    to_value: complete\n    relationships: [VERIFIES]\n    direction: incoming\n    severity: block\n";
4683        let criterion_yaml = "name: criterion\ndescription: c\nwhen_to_use: t\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nupdatable_fields:\n  - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4684        std::sync::Arc::new(
4685            memstead_schema::load_schema_from_memory(
4686                manifest,
4687                &[
4688                    ("plan".to_string(), plan_yaml.to_string()),
4689                    ("criterion".to_string(), criterion_yaml.to_string()),
4690                ],
4691            )
4692            .expect("gated-transition fixture schema loads"),
4693        )
4694    }
4695
4696    /// The gate triggers only at the declared value, quantifies over
4697    /// the declared incoming edges, requires derived `checked_ok`
4698    /// (stale and failed do not confirm), and treats a missing
4699    /// provider as never_checked — the no-ledger honesty posture. An
4700    /// empty related set satisfies (universal quantification).
4701    #[test]
4702    fn transition_requires_checks_gates_on_derived_state() {
4703        use crate::check::CheckState;
4704        use crate::entity::MetadataValue;
4705        let schema = gated_transition_schema();
4706        let td = schema.types.get("plan").unwrap().clone();
4707        let mut store = Store::default();
4708
4709        let mut plan = make_typed_entity("g", "the-plan", "plan");
4710        plan.metadata
4711            .insert("status".into(), MetadataValue::String("complete".into()));
4712        let mut ok_crit = make_typed_entity("g", "ok-crit", "criterion");
4713        ok_crit.relationships.push(crate::entity::Relationship {
4714            rel_type: "VERIFIES".into(),
4715            target: plan.id.clone(),
4716            description: None,
4717        });
4718        let mut stale_crit = make_typed_entity("g", "stale-crit", "criterion");
4719        stale_crit.relationships.push(crate::entity::Relationship {
4720            rel_type: "VERIFIES".into(),
4721            target: plan.id.clone(),
4722            description: None,
4723        });
4724        for e in [plan.clone(), ok_crit.clone(), stale_crit.clone()] {
4725            store.upsert(e.id.clone(), e);
4726        }
4727
4728        let state_of = |e: &crate::entity::Entity| {
4729            if e.id.0.contains("ok-crit") {
4730                CheckState::CheckedOk
4731            } else {
4732                CheckState::CheckStale
4733            }
4734        };
4735        let provider = |e: &crate::entity::Entity| {
4736            crate::engine::independence::CheckStanding::assumed_independent(state_of(e))
4737        };
4738        let violations = unsatisfied_constraints(&store, &plan, &td, None, Some(&provider));
4739        assert_eq!(violations.len(), 1, "{violations:?}");
4740        match &violations[0] {
4741            UnsatisfiedConstraint::TransitionRequiresChecks {
4742                unchecked,
4743                severity,
4744                ..
4745            } => {
4746                assert_eq!(
4747                    unchecked.len(),
4748                    1,
4749                    "only the unconfirmed criterion is listed"
4750                );
4751                assert_eq!(unchecked[0].id, "g--stale-crit");
4752                assert_eq!(unchecked[0].state, "check_stale");
4753                assert_eq!(*severity, memstead_schema::ConstraintSeverity::Block);
4754            }
4755            other => panic!("expected the gated-transition violation, got {other:?}"),
4756        }
4757        assert!(
4758            violations[0].describe().contains("g--stale-crit")
4759                && violations[0].describe().contains("check_stale"),
4760            "describe names the offender and its state: {}",
4761            violations[0].describe()
4762        );
4763
4764        // Every related entity confirmed -> satisfied.
4765        let all_ok = |_: &crate::entity::Entity| {
4766            crate::engine::independence::CheckStanding::assumed_independent(CheckState::CheckedOk)
4767        };
4768        assert!(
4769            unsatisfied_constraints(&store, &plan, &td, None, Some(&all_ok)).is_empty(),
4770            "all confirmed satisfies the gate"
4771        );
4772
4773        // Not at the gated value -> no evaluation.
4774        let mut draft = plan.clone();
4775        draft
4776            .metadata
4777            .insert("status".into(), MetadataValue::String("draft".into()));
4778        assert!(
4779            unsatisfied_constraints(&store, &draft, &td, None, Some(&provider)).is_empty(),
4780            "the gate triggers only at to_value"
4781        );
4782
4783        // No provider -> every related entity derives never_checked.
4784        let violations = unsatisfied_constraints(&store, &plan, &td, None, None);
4785        assert_eq!(violations.len(), 1);
4786        match &violations[0] {
4787            UnsatisfiedConstraint::TransitionRequiresChecks { unchecked, .. } => {
4788                assert_eq!(unchecked.len(), 2, "no ledger access confirms nothing");
4789                assert!(unchecked.iter().all(|u| u.state == "never_checked"));
4790            }
4791            other => panic!("expected the gated-transition violation, got {other:?}"),
4792        }
4793
4794        // Empty related set -> vacuously satisfied.
4795        let mut lone = make_typed_entity("g", "lone-plan", "plan");
4796        lone.metadata
4797            .insert("status".into(), MetadataValue::String("complete".into()));
4798        store.upsert(lone.id.clone(), lone.clone());
4799        assert!(
4800            unsatisfied_constraints(&store, &lone, &td, None, Some(&provider)).is_empty(),
4801            "an empty related set satisfies the universal quantification"
4802        );
4803    }
4804}