Skip to main content

memstead_engine/
health.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] memstead_base::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 memstead_base::Engine,
81    args: &HealthArgs,
82    drift_warnings: Vec<memstead_base::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<memstead_base::WarningHint> = drift_warnings;
93    warnings.extend(health.warnings.clone());
94    if requested_limit > HEALTH_LIMIT_MAX {
95        warnings.push(memstead_base::WarningHint::LimitClamped {
96            requested: requested_limit,
97            actual: HEALTH_LIMIT_MAX,
98        });
99    }
100
101    for key in include {
102        if !memstead_base::ops::health::HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
103            warnings.push(memstead_base::WarningHint::UnknownIncludeKey {
104                key: key.clone(),
105                allowed: memstead_base::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: &memstead_base::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<memstead_base::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<(memstead_base::EntityId, Vec<memstead_base::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) => memstead_base::graph::community::clusters_in_mem(
188            engine.store(),
189            engine.communities(),
190            v,
191        )
192        .len(),
193        None => engine.communities().count,
194    };
195
196    // Edge counts: under a mem filter, count only source-in-mem edges
197    // (asymmetric — matches the legacy contract).
198    let (edge_count, edge_types) = {
199        if let Some(v) = vf {
200            let mut counts: HashMap<String, usize> = HashMap::new();
201            let mut total: usize = 0;
202            for id in engine.store().all_ids() {
203                let source_mem = engine.store().get(id).map(|e| e.mem.clone());
204                if let Some(source) = source_mem.as_deref()
205                    && source != v
206                {
207                    continue;
208                }
209                for edge in engine.store().outgoing(id) {
210                    *counts.entry(edge.rel_type.clone()).or_insert(0) += 1;
211                    total += 1;
212                }
213            }
214            let mut pairs: Vec<_> = counts.into_iter().collect();
215            pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
216            let arr: Vec<serde_json::Value> = pairs
217                .into_iter()
218                .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
219                .collect();
220            (total, arr)
221        } else {
222            let mut pairs: Vec<_> = stats.edge_types.iter().collect();
223            pairs.sort_by(|a, b| b.1.cmp(a.1));
224            let arr: Vec<serde_json::Value> = pairs
225                .into_iter()
226                .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
227                .collect();
228            (stats.edge_count, arr)
229        }
230    };
231
232    let type_distribution: Vec<serde_json::Value> = {
233        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
234        for e in engine
235            .store()
236            .all_entities()
237            .filter(|e| !e.stub && in_mem(e))
238        {
239            *counts.entry(&e.entity_type).or_default() += 1;
240        }
241        let mut pairs: Vec<_> = counts.into_iter().collect();
242        pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
243        pairs
244            .into_iter()
245            .map(|(s, c)| serde_json::json!({"type": s, "count": c}))
246            .collect()
247    };
248
249    let writable_mems: Vec<String> = {
250        let mut names: Vec<String> = engine
251            .mem_router()
252            .writable_mems()
253            .iter()
254            .cloned()
255            .collect();
256        names.sort();
257        names
258    };
259    // The stable default an omitted-`mem` mutation lands in — the first
260    // writable mount in declaration order, not `writable_mems[0]` of this
261    // alphabetically-sorted roster. Surfaced so an omitted-`mem` write is
262    // predictable.
263    let default_writable_mem: Option<String> = engine.default_writable_mem().map(|s| s.to_string());
264    let read_mems: Vec<String> = {
265        let writable_set: std::collections::HashSet<&String> =
266            engine.mem_router().writable_mems().iter().collect();
267        let mut names: Vec<String> = engine
268            .mem_router()
269            .visible_mems()
270            .iter()
271            .filter(|n| !writable_set.contains(*n))
272            .cloned()
273            .collect();
274        names.sort();
275        names
276    };
277
278    // Per-mem schema pins. Source from `engine.mount(name).schema` which
279    // carries the pinned `SchemaRef`; render via `as_display()` to get the
280    // same `name@version` form full emits.
281    //
282    // Every *visible* mem appears — writable and read-only alike — each
283    // carrying an explicit `writable` attribute. A read-only mount's pinned
284    // schema is real; surfacing it here (rather than filtering to writable
285    // mems) is what keeps health from reporting "no schema" while the
286    // discovery manifest names one. Writable mems render first (sorted),
287    // then read-only ones (sorted), so a normal writable workspace — which
288    // has no read mems — keeps its existing entry order, gaining only the
289    // `writable: true` attribute.
290    let mem_schemas: Vec<serde_json::Value> = {
291        let mut entries: Vec<serde_json::Value> = Vec::new();
292        let writable_set: std::collections::HashSet<&String> = writable_mems.iter().collect();
293        for name in writable_mems.iter().chain(read_mems.iter()) {
294            if let Some(v) = vf
295                && name != v
296            {
297                continue;
298            }
299            if let Some(m) = engine.mount(name) {
300                // The mem's *settled* pin — `Mount.schema` (now an optional
301                // assertion). During a dual-pin migration this stays the
302                // settled pin; the in-flight target is the separate
303                // `migration_target` surface below.
304                let schema_ref = m
305                    .schema
306                    .as_ref()
307                    .map(|s| s.as_display())
308                    .unwrap_or_default();
309                let mut entry = serde_json::json!({
310                    "mem": name,
311                    "schema": schema_ref,
312                    "writable": writable_set.contains(name),
313                });
314                // Dual-pin confirmation surface: present only while a
315                // migration is in flight, so settled mems' entries stay
316                // byte-identical to before.
317                if let Some(target) = &m.migration_target {
318                    entry["migration_target"] = serde_json::json!(target.as_display());
319                }
320                entries.push(entry);
321            }
322        }
323        entries
324    };
325
326    // #49: segment the orphan / community headlines by the owning mem's
327    // schema. A blended total mixes schemas with opposite norms — ingest
328    // mems, where each finding is an isolated entity (orphan by design),
329    // versus code/spec mems, where an orphan is real debt — so a bare
330    // "54 orphans" reads as 54 units of debt when most are by-design
331    // isolates. The raw `total_orphans` / `total_communities` are retained
332    // in `summary`, and a `mem`-scoped call still exposes per-mem counts
333    // (the refusal AC); these maps only attribute the totals by schema.
334    // `orphan_ids` is already mem-scoped above; scope the community
335    // attribution to the same mem set.
336    let orphans_by_schema = engine.orphans_by_schema(&orphan_ids);
337    let scope_mems: Vec<String> = match vf {
338        Some(v) => vec![v.to_string()],
339        None => writable_mems
340            .iter()
341            .chain(read_mems.iter())
342            .cloned()
343            .collect(),
344    };
345    let communities_by_schema = engine.communities_by_schema(&scope_mems);
346
347    let mut result = serde_json::json!({
348        "mem": mem_filter,
349        "summary": {
350            "total_entities": real_count,
351            "total_orphans": orphan_ids.len(),
352            "total_stubs": stub_pairs.len(),
353            "total_stale": health.stale_entities.iter().filter(|e| match vf {
354                Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
355                None => true,
356            }).count(),
357            "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
358                Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
359                None => true,
360            }).count(),
361            "total_communities": community_count,
362            "orphans_by_schema": orphans_by_schema,
363            "communities_by_schema": communities_by_schema,
364        },
365        "total_nodes": total_count,
366        "real_nodes": real_count,
367        "stub_nodes": stub_count,
368        "total_edges": edge_count,
369        "edge_types": edge_types,
370        "type_distribution": type_distribution,
371        "writable_mems": writable_mems,
372        "default_writable_mem": default_writable_mem,
373        "read_mems": read_mems,
374        "mem_schemas": mem_schemas,
375    });
376    let obj = result.as_object_mut().unwrap();
377    if !warnings.is_empty() {
378        obj.insert("warnings".into(), serde_json::json!(warnings));
379    }
380    // Quarantine roster — a boot-honesty fact, present whenever
381    // non-empty, never behind an include gate (agent-trust plan 04).
382    if !health.quarantined.is_empty() {
383        obj.insert(
384            "quarantined".into(),
385            serde_json::to_value(&health.quarantined).unwrap_or_default(),
386        );
387    }
388    if let Some(diag) = &health.boot_diagnosis {
389        obj.insert("boot_diagnosis".into(), diag.clone());
390    }
391    // Leaf populations — visible beside the orphan axis they exempt
392    // (agent-trust plan 06); omitted when no type declares leaf.
393    if !health.leaf_entities_by_type.is_empty() {
394        obj.insert(
395            "leaf_entities_by_type".into(),
396            serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
397        );
398    }
399
400    if include.iter().any(|s| s == "orphans") {
401        let orphans_list: Vec<serde_json::Value> = orphan_ids
402            .into_iter()
403            .map(|id| {
404                let title = engine
405                    .get_entity(&id)
406                    .map(|e| e.title.clone())
407                    .unwrap_or_default();
408                serde_json::json!({"id": id.to_string(), "title": title})
409            })
410            .collect();
411        obj.insert("orphans".into(), serde_json::json!(orphans_list));
412    }
413    if include.iter().any(|s| s == "stubs") {
414        let stubs_list: Vec<serde_json::Value> = stub_pairs
415            .into_iter()
416            .map(|(id, refs)| {
417                serde_json::json!({
418                    "id": id.to_string(),
419                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
420                })
421            })
422            .collect();
423        obj.insert("stubs".into(), serde_json::json!(stubs_list));
424    }
425    if include.iter().any(|s| s == "most_connected") {
426        use memstead_base::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
427        // `typed_*` is the dependency degree (excludes auto-emitted mention
428        // edges); the list is ranked by it so a co-mention hub doesn't
429        // outrank a real dependency hub. `total`/`incoming`/`outgoing` keep
430        // the mentions and stay available — mention degree = total - typed.
431        let to_json = |c: Connectivity| {
432            let title = engine
433                .get_entity(&c.id)
434                .map(|e| e.title.clone())
435                .unwrap_or_default();
436            serde_json::json!({
437                "id": c.id.to_string(),
438                "title": title,
439                "total": c.total,
440                "incoming": c.incoming,
441                "outgoing": c.outgoing,
442                "typed_total": c.typed_total,
443                "typed_incoming": c.typed_incoming,
444                "typed_outgoing": c.typed_outgoing,
445            })
446        };
447        let connected: Vec<serde_json::Value> = if let Some(v) = vf {
448            // Source-in-mem scoping, to match this response's `edge_types`
449            // / `total_edges`. The node is in-mem, so all of its outgoing
450            // edges are source-in-mem and counted; an incoming edge counts
451            // only when its source is also in-mem, so a cross-mem edge
452            // the aggregate excluded does not inflate the node's degree here.
453            let mut entries: Vec<Connectivity> = engine
454                .store()
455                .all_entities()
456                .filter(|e| !e.stub && e.mem == v)
457                .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
458                .collect();
459            entries.sort_by(cmp_by_dependency);
460            entries.truncate(limit);
461            entries.into_iter().map(to_json).collect()
462        } else {
463            engine
464                .most_connected(limit)
465                .into_iter()
466                .map(to_json)
467                .collect()
468        };
469        obj.insert("most_connected".into(), serde_json::json!(connected));
470    }
471    if include.iter().any(|s| s == "missing_fields") {
472        let missing_fields: Vec<serde_json::Value> = health
473            .missing_fields
474            .iter()
475            .filter(|h| match vf {
476                Some(v) => engine
477                    .store()
478                    .get(&h.id)
479                    .map(|e| e.mem == v)
480                    .unwrap_or(false),
481                None => true,
482            })
483            .map(|h| {
484                // `missing` (bare field names) stays byte-identical for
485                // existing consumers; the per-issue detail rides next
486                // to it so the projection carries WHICH condition each
487                // issue reports (a heading mismatch must never surface
488                // as "missing" only).
489                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
490                let issues: Vec<serde_json::Value> = h
491                    .issues
492                    .iter()
493                    .map(|i| {
494                        serde_json::json!({
495                            "field": i.field,
496                            "code": i.code,
497                            "message": i.message,
498                        })
499                    })
500                    .collect();
501                serde_json::json!({
502                    "id": h.id.to_string(),
503                    "title": h.title,
504                    "missing": missing,
505                    "issues": issues,
506                })
507            })
508            .collect();
509        obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
510    }
511    if include.iter().any(|s| s == "stale") {
512        let stale: Vec<serde_json::Value> = health
513            .stale_entities
514            .iter()
515            .filter(|e| match vf {
516                Some(v) => engine
517                    .store()
518                    .get(&e.id)
519                    .map(|ent| ent.mem == v)
520                    .unwrap_or(false),
521                None => true,
522            })
523            .map(|e| {
524                serde_json::json!({
525                    "id": e.id.to_string(),
526                    "title": e.title,
527                    "days_since_modified": e.days_since_modified,
528                })
529            })
530            .collect();
531        obj.insert("stale".into(), serde_json::json!(stale));
532    }
533    if include.iter().any(|s| s == "dangling_links") {
534        let dangling = memstead_base::ops::health::collect_dangling_links(engine.store(), vf);
535        let arr: Vec<serde_json::Value> = dangling
536            .into_iter()
537            .map(|dl| serde_json::to_value(&dl).unwrap())
538            .collect();
539        obj.insert("dangling_links".into(), serde_json::json!(arr));
540    }
541    if include.iter().any(|s| s == "anchors") {
542        obj.insert(
543            "anchors".into(),
544            memstead_base::ops::health::health_anchors_axis(engine),
545        );
546    }
547    if include.iter().any(|s| s == "stale_derivations") {
548        obj.insert(
549            "stale_derivations".into(),
550            memstead_base::ops::health::health_stale_derivations_axis(engine, args.mem),
551        );
552    }
553    if include.iter().any(|s| s == "checks") {
554        obj.insert(
555            "checks".into(),
556            memstead_base::ops::health::health_checks_axis(engine, args.mem),
557        );
558    }
559    if include.iter().any(|s| s == "open_questions") {
560        obj.insert(
561            "open_questions".into(),
562            memstead_base::ops::health::health_open_questions_axis(engine, args.mem),
563        );
564    }
565    if include.iter().any(|s| s == "friction") {
566        // The friction ledger's read surface (agent-trust plan 08):
567        // counts per code / per verb over the workspace-local refusal
568        // ledger, whole-ledger plus a recent 24h window. A workspace
569        // without a root (in-memory boots) or without a ledger yet
570        // serves the empty summary — the axis never fails health.
571        let summary = match engine.workspace_root() {
572            Some(root) => memstead_base::friction::FrictionLedger::for_workspace(root).summarize(),
573            None => serde_json::json!({
574                "total": 0,
575                "by_code": {},
576                "by_verb": {},
577                "recent_24h": { "total": 0, "by_code": {} },
578                "ledger_bytes": 0,
579            }),
580        };
581        obj.insert("friction".into(), summary);
582    }
583    if include.iter().any(|s| s == "missing_required_outgoing") {
584        let reports = engine.missing_required_outgoing(vf);
585        let arr: Vec<serde_json::Value> = reports
586            .into_iter()
587            .map(|r| serde_json::to_value(&r).unwrap())
588            .collect();
589        obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
590    }
591    if include.iter().any(|s| s == "constraints") {
592        let reports = engine.constraint_findings(vf);
593        let arr: Vec<serde_json::Value> = reports
594            .into_iter()
595            .map(|r| serde_json::to_value(&r).unwrap())
596            .collect();
597        obj.insert("constraints".into(), serde_json::json!(arr));
598        let defects = engine.schema_format_defects();
599        if !defects.is_empty() {
600            obj.insert(
601                "schema_format_defects".into(),
602                serde_json::to_value(&defects).unwrap(),
603            );
604        }
605    }
606    if include.iter().any(|s| s == "tags") {
607        let (distribution, folded, untagged) =
608            memstead_base::ops::health::collect_tag_distribution(engine.store(), vf, limit);
609        obj.insert(
610            "tag_distribution".into(),
611            serde_json::to_value(&distribution).unwrap(),
612        );
613        obj.insert(
614            "tag_distribution_folded".into(),
615            serde_json::to_value(&folded).unwrap(),
616        );
617        obj.insert(
618            "untagged_entities".into(),
619            serde_json::to_value(&untagged).unwrap(),
620        );
621    }
622    // Conformance axis (`conformance`), or both axes (`integrity`). Findings
623    // ride one flat `findings` list in the pinned `{ id, axis, code, detail }`
624    // shape; ids are mem-qualified so the flat list stays unambiguous when
625    // unscoped. Mems scan in sorted order and each mem's findings are
626    // deterministic, so the whole list is.
627    let wants_conformance = include
628        .iter()
629        .any(|s| s == "conformance" || s == "integrity");
630    if wants_conformance {
631        let wants_consistency = include.iter().any(|s| s == "integrity");
632        let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
633            None => None,
634            Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
635                Ok(r) => Some(r),
636                Err(reason) => {
637                    return Err(ComposeHealthError::InvalidTargetSchema {
638                        raw: raw.to_string(),
639                        reason,
640                    });
641                }
642            },
643        };
644        let scan_mems: Vec<String> = match vf {
645            Some(v) => vec![v.to_string()],
646            None => {
647                let mut all = writable_mems.clone();
648                all.sort();
649                all
650            }
651        };
652        let mut findings = Vec::new();
653        for v in &scan_mems {
654            findings.extend(engine.conformance_findings(v, target.as_ref())?);
655            if wants_consistency {
656                findings.extend(engine.consistency_findings(v)?);
657            }
658        }
659        obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
660    }
661
662    // Workspace policy surface — opt-in via `include_config: true`
663    // (the documented boolean alias) OR the catalogue key
664    // `include=["config"]`; both render the same projection, and
665    // passing both renders it once (a single gate). The rendering
666    // itself is shared with the CLI's `--include config` via
667    // `memstead_base::ops::health::config_projection` — one
668    // implementation, every surface. `mutations` + `plugin` are passed
669    // in via [`HealthConfig`] (server-owned copies, inserted verbatim).
670    if args.include_config || include.iter().any(|s| s == "config") {
671        let entries = memstead_base::ops::health::config_projection(
672            engine,
673            &writable_mems,
674            config.mutations.clone(),
675            config.plugin.clone(),
676        );
677        for (k, v) in entries {
678            obj.insert(k, v);
679        }
680    }
681
682    Ok(result)
683}
684
685/// Render a composed health payload as a human-readable markdown report
686/// for the MCP text channel. `structured_content` remains the source of
687/// truth (this is never parsed back); the markdown exists so the text
688/// channel is *chunkable* like `memstead_overview` instead of a wall of
689/// JSON that overflows the response cap under several includes. The
690/// size-driving include arrays each render as their own section so the
691/// chunker can split a large report cleanly.
692pub fn render_health_markdown(v: &serde_json::Value) -> String {
693    use std::fmt::Write as _;
694    let mut s = String::new();
695    let _ = writeln!(s, "# Graph health");
696    if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
697        let _ = writeln!(s, "\nMem filter: `{mem}`");
698    }
699
700    if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
701        let _ = writeln!(s, "\n## Summary");
702        for key in [
703            "total_entities",
704            "total_orphans",
705            "total_stubs",
706            "total_stale",
707            "total_missing_fields",
708            "total_communities",
709        ] {
710            if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
711                let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
712            }
713        }
714        render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
715        render_count_map(
716            &mut s,
717            sum.get("communities_by_schema"),
718            "Communities by schema",
719        );
720    }
721
722    for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
723        if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
724            let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
725        }
726    }
727
728    // Size-driving include arrays — one section each so chunking splits them.
729    for (key, title) in [
730        ("orphans", "Orphans"),
731        ("stubs", "Stubs"),
732        ("most_connected", "Most connected"),
733        ("missing_fields", "Missing fields"),
734        ("stale", "Stale"),
735        ("dangling_links", "Dangling links"),
736        ("missing_required_outgoing", "Missing required outgoing"),
737        ("constraints", "Constraint violations"),
738        ("findings", "Findings"),
739    ] {
740        if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
741            let _ = writeln!(s, "\n## {title} ({})", arr.len());
742            for item in arr {
743                let _ = writeln!(s, "- {}", summarize_health_item(item));
744            }
745        }
746    }
747
748    // Anchors axis — an object (mem → four counts), not an array, so it
749    // renders its own compact section.
750    if let Some(obj) = v.get("anchors").and_then(|x| x.as_object()) {
751        let _ = writeln!(s, "\n## Anchors ({} mems)", obj.len());
752        for (mem, counts) in obj {
753            let _ = writeln!(
754                s,
755                "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable {}",
756                counts["resolved"].as_u64().unwrap_or(0),
757                counts["drifted"].as_u64().unwrap_or(0),
758                counts["recheck"].as_u64().unwrap_or(0),
759                counts["unresolvable"].as_u64().unwrap_or(0),
760            );
761        }
762    }
763
764    // Checks axis — an object (mem → state counts + independence
765    // gate). Null-is-a-statement (the Friction pattern): a requested
766    // axis with no mems renders the explicit zero heading; an absent
767    // key (not requested) renders nothing.
768    if let Some(obj) = v.get("checks").and_then(|x| x.as_object()) {
769        let _ = writeln!(s, "\n## Checks ({} mems)", obj.len());
770        for (mem, c) in obj {
771            let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
772            let gate = |key: &str| {
773                c.get("independence")
774                    .and_then(|g| g.get(key))
775                    .and_then(|e| e.get("count"))
776                    .and_then(|x| x.as_u64())
777                    .unwrap_or(0)
778            };
779            let _ = writeln!(
780                s,
781                "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
782                 check_stale {}; independence: self_checked {}, \
783                 confirmed_independent {}, unconfirmable {}",
784                count("never_checked"),
785                count("checked_ok"),
786                count("check_failed"),
787                count("check_stale"),
788                gate("self_checked"),
789                gate("confirmed_independent"),
790                gate("unconfirmable"),
791            );
792        }
793    }
794
795    // Stale-derivations axis — an object (mem → findings list). Same
796    // requested-vs-absent contract as the checks axis above.
797    if let Some(obj) = v.get("stale_derivations").and_then(|x| x.as_object()) {
798        let total: usize = obj
799            .values()
800            .filter_map(|a| a.as_array().map(|a| a.len()))
801            .sum();
802        let _ = writeln!(s, "\n## Stale derivations ({total} findings)");
803        for (mem, findings) in obj {
804            for f in findings.as_array().into_iter().flatten() {
805                let _ = writeln!(
806                    s,
807                    "- `{mem}`: {} -[{}]-> {} ({})",
808                    f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
809                    f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
810                    f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
811                    f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
812                );
813            }
814        }
815    }
816
817    // Quarantine roster — ungated in the JSON (present whenever
818    // non-empty), so the text channel renders it whenever present:
819    // per mem the reason code plus the message, which carries the
820    // repair command.
821    if let Some(arr) = v.get("quarantined").and_then(|x| x.as_array()) {
822        let _ = writeln!(s, "\n## Quarantined mems ({})", arr.len());
823        for q in arr {
824            let _ = writeln!(
825                s,
826                "- `{}` [{}] {}",
827                q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
828                q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
829                q.get("reason_message")
830                    .and_then(|x| x.as_str())
831                    .unwrap_or(""),
832            );
833        }
834    }
835
836    if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
837        && !arr.is_empty()
838    {
839        let _ = writeln!(s, "\n## Warnings ({})", arr.len());
840        for w in arr {
841            let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
842            let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
843            let _ = writeln!(s, "- [{code}] {msg}");
844        }
845    }
846
847    s
848}
849
850/// Render a `{ key: count }` map as an indented sub-list under `title`,
851/// skipping an empty/missing map. The empty-string schema key (an unpinned
852/// mem) renders as `(unpinned)`.
853fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
854    use std::fmt::Write as _;
855    let Some(map) = val.and_then(|x| x.as_object()) else {
856        return;
857    };
858    if map.is_empty() {
859        return;
860    }
861    let _ = writeln!(s, "- {title}:");
862    for (k, n) in map {
863        let label = if k.is_empty() {
864            "(unpinned)"
865        } else {
866            k.as_str()
867        };
868        let _ = writeln!(s, "  - {label}: {}", n.as_u64().unwrap_or(0));
869    }
870}
871
872/// One-line summary of a health detail item: prefer `id` (+ `title`),
873/// else a dangling-link `from → target_id`, else the compact JSON.
874fn summarize_health_item(item: &serde_json::Value) -> String {
875    if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
876        match item.get("title").and_then(|x| x.as_str()) {
877            Some(t) if !t.is_empty() => format!("{id} — {t}"),
878            _ => id.to_string(),
879        }
880    } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
881        let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
882        format!("{from} → {target}")
883    } else {
884        serde_json::to_string(item).unwrap_or_default()
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::render_health_markdown;
891    use serde_json::json;
892
893    fn base_payload() -> serde_json::Value {
894        json!({
895            "summary": { "total_entities": 1 },
896            "total_nodes": 1,
897        })
898    }
899
900    /// Text-channel parity: the `checks` / `stale_derivations` axes
901    /// and the quarantine roster render their own sections — content
902    /// when populated, the explicit zero statement when requested but
903    /// empty (null is a statement), and NOTHING when the JSON key is
904    /// absent: a payload without the keys renders byte-identically to
905    /// itself with sections appended, never mutated.
906    #[test]
907    fn render_health_markdown_covers_checks_derivations_and_quarantine() {
908        // Populated.
909        let mut v = base_payload();
910        v["checks"] = json!({
911            "specs": {
912                "never_checked": 2, "checked_ok": 1,
913                "check_failed": 0, "check_stale": 0,
914                "independence": {
915                    "self_checked": { "count": 0, "items": [] },
916                    "confirmed_independent": { "count": 0, "items": [] },
917                    "unconfirmable": { "count": 1, "items": ["specs--a"] },
918                },
919            }
920        });
921        v["stale_derivations"] = json!({
922            "specs": [{
923                "source": "specs--a", "rel_type": "DERIVES_FROM",
924                "target": "specs--b", "state": "stale",
925                "baseline": "aaa", "current": "bbb",
926            }]
927        });
928        v["quarantined"] = json!([{
929            "mem": "broken",
930            "reason_code": "SCHEMA_NOT_FOUND",
931            "reason_message": "no schema; repair via memstead mem set-schema",
932        }]);
933        let md = render_health_markdown(&v);
934        assert!(md.contains("## Checks (1 mems)"), "{md}");
935        assert!(
936            md.contains(
937                "- `specs`: never_checked 2, checked_ok 1, check_failed 0, \
938                 check_stale 0; independence: self_checked 0, \
939                 confirmed_independent 0, unconfirmable 1"
940            ),
941            "{md}"
942        );
943        assert!(md.contains("## Stale derivations (1 findings)"), "{md}");
944        assert!(
945            md.contains("- `specs`: specs--a -[DERIVES_FROM]-> specs--b (stale)"),
946            "{md}"
947        );
948        assert!(md.contains("## Quarantined mems (1)"), "{md}");
949        assert!(
950            md.contains(
951                "- `broken` [SCHEMA_NOT_FOUND] no schema; repair via memstead mem set-schema"
952            ),
953            "{md}"
954        );
955
956        // Requested but empty → the explicit zero statement.
957        let mut empty = base_payload();
958        empty["checks"] = json!({});
959        empty["stale_derivations"] = json!({ "specs": [] });
960        let md = render_health_markdown(&empty);
961        assert!(md.contains("## Checks (0 mems)"), "{md}");
962        assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
963
964        // Keys absent (not requested) → byte-unchanged: no section,
965        // and the populated render is the base render plus appendix.
966        let base_md = render_health_markdown(&base_payload());
967        for heading in ["## Checks", "## Stale derivations", "## Quarantined mems"] {
968            assert!(
969                !base_md.contains(heading),
970                "absent key must render nothing: {base_md}"
971            );
972        }
973        let appended = render_health_markdown(&v);
974        assert!(
975            appended.starts_with(&base_md),
976            "sections append; the base output stays byte-identical"
977        );
978    }
979}