Skip to main content

memstead_cli/commands/
health.rs

1use clap::Parser;
2use serde_json::json;
3
4use memstead_base::EntityId;
5use memstead_base::Store;
6use memstead_base::ops::{
7    DanglingLink, HealthSummary, health::ConstraintFindingReport, health::HEALTH_INCLUDE_KEYS,
8    health::MissingRequiredOutgoingReport,
9};
10
11use crate::output::{ExitKind, print_json, print_markdown};
12use crate::setup::{CliContext, CliEngine};
13
14/// Graph health summary.
15///
16/// Default: counts only. Pass `--include` to drill into details.
17#[derive(Parser, Debug)]
18pub struct Args {
19    /// Opt heavy content into the response: orphans, stubs,
20    /// most_connected, missing_fields, stale, dangling_links, tags,
21    /// missing_required_outgoing, constraints (standing violations of
22    /// declared schema constraints), conformance, integrity, config,
23    /// anchors (per-mem counts of the standalone anchor-verification
24    /// states, with `unresolvable` meaning the artifact is GONE and
25    /// `unobserved` meaning the pass could not measure it, plus the
26    /// population those counts cover), ledger (a FOLDER mem's change
27    /// ledger set against the markdown files beside it: entities the
28    /// ledger records with no file, and files the ledger never
29    /// mentions — read-only, it never writes or tidies a ledger line;
30    /// git-branch mems are absent rather than clean, because their
31    /// change set is a real two-tree diff and the divergence cannot
32    /// arise), friction (the workspace-local
33    /// refusal ledger's summary — counts per typed refusal code and
34    /// per verb, with per-code reason breakdowns where the code
35    /// carries a closed engine-owned discriminator, whole-ledger plus
36    /// a recent 24h window; local-only, values drawn from closed
37    /// engine-defined vocabularies only), open_questions (per-mem
38    /// composed worklist of
39    /// what the holding does not know: stubs, anchors that are recheck,
40    /// unresolvable (artifact gone), unobserved (not measured) or
41    /// dangling (entity gone), unsatisfied constraints, dangling links,
42    /// and a paired
43    /// process mem's open entries — negative findings separated as
44    /// already-searched; capped per kind with an explicit `more`
45    /// count), stale_derivations (per-mem derivation edges whose
46    /// target changed since the recorded baseline, plus unbaselined
47    /// edges — re-assert via `memstead relate` to refresh), checks
48    /// (per-mem counts of the four derived check states plus the
49    /// author≠checker independence gate: self_checked /
50    /// confirmed_independent / unconfirmable — transport is not
51    /// identity, so until a caller-declared identity exists every
52    /// ok-checked entity reports unconfirmable; the other two
53    /// categories are explicit empties), signals (entities whose
54    /// declared aggregate signals sit above `none`, each with value,
55    /// level and contributing entity ids, plus per-level counts;
56    /// `warn`-level signals participate in `--strict`, `notice`
57    /// never does), labelling (grounded labels per declaring mem:
58    /// accepted/defeated/undecided counts, the defeated and undecided
59    /// lists with their attacker evidence, and the excluded cross-mem
60    /// attack-edge count; an observation, never a strict violation).
61    /// `conformance` lints every entity against the effective schema
62    /// into a `findings` array (write-time typed codes); `integrity`
63    /// adds the consistency axis (dangling links, stubs) to the same
64    /// list. `config` renders the workspace-config projection (per-mem
65    /// origin/storage/vcs detail, `mutations`, `plugin`) — the same
66    /// block MCP's `include_config: true` serves.
67    /// Repeatable (`--include K --include K`)
68    /// AND comma-string (`--include K1,K2`) forms both parse — uniform
69    /// with `memstead overview --include`.
70    #[arg(long, value_delimiter = ',')]
71    pub include: Vec<String>,
72
73    /// Schema ref (`name@x.y.z`) the conformance/integrity includes
74    /// lint against instead of each mem's current pin.
75    #[arg(long)]
76    pub target_schema: Option<String>,
77
78    /// Max rows for `most_connected` and `tag_distribution` (default: 10).
79    #[arg(long, default_value_t = 10)]
80    pub limit: usize,
81
82    /// Exit non-zero (1) when any included Tier-2 warning kind has
83    /// present violations, or when an always-on configuration axis
84    /// reports findings. Always-on (no `--include` opt-in): the
85    /// authoring-drift axis (`SCHEMA_AUTHORING_SOURCE_MISSING` /
86    /// `SCHEMA_AUTHORING_SOURCE_DIVERGED`) and the configuration
87    /// defects `SCHEMA_PIN_MISMATCH`, `SCHEMA_UNSTAMPED_SOURCE_ROT`
88    /// and `MOUNT_UNBACKED` (a mount whose branch or folder does not
89    /// exist, or holds no entity). Include-gated participation:
90    /// `missing_required_outgoing`, `constraints`, `signals` (warn
91    /// level), and with `integrity` the consistency findings
92    /// `ORPHAN_STUB`, `DANGLING_LINK_TARGET_MISSING`,
93    /// `DANGLING_LINK_NOT_RELATED` and
94    /// `DANGLING_RELATION_TARGET_MISSING` and
95    /// `CROSS_MEM_EDGE_UNGRANTED`. Stale entities, drifted
96    /// anchors and `SCHEMA_GENERATIONS_BEHIND` stay advisory. The
97    /// output is rendered first, then the non-zero exit fires; new
98    /// Tier-2 codes opt in additively without breaking the flag's
99    /// semantics.
100    #[arg(long)]
101    pub strict: bool,
102}
103
104pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
105    let include = &args.include;
106    // Tier-2 violation tally, populated as the corresponding `--include`
107    // tokens are processed. Consulted at the end when `--strict` is set
108    // to decide between exit 0 and exit 1. Per-code so a future
109    // expansion (e.g. `cardinality_violations`) can list which codes
110    // tripped without re-walking the report JSON.
111    let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
112
113    // Validate include-keys against the shared catalogue. Unknown keys
114    // emit `UNKNOWN_INCLUDE_KEY` warnings the operator sees in both
115    // markdown and JSON output — matches the MCP sibling's behaviour
116    // and gives a typo zero-feedback path a typed signal instead.
117    let mut include_warnings: Vec<(String, Vec<String>)> = Vec::new();
118    for key in include {
119        if !HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
120            include_warnings.push((
121                key.clone(),
122                HEALTH_INCLUDE_KEYS.iter().map(|s| s.to_string()).collect(),
123            ));
124        }
125    }
126
127    let GatheredHealth {
128        health,
129        real_count,
130        orphan_ids,
131        stub_pairs,
132        community_count,
133        orphans_by_schema,
134        communities_by_schema,
135        most_connected_with_titles,
136        missing_required_outgoing,
137        constraint_findings,
138        schema_format_defects,
139        tag_distribution,
140        dangling_links,
141        findings,
142        body_observations,
143        config_entries,
144        anchors_axis,
145        ledger_axis,
146        open_questions_axis,
147        stale_derivations_axis,
148        checks_axis,
149        signals_axis,
150        labelling_axis,
151    } = match ctx.cli_engine()? {
152        #[cfg(feature = "mem-repo")]
153        CliEngine::MemRepo(mut engine) => {
154            let mut g = gather_mem_repo(&mut engine, args.limit, include);
155            g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
156            g.body_observations =
157                gather_body_observations(&engine, include, args.target_schema.as_deref())?;
158            g
159        }
160        CliEngine::Filesystem(mut engine) => {
161            let mut g = gather_filesystem(&mut engine, args.limit, include);
162            g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
163            g.body_observations =
164                gather_body_observations(&engine, include, args.target_schema.as_deref())?;
165            g
166        }
167    };
168
169    let mut result = json!({
170        // The coverage rule (memstead_base::ops::coverage): the axes
171        // this surface's verdict answers for, straight from the CLI's
172        // registry row so output and declaration cannot diverge.
173        "verdict_coverage": crate::coverage::HEALTH
174            .axis_coverage()
175            .expect("health is a verdict surface")
176            .wire_line(),
177        "summary": {
178            "total_entities": real_count,
179            "total_orphans": orphan_ids.len(),
180            "total_stubs": stub_pairs.len(),
181            "total_stale": health.stale_entities.len(),
182            "total_missing_fields": health.missing_fields.len(),
183            "total_communities": community_count,
184            "orphans_by_schema": orphans_by_schema,
185            "communities_by_schema": communities_by_schema,
186        },
187    });
188    let obj = result.as_object_mut().unwrap();
189
190    if include.iter().any(|s| s == "orphans") {
191        let list: Vec<_> = orphan_ids
192            .iter()
193            .map(|(id, title)| json!({ "id": id.to_string(), "title": title }))
194            .collect();
195        obj.insert("orphans".into(), json!(list));
196    }
197    if include.iter().any(|s| s == "stubs") {
198        let list: Vec<_> = stub_pairs
199            .iter()
200            .map(|(id, refs)| {
201                json!({
202                    "id": id.to_string(),
203                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
204                })
205            })
206            .collect();
207        obj.insert("stubs".into(), json!(list));
208    }
209    if include.iter().any(|s| s == "most_connected") {
210        let connected: Vec<_> = most_connected_with_titles
211            .iter()
212            .map(
213                |(
214                    id,
215                    title,
216                    total,
217                    incoming,
218                    outgoing,
219                    typed_total,
220                    typed_incoming,
221                    typed_outgoing,
222                )| {
223                    json!({
224                        "id": id.to_string(),
225                        "title": title,
226                        "total": total,
227                        "incoming": incoming,
228                        "outgoing": outgoing,
229                        "typed_total": typed_total,
230                        "typed_incoming": typed_incoming,
231                        "typed_outgoing": typed_outgoing,
232                    })
233                },
234            )
235            .collect();
236        obj.insert("most_connected".into(), json!(connected));
237    }
238    if include.iter().any(|s| s == "missing_fields") {
239        let list: Vec<_> = health
240            .missing_fields
241            .iter()
242            .map(|h| {
243                // `missing` (bare field names) stays byte-identical for
244                // existing consumers; the per-issue detail rides next to
245                // it so the CLI projection carries WHICH condition each
246                // issue reports — same additive shape as the MCP
247                // composer's.
248                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
249                let issues: Vec<_> = h
250                    .issues
251                    .iter()
252                    .map(|i| json!({ "field": i.field, "code": i.code, "message": i.message }))
253                    .collect();
254                json!({
255                    "id": h.id.to_string(),
256                    "title": h.title,
257                    "missing": missing,
258                    "issues": issues,
259                })
260            })
261            .collect();
262        obj.insert("missing_fields".into(), json!(list));
263    }
264    if include.iter().any(|s| s == "stale") {
265        let list: Vec<_> = health
266            .stale_entities
267            .iter()
268            .map(|e| {
269                json!({
270                    "id": e.id.to_string(),
271                    "title": e.title,
272                    "days_since_modified": e.days_since_modified,
273                })
274            })
275            .collect();
276        obj.insert("stale".into(), json!(list));
277    }
278    if include.iter().any(|s| s == "missing_required_outgoing") {
279        if !missing_required_outgoing.is_empty() {
280            strict_violations.push(("missing_required_outgoing", missing_required_outgoing.len()));
281        }
282        obj.insert(
283            "missing_required_outgoing".into(),
284            serde_json::to_value(&missing_required_outgoing)?,
285        );
286    }
287    if include.iter().any(|s| s == "constraints") {
288        if !constraint_findings.is_empty() {
289            strict_violations.push(("constraints", constraint_findings.len()));
290        }
291        obj.insert(
292            "constraints".into(),
293            serde_json::to_value(&constraint_findings)?,
294        );
295        // Defective section-format declarations (lenient boot):
296        // additive key, present only when defects exist.
297        if !schema_format_defects.is_empty() {
298            strict_violations.push(("schema_format_defects", schema_format_defects.len()));
299            obj.insert(
300                "schema_format_defects".into(),
301                serde_json::to_value(&schema_format_defects)?,
302            );
303        }
304    }
305    if include.iter().any(|s| s == "dangling_links") {
306        let arr: Vec<serde_json::Value> = dangling_links
307            .iter()
308            .map(|dl| serde_json::to_value(dl).unwrap_or(serde_json::Value::Null))
309            .collect();
310        obj.insert("dangling_links".into(), json!(arr));
311    }
312    if include
313        .iter()
314        .any(|s| s == "conformance" || s == "integrity")
315    {
316        // The consistency axis participates in `--strict` when asked
317        // for: a dangling link or an orphan stub is a graph that says
318        // something it cannot show, and a referee that ignored both
319        // exited 0 on a workspace with ten of one and seven of the
320        // other. Conformance findings keep their own reporting.
321        if include.iter().any(|s| s == "integrity") {
322            // Reads the family's own code list rather than a hand-written
323            // one, so splitting the fused code could not silently drop two of
324            // the three conditions out of the strict gate — the most likely
325            // accidental outcome of that change (04/06, criterion 3).
326            let dangling = findings
327                .iter()
328                .filter(|f| {
329                    memstead_base::ops::DanglingLinkKind::ALL_CODES.contains(&f.code.as_str())
330                })
331                .count();
332            if dangling > 0 {
333                strict_violations.push(("dangling_links", dangling));
334            }
335            let orphan_stubs = findings.iter().filter(|f| f.code == "ORPHAN_STUB").count();
336            if orphan_stubs > 0 {
337                strict_violations.push(("orphan_stubs", orphan_stubs));
338            }
339            // An edge the write gate would refuse to create today is a
340            // workspace whose policy file has stopped describing its graph.
341            // Strict is opt-in and is exactly the gate an operator runs after
342            // changing policy, so this is where the two are forced back into
343            // agreement (04/07, criterion 3).
344            let ungranted = findings
345                .iter()
346                .filter(|f| f.code == "CROSS_MEM_EDGE_UNGRANTED")
347                .count();
348            if ungranted > 0 {
349                strict_violations.push(("ungranted_cross_mem_edges", ungranted));
350            }
351        }
352        obj.insert("findings".into(), serde_json::to_value(&findings)?);
353        // Beside the findings, never among them (04/01, criterion 2). An
354        // observation names content the type does not declare and says whether
355        // it survives; it never marks the entity unconformant, and it is
356        // deliberately absent from `strict_violations` above.
357        obj.insert(
358            "body_observations".into(),
359            serde_json::to_value(&body_observations)?,
360        );
361    }
362    if include.iter().any(|s| s == "tags")
363        && let Some((distribution, folded, untagged)) = tag_distribution
364    {
365        obj.insert("tag_distribution".into(), distribution);
366        obj.insert("tag_distribution_folded".into(), folded);
367        obj.insert("untagged_entities".into(), untagged);
368    }
369    // `--include config`: the shared workspace-config projection
370    // (`mems` / `mutations` / `plugin`), rendered by the same
371    // implementation MCP's `include_config: true` uses.
372    if let Some(entries) = config_entries {
373        for (k, v) in entries {
374            obj.insert(k, v);
375        }
376    }
377    if let Some(axis) = &anchors_axis {
378        obj.insert("anchors".to_string(), axis.clone());
379    }
380    if let Some(axis) = &ledger_axis {
381        obj.insert("ledger".to_string(), axis.clone());
382    }
383    if let Some(axis) = &open_questions_axis {
384        obj.insert("open_questions".to_string(), axis.clone());
385    }
386    if let Some(axis) = &stale_derivations_axis {
387        obj.insert("stale_derivations".to_string(), axis.clone());
388    }
389    if let Some(axis) = &checks_axis {
390        obj.insert("checks".to_string(), axis.clone());
391    }
392    // `--include signals`: entities carrying above-`none` declared
393    // signals, with per-level counts. A `warn`-level signal
394    // participates in `--strict` like a warn-tier constraint finding;
395    // a `notice` never does.
396    if let Some(axis) = &signals_axis {
397        if let Some(warn) = axis
398            .get("counts")
399            .and_then(|c| c.get("warn"))
400            .and_then(|w| w.as_u64())
401            && warn > 0
402        {
403            strict_violations.push(("signals", warn as usize));
404        }
405        obj.insert("signals".to_string(), axis.clone());
406    }
407    // `--include labelling`: grounded labels per declaring mem — a
408    // reported observation with its evidence, never a strict
409    // violation.
410    if let Some(axis) = &labelling_axis {
411        obj.insert("labelling".to_string(), axis.clone());
412    }
413    // `--include friction`: the friction ledger's read surface
414    // (agent-trust plan 08) — counts per refusal code / per verb,
415    // whole ledger plus a recent 24h window. Same summarizer MCP's
416    // axis serves; no workspace resolvable → empty summary.
417    let friction_axis = if include.iter().any(|s| s == "friction") {
418        let summary = std::env::current_dir()
419            .ok()
420            .and_then(|cwd| crate::setup::find_workspace_root(&cwd))
421            .map(|root| memstead_base::friction::FrictionLedger::for_workspace(&root).summarize())
422            .unwrap_or_else(|| {
423                json!({
424                    "total": 0,
425                    "by_code": {},
426                    "by_verb": {},
427                    "recent_24h": { "total": 0, "by_code": {} },
428                    "ledger_bytes": 0,
429                })
430            });
431        obj.insert("friction".to_string(), summary.clone());
432        Some(summary)
433    } else {
434        None
435    };
436
437    // Typed warnings array — engine-level health warnings (load-time
438    // drift, the authoring-drift axis, …) in the same `{code, message,
439    // details}` shape MCP emits on `warnings[]`, plus any
440    // `UNKNOWN_INCLUDE_KEY` request warnings. Previously the CLI
441    // rendered only the include-key warnings, leaving engine warnings
442    // MCP-only — the blindness the authoring-drift axis exists to fix
443    // was measured through exactly this gap.
444    let mut warning_payload: Vec<serde_json::Value> = health
445        .warnings
446        .iter()
447        .filter_map(|w| serde_json::to_value(w).ok())
448        .collect();
449    warning_payload.extend(include_warnings.iter().map(|(key, allowed)| {
450        json!({
451            "code": "UNKNOWN_INCLUDE_KEY",
452            "message": format!(
453                "unknown include key: \"{key}\". Allowed: {}",
454                allowed.join(", ")
455            ),
456            "details": { "key": key, "allowed": allowed },
457        })
458    }));
459    if !warning_payload.is_empty() {
460        obj.insert("warnings".into(), json!(warning_payload));
461    }
462    // Leaf populations — the counts the orphan axis exempts because
463    // those types are terminal by construction (agent-trust plan 06).
464    if !health.leaf_entities_by_type.is_empty() {
465        obj.insert(
466            "leaf_entities_by_type".into(),
467            serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
468        );
469    }
470    // Quarantine roster — a boot-honesty fact, present whenever
471    // non-empty, never behind an include gate (agent-trust plan 04).
472    if !health.quarantined.is_empty() {
473        obj.insert(
474            "quarantined".into(),
475            serde_json::to_value(&health.quarantined).unwrap_or_default(),
476        );
477    }
478    // Per-file load failures — the same boot-honesty class: each
479    // entry's message names the remedy (the merge-conflict refusal
480    // names `memstead conflicts resolve`), and this hand-built
481    // envelope must carry them like the MCP surfaces do or a
482    // CLI-driven agent never finds the door (backlog-sweep plan 07).
483    if !health.load_errors.is_empty() {
484        obj.insert(
485            "load_errors".into(),
486            serde_json::to_value(&health.load_errors).unwrap_or_default(),
487        );
488    }
489    if let Some(diag) = &health.boot_diagnosis {
490        obj.insert("boot_diagnosis".into(), diag.clone());
491    }
492
493    // Authoring-drift findings participate in `--strict`
494    // unconditionally (no `--include` opt-in): they are
495    // default-visible warnings, and the axis exists because a
496    // `health --strict` run stayed silent on a vanished authoring
497    // source.
498    let authoring_drift = health
499        .warnings
500        .iter()
501        .filter(|w| {
502            matches!(
503                w.code(),
504                "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
505            )
506        })
507        .count();
508    if authoring_drift > 0 {
509        strict_violations.push(("schema_authoring_drift", authoring_drift));
510    }
511    // Configuration defects participate unconditionally too: a mount
512    // whose pin disagrees with its mem's config, a pinned schema whose
513    // sealed package has rotted, a mount that resolves to nothing.
514    // None of them is about an entity; each is the workspace
515    // describing itself wrongly, and `--strict` exited 0 on three pin
516    // mismatches, two rotted schemas and two unbacked mounts until
517    // 2026-08-23. Generations-behind pins stay advisory: the pin works.
518    for (label, code) in [
519        ("schema_pin_mismatch", "SCHEMA_PIN_MISMATCH"),
520        ("schema_unstamped_source_rot", "SCHEMA_UNSTAMPED_SOURCE_ROT"),
521        ("mount_unbacked", "MOUNT_UNBACKED"),
522    ] {
523        let n = health.warnings.iter().filter(|w| w.code() == code).count();
524        if n > 0 {
525            strict_violations.push((label, n));
526        }
527    }
528
529    if ctx.json {
530        print_json(&result)?;
531        return strict_exit(args.strict, &strict_violations);
532    }
533
534    // Markdown rendering
535    let mut lines = Vec::new();
536    lines.push("# Graph health".to_string());
537    lines.push(String::new());
538    // The coverage rule: the axes the strict verdict answers for, in
539    // the output itself (memstead_base::ops::coverage).
540    if let Some(cov) = crate::coverage::HEALTH.axis_coverage() {
541        lines.push(format!("**Verdict coverage:** {}", cov.wire_line()));
542        lines.push(String::new());
543    }
544    lines.push(format!("- Entities: {real_count}"));
545    if orphans_by_schema.len() > 1 {
546        // Attribute the orphan headline per schema so by-design isolates
547        // (ingest mems) aren't read as uniform debt.
548        let by: Vec<String> = orphans_by_schema
549            .iter()
550            .map(|(s, n)| format!("{}: {n}", if s.is_empty() { "(unpinned)" } else { s }))
551            .collect();
552        lines.push(format!(
553            "- Orphans: {} ({})",
554            orphan_ids.len(),
555            by.join(", ")
556        ));
557    } else {
558        lines.push(format!("- Orphans: {}", orphan_ids.len()));
559    }
560    lines.push(format!("- Stubs: {}", stub_pairs.len()));
561    lines.push(format!("- Stale: {}", health.stale_entities.len()));
562    lines.push(format!("- Missing fields: {}", health.missing_fields.len()));
563    lines.push(format!("- Communities: {community_count}"));
564    lines.push(String::new());
565
566    if let Some(v) = obj.get("orphans").and_then(|v| v.as_array()) {
567        lines.push("## Orphans".to_string());
568        for item in v {
569            lines.push(format!(
570                "- {} — {}",
571                item["id"].as_str().unwrap_or(""),
572                item["title"].as_str().unwrap_or("")
573            ));
574        }
575        lines.push(String::new());
576    }
577    if let Some(v) = obj.get("stubs").and_then(|v| v.as_array()) {
578        lines.push("## Stubs".to_string());
579        for item in v {
580            lines.push(format!("- {}", item["id"].as_str().unwrap_or("")));
581        }
582        lines.push(String::new());
583    }
584    if let Some(v) = obj.get("most_connected").and_then(|v| v.as_array()) {
585        lines.push("## Most connected".to_string());
586        lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
587        for item in v {
588            lines.push(format!(
589                "- {} — {} (typed {}, total {}, in {}, out {})",
590                item["id"].as_str().unwrap_or(""),
591                item["title"].as_str().unwrap_or(""),
592                item["typed_total"].as_u64().unwrap_or(0),
593                item["total"].as_u64().unwrap_or(0),
594                item["incoming"].as_u64().unwrap_or(0),
595                item["outgoing"].as_u64().unwrap_or(0),
596            ));
597        }
598        lines.push(String::new());
599    }
600    if let Some(v) = obj.get("missing_fields").and_then(|v| v.as_array()) {
601        lines.push("## Missing fields".to_string());
602        for item in v {
603            // Render per-issue `field (CODE)` so a heading mismatch never
604            // reads as "missing" to a human either — content under a
605            // non-deriving heading EXISTS; the label must say which
606            // condition fired. Falls back to the legacy field-name list
607            // for payloads without `issues` (older JSON piped back in).
608            let labels: Vec<String> = match item["issues"].as_array() {
609                Some(issues) if !issues.is_empty() => issues
610                    .iter()
611                    .map(|i| {
612                        format!(
613                            "{} ({})",
614                            i["field"].as_str().unwrap_or(""),
615                            i["code"].as_str().unwrap_or("MISSING"),
616                        )
617                    })
618                    .collect(),
619                _ => item["missing"]
620                    .as_array()
621                    .map(|a| {
622                        a.iter()
623                            .filter_map(|s| s.as_str())
624                            .map(str::to_string)
625                            .collect()
626                    })
627                    .unwrap_or_default(),
628            };
629            lines.push(format!(
630                "- {} — {} (issues: {})",
631                item["id"].as_str().unwrap_or(""),
632                item["title"].as_str().unwrap_or(""),
633                labels.join(", ")
634            ));
635        }
636        lines.push(String::new());
637    }
638    if let Some(v) = obj.get("stale").and_then(|v| v.as_array()) {
639        lines.push("## Stale entities".to_string());
640        for item in v {
641            lines.push(format!(
642                "- {} — {} ({} days)",
643                item["id"].as_str().unwrap_or(""),
644                item["title"].as_str().unwrap_or(""),
645                item["days_since_modified"].as_u64().unwrap_or(0)
646            ));
647        }
648        lines.push(String::new());
649    }
650    if let Some(v) = obj
651        .get("missing_required_outgoing")
652        .and_then(|v| v.as_array())
653    {
654        lines.push("## Missing required outgoing".to_string());
655        for item in v {
656            let blocks: Vec<String> = item["missing"]
657                .as_array()
658                .map(|arr| {
659                    arr.iter()
660                        .map(|b| {
661                            let rels: Vec<&str> = b["relationships"]
662                                .as_array()
663                                .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
664                                .unwrap_or_default();
665                            format!(
666                                "[{}] {}",
667                                rels.join(", "),
668                                b["cardinality"].as_str().unwrap_or("")
669                            )
670                        })
671                        .collect()
672                })
673                .unwrap_or_default();
674            lines.push(format!(
675                "- {} — {} (missing: {})",
676                item["id"].as_str().unwrap_or(""),
677                item["title"].as_str().unwrap_or(""),
678                blocks.join("; ")
679            ));
680        }
681        lines.push(String::new());
682    }
683    // Conformance / integrity findings — the include was accepted and the
684    // data gathered, so the human rendering must serve it: the JSON form
685    // carried a populated `findings` array while this path printed only
686    // the summary, and an operator diagnosing a mem by eye was told
687    // nothing about content the engine was holding and reporting
688    // (consistency-sweep 04/02's closing grade). An explicit zero is
689    // rendered too, so "requested and clean" never reads as "not served".
690    if let Some(v) = obj.get("findings").and_then(|v| v.as_array()) {
691        lines.push(format!("## Conformance findings ({})", v.len()));
692        if v.is_empty() {
693            lines.push("- none".to_string());
694        }
695        for item in v {
696            let mut line = format!(
697                "- [{}] {} (axis {})",
698                item["code"].as_str().unwrap_or("?"),
699                item["id"].as_str().unwrap_or(""),
700                item["axis"].as_str().unwrap_or("?"),
701            );
702            for key in ["field", "heading", "section"] {
703                if let Some(val) = item["detail"][key].as_str() {
704                    line.push_str(&format!(" — {key} `{val}`"));
705                }
706            }
707            lines.push(line);
708        }
709        lines.push(String::new());
710    }
711    if let Some(v) = obj.get("body_observations").and_then(|v| v.as_array())
712        && !v.is_empty()
713    {
714        lines.push(format!("## Body observations ({})", v.len()));
715        for item in v {
716            let mut line = format!(
717                "- [{}] {} — {}",
718                item["code"].as_str().unwrap_or("?"),
719                item["id"].as_str().unwrap_or(""),
720                item["fate"].as_str().unwrap_or("?"),
721            );
722            for key in ["heading", "key"] {
723                if let Some(val) = item["detail"][key].as_str() {
724                    line.push_str(&format!(", {key} `{val}`"));
725                }
726            }
727            lines.push(line);
728        }
729        lines.push(String::new());
730    }
731    // Same gap one include over: `--include constraints` filled the JSON
732    // and the strict tally while this rendering said nothing.
733    if let Some(v) = obj.get("constraints").and_then(|v| v.as_array()) {
734        lines.push(format!("## Constraint violations ({})", v.len()));
735        if v.is_empty() {
736            lines.push("- none".to_string());
737        }
738        for item in v {
739            let mut kinds: Vec<String> = item["violations"]
740                .as_array()
741                .map(|a| {
742                    a.iter()
743                        .filter_map(|x| x["kind"].as_str())
744                        .map(str::to_string)
745                        .collect()
746                })
747                .unwrap_or_default();
748            if item["format_violations"]
749                .as_array()
750                .is_some_and(|a| !a.is_empty())
751            {
752                kinds.push("section_format".to_string());
753            }
754            lines.push(format!(
755                "- {} — {} ({})",
756                item["id"].as_str().unwrap_or(""),
757                item["title"].as_str().unwrap_or(""),
758                kinds.join(", "),
759            ));
760        }
761        lines.push(String::new());
762    }
763    if let Some(v) = obj.get("schema_format_defects").and_then(|v| v.as_array()) {
764        lines.push(format!("## Schema format defects ({})", v.len()));
765        for item in v {
766            lines.push(format!("- {}", item));
767        }
768        lines.push(String::new());
769    }
770    if let Some(v) = obj.get("dangling_links").and_then(|v| v.as_array()) {
771        lines.push("## Dangling links".to_string());
772        for item in v {
773            // Name the condition and its repair. A reader used to get three
774            // different problems in one shape and had to work out which by
775            // noticing whether `section` was null (04/06, criterion 4).
776            lines.push(format!(
777                "- [{}] {} → {}{}",
778                item["kind"].as_str().unwrap_or("?"),
779                item["from"].as_str().unwrap_or(""),
780                item["target_id"].as_str().unwrap_or(""),
781                item["section"]
782                    .as_str()
783                    .map(|s| format!(" (in `{s}`)"))
784                    .unwrap_or_default(),
785            ));
786        }
787        lines.push(String::new());
788    }
789    if let Some(v) = obj.get("tag_distribution").and_then(|v| v.as_array()) {
790        lines.push("## Tags".to_string());
791        for item in v {
792            lines.push(format!(
793                "- {} ({})",
794                item["tag"].as_str().unwrap_or(""),
795                item["count"].as_u64().unwrap_or(0)
796            ));
797        }
798        lines.push(String::new());
799    }
800    if let Some(v) = obj.get("warnings").and_then(|v| v.as_array()) {
801        lines.push("## Warnings".to_string());
802        for w in v {
803            lines.push(format!(
804                "- {} — {}",
805                w["code"].as_str().unwrap_or(""),
806                w["message"].as_str().unwrap_or("")
807            ));
808        }
809        lines.push(String::new());
810    }
811    if let Some(u) = obj.get("untagged_entities") {
812        lines.push("## Untagged".to_string());
813        lines.push(format!("- Total: {}", u["total"].as_u64().unwrap_or(0)));
814        if let Some(by_type) = u["by_entity_type"].as_object() {
815            let mut entries: Vec<(&String, u64)> = by_type
816                .iter()
817                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
818                .collect();
819            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
820            for (kind, count) in entries {
821                lines.push(format!("  - {kind}: {count}"));
822            }
823        }
824        lines.push(String::new());
825    }
826
827    // The human-readable half of the ledger axis. Rendering it only in
828    // `--json` would put the reconciliation out of reach of the operator who
829    // runs `memstead health` by eye, which is the same class of gap this plan
830    // exists to close (04/04, criterion 11).
831    if let Some(axis) = ledger_axis.as_ref().and_then(|a| a.as_object()) {
832        lines.push(format!("## Ledger vs files ({} folder mem(s))", axis.len()));
833        if axis.is_empty() {
834            lines.push(
835                "- no folder mems: the check does not apply to git-branch storage, whose \
836                 change set is a real two-tree diff"
837                    .to_string(),
838            );
839        }
840        for (mem, r) in axis {
841            let ghosts = r["ledger_without_file"]
842                .as_array()
843                .map(Vec::len)
844                .unwrap_or(0);
845            let unlogged = r["file_without_ledger"]
846                .as_array()
847                .map(Vec::len)
848                .unwrap_or(0);
849            if ghosts == 0 && unlogged == 0 {
850                lines.push(format!("- `{mem}`: ledger and files agree"));
851                continue;
852            }
853            lines.push(format!(
854                "- `{mem}`: {ghosts} recorded with no file, {unlogged} file(s) the ledger \
855                 never mentions"
856            ));
857            for id in r["ledger_without_file"].as_array().into_iter().flatten() {
858                lines.push(format!(
859                    "  - recorded, no file: `{}`",
860                    id.as_str().unwrap_or("")
861                ));
862            }
863            for id in r["file_without_ledger"].as_array().into_iter().flatten() {
864                lines.push(format!(
865                    "  - file, never recorded: `{}`",
866                    id.as_str().unwrap_or("")
867                ));
868            }
869        }
870        lines.push(String::new());
871    }
872
873    if let Some(axis) = anchors_axis.as_ref().and_then(|a| a.as_object()) {
874        lines.push(format!("## Anchors ({} mems)", axis.len()));
875        for (mem, counts) in axis {
876            // The figure and its population in one rendering
877            // (consistency-sweep 03/05, criteria 1 and 3). This is the
878            // human-readable half of the health axis, and it used to print
879            // four numbers and stop: no unobserved count, no population, no
880            // statement of what was adjudicated. It reads its counts out of a
881            // `serde_json::Value` by index, which is how it stayed invisible
882            // to the figure check until that check learned the form.
883            lines.push(format!(
884                "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable (artifact gone) \
885                 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
886                counts["resolved"].as_u64().unwrap_or(0),
887                counts["drifted"].as_u64().unwrap_or(0),
888                counts["recheck"].as_u64().unwrap_or(0),
889                counts["unresolvable"].as_u64().unwrap_or(0),
890                counts["unobserved"].as_u64().unwrap_or(0),
891                counts["dangling"].as_u64().unwrap_or(0),
892                counts["population"]
893                    .as_str()
894                    .unwrap_or("population not stated"),
895            ));
896        }
897        lines.push(String::new());
898    }
899
900    if let Some(axis) = open_questions_axis.as_ref().and_then(|a| a.as_object()) {
901        let cap = axis
902            .get("_item_cap")
903            .and_then(|v| v.as_u64())
904            .unwrap_or_default();
905        lines.push(format!("## Open questions (item cap {cap} per kind)"));
906        for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
907            let total = entry["total_open"].as_u64().unwrap_or(0);
908            lines.push(format!("- `{mem}`: {total} open"));
909            for kind in [
910                "stubs",
911                "anchors_recheck",
912                "anchors_unresolvable",
913                // The bucket the axis inserts and counts into `total_open`,
914                // which this list did not print, so a hole the axis had
915                // measured never reached the reader (consistency-sweep 03/05).
916                "anchors_unobserved",
917                // Its sibling from 03/02, omitted for the same reason and
918                // with the same effect: the axis counts it into `total_open`,
919                // so a dangling row raised the total with nothing in the human
920                // rendering saying why.
921                "anchors_dangling",
922                "unsatisfied_constraints",
923                "dangling_links",
924            ] {
925                let count = entry[kind]["count"].as_u64().unwrap_or(0);
926                if count > 0 {
927                    let more = entry[kind]["more"].as_u64().unwrap_or(0);
928                    let suffix = if more > 0 {
929                        format!(" ({more} more not shown)")
930                    } else {
931                        String::new()
932                    };
933                    lines.push(format!("  - {kind}: {count}{suffix}"));
934                }
935            }
936            if let Some(process) = entry.get("process").and_then(|p| p.as_array()) {
937                for p in process {
938                    if p["resolvable"] == serde_json::json!(true) {
939                        lines.push(format!(
940                            "  - process `{}`: {} open entries; {} already searched (do not redo)",
941                            p["binding"].as_str().unwrap_or("?"),
942                            p["open_entries"]["count"].as_u64().unwrap_or(0),
943                            p["already_searched"]["count"].as_u64().unwrap_or(0),
944                        ));
945                    } else {
946                        lines.push(format!(
947                            "  - process `{}`: not resolvable (mem not mounted)",
948                            p["binding"].as_str().unwrap_or("?"),
949                        ));
950                    }
951                }
952            }
953        }
954        lines.push(String::new());
955    }
956
957    // Checks axis — same wording as the MCP text renderer
958    // (`render_health_markdown`). Null-is-a-statement: requested with
959    // no mems renders the explicit zero heading; not requested
960    // renders nothing.
961    if let Some(axis) = checks_axis.as_ref().and_then(|a| a.as_object()) {
962        lines.push(format!("## Checks ({} mems)", axis.len()));
963        for (mem, c) in axis {
964            let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
965            let conf = |key: &str| {
966                c.get("conformance")
967                    .and_then(|g| g.get(key))
968                    .and_then(|x| x.as_u64())
969                    .unwrap_or(0)
970            };
971            let gate = |key: &str| {
972                c.get("independence")
973                    .and_then(|g| g.get(key))
974                    .and_then(|e| e.get("count"))
975                    .and_then(|x| x.as_u64())
976                    .unwrap_or(0)
977            };
978            lines.push(format!(
979                "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
980                 check_stale {}; conformance: never_checked {}, \
981                 checked_ok {}, check_failed {}, check_stale {}; \
982                 independence: self_checked {}, \
983                 confirmed_independent {}, unconfirmable {}",
984                count("never_checked"),
985                count("checked_ok"),
986                count("check_failed"),
987                count("check_stale"),
988                conf("never_checked"),
989                conf("checked_ok"),
990                conf("check_failed"),
991                conf("check_stale"),
992                gate("self_checked"),
993                gate("confirmed_independent"),
994                gate("unconfirmable"),
995            ));
996            // Foreign `x-` kinds by count, and the structured finding each
997            // entity's newest record carries — the JSON axis has both;
998            // the text surface says the same or it says less than it knows.
999            if let Some(foreign) = c.get("foreign_kinds").and_then(|f| f.as_object())
1000                && !foreign.is_empty()
1001            {
1002                let listed: Vec<String> = foreign
1003                    .iter()
1004                    .map(|(k, n)| format!("{k} {}", n.as_u64().unwrap_or(0)))
1005                    .collect();
1006                lines.push(format!("  - foreign kinds: {}", listed.join(", ")));
1007            }
1008            if let Some(findings) = c.get("findings").and_then(|f| f.as_object()) {
1009                for (entity, f) in findings {
1010                    let code = f["finding"]["code"].as_str().unwrap_or("?");
1011                    let section = f["finding"]["section"]
1012                        .as_str()
1013                        .map(|s| format!(" [{s}]"))
1014                        .unwrap_or_default();
1015                    let message = f["finding"]["message"].as_str().unwrap_or("");
1016                    lines.push(format!(
1017                        "  - finding on `{entity}` ({} {}): {code}{section} — {message}",
1018                        f["kind"].as_str().unwrap_or("verification"),
1019                        f["verdict"].as_str().unwrap_or("?"),
1020                    ));
1021                }
1022            }
1023        }
1024        lines.push(String::new());
1025    }
1026
1027    // Signals axis — every above-`none` signal with its evidence.
1028    if let Some(axis) = obj.get("signals") {
1029        lines.push(format!(
1030            "## Signals (notice {}, warn {})",
1031            axis["counts"]["notice"].as_u64().unwrap_or(0),
1032            axis["counts"]["warn"].as_u64().unwrap_or(0),
1033        ));
1034        for e in axis["entities"].as_array().into_iter().flatten() {
1035            for s in e["signals"].as_array().into_iter().flatten() {
1036                let contributors = s["contributors"]
1037                    .as_array()
1038                    .map(|a| {
1039                        a.iter()
1040                            .filter_map(|c| c.as_str())
1041                            .collect::<Vec<_>>()
1042                            .join(", ")
1043                    })
1044                    .unwrap_or_default();
1045                lines.push(format!(
1046                    "- {} — {}: {} ({}) [{}]",
1047                    e["id"].as_str().unwrap_or(""),
1048                    s["name"].as_str().unwrap_or(""),
1049                    s["value"].as_u64().unwrap_or(0),
1050                    s["level"].as_str().unwrap_or(""),
1051                    contributors,
1052                ));
1053            }
1054        }
1055        lines.push(String::new());
1056    }
1057
1058    // Labelling axis — grounded labels with their evidence.
1059    if let Some(axis) = obj.get("labelling").and_then(|a| a.as_object()) {
1060        lines.push(format!("## Labelling ({} mems)", axis.len()));
1061        for (mem, m) in axis {
1062            let c = &m["counts"];
1063            lines.push(format!(
1064                "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
1065                c["accepted"].as_u64().unwrap_or(0),
1066                c["defeated"].as_u64().unwrap_or(0),
1067                c["undecided"].as_u64().unwrap_or(0),
1068                m["cross_mem_edges_excluded"].as_u64().unwrap_or(0),
1069            ));
1070            for d in m["defeated"].as_array().into_iter().flatten() {
1071                let by = d["defeated_by"]
1072                    .as_array()
1073                    .map(|a| {
1074                        a.iter()
1075                            .filter_map(|x| x.as_str())
1076                            .collect::<Vec<_>>()
1077                            .join(", ")
1078                    })
1079                    .unwrap_or_default();
1080                lines.push(format!(
1081                    "  - defeated: {} (by {by})",
1082                    d["id"].as_str().unwrap_or("")
1083                ));
1084            }
1085            for u in m["undecided"].as_array().into_iter().flatten() {
1086                let by = u["undecided_by"]
1087                    .as_array()
1088                    .map(|a| {
1089                        a.iter()
1090                            .filter_map(|x| x.as_str())
1091                            .collect::<Vec<_>>()
1092                            .join(", ")
1093                    })
1094                    .unwrap_or_default();
1095                lines.push(format!(
1096                    "  - undecided: {} (open attackers {by})",
1097                    u["id"].as_str().unwrap_or("")
1098                ));
1099            }
1100        }
1101        lines.push(String::new());
1102    }
1103
1104    // Stale-derivations axis — same requested-vs-absent contract and
1105    // wording as the MCP text renderer.
1106    if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
1107        let total: usize = axis
1108            .values()
1109            .filter_map(|a| a.as_array().map(|a| a.len()))
1110            .sum();
1111        lines.push(format!("## Stale derivations ({total} findings)"));
1112        for (mem, findings) in axis {
1113            for f in findings.as_array().into_iter().flatten() {
1114                lines.push(format!(
1115                    "- `{mem}`: {} -[{}]-> {} ({})",
1116                    f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1117                    f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1118                    f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1119                    f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1120                ));
1121            }
1122        }
1123        lines.push(String::new());
1124    }
1125
1126    // Quarantine roster — ungated (present in the JSON whenever
1127    // non-empty), so the markdown renders it whenever present: per
1128    // mem the reason code plus the message, which carries the repair
1129    // command.
1130    if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
1131        lines.push(format!("## Quarantined mems ({})", arr.len()));
1132        for q in arr {
1133            lines.push(format!(
1134                "- `{}` [{}] {}",
1135                q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1136                q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1137                q.get("reason_message")
1138                    .and_then(|x| x.as_str())
1139                    .unwrap_or(""),
1140            ));
1141        }
1142        lines.push(String::new());
1143    }
1144
1145    // Per-file load failures — ungated like the quarantine roster;
1146    // each message names its remedy, so the markdown must show it.
1147    if let Some(arr) = obj.get("load_errors").and_then(|v| v.as_array()) {
1148        lines.push(format!("## Load errors ({})", arr.len()));
1149        for e in arr {
1150            lines.push(format!(
1151                "- `{}` — {}",
1152                e.get("file").and_then(|x| x.as_str()).unwrap_or(""),
1153                e.get("error").and_then(|x| x.as_str()).unwrap_or(""),
1154            ));
1155        }
1156        lines.push(String::new());
1157    }
1158
1159    if let Some(f) = &friction_axis {
1160        lines.push(format!(
1161            "## Friction ({} refusals recorded, {} in the last 24h)",
1162            f["total"].as_u64().unwrap_or(0),
1163            f["recent_24h"]["total"].as_u64().unwrap_or(0),
1164        ));
1165        if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
1166            lines.push("- by code:".to_string());
1167            let mut entries: Vec<(&String, u64)> = by_code
1168                .iter()
1169                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1170                .collect();
1171            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1172            for (code, count) in entries {
1173                lines.push(format!("  - {code}: {count}"));
1174                // Reason breakdown where recorded — a code without
1175                // recorded reasons renders exactly as before.
1176                if let Some(reasons) = f["by_reason"][code.as_str()]
1177                    .as_object()
1178                    .filter(|m| !m.is_empty())
1179                {
1180                    let mut rs: Vec<(&String, u64)> = reasons
1181                        .iter()
1182                        .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1183                        .collect();
1184                    rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1185                    for (reason, count) in rs {
1186                        lines.push(format!("    - {reason}: {count}"));
1187                    }
1188                }
1189            }
1190        }
1191        if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
1192            lines.push("- by verb:".to_string());
1193            let mut entries: Vec<(&String, u64)> = by_verb
1194                .iter()
1195                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1196                .collect();
1197            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1198            for (verb, count) in entries {
1199                lines.push(format!("  - {verb}: {count}"));
1200            }
1201        }
1202        lines.push(String::new());
1203    }
1204
1205    print_markdown(&lines.join("\n"));
1206    strict_exit(args.strict, &strict_violations)
1207}
1208
1209/// Aggregated health data, engine-flavour-agnostic. Both
1210/// One `most_connected` row resolved at gather time:
1211/// `(id, title, total, incoming, outgoing, typed_total, typed_incoming,
1212/// typed_outgoing)`. `typed_*` excludes auto-emitted mention edges so the
1213/// ranking reflects dependency, not co-mention.
1214type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
1215
1216/// mem-repo and filesystem gather paths populate this struct
1217/// with the same shape so the rendering / JSON-envelope code below
1218/// runs once.
1219struct GatheredHealth {
1220    health: HealthSummary,
1221    /// Integrity findings (`{id, axis, code, detail}`) — populated by
1222    /// the caller (engine-shaped, so outside `gather_from_store`) when
1223    /// `--include conformance` / `--include integrity` is requested.
1224    findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
1225    /// Body observations (consistency-sweep 04/01) — what an entity's stored
1226    /// body carries that its type does not declare. Beside the findings, never
1227    /// among them: an observation is not a violation.
1228    body_observations: Vec<memstead_base::ops::integrity::BodyObservation>,
1229    real_count: usize,
1230    /// `(id, title)` pairs — title resolved at gather time so the
1231    /// rendering layer doesn't need to keep the engine alive.
1232    orphan_ids: Vec<(EntityId, String)>,
1233    stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
1234    community_count: usize,
1235    /// #49: orphan/community counts attributed per pinned schema, so a
1236    /// blended headline isn't read as uniform debt (ingest-mem isolates
1237    /// are orphans by design; code-mem orphans are debt). Filled by the
1238    /// engine-aware gather wrappers — `gather_from_store` leaves them empty.
1239    orphans_by_schema: std::collections::BTreeMap<String, usize>,
1240    communities_by_schema: std::collections::BTreeMap<String, usize>,
1241    /// [`MostConnectedRow`] tuples — same reasoning as `orphan_ids`.
1242    most_connected_with_titles: Vec<MostConnectedRow>,
1243    missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
1244    /// Standing violations of declared schema `constraints`
1245    /// (`--include constraints`), empty otherwise.
1246    constraint_findings: Vec<ConstraintFindingReport>,
1247    /// Defective section-format declarations the loaded schemas carry
1248    /// (rides the `constraints` include), empty otherwise.
1249    schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
1250    /// `Some(...)` when the caller asked for `--include tags`,
1251    /// `None` otherwise. The triple is `(distribution, folded,
1252    /// untagged)` mirroring `collect_tag_distribution`'s return
1253    /// shape.
1254    /// Pre-serialised tag triple: `(distribution, folded, untagged)`
1255    /// already converted to `serde_json::Value`. Keeps the gather
1256    /// step engine-flavour-agnostic without exposing the
1257    /// `memstead_base::ops::health` private tag types through this
1258    /// crate's public surface.
1259    tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
1260    /// Populated when `--include dangling_links` is set; empty
1261    /// otherwise. Matches the MCP `memstead_health` tool's response
1262    /// shape — `{from, target_id, target_path, section}` per entry.
1263    dangling_links: Vec<DanglingLink>,
1264    /// `Some(...)` when the caller asked for `--include config`: the
1265    /// same top-level entries (`mems`, `mutations`, `plugin`) the MCP
1266    /// composer renders for `include_config: true`, produced by the
1267    /// shared `memstead_base::ops::health::config_projection` with the
1268    /// policy values derived from `Engine::settings()`. `None`
1269    /// otherwise — absence of the key means "not requested".
1270    config_entries: Option<serde_json::Map<String, serde_json::Value>>,
1271    /// `Some(...)` when the caller asked for `--include anchors`: the
1272    /// per-mem anchor-verification counts (with `unresolvable` meaning the
1273    /// artifact is gone and `unobserved` meaning the pass could not measure
1274    /// it) plus the population they cover, from the shared
1275    /// `health_anchors_axis` helper (same axis MCP renders). `None`
1276    /// otherwise — absence of the key means "not requested".
1277    anchors_axis: Option<serde_json::Value>,
1278    /// `--include ledger`: a folder mem's ledger set against its file set.
1279    ledger_axis: Option<serde_json::Value>,
1280    /// `Some(...)` when the caller asked for `--include
1281    /// open_questions`: the composed per-mem worklist from the shared
1282    /// `health_open_questions_axis` helper (same axis MCP renders).
1283    open_questions_axis: Option<serde_json::Value>,
1284    /// `Some(...)` when the caller asked for `--include
1285    /// stale_derivations`: per-mem derivation-staleness findings from
1286    /// the shared `health_stale_derivations_axis` helper.
1287    stale_derivations_axis: Option<serde_json::Value>,
1288    /// `--include checks` — per-mem check-state counts + the
1289    /// author≠checker independence gate, via the shared
1290    /// `health_checks_axis` helper.
1291    checks_axis: Option<serde_json::Value>,
1292    /// `--include signals` — the shared `health_signals_axis`
1293    /// payload (entities above `none` plus per-level counts).
1294    signals_axis: Option<serde_json::Value>,
1295    /// `--include labelling` — the shared `health_labelling_axis`
1296    /// payload (per declaring mem: label counts, defeated/undecided
1297    /// lists with attacker evidence, excluded cross-mem edges).
1298    labelling_axis: Option<serde_json::Value>,
1299}
1300
1301/// Conformance/integrity findings across every mounted mem, in
1302/// sorted mem order. Engine-shaped (needs schema resolution), so it
1303/// runs beside `gather_from_store`, not inside it. `target_schema`
1304/// parse and resolution failures surface as typed CLI errors — the
1305/// same codes the MCP surface refuses with.
1306fn gather_findings(
1307    engine: &memstead_base::Engine,
1308    include: &[String],
1309    target_schema: Option<&str>,
1310) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
1311    let wants_conformance = include
1312        .iter()
1313        .any(|s| s == "conformance" || s == "integrity");
1314    if !wants_conformance {
1315        return Ok(Vec::new());
1316    }
1317    let target: Option<memstead_schema::SchemaRef> = match target_schema {
1318        None => None,
1319        Some(raw) => Some(
1320            raw.parse::<memstead_schema::SchemaRef>()
1321                .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1322        ),
1323    };
1324    let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1325    mems.sort();
1326    let mut findings = Vec::new();
1327    for v in &mems {
1328        findings.extend(
1329            engine
1330                .conformance_findings(v, target.as_ref())
1331                .map_err(crate::CliError::from_engine_op)?,
1332        );
1333        if include.iter().any(|s| s == "integrity") {
1334            findings.extend(
1335                engine
1336                    .consistency_findings(v)
1337                    .map_err(crate::CliError::from_engine_op)?,
1338            );
1339        }
1340    }
1341    Ok(findings)
1342}
1343
1344/// Body observations for every mem, when the caller asked for the conformance
1345/// or integrity axis (consistency-sweep 04/01).
1346///
1347/// Gathered beside the findings and rendered beside them, never among them:
1348/// an observation is not a violation and must never reach `strict_violations`,
1349/// because absorbing an undeclared heading is the catch-all working as
1350/// designed. What the reader gets is the distinction the axis could not make
1351/// before: content that was absorbed and survives, against content the next
1352/// write does not keep.
1353fn gather_body_observations(
1354    engine: &memstead_base::Engine,
1355    include: &[String],
1356    target_schema: Option<&str>,
1357) -> anyhow::Result<Vec<memstead_base::ops::integrity::BodyObservation>> {
1358    if !include
1359        .iter()
1360        .any(|s| s == "conformance" || s == "integrity")
1361    {
1362        return Ok(Vec::new());
1363    }
1364    let target = match target_schema {
1365        None => None,
1366        Some(raw) => Some(
1367            raw.parse::<memstead_schema::SchemaRef>()
1368                .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1369        ),
1370    };
1371    let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1372    mems.sort();
1373    let mut out = Vec::new();
1374    for v in &mems {
1375        out.extend(
1376            engine
1377                .body_observations(v, target.as_ref())
1378                .map_err(crate::CliError::from_engine_op)?,
1379        );
1380    }
1381    Ok(out)
1382}
1383
1384#[cfg(feature = "mem-repo")]
1385fn gather_mem_repo(
1386    engine: &mut memstead_base::Engine,
1387    limit: usize,
1388    include: &[String],
1389) -> GatheredHealth {
1390    let mut g = gather_from_store(
1391        engine.health(),
1392        engine.store(),
1393        engine.communities().count,
1394        limit,
1395        include,
1396        || engine.orphans(),
1397        |limit| engine_most_connected_mem_repo(engine, limit),
1398        || engine.missing_required_outgoing(None),
1399        || engine.constraint_findings(None),
1400        || engine.schema_format_defects(),
1401    );
1402    fill_schema_breakdowns(engine, &mut g);
1403    fill_config_projection(engine, include, &mut g);
1404    fill_anchors_axis(engine, include, &mut g);
1405    fill_open_questions_axis(engine, include, &mut g);
1406    fill_stale_derivations_axis(engine, include, &mut g);
1407    fill_checks_axis(engine, include, &mut g);
1408    fill_signals_axis(engine, include, &mut g);
1409    fill_labelling_axis(engine, include, &mut g);
1410    g
1411}
1412
1413fn gather_filesystem(
1414    engine: &mut memstead_base::Engine,
1415    limit: usize,
1416    include: &[String],
1417) -> GatheredHealth {
1418    let mut g = gather_from_store(
1419        engine.health(),
1420        engine.store(),
1421        engine.communities().count,
1422        limit,
1423        include,
1424        || engine.orphans(),
1425        |limit| engine_most_connected_filesystem(engine, limit),
1426        || engine.missing_required_outgoing(None),
1427        || engine.constraint_findings(None),
1428        || engine.schema_format_defects(),
1429    );
1430    fill_schema_breakdowns(engine, &mut g);
1431    fill_config_projection(engine, include, &mut g);
1432    fill_anchors_axis(engine, include, &mut g);
1433    fill_open_questions_axis(engine, include, &mut g);
1434    fill_stale_derivations_axis(engine, include, &mut g);
1435    fill_checks_axis(engine, include, &mut g);
1436    fill_signals_axis(engine, include, &mut g);
1437    fill_labelling_axis(engine, include, &mut g);
1438    g
1439}
1440
1441/// #49: attribute the orphan / community headlines per pinned schema (the
1442/// engine-aware step `gather_from_store` can't do off a bare `&Store`).
1443/// Engine-aware step for `--include config` — renders the shared
1444/// workspace-config projection (one implementation with the MCP
1445/// composer) off the engine's own settings.
1446fn fill_config_projection(
1447    engine: &memstead_base::Engine,
1448    include: &[String],
1449    g: &mut GatheredHealth,
1450) {
1451    if include.iter().any(|s| s == "config") {
1452        let mut mems: Vec<String> = engine
1453            .mem_router()
1454            .writable_mems()
1455            .iter()
1456            .cloned()
1457            .collect();
1458        mems.sort();
1459        let (mutations, plugin) =
1460            memstead_base::ops::health::config_projection_from_settings(engine.settings());
1461        g.config_entries = Some(memstead_base::ops::health::config_projection(
1462            engine, &mems, mutations, plugin,
1463        ));
1464    }
1465}
1466
1467/// Engine-aware step for `--include anchors` — the per-mem anchor-verification
1468/// counts from the shared axis helper.
1469/// Engine-aware step for `--include open_questions` — the composed
1470/// what-don't-we-know worklist (agent-trust plan 11), one shared
1471/// implementation with the MCP composer.
1472fn fill_open_questions_axis(
1473    engine: &memstead_base::Engine,
1474    include: &[String],
1475    g: &mut GatheredHealth,
1476) {
1477    if include.iter().any(|s| s == "open_questions") {
1478        g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
1479            engine, None,
1480        ));
1481    }
1482}
1483
1484/// Engine-aware step for `--include stale_derivations` — per-mem
1485/// derivation-staleness findings (agent-trust plan 12), one shared
1486/// implementation with the MCP composer.
1487fn fill_stale_derivations_axis(
1488    engine: &memstead_base::Engine,
1489    include: &[String],
1490    g: &mut GatheredHealth,
1491) {
1492    if include.iter().any(|s| s == "stale_derivations") {
1493        g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
1494            engine, None,
1495        ));
1496    }
1497}
1498
1499fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1500    if include.iter().any(|s| s == "checks") {
1501        g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1502    }
1503}
1504
1505fn fill_signals_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1506    if include.iter().any(|s| s == "signals") {
1507        g.signals_axis = Some(engine.health_signals_axis(None));
1508    }
1509}
1510
1511fn fill_labelling_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1512    if include.iter().any(|s| s == "labelling") {
1513        g.labelling_axis = Some(engine.health_labelling_axis(None));
1514    }
1515}
1516
1517fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1518    if include.iter().any(|s| s == "anchors") {
1519        g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1520    }
1521    // Folder mems only; a git-branch mem is absent rather than clean
1522    // (04/04, criterion 4).
1523    if include.iter().any(|s| s == "ledger") {
1524        g.ledger_axis = serde_json::to_value(engine.ledger_reconciliation()).ok();
1525    }
1526}
1527
1528fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1529    let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1530    g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1531    g.communities_by_schema = engine.communities_by_schema(&mems);
1532}
1533
1534/// Engine-agnostic gather pipeline. The two engine-shaped callbacks
1535/// (`most_connected_fn`, `missing_required_outgoing_fn`) handle the
1536/// surfaces that are not available off the bare `&Store`.
1537///
1538/// Ten parameters is deliberate: five of them are the engine-shaped callbacks
1539/// that keep this function engine-agnostic. Bundling them into a struct would
1540/// move the same arity behind a type that exists for one call site.
1541#[allow(clippy::too_many_arguments)]
1542fn gather_from_store(
1543    health: HealthSummary,
1544    store: &Store,
1545    community_count: usize,
1546    limit: usize,
1547    include: &[String],
1548    orphans_fn: impl FnOnce() -> Vec<EntityId>,
1549    most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1550    missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1551    constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1552    schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1553) -> GatheredHealth {
1554    let real_count = store.all_entities().filter(|e| !e.stub).count();
1555    let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1556        .into_iter()
1557        .map(|id| {
1558            let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1559            (id, title)
1560        })
1561        .collect();
1562    let stub_pairs = memstead_base::graph::query::find_stubs(store);
1563    let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1564        most_connected_fn(limit)
1565    } else {
1566        Vec::new()
1567    };
1568    let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1569        missing_required_outgoing_fn()
1570    } else {
1571        Vec::new()
1572    };
1573    let constraint_findings = if include.iter().any(|s| s == "constraints") {
1574        constraint_findings_fn()
1575    } else {
1576        Vec::new()
1577    };
1578    let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1579        schema_format_defects_fn()
1580    } else {
1581        Vec::new()
1582    };
1583    let tag_distribution = if include.iter().any(|s| s == "tags") {
1584        let (distribution, folded, untagged) =
1585            memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1586        Some((
1587            serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1588            serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1589            serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1590        ))
1591    } else {
1592        None
1593    };
1594    let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1595        memstead_base::ops::health::collect_dangling_links(store, None)
1596    } else {
1597        Vec::new()
1598    };
1599    GatheredHealth {
1600        ledger_axis: None,
1601        health,
1602        findings: Vec::new(),
1603        real_count,
1604        orphan_ids,
1605        stub_pairs,
1606        community_count,
1607        // Engine-agnostic path can't resolve schema pins; the engine-aware
1608        // wrappers (`gather_mem_repo` / `gather_filesystem`) fill these.
1609        orphans_by_schema: std::collections::BTreeMap::new(),
1610        communities_by_schema: std::collections::BTreeMap::new(),
1611        most_connected_with_titles,
1612        missing_required_outgoing,
1613        constraint_findings,
1614        schema_format_defects,
1615        tag_distribution,
1616        dangling_links,
1617        body_observations: Vec::new(),
1618        config_entries: None,
1619        anchors_axis: None,
1620        open_questions_axis: None,
1621        stale_derivations_axis: None,
1622        checks_axis: None,
1623        signals_axis: None,
1624        labelling_axis: None,
1625    }
1626}
1627
1628#[cfg(feature = "mem-repo")]
1629fn engine_most_connected_mem_repo(
1630    engine: &memstead_base::Engine,
1631    limit: usize,
1632) -> Vec<MostConnectedRow> {
1633    engine
1634        .most_connected(limit)
1635        .into_iter()
1636        .map(|c| {
1637            let title = engine
1638                .get_entity(&c.id)
1639                .map(|e| e.title.clone())
1640                .unwrap_or_default();
1641            (
1642                c.id,
1643                title,
1644                c.total,
1645                c.incoming,
1646                c.outgoing,
1647                c.typed_total,
1648                c.typed_incoming,
1649                c.typed_outgoing,
1650            )
1651        })
1652        .collect()
1653}
1654
1655fn engine_most_connected_filesystem(
1656    engine: &memstead_base::Engine,
1657    limit: usize,
1658) -> Vec<MostConnectedRow> {
1659    engine
1660        .most_connected(limit)
1661        .into_iter()
1662        .map(|c| {
1663            let title = engine
1664                .get_entity(&c.id)
1665                .map(|e| e.title.clone())
1666                .unwrap_or_default();
1667            (
1668                c.id,
1669                title,
1670                c.total,
1671                c.incoming,
1672                c.outgoing,
1673                c.typed_total,
1674                c.typed_incoming,
1675                c.typed_outgoing,
1676            )
1677        })
1678        .collect()
1679}
1680
1681/// Translate the strict-violation tally into an exit code. With
1682/// `--strict` set and any Tier-2 violations recorded, return a
1683/// `CliError(Generic)` so `main` exits 1 after the report has been
1684/// written to stdout. When `--strict` is unset, or when no Tier-2
1685/// `--include` token was supplied, this is a no-op.
1686fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1687    if !strict || violations.is_empty() {
1688        return Ok(());
1689    }
1690    let summary = violations
1691        .iter()
1692        .map(|(code, n)| format!("{code}: {n}"))
1693        .collect::<Vec<_>>()
1694        .join(", ");
1695    Err(crate::CliError::new(
1696        ExitKind::Generic,
1697        "HEALTH_STRICT_VIOLATIONS",
1698        format!("strict mode: tier-2 violations present ({summary})"),
1699    )
1700    .into())
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705    use super::*;
1706    use clap::CommandFactory;
1707
1708    #[test]
1709    fn help_lists_every_include_key() {
1710        let cmd = Args::command();
1711        let arg = cmd
1712            .get_arguments()
1713            .find(|a| a.get_id() == "include")
1714            .expect("--include arg must exist");
1715        let help = arg
1716            .get_help()
1717            .expect("--include must have help text")
1718            .to_string();
1719        for key in HEALTH_INCLUDE_KEYS {
1720            assert!(
1721                help.contains(key),
1722                "`memstead health --help` must name include key `{key}` (got: {help})"
1723            );
1724        }
1725    }
1726}