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