Skip to main content

memstead_base/ops/
health_compose.rs

1//! Shared health composer used by the `memstead_health` MCP tool and any
2//! non-MCP caller (CLI, a future HTTP surface).
3//!
4//! Lifted from `memstead-mcp/src/server.rs::memstead_health_unified` so the
5//! health read-envelope is produced by one transport-neutral builder with no
6//! rmcp type in the path — the MCP wrapper handles drift collection, the
7//! schema anchor, the `mem_changed` notice channel, and `CallToolResult`
8//! wrapping; none of that lives here.
9//!
10//! The composer returns the complete health payload as a `serde_json::Value`
11//! (warnings embedded, every `include` detail section applied). Surface state
12//! the engine does not own — the `[mutations]` posture and the opaque
13//! `[plugin.*]` map — is passed in via [`HealthConfig`] as prebuilt JSON so
14//! this crate stays free of the MCP server's config types and the wire bytes
15//! stay identical to the pre-lift handler.
16
17use std::collections::HashMap;
18
19/// Composer input — packed from the MCP `HealthParams` (or a CLI `Args`) at
20/// the call site. Mirrors the field set the pre-lift handler read off
21/// `HealthParams`.
22#[derive(Debug)]
23pub struct HealthArgs<'a> {
24    pub mem: Option<&'a str>,
25    pub include: &'a [String],
26    pub limit: Option<usize>,
27    pub target_schema: Option<&'a str>,
28    pub include_config: bool,
29}
30
31/// Surface-owned config the engine does not carry — supplied prebuilt so the
32/// composer inserts the bytes verbatim. `mutations` is `{"require_notes": …}`;
33/// `plugin` is the opaque `[plugin.*]` pass-through object. Only consulted
34/// when `args.include_config` is set.
35#[derive(Debug, Clone)]
36pub struct HealthConfig {
37    pub mutations: serde_json::Value,
38    pub plugin: serde_json::Value,
39}
40
41/// Typed input failures the composer surfaces. The MCP wrapper maps each
42/// variant to its existing envelope (`UNKNOWN_MEM`, `INVALID_INPUT`) and
43/// the engine fault to its typed translator, so the wire `code` stays put.
44// The engine-fault variant carries the lower-layer error verbatim so the typed
45// translator keeps its input; the size gap is inherent to that lifting, and a
46// health composition runs once per call.
47#[allow(clippy::large_enum_variant)]
48#[derive(Debug, thiserror::Error)]
49pub enum ComposeHealthError {
50    /// `args.mem` names a mem that isn't writable in this workspace. The
51    /// composer surfaces the sorted writable roster so the wrapper can echo
52    /// it in the `UNKNOWN_MEM` envelope.
53    #[error("unknown mem: \"{name}\"")]
54    UnknownMem {
55        name: String,
56        writable_mems: Vec<String>,
57    },
58    /// `args.mem` names a QUARANTINED mem — the scope refuses with the
59    /// typed quarantine reason rather than reporting the mem unknown
60    /// (agent-trust plan 04). The wrapper maps it through its ordinary
61    /// engine-error path via `Engine::unknown_mem_error`.
62    #[error("mem \"{0}\" is quarantined")]
63    MemQuarantined(String),
64    /// `args.target_schema` did not parse as a `name@x.y.z` ref. `reason` is
65    /// the parser's message, surfaced verbatim in the `INVALID_INPUT`
66    /// envelope's `details.reason`.
67    #[error("invalid target_schema {raw:?}: {reason}")]
68    InvalidTargetSchema { raw: String, reason: String },
69    /// A backend fault from the conformance / consistency scan. The wrapper
70    /// routes it through the typed `EngineError` translator unchanged.
71    #[error(transparent)]
72    Engine(#[from] crate::EngineError),
73}
74
75/// Build the complete health payload. `drift_warnings` are the reload warnings
76/// the wrapper collected before calling in; the composer extends them with the
77/// health report's own warnings, the limit-clamp notice, and unknown-include
78/// notices, then embeds the lot under `warnings`.
79pub fn compose_health(
80    engine: &mut crate::Engine,
81    args: &HealthArgs,
82    drift_warnings: Vec<crate::WarningHint>,
83    config: &HealthConfig,
84) -> Result<serde_json::Value, ComposeHealthError> {
85    let health = engine.health();
86    let stats = engine.status();
87    let include = args.include;
88    const HEALTH_LIMIT_MAX: usize = 100;
89    let requested_limit = args.limit.unwrap_or(10);
90    let limit = requested_limit.min(HEALTH_LIMIT_MAX);
91
92    let mut warnings: Vec<crate::WarningHint> = drift_warnings;
93    warnings.extend(health.warnings.clone());
94    if requested_limit > HEALTH_LIMIT_MAX {
95        warnings.push(crate::WarningHint::LimitClamped {
96            requested: requested_limit,
97            actual: HEALTH_LIMIT_MAX,
98        });
99    }
100
101    for key in include {
102        if !crate::ops::health::HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
103            warnings.push(crate::WarningHint::UnknownIncludeKey {
104                key: key.clone(),
105                allowed: crate::ops::health::HEALTH_INCLUDE_KEYS
106                    .iter()
107                    .map(|s| s.to_string())
108                    .collect(),
109            });
110        }
111    }
112
113    // Mem filter validation — only writable mems accepted.
114    let mem_filter: Option<String> = match args.mem {
115        Some(v) if engine.mem_router().is_writable(v) => Some(v.to_string()),
116        Some(v) if engine.quarantine_reason(v).is_some() => {
117            return Err(ComposeHealthError::MemQuarantined(v.to_string()));
118        }
119        Some(v) => {
120            let mut names: Vec<String> = engine
121                .mem_router()
122                .writable_mems()
123                .iter()
124                .cloned()
125                .collect();
126            names.sort();
127            return Err(ComposeHealthError::UnknownMem {
128                name: v.to_string(),
129                writable_mems: names,
130            });
131        }
132        None => None,
133    };
134    let vf = mem_filter.as_deref();
135
136    // Symmetric with the data filter below: mem-attributable warnings
137    // (SUSPICIOUS_NESTED_PREFIX, DUPLICATE_SECTION_HEADING, etc.) drop out when
138    // their source mem isn't the scoped one. Workspace- and request-scoped
139    // warnings (OUTER_REPO_…, UNKNOWN_INCLUDE_KEY, LIMIT_CLAMPED) report `None`
140    // from `source_mem()` and stay visible — agents should see them
141    // regardless of which mem they're scoping to.
142    if let Some(v) = vf {
143        warnings.retain(|w| w.source_mem().is_none_or(|wv| wv == v));
144    }
145
146    let in_mem = |e: &crate::Entity| -> bool {
147        match vf {
148            Some(v) => e.mem == v,
149            None => true,
150        }
151    };
152    let real_count = engine
153        .store()
154        .all_entities()
155        .filter(|e| !e.stub && in_mem(e))
156        .count();
157    let stub_count = engine
158        .store()
159        .all_entities()
160        .filter(|e| e.stub && in_mem(e))
161        .count();
162    let total_count = real_count + stub_count;
163
164    let orphan_ids: Vec<crate::EntityId> = engine
165        .orphans()
166        .into_iter()
167        .filter(|id| match vf {
168            Some(v) => engine.store().get(id).map(|e| e.mem == v).unwrap_or(false),
169            None => true,
170        })
171        .collect();
172    let stub_pairs: Vec<(crate::EntityId, Vec<crate::EntityId>)> = engine
173        .stubs()
174        .into_iter()
175        .filter(|(id, _)| match vf {
176            Some(v) => engine.store().get(id).map(|e| e.mem == v).unwrap_or(false),
177            None => true,
178        })
179        .collect();
180
181    // Under a `mem` filter, scope the community count to clusters with ≥1
182    // member in that mem (filtering the global partition, not re-running
183    // detection) so it can't contradict the scoped `total_entities` — e.g. an
184    // empty mem reports 0 entities and 0 communities. Mirrors
185    // `memstead_overview` via the shared helper.
186    let community_count = match vf {
187        Some(v) => {
188            crate::graph::community::clusters_in_mem(engine.store(), engine.communities(), v).len()
189        }
190        None => engine.communities().count,
191    };
192
193    // Edge counts: under a mem filter, count only source-in-mem edges
194    // (asymmetric — matches the legacy contract).
195    let (edge_count, edge_types) = {
196        if let Some(v) = vf {
197            let mut counts: HashMap<String, usize> = HashMap::new();
198            let mut total: usize = 0;
199            for id in engine.store().all_ids() {
200                let source_mem = engine.store().get(id).map(|e| e.mem.clone());
201                if let Some(source) = source_mem.as_deref()
202                    && source != v
203                {
204                    continue;
205                }
206                for edge in engine.store().outgoing(id) {
207                    *counts.entry(edge.rel_type.clone()).or_insert(0) += 1;
208                    total += 1;
209                }
210            }
211            // Name order (A7 AC3): the lists are byte-stable across runs.
212            let mut pairs: Vec<_> = counts.into_iter().collect();
213            pairs.sort_by(|a, b| a.0.cmp(&b.0));
214            let arr: Vec<serde_json::Value> = pairs
215                .into_iter()
216                .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
217                .collect();
218            (total, arr)
219        } else {
220            // `Status.edge_types` is a `BTreeMap`: already in name order.
221            let arr: Vec<serde_json::Value> = stats
222                .edge_types
223                .iter()
224                .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
225                .collect();
226            (stats.edge_count, arr)
227        }
228    };
229
230    let type_distribution: Vec<serde_json::Value> = {
231        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
232        for e in engine
233            .store()
234            .all_entities()
235            .filter(|e| !e.stub && in_mem(e))
236        {
237            *counts.entry(&e.entity_type).or_default() += 1;
238        }
239        let mut pairs: Vec<_> = counts.into_iter().collect();
240        pairs.sort_by(|a, b| a.0.cmp(b.0));
241        pairs
242            .into_iter()
243            .map(|(s, c)| serde_json::json!({"type": s, "count": c}))
244            .collect()
245    };
246
247    let writable_mems: Vec<String> = {
248        let mut names: Vec<String> = engine
249            .mem_router()
250            .writable_mems()
251            .iter()
252            .cloned()
253            .collect();
254        names.sort();
255        names
256    };
257    // The stable default an omitted-`mem` mutation lands in — the first
258    // writable mount in declaration order, not `writable_mems[0]` of this
259    // alphabetically-sorted roster. Surfaced so an omitted-`mem` write is
260    // predictable.
261    let default_writable_mem: Option<String> = engine.default_writable_mem().map(|s| s.to_string());
262    let read_mems: Vec<String> = {
263        let writable_set: std::collections::HashSet<&String> =
264            engine.mem_router().writable_mems().iter().collect();
265        let mut names: Vec<String> = engine
266            .mem_router()
267            .visible_mems()
268            .iter()
269            .filter(|n| !writable_set.contains(*n))
270            .cloned()
271            .collect();
272        names.sort();
273        names
274    };
275
276    // Per-mem schema pins. Source from `engine.mount(name).schema` which
277    // carries the pinned `SchemaRef`; render via `as_display()` to get the
278    // same `name@version` form full emits.
279    //
280    // Every *visible* mem appears — writable and read-only alike — each
281    // carrying an explicit `writable` attribute. A read-only mount's pinned
282    // schema is real; surfacing it here (rather than filtering to writable
283    // mems) is what keeps health from reporting "no schema" while the
284    // discovery manifest names one. Writable mems render first (sorted),
285    // then read-only ones (sorted), so a normal writable workspace — which
286    // has no read mems — keeps its existing entry order, gaining only the
287    // `writable: true` attribute.
288    let mem_schemas: Vec<serde_json::Value> = {
289        let mut entries: Vec<serde_json::Value> = Vec::new();
290        let writable_set: std::collections::HashSet<&String> = writable_mems.iter().collect();
291        for name in writable_mems.iter().chain(read_mems.iter()) {
292            if let Some(v) = vf
293                && name != v
294            {
295                continue;
296            }
297            if let Some(m) = engine.mount(name) {
298                // The mem's *settled* pin — `Mount.schema` (now an optional
299                // assertion). During a dual-pin migration this stays the
300                // settled pin; the in-flight target is the separate
301                // `migration_target` surface below.
302                let schema_ref = m
303                    .schema
304                    .as_ref()
305                    .map(|s| s.as_display())
306                    .unwrap_or_default();
307                let mut entry = serde_json::json!({
308                    "mem": name,
309                    "schema": schema_ref,
310                    "writable": writable_set.contains(name),
311                });
312                // Dual-pin confirmation surface: present only while a
313                // migration is in flight, so settled mems' entries stay
314                // byte-identical to before.
315                if let Some(target) = &m.migration_target {
316                    entry["migration_target"] = serde_json::json!(target.as_display());
317                }
318                entries.push(entry);
319            }
320        }
321        entries
322    };
323
324    // #49: segment the orphan / community headlines by the owning mem's
325    // schema. A blended total mixes schemas with opposite norms — ingest
326    // mems, where each finding is an isolated entity (orphan by design),
327    // versus code/spec mems, where an orphan is real debt — so a bare
328    // "54 orphans" reads as 54 units of debt when most are by-design
329    // isolates. The raw `total_orphans` / `total_communities` are retained
330    // in `summary`, and a `mem`-scoped call still exposes per-mem counts
331    // (the refusal AC); these maps only attribute the totals by schema.
332    // `orphan_ids` is already mem-scoped above; scope the community
333    // attribution to the same mem set.
334    let orphans_by_schema = engine.orphans_by_schema(&orphan_ids);
335    let scope_mems: Vec<String> = match vf {
336        Some(v) => vec![v.to_string()],
337        None => writable_mems
338            .iter()
339            .chain(read_mems.iter())
340            .cloned()
341            .collect(),
342    };
343    let communities_by_schema = engine.communities_by_schema(&scope_mems);
344
345    let mut result = serde_json::json!({
346        "mem": mem_filter,
347        // The coverage rule (crate::ops::coverage): the axes
348        // this report's defect statement answers for, stamped by the
349        // composer itself so every consumer of the composition
350        // carries the same declaration.
351        // An opt-in axis the report rendered this pass was examined by it:
352        // `anchors` moves into the examined set when included.
353        "verdict_coverage": crate::ops::coverage::HEALTH_COVERAGE.wire_line_promoting(
354            if include.iter().any(|s| s == "anchors") { &["anchors"] } else { &[] },
355        ),
356        "summary": {
357            "total_entities": real_count,
358            "total_orphans": orphan_ids.len(),
359            "total_stubs": stub_pairs.len(),
360            "total_stale": health.stale_entities.iter().filter(|e| match vf {
361                Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
362                None => true,
363            }).count(),
364            "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
365                Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
366                None => true,
367            }).count(),
368            "total_communities": community_count,
369            "orphans_by_schema": orphans_by_schema,
370            "communities_by_schema": communities_by_schema,
371        },
372        "total_nodes": total_count,
373        "real_nodes": real_count,
374        "stub_nodes": stub_count,
375        "total_edges": edge_count,
376        "edge_types": edge_types,
377        "type_distribution": type_distribution,
378        "writable_mems": writable_mems,
379        "default_writable_mem": default_writable_mem,
380        "read_mems": read_mems,
381        "mem_schemas": mem_schemas,
382    });
383    let obj = result.as_object_mut().unwrap();
384    // The schema anchor every mem-scoped read carries (`_mem_schema`),
385    // set by the composer so the CLI and the MCP tool render the same
386    // bytes; the MCP wrapper's own anchor step re-inserts the same value.
387    if let Some(v) = vf
388        && let Some(schema) = crate::overview::mem_schema_ref(engine, v)
389    {
390        obj.insert("_mem_schema".into(), serde_json::Value::String(schema));
391    }
392    if !warnings.is_empty() {
393        obj.insert("warnings".into(), serde_json::json!(warnings));
394    }
395    // Quarantine roster — a boot-honesty fact, present whenever
396    // non-empty, never behind an include gate (agent-trust plan 04).
397    if !health.quarantined.is_empty() {
398        obj.insert(
399            "quarantined".into(),
400            serde_json::to_value(&health.quarantined).unwrap_or_default(),
401        );
402    }
403    // Per-file load failures — same boot-honesty class: each entry's
404    // message names the remedy (the merge-conflict refusal names
405    // `memstead conflicts resolve`), so the composed report must carry
406    // them for the failure mode to name its door (backlog-sweep 07).
407    if !health.load_errors.is_empty() {
408        obj.insert(
409            "load_errors".into(),
410            serde_json::to_value(&health.load_errors).unwrap_or_default(),
411        );
412    }
413    if let Some(diag) = &health.boot_diagnosis {
414        obj.insert("boot_diagnosis".into(), diag.clone());
415    }
416    // Leaf populations — visible beside the orphan axis they exempt
417    // (agent-trust plan 06); omitted when no type declares leaf.
418    if !health.leaf_entities_by_type.is_empty() {
419        obj.insert(
420            "leaf_entities_by_type".into(),
421            serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
422        );
423    }
424
425    if include.iter().any(|s| s == "orphans") {
426        let orphans_list: Vec<serde_json::Value> = orphan_ids
427            .into_iter()
428            .map(|id| {
429                let title = engine
430                    .get_entity(&id)
431                    .map(|e| e.title.clone())
432                    .unwrap_or_default();
433                serde_json::json!({"id": id.to_string(), "title": title})
434            })
435            .collect();
436        obj.insert("orphans".into(), serde_json::json!(orphans_list));
437    }
438    if include.iter().any(|s| s == "stubs") {
439        let stubs_list: Vec<serde_json::Value> = stub_pairs
440            .into_iter()
441            .map(|(id, refs)| {
442                serde_json::json!({
443                    "id": id.to_string(),
444                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
445                })
446            })
447            .collect();
448        obj.insert("stubs".into(), serde_json::json!(stubs_list));
449    }
450    if include.iter().any(|s| s == "most_connected") {
451        use crate::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
452        // `typed_*` is the dependency degree (excludes auto-emitted mention
453        // edges); the list is ranked by it so a co-mention hub doesn't
454        // outrank a real dependency hub. `total`/`incoming`/`outgoing` keep
455        // the mentions and stay available — mention degree = total - typed.
456        let to_json = |c: Connectivity| {
457            let title = engine
458                .get_entity(&c.id)
459                .map(|e| e.title.clone())
460                .unwrap_or_default();
461            serde_json::json!({
462                "id": c.id.to_string(),
463                "title": title,
464                "total": c.total,
465                "incoming": c.incoming,
466                "outgoing": c.outgoing,
467                "typed_total": c.typed_total,
468                "typed_incoming": c.typed_incoming,
469                "typed_outgoing": c.typed_outgoing,
470            })
471        };
472        let connected: Vec<serde_json::Value> = if let Some(v) = vf {
473            // Source-in-mem scoping, to match this response's `edge_types`
474            // / `total_edges`. The node is in-mem, so all of its outgoing
475            // edges are source-in-mem and counted; an incoming edge counts
476            // only when its source is also in-mem, so a cross-mem edge
477            // the aggregate excluded does not inflate the node's degree here.
478            let mut entries: Vec<Connectivity> = engine
479                .store()
480                .all_entities()
481                .filter(|e| !e.stub && e.mem == v)
482                .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
483                .collect();
484            entries.sort_by(cmp_by_dependency);
485            entries.truncate(limit);
486            entries.into_iter().map(to_json).collect()
487        } else {
488            engine
489                .most_connected(limit)
490                .into_iter()
491                .map(to_json)
492                .collect()
493        };
494        obj.insert("most_connected".into(), serde_json::json!(connected));
495    }
496    if include.iter().any(|s| s == "missing_fields") {
497        let missing_fields: Vec<serde_json::Value> = health
498            .missing_fields
499            .iter()
500            .filter(|h| match vf {
501                Some(v) => engine
502                    .store()
503                    .get(&h.id)
504                    .map(|e| e.mem == v)
505                    .unwrap_or(false),
506                None => true,
507            })
508            .map(|h| {
509                // `missing` (bare field names) stays byte-identical for
510                // existing consumers; the per-issue detail rides next
511                // to it so the projection carries WHICH condition each
512                // issue reports (a heading mismatch must never surface
513                // as "missing" only).
514                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
515                let issues: Vec<serde_json::Value> = h
516                    .issues
517                    .iter()
518                    .map(|i| {
519                        serde_json::json!({
520                            "field": i.field,
521                            "code": i.code,
522                            "message": i.message,
523                        })
524                    })
525                    .collect();
526                serde_json::json!({
527                    "id": h.id.to_string(),
528                    "title": h.title,
529                    "missing": missing,
530                    "issues": issues,
531                })
532            })
533            .collect();
534        obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
535    }
536    if include.iter().any(|s| s == "stale") {
537        let stale: Vec<serde_json::Value> = health
538            .stale_entities
539            .iter()
540            .filter(|e| match vf {
541                Some(v) => engine
542                    .store()
543                    .get(&e.id)
544                    .map(|ent| ent.mem == v)
545                    .unwrap_or(false),
546                None => true,
547            })
548            .map(|e| {
549                serde_json::json!({
550                    "id": e.id.to_string(),
551                    "title": e.title,
552                    "days_since_modified": e.days_since_modified,
553                })
554            })
555            .collect();
556        obj.insert("stale".into(), serde_json::json!(stale));
557    }
558    if include.iter().any(|s| s == "dangling_links") {
559        let dangling = crate::ops::health::collect_dangling_links(engine.store(), vf);
560        let arr: Vec<serde_json::Value> = dangling
561            .into_iter()
562            .map(|dl| serde_json::to_value(&dl).unwrap())
563            .collect();
564        obj.insert("dangling_links".into(), serde_json::json!(arr));
565    }
566    if include.iter().any(|s| s == "anchors") {
567        obj.insert(
568            "anchors".into(),
569            crate::ops::health::health_anchors_axis(engine),
570        );
571    }
572    if include.iter().any(|s| s == "stale_derivations") {
573        obj.insert(
574            "stale_derivations".into(),
575            crate::ops::health::health_stale_derivations_axis(engine, args.mem),
576        );
577    }
578    if include.iter().any(|s| s == "checks") {
579        obj.insert(
580            "checks".into(),
581            crate::ops::health::health_checks_axis(engine, args.mem),
582        );
583    }
584    if include.iter().any(|s| s == "signals") {
585        // Declared aggregate signals above `none`, with per-level
586        // counts — the same composer the CLI and the filesystem
587        // flavour serve.
588        obj.insert("signals".into(), engine.health_signals_axis(args.mem));
589    }
590    if include.iter().any(|s| s == "labelling") {
591        // Grounded labelling per declaring mem — counts per label,
592        // defeated/undecided lists with their attacker evidence, and
593        // the excluded cross-mem attack-edge count.
594        obj.insert("labelling".into(), engine.health_labelling_axis(args.mem));
595    }
596    if include.iter().any(|s| s == "open_questions") {
597        obj.insert(
598            "open_questions".into(),
599            crate::ops::health::health_open_questions_axis(engine, args.mem),
600        );
601    }
602    if include.iter().any(|s| s == "vital_signs") {
603        // The model-truth signals the remodel skill reads (A6): counts
604        // and capped lists, never a verdict.
605        obj.insert(
606            "vital_signs".into(),
607            crate::ops::health::health_vital_signs_axis(engine, args.mem),
608        );
609    }
610    if include.iter().any(|s| s == "friction") {
611        // The friction ledger's read surface (agent-trust plan 08):
612        // counts per code / per verb over the workspace-local refusal
613        // ledger, whole-ledger plus a recent 24h window. A workspace
614        // without a root (in-memory boots) or without a ledger yet
615        // serves the empty summary — the axis never fails health.
616        let summary = match engine.workspace_root() {
617            Some(root) => crate::friction::FrictionLedger::for_workspace(root).summarize(),
618            None => serde_json::json!({
619                "total": 0,
620                "by_code": {},
621                "by_verb": {},
622                "recent_24h": { "total": 0, "by_code": {} },
623                "ledger_bytes": 0,
624            }),
625        };
626        obj.insert("friction".into(), summary);
627    }
628    if include.iter().any(|s| s == "missing_required_outgoing") {
629        let reports = engine.missing_required_outgoing(vf);
630        let arr: Vec<serde_json::Value> = reports
631            .into_iter()
632            .map(|r| serde_json::to_value(&r).unwrap())
633            .collect();
634        obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
635    }
636    if include.iter().any(|s| s == "constraints") {
637        let reports = engine.constraint_findings(vf);
638        let arr: Vec<serde_json::Value> = reports
639            .into_iter()
640            .map(|r| serde_json::to_value(&r).unwrap())
641            .collect();
642        obj.insert("constraints".into(), serde_json::json!(arr));
643        let defects = engine.schema_format_defects();
644        if !defects.is_empty() {
645            obj.insert(
646                "schema_format_defects".into(),
647                serde_json::to_value(&defects).unwrap(),
648            );
649        }
650    }
651    if include.iter().any(|s| s == "tags") {
652        let (distribution, folded, untagged) =
653            crate::ops::health::collect_tag_distribution(engine.store(), vf, limit);
654        obj.insert(
655            "tag_distribution".into(),
656            serde_json::to_value(&distribution).unwrap(),
657        );
658        obj.insert(
659            "tag_distribution_folded".into(),
660            serde_json::to_value(&folded).unwrap(),
661        );
662        obj.insert(
663            "untagged_entities".into(),
664            serde_json::to_value(&untagged).unwrap(),
665        );
666    }
667    // Conformance axis (`conformance`), or both axes (`integrity`). Findings
668    // ride one flat `findings` list in the pinned `{ id, axis, code, detail }`
669    // shape; ids are mem-qualified so the flat list stays unambiguous when
670    // unscoped. Mems scan in sorted order and each mem's findings are
671    // deterministic, so the whole list is.
672    let wants_conformance = include
673        .iter()
674        .any(|s| s == "conformance" || s == "integrity");
675    if wants_conformance {
676        let wants_consistency = include.iter().any(|s| s == "integrity");
677        let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
678            None => None,
679            Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
680                Ok(r) => Some(r),
681                Err(reason) => {
682                    return Err(ComposeHealthError::InvalidTargetSchema {
683                        raw: raw.to_string(),
684                        reason,
685                    });
686                }
687            },
688        };
689        let scan_mems: Vec<String> = match vf {
690            Some(v) => vec![v.to_string()],
691            None => {
692                let mut all = writable_mems.clone();
693                all.sort();
694                all
695            }
696        };
697        let mut findings = Vec::new();
698        let mut observations = Vec::new();
699        for v in &scan_mems {
700            findings.extend(engine.conformance_findings(v, target.as_ref())?);
701            // Beside `findings`, never among them: an observation says what an
702            // entity body silently loses, and none of it makes the entity
703            // unconformant.
704            observations.extend(engine.body_observations(v, target.as_ref())?);
705            if wants_consistency {
706                findings.extend(engine.consistency_findings(v)?);
707            }
708        }
709        obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
710        obj.insert(
711            "body_observations".into(),
712            serde_json::to_value(&observations).unwrap(),
713        );
714    }
715
716    // `include=["ledger"]` — a folder mem's ledger set against its file set
717    // (04/04, criteria 1 and 2). Reads only: no ledger line is written,
718    // rewritten or removed and no file is touched, because writing lines for
719    // edits the engine did not author would fabricate provenance for a change
720    // it cannot attribute. Git-branch mems are absent from the map rather than
721    // present and clean (criterion 4).
722    if include.iter().any(|s| s == "ledger") {
723        obj.insert(
724            "ledger".into(),
725            serde_json::to_value(engine.ledger_reconciliation()).unwrap_or_default(),
726        );
727    }
728
729    // Workspace policy surface — opt-in via `include_config: true`
730    // (the documented boolean alias) OR the catalogue key
731    // `include=["config"]`; both render the same projection, and
732    // passing both renders it once (a single gate). The rendering
733    // itself is shared with the CLI's `--include config` via
734    // `crate::ops::health::config_projection` — one
735    // implementation, every surface. `mutations` + `plugin` are passed
736    // in via [`HealthConfig`] (server-owned copies, inserted verbatim).
737    if args.include_config || include.iter().any(|s| s == "config") {
738        let entries = crate::ops::health::config_projection(
739            engine,
740            &writable_mems,
741            config.mutations.clone(),
742            config.plugin.clone(),
743        );
744        for (k, v) in entries {
745            obj.insert(k, v);
746        }
747    }
748
749    Ok(result)
750}
751
752/// Render a composed health payload as a human-readable markdown report
753/// for the MCP text channel. `structured_content` remains the source of
754/// truth (this is never parsed back); the markdown exists so the text
755/// channel is *chunkable* like `memstead_overview` instead of a wall of
756/// JSON that overflows the response cap under several includes. The
757/// size-driving include arrays each render as their own section so the
758/// chunker can split a large report cleanly.
759pub fn render_health_markdown(v: &serde_json::Value) -> String {
760    use std::fmt::Write as _;
761    let mut s = String::new();
762    let _ = writeln!(s, "# Graph health");
763    if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
764        let _ = writeln!(s, "\nMem filter: `{mem}`");
765    }
766
767    if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
768        let _ = writeln!(s, "\n## Summary");
769        for key in [
770            "total_entities",
771            "total_orphans",
772            "total_stubs",
773            "total_stale",
774            "total_missing_fields",
775            "total_communities",
776        ] {
777            if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
778                let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
779            }
780        }
781        render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
782        render_count_map(
783            &mut s,
784            sum.get("communities_by_schema"),
785            "Communities by schema",
786        );
787    }
788
789    for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
790        if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
791            let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
792        }
793    }
794
795    // Size-driving include arrays — one section each so chunking splits them.
796    for (key, title) in [
797        ("orphans", "Orphans"),
798        ("stubs", "Stubs"),
799        ("most_connected", "Most connected"),
800        ("missing_fields", "Missing fields"),
801        ("stale", "Stale"),
802        ("dangling_links", "Dangling links"),
803        ("missing_required_outgoing", "Missing required outgoing"),
804        ("constraints", "Constraint violations"),
805        ("findings", "Findings"),
806    ] {
807        if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
808            let _ = writeln!(s, "\n## {title} ({})", arr.len());
809            for item in arr {
810                let _ = writeln!(s, "- {}", summarize_health_item(item));
811            }
812        }
813    }
814
815    // Body observations — their own section, not a row in the table above:
816    // `summarize_health_item` prints an entity id and stops, and an
817    // observation whose fate is not stated says nothing at all. This is the
818    // MCP text channel, so it is what a cold agent reads (04/01, criterion 1).
819    if let Some(arr) = v.get("body_observations").and_then(|x| x.as_array()) {
820        let _ = writeln!(s, "\n## Body observations ({})", arr.len());
821        for item in arr {
822            let detail = &item["detail"];
823            let subject = detail
824                .get("heading")
825                .or_else(|| detail.get("key"))
826                .and_then(|x| x.as_str())
827                .unwrap_or("");
828            let _ = writeln!(
829                s,
830                "- {} [{}] `{subject}`: {} ({})",
831                item["id"].as_str().unwrap_or(""),
832                item["code"].as_str().unwrap_or(""),
833                item["fate"].as_str().unwrap_or(""),
834                detail
835                    .get("note")
836                    .and_then(|x| x.as_str())
837                    .unwrap_or("no note"),
838            );
839        }
840    }
841
842    // Anchors axis — an object (mem → counts plus the population they cover),
843    // not an array, so it renders its own compact section. The figure and the
844    // population render together (consistency-sweep 03/05, criteria 1 and 3):
845    // this is the MCP text channel, so it is what a cold agent reads, and it
846    // used to print four numbers and stop.
847    if let Some(obj) = v.get("anchors").and_then(|x| x.as_object()) {
848        let _ = writeln!(s, "\n## Anchors ({} mems)", obj.len());
849        for (mem, counts) in obj {
850            if let Some(c) = counts.get("condition").filter(|c| !c.is_null()) {
851                let _ = writeln!(
852                    s,
853                    "- `{mem}`: ANCHORS_SIDECAR_UNREADABLE — {} — {}",
854                    c["reason"].as_str().unwrap_or("reason not stated"),
855                    counts["population"]
856                        .as_str()
857                        .unwrap_or("population not stated"),
858                );
859                continue;
860            }
861            let _ = writeln!(
862                s,
863                "- `{mem}`: resolves {}, drifted {}, recheck {}, unresolvable (artifact gone) \
864                 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
865                counts["resolves"].as_u64().unwrap_or(0),
866                counts["drifted"].as_u64().unwrap_or(0),
867                counts["recheck"].as_u64().unwrap_or(0),
868                counts["unresolvable"].as_u64().unwrap_or(0),
869                counts["unobserved"].as_u64().unwrap_or(0),
870                counts["dangling"].as_u64().unwrap_or(0),
871                counts["population"]
872                    .as_str()
873                    .unwrap_or("population not stated"),
874            );
875        }
876    }
877
878    // Vital signs — per mem, the five model-truth signal counts (A6);
879    // the lists stay in the structured payload.
880    if let Some(obj) = v.get("vital_signs").and_then(|x| x.as_object()) {
881        let mems: Vec<(&String, &serde_json::Value)> =
882            obj.iter().filter(|(k, _)| *k != "_item_cap").collect();
883        let _ = writeln!(s, "\n## Vital signs ({} mems)", mems.len());
884        for (mem, sig) in mems {
885            let count = |k: &str| sig[k]["count"].as_u64().unwrap_or(0);
886            let share = match sig["type_share_by_community"]["status"].as_str() {
887                Some("declared") => format!(
888                    "last-resort type `{}` over {} communit{}",
889                    sig["type_share_by_community"]["last_resort_type"]
890                        .as_str()
891                        .unwrap_or("?"),
892                    count("type_share_by_community"),
893                    if count("type_share_by_community") == 1 {
894                        "y"
895                    } else {
896                        "ies"
897                    }
898                ),
899                _ => "last-resort type not declared".to_string(),
900            };
901            let unclaimed = match sig["unclaimed_source_files"]["status"].as_str() {
902                Some("enumerated") => format!(
903                    "{} unclaimed source file(s)",
904                    count("unclaimed_source_files")
905                ),
906                _ => "no bound source".to_string(),
907            };
908            let _ = writeln!(
909                s,
910                "- `{mem}`: {share}; {unclaimed}; {} contested unowned file(s); {} zero-outgoing \
911                 entit{} in {} communit{}; {} empty declared section(s)",
912                count("contested_unowned_files"),
913                sig["zero_outgoing_entities"]["entities"]
914                    .as_u64()
915                    .unwrap_or(0),
916                if sig["zero_outgoing_entities"]["entities"]
917                    .as_u64()
918                    .unwrap_or(0)
919                    == 1
920                {
921                    "y"
922                } else {
923                    "ies"
924                },
925                count("zero_outgoing_entities"),
926                if count("zero_outgoing_entities") == 1 {
927                    "y"
928                } else {
929                    "ies"
930                },
931                count("empty_declared_sections"),
932            );
933        }
934    }
935
936    // Checks axis — an object (mem → state counts + independence
937    // gate). Null-is-a-statement (the Friction pattern): a requested
938    // axis with no mems renders the explicit zero heading; an absent
939    // key (not requested) renders nothing.
940    if let Some(obj) = v.get("checks").and_then(|x| x.as_object()) {
941        let _ = writeln!(s, "\n## Checks ({} mems)", obj.len());
942        for (mem, c) in obj {
943            let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
944            let conf = |key: &str| {
945                c.get("conformance")
946                    .and_then(|g| g.get(key))
947                    .and_then(|x| x.as_u64())
948                    .unwrap_or(0)
949            };
950            let gate = |key: &str| {
951                c.get("independence")
952                    .and_then(|g| g.get(key))
953                    .and_then(|e| e.get("count"))
954                    .and_then(|x| x.as_u64())
955                    .unwrap_or(0)
956            };
957            let _ = writeln!(
958                s,
959                "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
960                 check_stale {}; conformance: never_checked {}, \
961                 checked_ok {}, check_failed {}, check_stale {}; \
962                 independence: self_checked {}, \
963                 confirmed_independent {}, unconfirmable {}",
964                count("never_checked"),
965                count("checked_ok"),
966                count("check_failed"),
967                count("check_stale"),
968                conf("never_checked"),
969                conf("checked_ok"),
970                conf("check_failed"),
971                conf("check_stale"),
972                gate("self_checked"),
973                gate("confirmed_independent"),
974                gate("unconfirmable"),
975            );
976            // Foreign `x-` kinds by count and each entity's newest finding —
977            // the same lines the CLI text surface prints, so the two
978            // renderers say the same thing.
979            if let Some(foreign) = c.get("foreign_kinds").and_then(|f| f.as_object())
980                && !foreign.is_empty()
981            {
982                let listed: Vec<String> = foreign
983                    .iter()
984                    .map(|(k, n)| format!("{k} {}", n.as_u64().unwrap_or(0)))
985                    .collect();
986                let _ = writeln!(s, "  - foreign kinds: {}", listed.join(", "));
987            }
988            if let Some(findings) = c.get("findings").and_then(|f| f.as_object()) {
989                for (entity, f) in findings {
990                    let code = f["finding"]["code"].as_str().unwrap_or("?");
991                    let section = f["finding"]["section"]
992                        .as_str()
993                        .map(|x| format!(" [{x}]"))
994                        .unwrap_or_default();
995                    let message = f["finding"]["message"].as_str().unwrap_or("");
996                    let _ = writeln!(
997                        s,
998                        "  - finding on `{entity}` ({} {}): {code}{section} — {message}",
999                        f["kind"].as_str().unwrap_or("verification"),
1000                        f["verdict"].as_str().unwrap_or("?"),
1001                    );
1002                }
1003            }
1004        }
1005    }
1006
1007    // Stale-derivations axis — an object (mem → findings list). Same
1008    // requested-vs-absent contract as the checks axis above.
1009    if let Some(obj) = v.get("stale_derivations").and_then(|x| x.as_object()) {
1010        let total: usize = obj
1011            .values()
1012            .filter_map(|a| a.as_array().map(|a| a.len()))
1013            .sum();
1014        let _ = writeln!(s, "\n## Stale derivations ({total} findings)");
1015        for (mem, findings) in obj {
1016            for f in findings.as_array().into_iter().flatten() {
1017                let _ = writeln!(
1018                    s,
1019                    "- `{mem}`: {} -[{}]-> {} ({})",
1020                    f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1021                    f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1022                    f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1023                    f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1024                );
1025            }
1026        }
1027    }
1028
1029    // Quarantine roster — ungated in the JSON (present whenever
1030    // non-empty), so the text channel renders it whenever present:
1031    // per mem the reason code plus the message, which carries the
1032    // repair command.
1033    if let Some(arr) = v.get("quarantined").and_then(|x| x.as_array()) {
1034        let _ = writeln!(s, "\n## Quarantined mems ({})", arr.len());
1035        for q in arr {
1036            let _ = writeln!(
1037                s,
1038                "- `{}` [{}] {}",
1039                q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1040                q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1041                q.get("reason_message")
1042                    .and_then(|x| x.as_str())
1043                    .unwrap_or(""),
1044            );
1045        }
1046    }
1047
1048    if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
1049        && !arr.is_empty()
1050    {
1051        let _ = writeln!(s, "\n## Warnings ({})", arr.len());
1052        for w in arr {
1053            let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
1054            let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
1055            let _ = writeln!(s, "- [{code}] {msg}");
1056        }
1057    }
1058
1059    s
1060}
1061
1062/// Render a `{ key: count }` map as an indented sub-list under `title`,
1063/// skipping an empty/missing map. The empty-string schema key (an unpinned
1064/// mem) renders as `(unpinned)`.
1065fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
1066    use std::fmt::Write as _;
1067    let Some(map) = val.and_then(|x| x.as_object()) else {
1068        return;
1069    };
1070    if map.is_empty() {
1071        return;
1072    }
1073    let _ = writeln!(s, "- {title}:");
1074    for (k, n) in map {
1075        let label = if k.is_empty() {
1076            "(unpinned)"
1077        } else {
1078            k.as_str()
1079        };
1080        let _ = writeln!(s, "  - {label}: {}", n.as_u64().unwrap_or(0));
1081    }
1082}
1083
1084/// One-line summary of a health detail item: prefer `id` (+ `title`),
1085/// else a dangling reference as `[kind] from → target_id`, else the
1086/// compact JSON.
1087///
1088/// The `kind` prefix matters because this is the health TEXT channel —
1089/// what an agent reads once the JSON exceeds `token_budget`, or whenever
1090/// it passes `chunk`. Without it the three dangling conditions, which
1091/// have three different repairs, render as one identical line shape and
1092/// the reader is back to the fused report the codes were split to end
1093/// (04/06, criteria 2 and 4). Mirrors `overview.rs`'s rendered section
1094/// and the CLI's markdown block.
1095fn summarize_health_item(item: &serde_json::Value) -> String {
1096    if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
1097        match item.get("title").and_then(|x| x.as_str()) {
1098            Some(t) if !t.is_empty() => format!("{id} — {t}"),
1099            _ => id.to_string(),
1100        }
1101    } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
1102        let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
1103        match item.get("kind").and_then(|x| x.as_str()) {
1104            Some(kind) => format!("[{kind}] {from} → {target}"),
1105            None => format!("{from} → {target}"),
1106        }
1107    } else {
1108        serde_json::to_string(item).unwrap_or_default()
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::render_health_markdown;
1115    use serde_json::json;
1116
1117    fn base_payload() -> serde_json::Value {
1118        json!({
1119            "summary": { "total_entities": 1 },
1120            "total_nodes": 1,
1121        })
1122    }
1123
1124    /// The description advertises `body_observations` on the conformance
1125    /// axis, and a description is not a shipped field: the drift gate
1126    /// compares description tokens against an allowlist, so it stayed green
1127    /// while no server emitted the key at all (grade, 2026-08-27). This
1128    /// pins the text channel end of it; the JSON end is pinned by the
1129    /// handler test in `ops::integrity`.
1130    #[test]
1131    fn body_observations_render_their_code_and_fate_not_just_an_id() {
1132        let mut v = base_payload();
1133        v["body_observations"] = json!([{
1134            "id": "specs--alpha",
1135            "code": "ABSORBED_SECTION",
1136            "fate": "absorbed",
1137            "detail": { "heading": "Rogue", "note": "survives the next write" },
1138        }, {
1139            "id": "specs--beta",
1140            "code": "UNDECLARED_METADATA_KEY",
1141            "fate": "dropped",
1142            "detail": { "key": "reviewer", "note": "the next write drops it" },
1143        }]);
1144        let md = render_health_markdown(&v);
1145        assert!(md.contains("## Body observations (2)"), "{md}");
1146        assert!(
1147            md.contains(
1148                "- specs--alpha [ABSORBED_SECTION] `Rogue`: absorbed (survives the next write)"
1149            ),
1150            "{md}"
1151        );
1152        assert!(
1153            md.contains(
1154                "- specs--beta [UNDECLARED_METADATA_KEY] `reviewer`: dropped \
1155                 (the next write drops it)"
1156            ),
1157            "{md}"
1158        );
1159        // Absent key renders nothing at all.
1160        assert!(!render_health_markdown(&base_payload()).contains("Body observations"));
1161    }
1162
1163    /// 04/06, criteria 2 and 4: the text channel names the condition.
1164    /// This is the surface an agent gets once the JSON exceeds
1165    /// `token_budget`, and it rendered all three dangling conditions as
1166    /// one indistinguishable `from → target` line while every other
1167    /// surface had been migrated — the last place the fused report
1168    /// survived.
1169    #[test]
1170    fn render_health_markdown_names_the_dangling_condition() {
1171        let mut v = base_payload();
1172        v["dangling_links"] = json!([
1173            {
1174                "kind": "DANGLING_LINK_TARGET_MISSING",
1175                "from": "specs--a", "target_id": "specs--gone",
1176                "target_path": "gone", "section": "purpose",
1177            },
1178            {
1179                "kind": "DANGLING_RELATION_TARGET_MISSING",
1180                "from": "specs--b", "target_id": "specs--vanished",
1181                "target_path": "vanished", "section": null,
1182            },
1183        ]);
1184        let md = render_health_markdown(&v);
1185        assert!(
1186            md.contains("[DANGLING_LINK_TARGET_MISSING] specs--a → specs--gone"),
1187            "{md}"
1188        );
1189        assert!(
1190            md.contains("[DANGLING_RELATION_TARGET_MISSING] specs--b → specs--vanished"),
1191            "{md}"
1192        );
1193        // The two conditions do not render as the same line shape.
1194        assert!(
1195            !md.contains("- specs--a → specs--gone"),
1196            "the unprefixed form is what fused them: {md}"
1197        );
1198    }
1199
1200    /// Text-channel parity: the `checks` / `stale_derivations` axes
1201    /// and the quarantine roster render their own sections — content
1202    /// when populated, the explicit zero statement when requested but
1203    /// empty (null is a statement), and NOTHING when the JSON key is
1204    /// absent: a payload without the keys renders byte-identically to
1205    /// itself with sections appended, never mutated.
1206    #[test]
1207    fn render_health_markdown_covers_checks_derivations_and_quarantine() {
1208        // Populated.
1209        let mut v = base_payload();
1210        v["checks"] = json!({
1211            "specs": {
1212                "never_checked": 2, "checked_ok": 1,
1213                "check_failed": 0, "check_stale": 0,
1214                "conformance": {
1215                    "never_checked": 3, "checked_ok": 0,
1216                    "check_failed": 0, "check_stale": 0,
1217                },
1218                "independence": {
1219                    "self_checked": { "count": 0, "items": [] },
1220                    "confirmed_independent": { "count": 0, "items": [] },
1221                    "unconfirmable": { "count": 1, "items": ["specs--a"] },
1222                },
1223            }
1224        });
1225        v["stale_derivations"] = json!({
1226            "specs": [{
1227                "source": "specs--a", "rel_type": "DERIVES_FROM",
1228                "target": "specs--b", "state": "stale",
1229                "baseline": "aaa", "current": "bbb",
1230            }]
1231        });
1232        v["quarantined"] = json!([{
1233            "mem": "broken",
1234            "reason_code": "SCHEMA_NOT_FOUND",
1235            "reason_message": "no schema; repair via memstead mem set-schema",
1236        }]);
1237        let md = render_health_markdown(&v);
1238        assert!(md.contains("## Checks (1 mems)"), "{md}");
1239        assert!(
1240            md.contains(
1241                "- `specs`: never_checked 2, checked_ok 1, check_failed 0, \
1242                 check_stale 0; conformance: never_checked 3, checked_ok 0, \
1243                 check_failed 0, check_stale 0; independence: self_checked 0, \
1244                 confirmed_independent 0, unconfirmable 1"
1245            ),
1246            "{md}"
1247        );
1248        assert!(md.contains("## Stale derivations (1 findings)"), "{md}");
1249        assert!(
1250            md.contains("- `specs`: specs--a -[DERIVES_FROM]-> specs--b (stale)"),
1251            "{md}"
1252        );
1253        assert!(md.contains("## Quarantined mems (1)"), "{md}");
1254        assert!(
1255            md.contains(
1256                "- `broken` [SCHEMA_NOT_FOUND] no schema; repair via memstead mem set-schema"
1257            ),
1258            "{md}"
1259        );
1260
1261        // Requested but empty → the explicit zero statement.
1262        let mut empty = base_payload();
1263        empty["checks"] = json!({});
1264        empty["stale_derivations"] = json!({ "specs": [] });
1265        let md = render_health_markdown(&empty);
1266        assert!(md.contains("## Checks (0 mems)"), "{md}");
1267        assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
1268
1269        // Keys absent (not requested) → byte-unchanged: no section,
1270        // and the populated render is the base render plus appendix.
1271        let base_md = render_health_markdown(&base_payload());
1272        for heading in ["## Checks", "## Stale derivations", "## Quarantined mems"] {
1273            assert!(
1274                !base_md.contains(heading),
1275                "absent key must render nothing: {base_md}"
1276            );
1277        }
1278        let appended = render_health_markdown(&v);
1279        assert!(
1280            appended.starts_with(&base_md),
1281            "sections append; the base output stays byte-identical"
1282        );
1283    }
1284}