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        }
997        lines.push(String::new());
998    }
999
1000    // Signals axis — every above-`none` signal with its evidence.
1001    if let Some(axis) = obj.get("signals") {
1002        lines.push(format!(
1003            "## Signals (notice {}, warn {})",
1004            axis["counts"]["notice"].as_u64().unwrap_or(0),
1005            axis["counts"]["warn"].as_u64().unwrap_or(0),
1006        ));
1007        for e in axis["entities"].as_array().into_iter().flatten() {
1008            for s in e["signals"].as_array().into_iter().flatten() {
1009                let contributors = s["contributors"]
1010                    .as_array()
1011                    .map(|a| {
1012                        a.iter()
1013                            .filter_map(|c| c.as_str())
1014                            .collect::<Vec<_>>()
1015                            .join(", ")
1016                    })
1017                    .unwrap_or_default();
1018                lines.push(format!(
1019                    "- {} — {}: {} ({}) [{}]",
1020                    e["id"].as_str().unwrap_or(""),
1021                    s["name"].as_str().unwrap_or(""),
1022                    s["value"].as_u64().unwrap_or(0),
1023                    s["level"].as_str().unwrap_or(""),
1024                    contributors,
1025                ));
1026            }
1027        }
1028        lines.push(String::new());
1029    }
1030
1031    // Labelling axis — grounded labels with their evidence.
1032    if let Some(axis) = obj.get("labelling").and_then(|a| a.as_object()) {
1033        lines.push(format!("## Labelling ({} mems)", axis.len()));
1034        for (mem, m) in axis {
1035            let c = &m["counts"];
1036            lines.push(format!(
1037                "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
1038                c["accepted"].as_u64().unwrap_or(0),
1039                c["defeated"].as_u64().unwrap_or(0),
1040                c["undecided"].as_u64().unwrap_or(0),
1041                m["cross_mem_edges_excluded"].as_u64().unwrap_or(0),
1042            ));
1043            for d in m["defeated"].as_array().into_iter().flatten() {
1044                let by = d["defeated_by"]
1045                    .as_array()
1046                    .map(|a| {
1047                        a.iter()
1048                            .filter_map(|x| x.as_str())
1049                            .collect::<Vec<_>>()
1050                            .join(", ")
1051                    })
1052                    .unwrap_or_default();
1053                lines.push(format!(
1054                    "  - defeated: {} (by {by})",
1055                    d["id"].as_str().unwrap_or("")
1056                ));
1057            }
1058            for u in m["undecided"].as_array().into_iter().flatten() {
1059                let by = u["undecided_by"]
1060                    .as_array()
1061                    .map(|a| {
1062                        a.iter()
1063                            .filter_map(|x| x.as_str())
1064                            .collect::<Vec<_>>()
1065                            .join(", ")
1066                    })
1067                    .unwrap_or_default();
1068                lines.push(format!(
1069                    "  - undecided: {} (open attackers {by})",
1070                    u["id"].as_str().unwrap_or("")
1071                ));
1072            }
1073        }
1074        lines.push(String::new());
1075    }
1076
1077    // Stale-derivations axis — same requested-vs-absent contract and
1078    // wording as the MCP text renderer.
1079    if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
1080        let total: usize = axis
1081            .values()
1082            .filter_map(|a| a.as_array().map(|a| a.len()))
1083            .sum();
1084        lines.push(format!("## Stale derivations ({total} findings)"));
1085        for (mem, findings) in axis {
1086            for f in findings.as_array().into_iter().flatten() {
1087                lines.push(format!(
1088                    "- `{mem}`: {} -[{}]-> {} ({})",
1089                    f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1090                    f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1091                    f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1092                    f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1093                ));
1094            }
1095        }
1096        lines.push(String::new());
1097    }
1098
1099    // Quarantine roster — ungated (present in the JSON whenever
1100    // non-empty), so the markdown renders it whenever present: per
1101    // mem the reason code plus the message, which carries the repair
1102    // command.
1103    if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
1104        lines.push(format!("## Quarantined mems ({})", arr.len()));
1105        for q in arr {
1106            lines.push(format!(
1107                "- `{}` [{}] {}",
1108                q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1109                q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1110                q.get("reason_message")
1111                    .and_then(|x| x.as_str())
1112                    .unwrap_or(""),
1113            ));
1114        }
1115        lines.push(String::new());
1116    }
1117
1118    // Per-file load failures — ungated like the quarantine roster;
1119    // each message names its remedy, so the markdown must show it.
1120    if let Some(arr) = obj.get("load_errors").and_then(|v| v.as_array()) {
1121        lines.push(format!("## Load errors ({})", arr.len()));
1122        for e in arr {
1123            lines.push(format!(
1124                "- `{}` — {}",
1125                e.get("file").and_then(|x| x.as_str()).unwrap_or(""),
1126                e.get("error").and_then(|x| x.as_str()).unwrap_or(""),
1127            ));
1128        }
1129        lines.push(String::new());
1130    }
1131
1132    if let Some(f) = &friction_axis {
1133        lines.push(format!(
1134            "## Friction ({} refusals recorded, {} in the last 24h)",
1135            f["total"].as_u64().unwrap_or(0),
1136            f["recent_24h"]["total"].as_u64().unwrap_or(0),
1137        ));
1138        if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
1139            lines.push("- by code:".to_string());
1140            let mut entries: Vec<(&String, u64)> = by_code
1141                .iter()
1142                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1143                .collect();
1144            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1145            for (code, count) in entries {
1146                lines.push(format!("  - {code}: {count}"));
1147                // Reason breakdown where recorded — a code without
1148                // recorded reasons renders exactly as before.
1149                if let Some(reasons) = f["by_reason"][code.as_str()]
1150                    .as_object()
1151                    .filter(|m| !m.is_empty())
1152                {
1153                    let mut rs: Vec<(&String, u64)> = reasons
1154                        .iter()
1155                        .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1156                        .collect();
1157                    rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1158                    for (reason, count) in rs {
1159                        lines.push(format!("    - {reason}: {count}"));
1160                    }
1161                }
1162            }
1163        }
1164        if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
1165            lines.push("- by verb:".to_string());
1166            let mut entries: Vec<(&String, u64)> = by_verb
1167                .iter()
1168                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1169                .collect();
1170            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1171            for (verb, count) in entries {
1172                lines.push(format!("  - {verb}: {count}"));
1173            }
1174        }
1175        lines.push(String::new());
1176    }
1177
1178    print_markdown(&lines.join("\n"));
1179    strict_exit(args.strict, &strict_violations)
1180}
1181
1182/// Aggregated health data, engine-flavour-agnostic. Both
1183/// One `most_connected` row resolved at gather time:
1184/// `(id, title, total, incoming, outgoing, typed_total, typed_incoming,
1185/// typed_outgoing)`. `typed_*` excludes auto-emitted mention edges so the
1186/// ranking reflects dependency, not co-mention.
1187type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
1188
1189/// mem-repo and filesystem gather paths populate this struct
1190/// with the same shape so the rendering / JSON-envelope code below
1191/// runs once.
1192struct GatheredHealth {
1193    health: HealthSummary,
1194    /// Integrity findings (`{id, axis, code, detail}`) — populated by
1195    /// the caller (engine-shaped, so outside `gather_from_store`) when
1196    /// `--include conformance` / `--include integrity` is requested.
1197    findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
1198    /// Body observations (consistency-sweep 04/01) — what an entity's stored
1199    /// body carries that its type does not declare. Beside the findings, never
1200    /// among them: an observation is not a violation.
1201    body_observations: Vec<memstead_base::ops::integrity::BodyObservation>,
1202    real_count: usize,
1203    /// `(id, title)` pairs — title resolved at gather time so the
1204    /// rendering layer doesn't need to keep the engine alive.
1205    orphan_ids: Vec<(EntityId, String)>,
1206    stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
1207    community_count: usize,
1208    /// #49: orphan/community counts attributed per pinned schema, so a
1209    /// blended headline isn't read as uniform debt (ingest-mem isolates
1210    /// are orphans by design; code-mem orphans are debt). Filled by the
1211    /// engine-aware gather wrappers — `gather_from_store` leaves them empty.
1212    orphans_by_schema: std::collections::BTreeMap<String, usize>,
1213    communities_by_schema: std::collections::BTreeMap<String, usize>,
1214    /// [`MostConnectedRow`] tuples — same reasoning as `orphan_ids`.
1215    most_connected_with_titles: Vec<MostConnectedRow>,
1216    missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
1217    /// Standing violations of declared schema `constraints`
1218    /// (`--include constraints`), empty otherwise.
1219    constraint_findings: Vec<ConstraintFindingReport>,
1220    /// Defective section-format declarations the loaded schemas carry
1221    /// (rides the `constraints` include), empty otherwise.
1222    schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
1223    /// `Some(...)` when the caller asked for `--include tags`,
1224    /// `None` otherwise. The triple is `(distribution, folded,
1225    /// untagged)` mirroring `collect_tag_distribution`'s return
1226    /// shape.
1227    /// Pre-serialised tag triple: `(distribution, folded, untagged)`
1228    /// already converted to `serde_json::Value`. Keeps the gather
1229    /// step engine-flavour-agnostic without exposing the
1230    /// `memstead_base::ops::health` private tag types through this
1231    /// crate's public surface.
1232    tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
1233    /// Populated when `--include dangling_links` is set; empty
1234    /// otherwise. Matches the MCP `memstead_health` tool's response
1235    /// shape — `{from, target_id, target_path, section}` per entry.
1236    dangling_links: Vec<DanglingLink>,
1237    /// `Some(...)` when the caller asked for `--include config`: the
1238    /// same top-level entries (`mems`, `mutations`, `plugin`) the MCP
1239    /// composer renders for `include_config: true`, produced by the
1240    /// shared `memstead_base::ops::health::config_projection` with the
1241    /// policy values derived from `Engine::settings()`. `None`
1242    /// otherwise — absence of the key means "not requested".
1243    config_entries: Option<serde_json::Map<String, serde_json::Value>>,
1244    /// `Some(...)` when the caller asked for `--include anchors`: the
1245    /// per-mem anchor-verification counts (with `unresolvable` meaning the
1246    /// artifact is gone and `unobserved` meaning the pass could not measure
1247    /// it) plus the population they cover, from the shared
1248    /// `health_anchors_axis` helper (same axis MCP renders). `None`
1249    /// otherwise — absence of the key means "not requested".
1250    anchors_axis: Option<serde_json::Value>,
1251    /// `--include ledger`: a folder mem's ledger set against its file set.
1252    ledger_axis: Option<serde_json::Value>,
1253    /// `Some(...)` when the caller asked for `--include
1254    /// open_questions`: the composed per-mem worklist from the shared
1255    /// `health_open_questions_axis` helper (same axis MCP renders).
1256    open_questions_axis: Option<serde_json::Value>,
1257    /// `Some(...)` when the caller asked for `--include
1258    /// stale_derivations`: per-mem derivation-staleness findings from
1259    /// the shared `health_stale_derivations_axis` helper.
1260    stale_derivations_axis: Option<serde_json::Value>,
1261    /// `--include checks` — per-mem check-state counts + the
1262    /// author≠checker independence gate, via the shared
1263    /// `health_checks_axis` helper.
1264    checks_axis: Option<serde_json::Value>,
1265    /// `--include signals` — the shared `health_signals_axis`
1266    /// payload (entities above `none` plus per-level counts).
1267    signals_axis: Option<serde_json::Value>,
1268    /// `--include labelling` — the shared `health_labelling_axis`
1269    /// payload (per declaring mem: label counts, defeated/undecided
1270    /// lists with attacker evidence, excluded cross-mem edges).
1271    labelling_axis: Option<serde_json::Value>,
1272}
1273
1274/// Conformance/integrity findings across every mounted mem, in
1275/// sorted mem order. Engine-shaped (needs schema resolution), so it
1276/// runs beside `gather_from_store`, not inside it. `target_schema`
1277/// parse and resolution failures surface as typed CLI errors — the
1278/// same codes the MCP surface refuses with.
1279fn gather_findings(
1280    engine: &memstead_base::Engine,
1281    include: &[String],
1282    target_schema: Option<&str>,
1283) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
1284    let wants_conformance = include
1285        .iter()
1286        .any(|s| s == "conformance" || s == "integrity");
1287    if !wants_conformance {
1288        return Ok(Vec::new());
1289    }
1290    let target: Option<memstead_schema::SchemaRef> = match target_schema {
1291        None => None,
1292        Some(raw) => Some(
1293            raw.parse::<memstead_schema::SchemaRef>()
1294                .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1295        ),
1296    };
1297    let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1298    mems.sort();
1299    let mut findings = Vec::new();
1300    for v in &mems {
1301        findings.extend(
1302            engine
1303                .conformance_findings(v, target.as_ref())
1304                .map_err(crate::CliError::from_engine_op)?,
1305        );
1306        if include.iter().any(|s| s == "integrity") {
1307            findings.extend(
1308                engine
1309                    .consistency_findings(v)
1310                    .map_err(crate::CliError::from_engine_op)?,
1311            );
1312        }
1313    }
1314    Ok(findings)
1315}
1316
1317/// Body observations for every mem, when the caller asked for the conformance
1318/// or integrity axis (consistency-sweep 04/01).
1319///
1320/// Gathered beside the findings and rendered beside them, never among them:
1321/// an observation is not a violation and must never reach `strict_violations`,
1322/// because absorbing an undeclared heading is the catch-all working as
1323/// designed. What the reader gets is the distinction the axis could not make
1324/// before: content that was absorbed and survives, against content the next
1325/// write does not keep.
1326fn gather_body_observations(
1327    engine: &memstead_base::Engine,
1328    include: &[String],
1329    target_schema: Option<&str>,
1330) -> anyhow::Result<Vec<memstead_base::ops::integrity::BodyObservation>> {
1331    if !include
1332        .iter()
1333        .any(|s| s == "conformance" || s == "integrity")
1334    {
1335        return Ok(Vec::new());
1336    }
1337    let target = match target_schema {
1338        None => None,
1339        Some(raw) => Some(
1340            raw.parse::<memstead_schema::SchemaRef>()
1341                .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1342        ),
1343    };
1344    let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1345    mems.sort();
1346    let mut out = Vec::new();
1347    for v in &mems {
1348        out.extend(
1349            engine
1350                .body_observations(v, target.as_ref())
1351                .map_err(crate::CliError::from_engine_op)?,
1352        );
1353    }
1354    Ok(out)
1355}
1356
1357#[cfg(feature = "mem-repo")]
1358fn gather_mem_repo(
1359    engine: &mut memstead_base::Engine,
1360    limit: usize,
1361    include: &[String],
1362) -> GatheredHealth {
1363    let mut g = gather_from_store(
1364        engine.health(),
1365        engine.store(),
1366        engine.communities().count,
1367        limit,
1368        include,
1369        || engine.orphans(),
1370        |limit| engine_most_connected_mem_repo(engine, limit),
1371        || engine.missing_required_outgoing(None),
1372        || engine.constraint_findings(None),
1373        || engine.schema_format_defects(),
1374    );
1375    fill_schema_breakdowns(engine, &mut g);
1376    fill_config_projection(engine, include, &mut g);
1377    fill_anchors_axis(engine, include, &mut g);
1378    fill_open_questions_axis(engine, include, &mut g);
1379    fill_stale_derivations_axis(engine, include, &mut g);
1380    fill_checks_axis(engine, include, &mut g);
1381    fill_signals_axis(engine, include, &mut g);
1382    fill_labelling_axis(engine, include, &mut g);
1383    g
1384}
1385
1386fn gather_filesystem(
1387    engine: &mut memstead_base::Engine,
1388    limit: usize,
1389    include: &[String],
1390) -> GatheredHealth {
1391    let mut g = gather_from_store(
1392        engine.health(),
1393        engine.store(),
1394        engine.communities().count,
1395        limit,
1396        include,
1397        || engine.orphans(),
1398        |limit| engine_most_connected_filesystem(engine, limit),
1399        || engine.missing_required_outgoing(None),
1400        || engine.constraint_findings(None),
1401        || engine.schema_format_defects(),
1402    );
1403    fill_schema_breakdowns(engine, &mut g);
1404    fill_config_projection(engine, include, &mut g);
1405    fill_anchors_axis(engine, include, &mut g);
1406    fill_open_questions_axis(engine, include, &mut g);
1407    fill_stale_derivations_axis(engine, include, &mut g);
1408    fill_checks_axis(engine, include, &mut g);
1409    fill_signals_axis(engine, include, &mut g);
1410    fill_labelling_axis(engine, include, &mut g);
1411    g
1412}
1413
1414/// #49: attribute the orphan / community headlines per pinned schema (the
1415/// engine-aware step `gather_from_store` can't do off a bare `&Store`).
1416/// Engine-aware step for `--include config` — renders the shared
1417/// workspace-config projection (one implementation with the MCP
1418/// composer) off the engine's own settings.
1419fn fill_config_projection(
1420    engine: &memstead_base::Engine,
1421    include: &[String],
1422    g: &mut GatheredHealth,
1423) {
1424    if include.iter().any(|s| s == "config") {
1425        let mut mems: Vec<String> = engine
1426            .mem_router()
1427            .writable_mems()
1428            .iter()
1429            .cloned()
1430            .collect();
1431        mems.sort();
1432        let (mutations, plugin) =
1433            memstead_base::ops::health::config_projection_from_settings(engine.settings());
1434        g.config_entries = Some(memstead_base::ops::health::config_projection(
1435            engine, &mems, mutations, plugin,
1436        ));
1437    }
1438}
1439
1440/// Engine-aware step for `--include anchors` — the per-mem anchor-verification
1441/// counts from the shared axis helper.
1442/// Engine-aware step for `--include open_questions` — the composed
1443/// what-don't-we-know worklist (agent-trust plan 11), one shared
1444/// implementation with the MCP composer.
1445fn fill_open_questions_axis(
1446    engine: &memstead_base::Engine,
1447    include: &[String],
1448    g: &mut GatheredHealth,
1449) {
1450    if include.iter().any(|s| s == "open_questions") {
1451        g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
1452            engine, None,
1453        ));
1454    }
1455}
1456
1457/// Engine-aware step for `--include stale_derivations` — per-mem
1458/// derivation-staleness findings (agent-trust plan 12), one shared
1459/// implementation with the MCP composer.
1460fn fill_stale_derivations_axis(
1461    engine: &memstead_base::Engine,
1462    include: &[String],
1463    g: &mut GatheredHealth,
1464) {
1465    if include.iter().any(|s| s == "stale_derivations") {
1466        g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
1467            engine, None,
1468        ));
1469    }
1470}
1471
1472fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1473    if include.iter().any(|s| s == "checks") {
1474        g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1475    }
1476}
1477
1478fn fill_signals_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1479    if include.iter().any(|s| s == "signals") {
1480        g.signals_axis = Some(engine.health_signals_axis(None));
1481    }
1482}
1483
1484fn fill_labelling_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1485    if include.iter().any(|s| s == "labelling") {
1486        g.labelling_axis = Some(engine.health_labelling_axis(None));
1487    }
1488}
1489
1490fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1491    if include.iter().any(|s| s == "anchors") {
1492        g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1493    }
1494    // Folder mems only; a git-branch mem is absent rather than clean
1495    // (04/04, criterion 4).
1496    if include.iter().any(|s| s == "ledger") {
1497        g.ledger_axis = serde_json::to_value(engine.ledger_reconciliation()).ok();
1498    }
1499}
1500
1501fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1502    let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1503    g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1504    g.communities_by_schema = engine.communities_by_schema(&mems);
1505}
1506
1507/// Engine-agnostic gather pipeline. The two engine-shaped callbacks
1508/// (`most_connected_fn`, `missing_required_outgoing_fn`) handle the
1509/// surfaces that are not available off the bare `&Store`.
1510///
1511/// Ten parameters is deliberate: five of them are the engine-shaped callbacks
1512/// that keep this function engine-agnostic. Bundling them into a struct would
1513/// move the same arity behind a type that exists for one call site.
1514#[allow(clippy::too_many_arguments)]
1515fn gather_from_store(
1516    health: HealthSummary,
1517    store: &Store,
1518    community_count: usize,
1519    limit: usize,
1520    include: &[String],
1521    orphans_fn: impl FnOnce() -> Vec<EntityId>,
1522    most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1523    missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1524    constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1525    schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1526) -> GatheredHealth {
1527    let real_count = store.all_entities().filter(|e| !e.stub).count();
1528    let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1529        .into_iter()
1530        .map(|id| {
1531            let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1532            (id, title)
1533        })
1534        .collect();
1535    let stub_pairs = memstead_base::graph::query::find_stubs(store);
1536    let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1537        most_connected_fn(limit)
1538    } else {
1539        Vec::new()
1540    };
1541    let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1542        missing_required_outgoing_fn()
1543    } else {
1544        Vec::new()
1545    };
1546    let constraint_findings = if include.iter().any(|s| s == "constraints") {
1547        constraint_findings_fn()
1548    } else {
1549        Vec::new()
1550    };
1551    let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1552        schema_format_defects_fn()
1553    } else {
1554        Vec::new()
1555    };
1556    let tag_distribution = if include.iter().any(|s| s == "tags") {
1557        let (distribution, folded, untagged) =
1558            memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1559        Some((
1560            serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1561            serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1562            serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1563        ))
1564    } else {
1565        None
1566    };
1567    let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1568        memstead_base::ops::health::collect_dangling_links(store, None)
1569    } else {
1570        Vec::new()
1571    };
1572    GatheredHealth {
1573        ledger_axis: None,
1574        health,
1575        findings: Vec::new(),
1576        real_count,
1577        orphan_ids,
1578        stub_pairs,
1579        community_count,
1580        // Engine-agnostic path can't resolve schema pins; the engine-aware
1581        // wrappers (`gather_mem_repo` / `gather_filesystem`) fill these.
1582        orphans_by_schema: std::collections::BTreeMap::new(),
1583        communities_by_schema: std::collections::BTreeMap::new(),
1584        most_connected_with_titles,
1585        missing_required_outgoing,
1586        constraint_findings,
1587        schema_format_defects,
1588        tag_distribution,
1589        dangling_links,
1590        body_observations: Vec::new(),
1591        config_entries: None,
1592        anchors_axis: None,
1593        open_questions_axis: None,
1594        stale_derivations_axis: None,
1595        checks_axis: None,
1596        signals_axis: None,
1597        labelling_axis: None,
1598    }
1599}
1600
1601#[cfg(feature = "mem-repo")]
1602fn engine_most_connected_mem_repo(
1603    engine: &memstead_base::Engine,
1604    limit: usize,
1605) -> Vec<MostConnectedRow> {
1606    engine
1607        .most_connected(limit)
1608        .into_iter()
1609        .map(|c| {
1610            let title = engine
1611                .get_entity(&c.id)
1612                .map(|e| e.title.clone())
1613                .unwrap_or_default();
1614            (
1615                c.id,
1616                title,
1617                c.total,
1618                c.incoming,
1619                c.outgoing,
1620                c.typed_total,
1621                c.typed_incoming,
1622                c.typed_outgoing,
1623            )
1624        })
1625        .collect()
1626}
1627
1628fn engine_most_connected_filesystem(
1629    engine: &memstead_base::Engine,
1630    limit: usize,
1631) -> Vec<MostConnectedRow> {
1632    engine
1633        .most_connected(limit)
1634        .into_iter()
1635        .map(|c| {
1636            let title = engine
1637                .get_entity(&c.id)
1638                .map(|e| e.title.clone())
1639                .unwrap_or_default();
1640            (
1641                c.id,
1642                title,
1643                c.total,
1644                c.incoming,
1645                c.outgoing,
1646                c.typed_total,
1647                c.typed_incoming,
1648                c.typed_outgoing,
1649            )
1650        })
1651        .collect()
1652}
1653
1654/// Translate the strict-violation tally into an exit code. With
1655/// `--strict` set and any Tier-2 violations recorded, return a
1656/// `CliError(Generic)` so `main` exits 1 after the report has been
1657/// written to stdout. When `--strict` is unset, or when no Tier-2
1658/// `--include` token was supplied, this is a no-op.
1659fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1660    if !strict || violations.is_empty() {
1661        return Ok(());
1662    }
1663    let summary = violations
1664        .iter()
1665        .map(|(code, n)| format!("{code}: {n}"))
1666        .collect::<Vec<_>>()
1667        .join(", ");
1668    Err(crate::CliError::new(
1669        ExitKind::Generic,
1670        "HEALTH_STRICT_VIOLATIONS",
1671        format!("strict mode: tier-2 violations present ({summary})"),
1672    )
1673    .into())
1674}
1675
1676#[cfg(test)]
1677mod tests {
1678    use super::*;
1679    use clap::CommandFactory;
1680
1681    #[test]
1682    fn help_lists_every_include_key() {
1683        let cmd = Args::command();
1684        let arg = cmd
1685            .get_arguments()
1686            .find(|a| a.get_id() == "include")
1687            .expect("--include arg must exist");
1688        let help = arg
1689            .get_help()
1690            .expect("--include must have help text")
1691            .to_string();
1692        for key in HEALTH_INCLUDE_KEYS {
1693            assert!(
1694                help.contains(key),
1695                "`memstead health --help` must name include key `{key}` (got: {help})"
1696            );
1697        }
1698    }
1699}