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, and the one rule every warning
137    // follows under a filter: `WarningHint::concerns_mem` keeps a warning
138    // attributed to the scoped mem (or naming it among several), drops one
139    // attributed to another mem, and keeps a warning attributed to no mem
140    // at all (OUTER_REPO_…, UNKNOWN_INCLUDE_KEY, LIMIT_CLAMPED: workspace-
141    // or request-scoped, so they concern every mem, this one included).
142    if let Some(v) = vf {
143        warnings.retain(|w| w.concerns_mem(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        // Rendered is not examined (C10, 2026-09-03). `anchors` used to be
352        // promoted into the examined set for any pass that included it, on a
353        // "rendered this pass, therefore examined" rule whose only instance
354        // this was. But `--strict` fails on an unreadable anchors sidecar
355        // alone and never on drift, and its help says drifted anchors stay
356        // advisory, so the promoted line told a gate that the health verdict
357        // polices anchor drift when the verify surfaces do. The line now
358        // renders straight from the registry, which already declares
359        // `anchors` advisory with that reason.
360        "verdict_coverage": crate::ops::coverage::HEALTH_COVERAGE.wire_line(),
361        "summary": {
362            "total_entities": real_count,
363            "total_orphans": orphan_ids.len(),
364            "total_stubs": stub_pairs.len(),
365            "total_stale": health.stale_entities.iter().filter(|e| match vf {
366                Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
367                None => true,
368            }).count(),
369            "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
370                Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
371                None => true,
372            }).count(),
373            "total_communities": community_count,
374            "orphans_by_schema": orphans_by_schema,
375            "communities_by_schema": communities_by_schema,
376        },
377        "total_nodes": total_count,
378        "real_nodes": real_count,
379        "stub_nodes": stub_count,
380        "total_edges": edge_count,
381        "edge_types": edge_types,
382        "type_distribution": type_distribution,
383        "writable_mems": writable_mems,
384        "default_writable_mem": default_writable_mem,
385        "read_mems": read_mems,
386        "mem_schemas": mem_schemas,
387    });
388    let obj = result.as_object_mut().unwrap();
389    // The schema anchor every mem-scoped read carries (`_mem_schema`),
390    // set by the composer so the CLI and the MCP tool render the same
391    // bytes; the MCP wrapper's own anchor step re-inserts the same value.
392    if let Some(v) = vf
393        && let Some(schema) = crate::overview::mem_schema_ref(engine, v)
394    {
395        obj.insert("_mem_schema".into(), serde_json::Value::String(schema));
396    }
397    if !warnings.is_empty() {
398        obj.insert("warnings".into(), serde_json::json!(warnings));
399    }
400    // Quarantine roster — a boot-honesty fact, present whenever
401    // non-empty, never behind an include gate (agent-trust plan 04).
402    if !health.quarantined.is_empty() {
403        obj.insert(
404            "quarantined".into(),
405            serde_json::to_value(&health.quarantined).unwrap_or_default(),
406        );
407    }
408    // Per-file load failures — same boot-honesty class: each entry's
409    // message names the remedy (the merge-conflict refusal names
410    // `memstead conflicts resolve`), so the composed report must carry
411    // them for the failure mode to name its door (backlog-sweep 07).
412    if !health.load_errors.is_empty() {
413        obj.insert(
414            "load_errors".into(),
415            serde_json::to_value(&health.load_errors).unwrap_or_default(),
416        );
417    }
418    if let Some(diag) = &health.boot_diagnosis {
419        obj.insert("boot_diagnosis".into(), diag.clone());
420    }
421    // Leaf populations — visible beside the orphan axis they exempt
422    // (agent-trust plan 06); omitted when no type declares leaf.
423    if !health.leaf_entities_by_type.is_empty() {
424        obj.insert(
425            "leaf_entities_by_type".into(),
426            serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
427        );
428    }
429
430    if include.iter().any(|s| s == "orphans") {
431        let orphans_list: Vec<serde_json::Value> = orphan_ids
432            .into_iter()
433            .map(|id| {
434                let title = engine
435                    .get_entity(&id)
436                    .map(|e| e.title.clone())
437                    .unwrap_or_default();
438                serde_json::json!({"id": id.to_string(), "title": title})
439            })
440            .collect();
441        obj.insert("orphans".into(), serde_json::json!(orphans_list));
442    }
443    if include.iter().any(|s| s == "stubs") {
444        let stubs_list: Vec<serde_json::Value> = stub_pairs
445            .into_iter()
446            .map(|(id, refs)| {
447                serde_json::json!({
448                    "id": id.to_string(),
449                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
450                })
451            })
452            .collect();
453        obj.insert("stubs".into(), serde_json::json!(stubs_list));
454    }
455    if include.iter().any(|s| s == "most_connected") {
456        use crate::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
457        // `typed_*` is the dependency degree (excludes auto-emitted mention
458        // edges); the list is ranked by it so a co-mention hub doesn't
459        // outrank a real dependency hub. `total`/`incoming`/`outgoing` keep
460        // the mentions and stay available — mention degree = total - typed.
461        let to_json = |c: Connectivity| {
462            let title = engine
463                .get_entity(&c.id)
464                .map(|e| e.title.clone())
465                .unwrap_or_default();
466            serde_json::json!({
467                "id": c.id.to_string(),
468                "title": title,
469                "total": c.total,
470                "incoming": c.incoming,
471                "outgoing": c.outgoing,
472                "typed_total": c.typed_total,
473                "typed_incoming": c.typed_incoming,
474                "typed_outgoing": c.typed_outgoing,
475            })
476        };
477        let connected: Vec<serde_json::Value> = if let Some(v) = vf {
478            // Source-in-mem scoping, to match this response's `edge_types`
479            // / `total_edges`. The node is in-mem, so all of its outgoing
480            // edges are source-in-mem and counted; an incoming edge counts
481            // only when its source is also in-mem, so a cross-mem edge
482            // the aggregate excluded does not inflate the node's degree here.
483            let mut entries: Vec<Connectivity> = engine
484                .store()
485                .all_entities()
486                .filter(|e| !e.stub && e.mem == v)
487                .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
488                .collect();
489            entries.sort_by(cmp_by_dependency);
490            entries.truncate(limit);
491            entries.into_iter().map(to_json).collect()
492        } else {
493            engine
494                .most_connected(limit)
495                .into_iter()
496                .map(to_json)
497                .collect()
498        };
499        obj.insert("most_connected".into(), serde_json::json!(connected));
500    }
501    if include.iter().any(|s| s == "missing_fields") {
502        let missing_fields: Vec<serde_json::Value> = health
503            .missing_fields
504            .iter()
505            .filter(|h| match vf {
506                Some(v) => engine
507                    .store()
508                    .get(&h.id)
509                    .map(|e| e.mem == v)
510                    .unwrap_or(false),
511                None => true,
512            })
513            .map(|h| {
514                // `missing` (bare field names) stays byte-identical for
515                // existing consumers; the per-issue detail rides next
516                // to it so the projection carries WHICH condition each
517                // issue reports (a heading mismatch must never surface
518                // as "missing" only).
519                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
520                let issues: Vec<serde_json::Value> = h
521                    .issues
522                    .iter()
523                    .map(|i| {
524                        serde_json::json!({
525                            "field": i.field,
526                            "code": i.code,
527                            "message": i.message,
528                        })
529                    })
530                    .collect();
531                serde_json::json!({
532                    "id": h.id.to_string(),
533                    "title": h.title,
534                    "missing": missing,
535                    "issues": issues,
536                })
537            })
538            .collect();
539        obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
540    }
541    if include.iter().any(|s| s == "stale") {
542        let stale: Vec<serde_json::Value> = health
543            .stale_entities
544            .iter()
545            .filter(|e| match vf {
546                Some(v) => engine
547                    .store()
548                    .get(&e.id)
549                    .map(|ent| ent.mem == v)
550                    .unwrap_or(false),
551                None => true,
552            })
553            .map(stale_row)
554            .collect();
555        obj.insert("stale".into(), serde_json::json!(stale));
556        // Fresh by the anchor clock: only present when an anchor overruled
557        // the day threshold, so an anchor-less workspace renders unchanged.
558        let fresh: Vec<serde_json::Value> = health
559            .anchor_fresh
560            .iter()
561            .filter(|e| match vf {
562                Some(v) => engine
563                    .store()
564                    .get(&e.id)
565                    .map(|ent| ent.mem == v)
566                    .unwrap_or(false),
567                None => true,
568            })
569            .map(stale_row)
570            .collect();
571        if !fresh.is_empty() {
572            obj.insert("anchor_fresh".into(), serde_json::json!(fresh));
573        }
574    }
575    if include.iter().any(|s| s == "dangling_links") {
576        let dangling = crate::ops::health::collect_dangling_links(engine.store(), vf);
577        let arr: Vec<serde_json::Value> = dangling
578            .into_iter()
579            .map(|dl| serde_json::to_value(&dl).unwrap())
580            .collect();
581        obj.insert("dangling_links".into(), serde_json::json!(arr));
582    }
583    if include.iter().any(|s| s == "anchors") {
584        obj.insert(
585            "anchors".into(),
586            crate::ops::health::health_anchors_axis(engine, vf),
587        );
588    }
589    if include.iter().any(|s| s == "stale_derivations") {
590        obj.insert(
591            "stale_derivations".into(),
592            crate::ops::health::health_stale_derivations_axis(engine, args.mem),
593        );
594    }
595    if include.iter().any(|s| s == "checks") {
596        obj.insert(
597            "checks".into(),
598            crate::ops::health::health_checks_axis(engine, args.mem),
599        );
600    }
601    if include.iter().any(|s| s == "signals") {
602        // Declared aggregate signals above `none`, with per-level
603        // counts — the same composer the CLI and the filesystem
604        // flavour serve.
605        obj.insert("signals".into(), engine.health_signals_axis(args.mem));
606    }
607    if include.iter().any(|s| s == "labelling") {
608        // Grounded labelling per declaring mem — counts per label,
609        // defeated/undecided lists with their attacker evidence, and
610        // the excluded cross-mem attack-edge count.
611        obj.insert("labelling".into(), engine.health_labelling_axis(args.mem));
612    }
613    if include.iter().any(|s| s == "open_questions") {
614        obj.insert(
615            "open_questions".into(),
616            crate::ops::health::health_open_questions_axis(engine, args.mem),
617        );
618    }
619    if include.iter().any(|s| s == "vital_signs") {
620        // The model-truth signals the remodel skill reads (A6): counts
621        // and capped lists, never a verdict.
622        obj.insert(
623            "vital_signs".into(),
624            crate::ops::health::health_vital_signs_axis(engine, args.mem),
625        );
626    }
627    if include.iter().any(|s| s == "friction") {
628        // The friction ledger's read surface (agent-trust plan 08):
629        // counts per code / per verb over the workspace-local refusal
630        // ledger, whole-ledger plus a recent 24h window. A workspace
631        // without a root (in-memory boots) or without a ledger yet
632        // serves the empty summary — the axis never fails health.
633        let summary = match engine.workspace_root() {
634            Some(root) => crate::friction::FrictionLedger::for_workspace(root).summarize(),
635            None => serde_json::json!({
636                "total": 0,
637                "by_code": {},
638                "by_verb": {},
639                "recent_24h": { "total": 0, "by_code": {} },
640                "ledger_bytes": 0,
641            }),
642        };
643        obj.insert("friction".into(), summary);
644    }
645    if include.iter().any(|s| s == "missing_required_outgoing") {
646        let reports = engine.missing_required_outgoing(vf);
647        let arr: Vec<serde_json::Value> = reports
648            .into_iter()
649            .map(|r| serde_json::to_value(&r).unwrap())
650            .collect();
651        obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
652    }
653    if include.iter().any(|s| s == "constraints") {
654        let reports = engine.constraint_findings(vf);
655        let arr: Vec<serde_json::Value> = reports
656            .into_iter()
657            .map(|r| serde_json::to_value(&r).unwrap())
658            .collect();
659        obj.insert("constraints".into(), serde_json::json!(arr));
660        let defects = engine.schema_format_defects();
661        if !defects.is_empty() {
662            obj.insert(
663                "schema_format_defects".into(),
664                serde_json::to_value(&defects).unwrap(),
665            );
666        }
667    }
668    if include.iter().any(|s| s == "tags") {
669        let (distribution, folded, untagged) =
670            crate::ops::health::collect_tag_distribution(engine.store(), vf, limit);
671        obj.insert(
672            "tag_distribution".into(),
673            serde_json::to_value(&distribution).unwrap(),
674        );
675        obj.insert(
676            "tag_distribution_folded".into(),
677            serde_json::to_value(&folded).unwrap(),
678        );
679        obj.insert(
680            "untagged_entities".into(),
681            serde_json::to_value(&untagged).unwrap(),
682        );
683    }
684    // Conformance axis (`conformance`), or both axes (`integrity`). Findings
685    // ride one flat `findings` list in the pinned `{ id, axis, code, detail }`
686    // shape; ids are mem-qualified so the flat list stays unambiguous when
687    // unscoped. Mems scan in sorted order and each mem's findings are
688    // deterministic, so the whole list is.
689    let wants_conformance = include
690        .iter()
691        .any(|s| s == "conformance" || s == "integrity");
692    if wants_conformance {
693        let wants_consistency = include.iter().any(|s| s == "integrity");
694        let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
695            None => None,
696            Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
697                Ok(r) => Some(r),
698                Err(reason) => {
699                    return Err(ComposeHealthError::InvalidTargetSchema {
700                        raw: raw.to_string(),
701                        reason,
702                    });
703                }
704            },
705        };
706        let scan_mems: Vec<String> = match vf {
707            Some(v) => vec![v.to_string()],
708            None => {
709                let mut all = writable_mems.clone();
710                all.sort();
711                all
712            }
713        };
714        let mut findings = Vec::new();
715        let mut observations = Vec::new();
716        for v in &scan_mems {
717            findings.extend(engine.conformance_findings(v, target.as_ref())?);
718            // Beside `findings`, never among them: an observation says what an
719            // entity body silently loses, and none of it makes the entity
720            // unconformant.
721            observations.extend(engine.body_observations(v, target.as_ref())?);
722            if wants_consistency {
723                findings.extend(engine.consistency_findings(v)?);
724            }
725        }
726        obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
727        obj.insert(
728            "body_observations".into(),
729            serde_json::to_value(&observations).unwrap(),
730        );
731    }
732
733    // `include=["ledger"]` — a folder mem's ledger set against its file set
734    // (04/04, criteria 1 and 2). Reads only: no ledger line is written,
735    // rewritten or removed and no file is touched, because writing lines for
736    // edits the engine did not author would fabricate provenance for a change
737    // it cannot attribute. Git-branch mems are absent from the map rather than
738    // present and clean (criterion 4).
739    if include.iter().any(|s| s == "ledger") {
740        let mut ledger = engine.ledger_reconciliation();
741        if let Some(v) = vf {
742            ledger.retain(|mem, _| mem == v);
743        }
744        obj.insert(
745            "ledger".into(),
746            serde_json::to_value(ledger).unwrap_or_default(),
747        );
748    }
749
750    // Workspace policy surface — opt-in via `include_config: true`
751    // (the documented boolean alias) OR the catalogue key
752    // `include=["config"]`; both render the same projection, and
753    // passing both renders it once (a single gate). The rendering
754    // itself is shared with the CLI's `--include config` via
755    // `crate::ops::health::config_projection` — one
756    // implementation, every surface. `mutations` + `plugin` are passed
757    // in via [`HealthConfig`] (server-owned copies, inserted verbatim).
758    if args.include_config || include.iter().any(|s| s == "config") {
759        // Per-mem config entries follow the same scope as every other
760        // section: one mem under a filter, every writable mem without.
761        let config_mems: Vec<String> = match vf {
762            Some(v) => writable_mems.iter().filter(|m| *m == v).cloned().collect(),
763            None => writable_mems.clone(),
764        };
765        let entries = crate::ops::health::config_projection(
766            engine,
767            &config_mems,
768            config.mutations.clone(),
769            config.plugin.clone(),
770        );
771        for (k, v) in entries {
772            obj.insert(k, v);
773        }
774    }
775
776    Ok(result)
777}
778
779/// Render a composed health payload as a human-readable markdown report
780/// for the MCP text channel. `structured_content` remains the source of
781/// truth (this is never parsed back); the markdown exists so the text
782/// channel is *chunkable* like `memstead_overview` instead of a wall of
783/// JSON that overflows the response cap under several includes. The
784/// size-driving include arrays each render as their own section so the
785/// chunker can split a large report cleanly.
786pub fn render_health_markdown(v: &serde_json::Value) -> String {
787    use std::fmt::Write as _;
788    let mut s = String::new();
789    let _ = writeln!(s, "# Graph health");
790    if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
791        let _ = writeln!(s, "\nMem filter: `{mem}`");
792    }
793
794    if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
795        let _ = writeln!(s, "\n## Summary");
796        for key in [
797            "total_entities",
798            "total_orphans",
799            "total_stubs",
800            "total_stale",
801            "total_missing_fields",
802            "total_communities",
803        ] {
804            if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
805                let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
806            }
807        }
808        render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
809        render_count_map(
810            &mut s,
811            sum.get("communities_by_schema"),
812            "Communities by schema",
813        );
814    }
815
816    for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
817        if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
818            let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
819        }
820    }
821
822    // Size-driving include arrays — one section each so chunking splits them.
823    for (key, title) in [
824        ("orphans", "Orphans"),
825        ("stubs", "Stubs"),
826        ("most_connected", "Most connected"),
827        ("missing_fields", "Missing fields"),
828        ("stale", "Stale"),
829        ("dangling_links", "Dangling links"),
830        ("missing_required_outgoing", "Missing required outgoing"),
831        ("constraints", "Constraint violations"),
832        ("findings", "Findings"),
833    ] {
834        if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
835            let _ = writeln!(s, "\n## {title} ({})", arr.len());
836            for item in arr {
837                let _ = writeln!(s, "- {}", summarize_health_item(item));
838            }
839        }
840    }
841
842    // Body observations — their own section, not a row in the table above:
843    // `summarize_health_item` prints an entity id and stops, and an
844    // observation whose fate is not stated says nothing at all. This is the
845    // MCP text channel, so it is what a cold agent reads (04/01, criterion 1).
846    if let Some(arr) = v.get("body_observations").and_then(|x| x.as_array()) {
847        let _ = writeln!(s, "\n## Body observations ({})", arr.len());
848        for item in arr {
849            let detail = &item["detail"];
850            let subject = detail
851                .get("heading")
852                .or_else(|| detail.get("key"))
853                .and_then(|x| x.as_str())
854                .unwrap_or("");
855            let _ = writeln!(
856                s,
857                "- {} [{}] `{subject}`: {} ({})",
858                item["id"].as_str().unwrap_or(""),
859                item["code"].as_str().unwrap_or(""),
860                item["fate"].as_str().unwrap_or(""),
861                detail
862                    .get("note")
863                    .and_then(|x| x.as_str())
864                    .unwrap_or("no note"),
865            );
866        }
867    }
868
869    // Anchors axis — an object (mem → counts plus the population they cover),
870    // not an array, so it renders its own compact section. The figure and the
871    // population render together (consistency-sweep 03/05, criteria 1 and 3):
872    // this is the MCP text channel, so it is what a cold agent reads, and it
873    // used to print four numbers and stop.
874    if let Some(obj) = v.get("anchors").and_then(|x| x.as_object()) {
875        let _ = writeln!(s, "\n## Anchors ({} mems)", obj.len());
876        for (mem, counts) in obj {
877            if let Some(c) = counts.get("condition").filter(|c| !c.is_null()) {
878                let _ = writeln!(
879                    s,
880                    "- `{mem}`: ANCHORS_SIDECAR_UNREADABLE — {} — {}",
881                    c["reason"].as_str().unwrap_or("reason not stated"),
882                    counts["population"]
883                        .as_str()
884                        .unwrap_or("population not stated"),
885                );
886                continue;
887            }
888            let _ = writeln!(
889                s,
890                "- `{mem}`: resolves {}, drifted {}, recheck {}, unresolvable (artifact gone) \
891                 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
892                counts["resolves"].as_u64().unwrap_or(0),
893                counts["drifted"].as_u64().unwrap_or(0),
894                counts["recheck"].as_u64().unwrap_or(0),
895                counts["unresolvable"].as_u64().unwrap_or(0),
896                counts["unobserved"].as_u64().unwrap_or(0),
897                counts["dangling"].as_u64().unwrap_or(0),
898                counts["population"]
899                    .as_str()
900                    .unwrap_or("population not stated"),
901            );
902        }
903    }
904
905    // Vital signs — per mem, the five model-truth signal counts (A6);
906    // the lists stay in the structured payload.
907    if let Some(obj) = v.get("vital_signs").and_then(|x| x.as_object()) {
908        let mems: Vec<(&String, &serde_json::Value)> =
909            obj.iter().filter(|(k, _)| *k != "_item_cap").collect();
910        let _ = writeln!(s, "\n## Vital signs ({} mems)", mems.len());
911        for (mem, sig) in mems {
912            let count = |k: &str| sig[k]["count"].as_u64().unwrap_or(0);
913            let share = match sig["type_share_by_community"]["status"].as_str() {
914                Some("declared") => format!(
915                    "last-resort type `{}` over {} communit{}",
916                    sig["type_share_by_community"]["last_resort_type"]
917                        .as_str()
918                        .unwrap_or("?"),
919                    count("type_share_by_community"),
920                    if count("type_share_by_community") == 1 {
921                        "y"
922                    } else {
923                        "ies"
924                    }
925                ),
926                _ => "last-resort type not declared".to_string(),
927            };
928            let unclaimed = match sig["unclaimed_source_files"]["status"].as_str() {
929                Some("enumerated") => format!(
930                    "{} unclaimed source file(s)",
931                    count("unclaimed_source_files")
932                ),
933                _ => "no bound source".to_string(),
934            };
935            let _ = writeln!(
936                s,
937                "- `{mem}`: {share}; {unclaimed}; {} contested unowned file(s); {} zero-outgoing \
938                 entit{} in {} communit{}; {} empty declared section(s)",
939                count("contested_unowned_files"),
940                sig["zero_outgoing_entities"]["entities"]
941                    .as_u64()
942                    .unwrap_or(0),
943                if sig["zero_outgoing_entities"]["entities"]
944                    .as_u64()
945                    .unwrap_or(0)
946                    == 1
947                {
948                    "y"
949                } else {
950                    "ies"
951                },
952                count("zero_outgoing_entities"),
953                if count("zero_outgoing_entities") == 1 {
954                    "y"
955                } else {
956                    "ies"
957                },
958                count("empty_declared_sections"),
959            );
960        }
961    }
962
963    // Checks axis — an object (mem → state counts + independence
964    // gate). Null-is-a-statement (the Friction pattern): a requested
965    // axis with no mems renders the explicit zero heading; an absent
966    // key (not requested) renders nothing.
967    if let Some(obj) = v.get("checks").and_then(|x| x.as_object()) {
968        let _ = writeln!(s, "\n## Checks ({} mems)", obj.len());
969        for (mem, c) in obj {
970            let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
971            let conf = |key: &str| {
972                c.get("conformance")
973                    .and_then(|g| g.get(key))
974                    .and_then(|x| x.as_u64())
975                    .unwrap_or(0)
976            };
977            let gate = |key: &str| {
978                c.get("independence")
979                    .and_then(|g| g.get(key))
980                    .and_then(|e| e.get("count"))
981                    .and_then(|x| x.as_u64())
982                    .unwrap_or(0)
983            };
984            let _ = writeln!(
985                s,
986                "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
987                 check_stale {}; conformance: never_checked {}, \
988                 checked_ok {}, check_failed {}, check_stale {}; \
989                 independence: self_checked {}, \
990                 confirmed_independent {}, unconfirmable {}",
991                count("never_checked"),
992                count("checked_ok"),
993                count("check_failed"),
994                count("check_stale"),
995                conf("never_checked"),
996                conf("checked_ok"),
997                conf("check_failed"),
998                conf("check_stale"),
999                gate("self_checked"),
1000                gate("confirmed_independent"),
1001                gate("unconfirmable"),
1002            );
1003            // Foreign `x-` kinds by count and each entity's newest finding —
1004            // the same lines the CLI text surface prints, so the two
1005            // renderers say the same thing.
1006            if let Some(foreign) = c.get("foreign_kinds").and_then(|f| f.as_object())
1007                && !foreign.is_empty()
1008            {
1009                let listed: Vec<String> = foreign
1010                    .iter()
1011                    .map(|(k, n)| format!("{k} {}", n.as_u64().unwrap_or(0)))
1012                    .collect();
1013                let _ = writeln!(s, "  - foreign kinds: {}", listed.join(", "));
1014            }
1015            if let Some(findings) = c.get("findings").and_then(|f| f.as_object()) {
1016                for (entity, f) in findings {
1017                    let code = f["finding"]["code"].as_str().unwrap_or("?");
1018                    let section = f["finding"]["section"]
1019                        .as_str()
1020                        .map(|x| format!(" [{x}]"))
1021                        .unwrap_or_default();
1022                    let message = f["finding"]["message"].as_str().unwrap_or("");
1023                    let _ = writeln!(
1024                        s,
1025                        "  - finding on `{entity}` ({} {}): {code}{section} — {message}",
1026                        f["kind"].as_str().unwrap_or("verification"),
1027                        f["verdict"].as_str().unwrap_or("?"),
1028                    );
1029                }
1030            }
1031        }
1032    }
1033
1034    // Stale-derivations axis — an object (mem → findings list). Same
1035    // requested-vs-absent contract as the checks axis above.
1036    if let Some(obj) = v.get("stale_derivations").and_then(|x| x.as_object()) {
1037        let total: usize = obj
1038            .values()
1039            .filter_map(|a| a.as_array().map(|a| a.len()))
1040            .sum();
1041        let _ = writeln!(s, "\n## Stale derivations ({total} findings)");
1042        for (mem, findings) in obj {
1043            for f in findings.as_array().into_iter().flatten() {
1044                let _ = writeln!(
1045                    s,
1046                    "- `{mem}`: {} -[{}]-> {} ({})",
1047                    f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1048                    f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1049                    f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1050                    f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1051                );
1052            }
1053        }
1054    }
1055
1056    // Quarantine roster — ungated in the JSON (present whenever
1057    // non-empty), so the text channel renders it whenever present:
1058    // per mem the reason code plus the message, which carries the
1059    // repair command.
1060    if let Some(arr) = v.get("quarantined").and_then(|x| x.as_array()) {
1061        let _ = writeln!(s, "\n## Quarantined mems ({})", arr.len());
1062        for q in arr {
1063            let _ = writeln!(
1064                s,
1065                "- `{}` [{}] {}",
1066                q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1067                q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1068                q.get("reason_message")
1069                    .and_then(|x| x.as_str())
1070                    .unwrap_or(""),
1071            );
1072        }
1073    }
1074
1075    if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
1076        && !arr.is_empty()
1077    {
1078        let _ = writeln!(s, "\n## Warnings ({})", arr.len());
1079        for w in arr {
1080            let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
1081            let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
1082            let _ = writeln!(s, "- [{code}] {msg}");
1083        }
1084    }
1085
1086    s
1087}
1088
1089/// Render a `{ key: count }` map as an indented sub-list under `title`,
1090/// skipping an empty/missing map. The empty-string schema key (an unpinned
1091/// mem) renders as `(unpinned)`.
1092fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
1093    use std::fmt::Write as _;
1094    let Some(map) = val.and_then(|x| x.as_object()) else {
1095        return;
1096    };
1097    if map.is_empty() {
1098        return;
1099    }
1100    let _ = writeln!(s, "- {title}:");
1101    for (k, n) in map {
1102        let label = if k.is_empty() {
1103            "(unpinned)"
1104        } else {
1105            k.as_str()
1106        };
1107        let _ = writeln!(s, "  - {label}: {}", n.as_u64().unwrap_or(0));
1108    }
1109}
1110
1111/// One-line summary of a health detail item: prefer `id` (+ `title`),
1112/// else a dangling reference as `[kind] from → target_id`, else the
1113/// compact JSON.
1114///
1115/// The `kind` prefix matters because this is the health TEXT channel —
1116/// what an agent reads once the JSON exceeds `token_budget`, or whenever
1117/// it passes `chunk`. Without it the three dangling conditions, which
1118/// have three different repairs, render as one identical line shape and
1119/// the reader is back to the fused report the codes were split to end
1120/// (04/06, criteria 2 and 4). Mirrors `overview.rs`'s rendered section
1121/// and the CLI's markdown block.
1122fn summarize_health_item(item: &serde_json::Value) -> String {
1123    if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
1124        match item.get("title").and_then(|x| x.as_str()) {
1125            Some(t) if !t.is_empty() => format!("{id} — {t}"),
1126            _ => id.to_string(),
1127        }
1128    } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
1129        let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
1130        match item.get("kind").and_then(|x| x.as_str()) {
1131            Some(kind) => format!("[{kind}] {from} → {target}"),
1132            None => format!("{from} → {target}"),
1133        }
1134    } else {
1135        serde_json::to_string(item).unwrap_or_default()
1136    }
1137}
1138
1139/// One stale-axis row. A row the day threshold produced carries the three
1140/// historical keys and nothing else (the anchor-less render is byte for
1141/// byte what it was); a row the anchor clock produced adds `clock:
1142/// "anchors"` and the `anchor_state` that made it.
1143fn stale_row(e: &crate::ops::StaleEntity) -> serde_json::Value {
1144    let mut row = serde_json::json!({
1145        "id": e.id.to_string(),
1146        "title": e.title,
1147        "days_since_modified": e.days_since_modified,
1148    });
1149    if let Some(state) = &e.anchor_state {
1150        let obj = row.as_object_mut().unwrap();
1151        obj.insert("clock".into(), serde_json::json!("anchors"));
1152        obj.insert("anchor_state".into(), serde_json::json!(state));
1153    }
1154    row
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::render_health_markdown;
1160    use serde_json::json;
1161
1162    fn base_payload() -> serde_json::Value {
1163        json!({
1164            "summary": { "total_entities": 1 },
1165            "total_nodes": 1,
1166        })
1167    }
1168
1169    /// The description advertises `body_observations` on the conformance
1170    /// axis, and a description is not a shipped field: the drift gate
1171    /// compares description tokens against an allowlist, so it stayed green
1172    /// while no server emitted the key at all (grade, 2026-08-27). This
1173    /// pins the text channel end of it; the JSON end is pinned by the
1174    /// handler test in `ops::integrity`.
1175    #[test]
1176    fn body_observations_render_their_code_and_fate_not_just_an_id() {
1177        let mut v = base_payload();
1178        v["body_observations"] = json!([{
1179            "id": "specs--alpha",
1180            "code": "ABSORBED_SECTION",
1181            "fate": "absorbed",
1182            "detail": { "heading": "Rogue", "note": "survives the next write" },
1183        }, {
1184            "id": "specs--beta",
1185            "code": "UNDECLARED_METADATA_KEY",
1186            "fate": "dropped",
1187            "detail": { "key": "reviewer", "note": "the next write drops it" },
1188        }]);
1189        let md = render_health_markdown(&v);
1190        assert!(md.contains("## Body observations (2)"), "{md}");
1191        assert!(
1192            md.contains(
1193                "- specs--alpha [ABSORBED_SECTION] `Rogue`: absorbed (survives the next write)"
1194            ),
1195            "{md}"
1196        );
1197        assert!(
1198            md.contains(
1199                "- specs--beta [UNDECLARED_METADATA_KEY] `reviewer`: dropped \
1200                 (the next write drops it)"
1201            ),
1202            "{md}"
1203        );
1204        // Absent key renders nothing at all.
1205        assert!(!render_health_markdown(&base_payload()).contains("Body observations"));
1206    }
1207
1208    /// 04/06, criteria 2 and 4: the text channel names the condition.
1209    /// This is the surface an agent gets once the JSON exceeds
1210    /// `token_budget`, and it rendered all three dangling conditions as
1211    /// one indistinguishable `from → target` line while every other
1212    /// surface had been migrated — the last place the fused report
1213    /// survived.
1214    #[test]
1215    fn render_health_markdown_names_the_dangling_condition() {
1216        let mut v = base_payload();
1217        v["dangling_links"] = json!([
1218            {
1219                "kind": "DANGLING_LINK_TARGET_MISSING",
1220                "from": "specs--a", "target_id": "specs--gone",
1221                "target_path": "gone", "section": "purpose",
1222            },
1223            {
1224                "kind": "DANGLING_RELATION_TARGET_MISSING",
1225                "from": "specs--b", "target_id": "specs--vanished",
1226                "target_path": "vanished", "section": null,
1227            },
1228        ]);
1229        let md = render_health_markdown(&v);
1230        assert!(
1231            md.contains("[DANGLING_LINK_TARGET_MISSING] specs--a → specs--gone"),
1232            "{md}"
1233        );
1234        assert!(
1235            md.contains("[DANGLING_RELATION_TARGET_MISSING] specs--b → specs--vanished"),
1236            "{md}"
1237        );
1238        // The two conditions do not render as the same line shape.
1239        assert!(
1240            !md.contains("- specs--a → specs--gone"),
1241            "the unprefixed form is what fused them: {md}"
1242        );
1243    }
1244
1245    /// Text-channel parity: the `checks` / `stale_derivations` axes
1246    /// and the quarantine roster render their own sections — content
1247    /// when populated, the explicit zero statement when requested but
1248    /// empty (null is a statement), and NOTHING when the JSON key is
1249    /// absent: a payload without the keys renders byte-identically to
1250    /// itself with sections appended, never mutated.
1251    #[test]
1252    fn render_health_markdown_covers_checks_derivations_and_quarantine() {
1253        // Populated.
1254        let mut v = base_payload();
1255        v["checks"] = json!({
1256            "specs": {
1257                "never_checked": 2, "checked_ok": 1,
1258                "check_failed": 0, "check_stale": 0,
1259                "conformance": {
1260                    "never_checked": 3, "checked_ok": 0,
1261                    "check_failed": 0, "check_stale": 0,
1262                },
1263                "independence": {
1264                    "self_checked": { "count": 0, "items": [] },
1265                    "confirmed_independent": { "count": 0, "items": [] },
1266                    "unconfirmable": { "count": 1, "items": ["specs--a"] },
1267                },
1268            }
1269        });
1270        v["stale_derivations"] = json!({
1271            "specs": [{
1272                "source": "specs--a", "rel_type": "DERIVES_FROM",
1273                "target": "specs--b", "state": "stale",
1274                "baseline": "aaa", "current": "bbb",
1275            }]
1276        });
1277        v["quarantined"] = json!([{
1278            "mem": "broken",
1279            "reason_code": "SCHEMA_NOT_FOUND",
1280            "reason_message": "no schema; repair via memstead mem set-schema",
1281        }]);
1282        let md = render_health_markdown(&v);
1283        assert!(md.contains("## Checks (1 mems)"), "{md}");
1284        assert!(
1285            md.contains(
1286                "- `specs`: never_checked 2, checked_ok 1, check_failed 0, \
1287                 check_stale 0; conformance: never_checked 3, checked_ok 0, \
1288                 check_failed 0, check_stale 0; independence: self_checked 0, \
1289                 confirmed_independent 0, unconfirmable 1"
1290            ),
1291            "{md}"
1292        );
1293        assert!(md.contains("## Stale derivations (1 findings)"), "{md}");
1294        assert!(
1295            md.contains("- `specs`: specs--a -[DERIVES_FROM]-> specs--b (stale)"),
1296            "{md}"
1297        );
1298        assert!(md.contains("## Quarantined mems (1)"), "{md}");
1299        assert!(
1300            md.contains(
1301                "- `broken` [SCHEMA_NOT_FOUND] no schema; repair via memstead mem set-schema"
1302            ),
1303            "{md}"
1304        );
1305
1306        // Requested but empty → the explicit zero statement.
1307        let mut empty = base_payload();
1308        empty["checks"] = json!({});
1309        empty["stale_derivations"] = json!({ "specs": [] });
1310        let md = render_health_markdown(&empty);
1311        assert!(md.contains("## Checks (0 mems)"), "{md}");
1312        assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
1313
1314        // Keys absent (not requested) → byte-unchanged: no section,
1315        // and the populated render is the base render plus appendix.
1316        let base_md = render_health_markdown(&base_payload());
1317        for heading in ["## Checks", "## Stale derivations", "## Quarantined mems"] {
1318            assert!(
1319                !base_md.contains(heading),
1320                "absent key must render nothing: {base_md}"
1321            );
1322        }
1323        let appended = render_health_markdown(&v);
1324        assert!(
1325            appended.starts_with(&base_md),
1326            "sections append; the base output stays byte-identical"
1327        );
1328    }
1329}