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