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