Skip to main content

memstead_base/
render.rs

1//! Markdown rendering of Engine result types.
2//!
3//! Shared by `memstead-mcp` (wraps output in MCP `CallToolResult`) and
4//! `memstead-cli` (prints directly to stdout).
5
6use std::collections::HashMap;
7use std::sync::{Arc, OnceLock};
8
9use memstead_schema::{
10    FieldType, Filterable, ManualAuthoring, PerEdgeDescription, RelationshipMode, Schema,
11    Serialization, TypeDefinition, all_types, type_by_name,
12};
13use serde::Serialize;
14
15use crate::chunking::estimate_tokens;
16use crate::graph::community::generate_auto_summary;
17use crate::ops::Direction;
18use crate::ops::{ExpansionInfo, Facets, ScoreBreakdown, SubsectionFacet, TermMatch};
19use crate::store::Store;
20use crate::{
21    ContextResult, Edge, Entity, InEdge, ListResult, LouvainOutput, SearchHit, SearchResult,
22};
23
24// ---------------------------------------------------------------------------
25// Entity rendering
26// ---------------------------------------------------------------------------
27
28/// Render a single entity as markdown with frontmatter metadata.
29///
30/// Projection-free by contract: this is the canonical form (anchor
31/// hashing, export, parser round-trips). Serving surfaces that
32/// present declared signals or the grounded labelling call
33/// [`render_entity_markdown_with_signals`] instead — computed values
34/// are a projection and must never enter the canonical bytes.
35pub fn render_entity_markdown(entity: &Entity, sections_filter: Option<&[String]>) -> String {
36    render_entity_markdown_with_signals(entity, sections_filter, None, None)
37}
38
39/// Serving-surface variant of [`render_entity_markdown`]: when the
40/// entity's type declares signals, the headline (`name`, `value`,
41/// `level` per signal) rides in the frontmatter block — the one
42/// pre-body slot the format has — and the contributors in a
43/// `## Signals` section appended after the body, in the style of
44/// `## Relations`. When the mem's schema declares labelling, the
45/// grounded label rides as `_label` in the frontmatter and the
46/// evidence in a `## Labelling` section. `None`/`None` renders
47/// byte-identically to the canonical form.
48pub fn render_entity_markdown_with_signals(
49    entity: &Entity,
50    sections_filter: Option<&[String]>,
51    signals: Option<&[crate::ops::signals::ComputedSignal]>,
52    labelling: Option<&crate::ops::labelling::LabellingView>,
53) -> String {
54    let body_text = render_entity_body(entity, sections_filter);
55
56    // Frontmatter — _tokens reflects the rendered output, not the full entity.
57    let mut lines = Vec::new();
58    lines.push("---".to_string());
59    lines.push(format!("_hash: {}", entity.content_hash));
60    // Typed stub provenance — only emitted when the entity carries
61    // a `stub_kind` (real entities are absent from this surface).
62    // Agents reading a stub three calls after the mutation that
63    // produced it recover the diagnostic context that the
64    // mutation-time warning carried.
65    if let Some(kind) = &entity.stub_kind {
66        match kind {
67            crate::entity::StubKind::ForwardReference => {
68                lines.push("_stub_kind: forward_reference".to_string());
69            }
70            crate::entity::StubKind::LoadTime => {
71                lines.push("_stub_kind: load_time".to_string());
72            }
73            crate::entity::StubKind::Residual {
74                since_commit,
75                readonly_referrers,
76            } => {
77                lines.push("_stub_kind: residual".to_string());
78                if !since_commit.is_empty() {
79                    lines.push(format!("_stub_since_commit: {since_commit}"));
80                }
81                if !readonly_referrers.is_empty() {
82                    let refs: Vec<String> =
83                        readonly_referrers.iter().map(|r| r.to_string()).collect();
84                    lines.push(format!("_stub_readonly_referrers: [{}]", refs.join(", ")));
85                }
86            }
87        }
88    }
89    // Signal headline — name, value, level per declared signal, in
90    // declaration order. The contributors ride in the appended
91    // `## Signals` section below, never here.
92    if let Some(sigs) = signals
93        && !sigs.is_empty()
94    {
95        let headline: Vec<String> = sigs
96            .iter()
97            .map(|s| format!("{}: {} ({})", s.name, s.value, s.level_wire()))
98            .collect();
99        lines.push(format!("_signals: [{}]", headline.join(", ")));
100    }
101    // Grounded-label headline; the evidence rides in the appended
102    // `## Labelling` section below.
103    if let Some(lab) = labelling {
104        lines.push(format!("_label: {}", lab.label.wire()));
105    }
106    // The text channel's half of `_unread_sections`. The JSON envelope carries
107    // the structured block; a reader on this channel sees the same sections
108    // rendered blank and, without this line, has nothing to tell them apart
109    // from sections the author left empty. This is the channel a cold agent
110    // reads, so it cannot be the one that stays quiet (04/02, criterion 4).
111    if let Some((absorbing, _)) = entity.sections.iter().find_map(|(k, v)| {
112        crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
113    }) {
114        let unread: Vec<&str> = entity
115            .sections
116            .iter()
117            .filter(|(k, v)| **k != absorbing && v.trim().is_empty())
118            .map(|(k, _)| k.as_str())
119            .collect();
120        if !unread.is_empty() {
121            lines.push(format!(
122                "_unread_sections: [{}] NOT empty: an unterminated code fence in `{absorbing}` \
123                 swallowed them, and their content is inside that section's body",
124                unread.join(", "),
125            ));
126        }
127    }
128    let tokens = estimate_tokens(&body_text);
129    lines.push(format!("_tokens: {tokens}"));
130
131    // When sections are filtered and some were excluded, show full entity size
132    // so agents know how much they're missing.
133    let is_filtered = sections_filter.is_some_and(|f| {
134        let all_keys: Vec<&String> = entity.sections.keys().collect();
135        f.len() < all_keys.len() || !all_keys.iter().all(|k| f.iter().any(|fk| fk == *k))
136    });
137    if is_filtered {
138        let full_body = render_entity_body(entity, None);
139        let full_tokens = estimate_tokens(&full_body);
140        lines.push(format!("_tokens_unfiltered_body: {full_tokens}"));
141    }
142
143    // Emit entity metadata
144    for (key, value) in &entity.metadata {
145        lines.push(format!("{key}: {value}"));
146    }
147    lines.push("---".to_string());
148    lines.push(String::new());
149
150    lines.push(body_text);
151
152    // Contributors — the evidence ships with the number, always. One
153    // bullet per signal, mirroring the `## Relations` append style.
154    if let Some(sigs) = signals
155        && !sigs.is_empty()
156    {
157        lines.push(String::new());
158        lines.push("## Signals".to_string());
159        lines.push(String::new());
160        for s in sigs {
161            if s.contributors.is_empty() {
162                lines.push(format!(
163                    "- **{}**: {} ({})",
164                    s.name,
165                    s.value,
166                    s.level_wire()
167                ));
168            } else {
169                let ids: Vec<String> = s.contributors.iter().map(|c| c.to_string()).collect();
170                lines.push(format!(
171                    "- **{}**: {} ({}) — {}",
172                    s.name,
173                    s.value,
174                    s.level_wire(),
175                    ids.join(", ")
176                ));
177            }
178        }
179    }
180    // Labelling evidence — a defeated label always carries its
181    // accepted direct attackers (one unanswered counter-claim
182    // flipping a well-supported claim is visible as exactly that);
183    // an undecided label the open attacker set that keeps it open.
184    if let Some(lab) = labelling {
185        lines.push(String::new());
186        lines.push("## Labelling".to_string());
187        lines.push(String::new());
188        lines.push(format!("- label: {}", lab.label.wire()));
189        if !lab.defeated_by.is_empty() {
190            lines.push(format!("- defeated_by: {}", lab.defeated_by.join(", ")));
191        }
192        if !lab.undecided_by.is_empty() {
193            lines.push(format!("- undecided_by: {}", lab.undecided_by.join(", ")));
194        }
195        if let Some(shape) = &lab.shape {
196            let share = match shape.terminal_share {
197                Some(s) => format!("{s:.2}"),
198                None => "null".to_string(),
199            };
200            lines.push(format!(
201                "- shape: depth {}, branching {:.2}, terminal_share {}, defeated_in_support {}, undecided_in_support {}",
202                shape.depth,
203                shape.branching,
204                share,
205                shape.defeated_in_support,
206                shape.undecided_in_support,
207            ));
208        }
209    }
210    lines.join("\n")
211}
212
213/// Token estimate for an entity's rendered body (title + sections +
214/// relationships, filter applied) — the exact number `render_entity_markdown`
215/// embeds as its frontmatter `_tokens`. Use this when building a structured
216/// envelope so the envelope's `_tokens` and the markdown channel's frontmatter
217/// `_tokens` describe the *same* thing for a given `_hash`: the rendered body,
218/// not the full markdown document (which would additionally count frontmatter).
219pub fn rendered_body_tokens(entity: &Entity, sections_filter: Option<&[String]>) -> usize {
220    estimate_tokens(&render_entity_body(entity, sections_filter))
221}
222
223/// Build the body (title + sections + relationships) for an entity, optionally filtered.
224///
225/// Section iteration order follows `entity.sections` — an `IndexMap`, so
226/// insertion order is the authoritative render order. The parser inserts keys
227/// in the schema's declared order, which is what ships to clients. Do not
228/// migrate `entity.sections` back to `HashMap`.
229fn render_entity_body(entity: &Entity, sections_filter: Option<&[String]>) -> String {
230    let mut body = Vec::new();
231
232    body.push(format!("# {}", entity.title));
233    body.push(String::new());
234
235    // Look up the entity's TypeDefinition across every built-in schema
236    // so non-default schemas (e.g. `ingest.inconsistency`) get their
237    // declared headings rendered exactly as the on-disk markdown
238    // emitted them. Falls back to key→heading derivation when no
239    // built-in schema declares this type — preserves the prior shape
240    // for custom workspace schemas not yet bridged through the
241    // renderer.
242    let type_def = lookup_builtin_type(&entity.entity_type);
243
244    for (key, content) in &entity.sections {
245        if let Some(filter) = sections_filter
246            && !filter.iter().any(|f| f == key)
247        {
248            continue;
249        }
250        let heading = section_heading_for(type_def.as_deref(), key);
251        body.push(format!("## {heading}"));
252        body.push(String::new());
253        body.push(content.trim().to_string());
254        body.push(String::new());
255    }
256
257    if !entity.relationships.is_empty()
258        && sections_filter.is_none_or(|f| f.iter().any(|s| s == "relationships"))
259    {
260        body.push("## Relationships".to_string());
261        body.push(String::new());
262        for rel in &entity.relationships {
263            // Mirror the on-disk renderer (`entity::generator`):
264            // canonical em-dash delimiter when the relation carries a
265            // per-edge description, simple form otherwise.
266            match rel
267                .description
268                .as_deref()
269                .map(str::trim)
270                .filter(|s| !s.is_empty())
271            {
272                Some(text) => body.push(format!(
273                    "- **{}**: [[{}]] \u{2014} {text}",
274                    rel.rel_type, rel.target
275                )),
276                None => body.push(format!("- **{}**: [[{}]]", rel.rel_type, rel.target)),
277            }
278        }
279        body.push(String::new());
280    }
281
282    body.join("\n")
283}
284
285/// Render a `## Relations` section as markdown — typed edges grouped by
286/// direction. Appended to `memstead_entity` output when `include_relations: true`.
287/// A JSON-shaped version is available via `render_relations_json` for the
288/// `memstead-cli relations --json` consumer.
289pub fn render_relations_markdown(
290    entity_id: &str,
291    outgoing: &[Edge],
292    incoming: &[InEdge],
293) -> String {
294    let mut lines = Vec::new();
295    lines.push(String::new());
296    lines.push("## Relations".to_string());
297    lines.push(String::new());
298
299    if outgoing.is_empty() && incoming.is_empty() {
300        lines.push(format!("(no relations for {entity_id})"));
301        lines.push(String::new());
302        return lines.join("\n");
303    }
304
305    if !outgoing.is_empty() {
306        lines.push("### Outgoing".to_string());
307        for e in outgoing {
308            lines.push(format!("- **{}** → [[{}]]", e.rel_type, e.target));
309        }
310        lines.push(String::new());
311    }
312
313    if !incoming.is_empty() {
314        lines.push("### Incoming".to_string());
315        for e in incoming {
316            lines.push(format!("- [[{}]] → **{}** → (this)", e.from, e.rel_type));
317        }
318        lines.push(String::new());
319    }
320
321    lines.join("\n")
322}
323
324/// Render outgoing/incoming relations as a JSON envelope. Consumed by
325/// `memstead-cli relations --json`; no MCP path uses it.
326pub fn render_relations_json(
327    entity_id: &str,
328    outgoing: &[Edge],
329    incoming: &[InEdge],
330) -> serde_json::Value {
331    let out: Vec<serde_json::Value> = outgoing
332        .iter()
333        .map(|e| {
334            serde_json::json!({
335                "rel_type": e.rel_type,
336                "target": e.target.to_string(),
337                "source": format!("{:?}", e.source).to_lowercase(),
338            })
339        })
340        .collect();
341
342    let inc: Vec<serde_json::Value> = incoming
343        .iter()
344        .map(|e| {
345            serde_json::json!({
346                "rel_type": e.rel_type,
347                "from": e.from.to_string(),
348                "source": format!("{:?}", e.source).to_lowercase(),
349            })
350        })
351        .collect();
352
353    serde_json::json!({
354        "entity": entity_id,
355        "outgoing": out,
356        "incoming": inc,
357    })
358}
359
360// ---------------------------------------------------------------------------
361// Search / List rendering
362// ---------------------------------------------------------------------------
363
364/// Render search results as markdown.
365pub fn render_search_markdown(result: &SearchResult, offset: usize) -> String {
366    let mut lines = Vec::new();
367
368    lines.push("---".to_string());
369    lines.push(format!("_total: {}", result.total));
370    lines.push(format!("_returned: {}", result.returned));
371    lines.push(format!("_offset: {offset}"));
372    lines.push(format!("_total_tokens: {}", result.total_tokens));
373    lines.push("---".to_string());
374    lines.push(String::new());
375
376    if !result.warnings.is_empty() {
377        // Render each search warning with its typed code as the lead — same
378        // shape mutation-tool `## Warnings` blocks already use — so an
379        // agent reading the markdown sees the code without decoding
380        // the structured channel.
381        lines.push("## Filter warnings".to_string());
382        for w in &result.warnings {
383            lines.push(format!("- **{}**: {}", w.code(), w.message()));
384        }
385        lines.push(String::new());
386    }
387
388    if let Some(facets) = &result.facets
389        && let Some(block) = render_facets_block(facets)
390    {
391        lines.push(block);
392    }
393
394    for hit in &result.hits {
395        lines.push(format!(
396            "### {} — {} (_score: {:.1}, _tokens: {})",
397            hit.id, hit.title, hit.score, hit.tokens,
398        ));
399        lines.push(hit_summary_line(hit));
400        if let Some(line) = render_matched_terms_line(hit.matched_terms.as_ref()) {
401            lines.push(line);
402        }
403        if let Some(line) = render_score_breakdown_line(hit.score_breakdown.as_ref()) {
404            lines.push(line);
405        }
406        if let Some(line) = render_heading_paths_line(hit.matched_terms.as_ref()) {
407            lines.push(line);
408        }
409        if let Some(line) = render_expansion_line(hit.expansion.as_ref()) {
410            lines.push(line);
411        }
412        if let Some(snippet) = &hit.snippet {
413            lines.push(format!("> ...{snippet}..."));
414        }
415        lines.push(String::new());
416    }
417
418    lines.join("\n")
419}
420
421/// Render the `## Facets` block for a `SearchResult`. Returns `None` when
422/// every facet bucket is empty — callers elide the section entirely in
423/// that case. Buckets with mixed presence each ship independently.
424///
425/// Ordering: keys inside a bucket sort by count desc, then key asc so the
426/// output is deterministic for tests. `by_subsection` uses its native
427/// stored order (already sorted by count desc in `ops::search`).
428fn render_facets_block(facets: &Facets) -> Option<String> {
429    let blocks: Vec<(&str, String)> = [
430        ("by_type", &facets.by_type),
431        ("by_mem", &facets.by_mem),
432        ("by_level", &facets.by_level),
433        ("by_status", &facets.by_status),
434        ("by_confidence", &facets.by_confidence),
435        ("by_expansion", &facets.by_expansion),
436    ]
437    .into_iter()
438    .filter_map(|(name, bucket)| format_facet_bucket(bucket).map(|s| (name, s)))
439    .collect();
440
441    if blocks.is_empty() && facets.by_subsection.is_empty() {
442        return None;
443    }
444
445    let mut out = String::new();
446    out.push_str("## Facets\n");
447    for (name, body) in blocks {
448        out.push_str(&format!("- **{name}:** {body}\n"));
449    }
450    if !facets.by_subsection.is_empty() {
451        out.push_str("- **by_subsection:**\n");
452        for entry in &facets.by_subsection {
453            out.push_str(&format!("  - {}\n", format_subsection_facet(entry)));
454        }
455    }
456    Some(out)
457}
458
459fn format_facet_bucket(bucket: &HashMap<String, usize>) -> Option<String> {
460    if bucket.is_empty() {
461        return None;
462    }
463    let mut entries: Vec<(&String, &usize)> = bucket.iter().collect();
464    entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
465    Some(
466        entries
467            .iter()
468            .map(|(k, v)| format!("{k}={v}"))
469            .collect::<Vec<_>>()
470            .join(", "),
471    )
472}
473
474fn format_subsection_facet(entry: &SubsectionFacet) -> String {
475    let path = entry.path.join(" › ");
476    format!("`{path}`: {}", entry.count)
477}
478
479/// Render the `**Matched terms:**` line for one hit. `matched_terms`
480/// groups `TermMatch`es per query term; output is one `term (field×N, ...)`
481/// group per term, joined with `, `. Terms and fields both sort
482/// alphabetically for deterministic output.
483fn render_matched_terms_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
484    let matched = matched?;
485    if matched.is_empty() {
486        return None;
487    }
488    let mut terms: Vec<(&String, &Vec<TermMatch>)> = matched.iter().collect();
489    terms.sort_by(|a, b| a.0.cmp(b.0));
490    let groups: Vec<String> = terms
491        .iter()
492        .map(|(term, tms)| {
493            let mut field_counts: HashMap<&str, usize> = HashMap::new();
494            for tm in tms.iter() {
495                *field_counts.entry(tm.field.as_str()).or_insert(0) += 1;
496            }
497            let mut fields: Vec<(&&str, &usize)> = field_counts.iter().collect();
498            fields.sort_by(|a, b| a.0.cmp(b.0));
499            let inner: Vec<String> = fields.iter().map(|(f, n)| format!("{f}×{n}")).collect();
500            format!("`{term}` ({})", inner.join(", "))
501        })
502        .collect();
503    Some(format!("**Matched terms:** {}", groups.join(", ")))
504}
505
506/// Render the `**Score:**` line from a `ScoreBreakdown`. Fields render as
507/// `bm25 X.X + title X.X + <field> X.X [+ expansion_decay ×X.X]`. Zero-
508/// valued components still ship — the breakdown is informational, and the
509/// composition "title 0.0" is itself a fact worth surfacing.
510fn render_score_breakdown_line(breakdown: Option<&ScoreBreakdown>) -> Option<String> {
511    let b = breakdown?;
512    let mut parts: Vec<String> = Vec::new();
513    parts.push(format!("bm25 {:.1}", b.bm25));
514    parts.push(format!("title {:.1}", b.title_boost));
515    let mut fields: Vec<(&String, &f32)> = b.field_weights.iter().collect();
516    fields.sort_by(|a, b| a.0.cmp(b.0));
517    for (k, v) in fields {
518        parts.push(format!("{k} {v:.1}"));
519    }
520    if let Some(decay) = b.expansion_decay {
521        parts.push(format!("expansion_decay ×{decay:.1}"));
522    }
523    Some(format!("**Score:** {}", parts.join(" + ")))
524}
525
526/// Render the `**Heading path:**` line for one hit. Collects distinct
527/// non-empty `heading_path`s across the hit's `TermMatch`es. Single path
528/// renders inline (`A › B`), multiple paths render as `A › B; C › D`.
529fn render_heading_paths_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
530    let matched = matched?;
531    let mut paths: Vec<Vec<String>> = Vec::new();
532    let mut term_keys: Vec<&String> = matched.keys().collect();
533    term_keys.sort();
534    for term in term_keys {
535        for tm in &matched[term] {
536            if let Some(path) = &tm.heading_path
537                && !path.is_empty()
538                && !paths.iter().any(|p| p == path)
539            {
540                paths.push(path.clone());
541            }
542        }
543    }
544    if paths.is_empty() {
545        return None;
546    }
547    let formatted: Vec<String> = paths.iter().map(|p| p.join(" › ")).collect();
548    Some(format!("**Heading path:** {}", formatted.join("; ")))
549}
550
551/// Render the `**Expansion:**` line for one hit — `from <id> via <edge>
552/// [out|in] (depth N)`. The direction rides wherever the label does,
553/// so a `both` walk stays interpretable per hit.
554fn render_expansion_line(expansion: Option<&ExpansionInfo>) -> Option<String> {
555    let e = expansion?;
556    let dir = match e.via_direction {
557        crate::graph::query::TraversalDirection::Out => "out",
558        crate::graph::query::TraversalDirection::In => "in",
559        // A concrete reaching edge always has one direction; `Both`
560        // cannot occur here by construction.
561        crate::graph::query::TraversalDirection::Both => "both",
562    };
563    Some(format!(
564        "**Expansion:** from `{}` via `{}` [{dir}] (depth {})",
565        e.of, e.via_edge, e.depth,
566    ))
567}
568
569/// Render list results as markdown.
570pub fn render_list_markdown(result: &ListResult) -> String {
571    let mut lines = Vec::new();
572
573    lines.push("---".to_string());
574    lines.push(format!("_total: {}", result.total));
575    lines.push(format!("_returned: {}", result.returned));
576    lines.push(format!("_offset: {}", result.offset));
577    lines.push(format!("_total_tokens: {}", result.total_tokens));
578    lines.push("---".to_string());
579    lines.push(String::new());
580
581    if !result.warnings.is_empty() {
582        lines.push("## Filter warnings".to_string());
583        for w in &result.warnings {
584            lines.push(format!("- **{}**: {}", w.code(), w.message()));
585        }
586        lines.push(String::new());
587    }
588
589    for hit in &result.hits {
590        let meta = hit
591            .sections
592            .get("level")
593            .map(|l| format!("{l}, "))
594            .unwrap_or_default();
595        lines.push(format!(
596            "### {} — {} ({meta}_tokens: {})",
597            hit.id, hit.title, hit.tokens,
598        ));
599        lines.push(hit_summary_line(hit));
600        lines.push(String::new());
601    }
602
603    lines.join("\n")
604}
605
606// ---------------------------------------------------------------------------
607// Context / Overview rendering
608// ---------------------------------------------------------------------------
609
610/// Render a `## Community Context` section — cluster id + neighbor list —
611/// appended to `memstead_entity` output when `include_context: true`. No
612/// frontmatter; the entity body owns that.
613pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
614    let mut lines = Vec::new();
615    lines.push(String::new());
616    lines.push("## Community Context".to_string());
617    lines.push(String::new());
618    lines.push(format!("**Cluster {cluster_id}**"));
619    lines.push(String::new());
620
621    if !result.neighbors.is_empty() {
622        lines.push("### Neighbors".to_string());
623        for n in &result.neighbors {
624            let dir = match n.direction {
625                Direction::Outgoing => "→",
626                Direction::Incoming => "←",
627            };
628            lines.push(format!(
629                "- {} —{}— **{}** ({})",
630                result.entity_id, dir, n.id, n.relationship,
631            ));
632        }
633        lines.push(String::new());
634    }
635
636    lines.join("\n")
637}
638
639/// Render context (community cluster) as markdown.
640pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
641    let mut lines = Vec::new();
642
643    lines.push("---".to_string());
644    lines.push(format!("_cluster_id: {cluster_id}"));
645    lines.push("---".to_string());
646    lines.push(String::new());
647    lines.push(format!("## Cluster {cluster_id}"));
648    lines.push(String::new());
649
650    // Neighbors grouped by direction
651    lines.push("### Neighbors".to_string());
652    for n in &result.neighbors {
653        let dir = match n.direction {
654            Direction::Outgoing => "→",
655            Direction::Incoming => "←",
656        };
657        lines.push(format!(
658            "- {} —{}— **{}** ({})",
659            result.entity_id, dir, n.id, n.relationship,
660        ));
661    }
662    lines.push(String::new());
663
664    lines.join("\n")
665}
666
667/// Render overview (all clusters) as markdown. `store` provides entity titles
668/// for the on-the-fly auto-summary (title-join) — there is no stored summary.
669pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
670    let mut lines = Vec::new();
671
672    let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
673
674    lines.push("---".to_string());
675    lines.push(format!("_cluster_count: {}", output.count));
676    lines.push(format!("_entity_count: {entity_count}"));
677    // Use compact formatting to match JS: "0" instead of "0.0000"
678    let mod_str = if output.modularity == 0.0 {
679        "0".to_string()
680    } else {
681        format!("{:.4}", output.modularity)
682    };
683    lines.push(format!("_modularity: {mod_str}"));
684    lines.push("---".to_string());
685    lines.push(String::new());
686
687    // Sort clusters by ID for deterministic output
688    let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
689    cluster_ids.sort();
690
691    for cluster_id in cluster_ids {
692        let info = &output.clusters[cluster_id];
693        let summary = generate_auto_summary(store, &info.entities);
694
695        lines.push(format!(
696            "## Cluster {cluster_id} ({} entities)",
697            info.entities.len(),
698        ));
699        if !summary.is_empty() {
700            lines.push(summary);
701        }
702        for entity_id in &info.entities {
703            lines.push(format!("- {entity_id}"));
704        }
705        lines.push(String::new());
706    }
707
708    lines.join("\n")
709}
710
711// ---------------------------------------------------------------------------
712// JSON envelopes for search / list — consumed by `memstead-cli` only
713// ---------------------------------------------------------------------------
714//
715// These wrap the core `SearchResult` / `ListResult` with precomputed
716// `summary_heading` / `summary_value` per hit — the same values the
717// markdown renderer emits — so the CLI's `--json` output doesn't
718// reimplement schema lead-section lookup. The MCP side carries no JSON
719// sidecar; these envelopes remain on the `memstead-cli search --json` /
720// `memstead-cli list --json` path.
721//
722// Snake-case field names are intentional: they match on-disk YAML and the
723// core `SearchHit` struct. Do not add `rename_all = "camelCase"`.
724
725/// Envelope wrapping a `SearchHit` with precomputed summary fields.
726#[derive(Serialize)]
727pub struct SearchHitEnvelope<'a> {
728    #[serde(flatten)]
729    pub hit: &'a SearchHit,
730    pub summary_heading: String,
731    pub summary_value: String,
732}
733
734/// Envelope for a full `SearchResult`:
735/// `_-prefixed` engine-emitted counters at the top level, `facets`
736/// as a structured object (not a markdown blob), and the full per-hit
737/// shape (score, score_breakdown, matched_terms, expansion) inherited
738/// verbatim from `SearchHit` so the structured envelope is the
739/// branching surface — agents reading `structured_content` don't have
740/// to parse the text channel's rendered prose to recover scores or
741/// score components. CLI `--json` and MCP `structured_content` share
742/// this shape.
743#[derive(Serialize)]
744pub struct SearchResultEnvelope<'a> {
745    #[serde(rename = "_total")]
746    pub total: usize,
747    #[serde(rename = "_returned")]
748    pub returned: usize,
749    #[serde(rename = "_offset")]
750    pub offset: usize,
751    /// Sum of estimated tokens across all matching entities (pre-pagination).
752    /// Mirrors `ListResultEnvelope.total_tokens` so the field has consistent
753    /// meaning across both surfaces — migration cost for agents is zero.
754    #[serde(rename = "_total_tokens")]
755    pub total_tokens: usize,
756    pub hits: Vec<SearchHitEnvelope<'a>>,
757    /// Faceted counts over the unpaginated hit set. Skipped on the
758    /// wire when the engine produced no facets (rare; the unified
759    /// engine always populates an empty `Facets::default()` for
760    /// shape stability).
761    #[serde(skip_serializing_if = "Option::is_none")]
762    pub facets: Option<&'a Facets>,
763    #[serde(skip_serializing_if = "Vec::is_empty")]
764    pub warnings: &'a Vec<crate::ops::WarningHint>,
765}
766
767/// Envelope for a full `ListResult`. The engine-meta counters carry the
768/// same `_`-prefixed wire keys as [`SearchResultEnvelope`] (and as both
769/// surfaces' markdown form) so an agent moving between `memstead list --json`
770/// and `memstead search --json` parses one envelope-meta convention. The
771/// `_` prefix reads as "engine-meta, not entity content".
772#[derive(Serialize)]
773pub struct ListResultEnvelope<'a> {
774    #[serde(rename = "_total")]
775    pub total: usize,
776    #[serde(rename = "_returned")]
777    pub returned: usize,
778    #[serde(rename = "_offset")]
779    pub offset: usize,
780    #[serde(rename = "_total_tokens")]
781    pub total_tokens: usize,
782    pub hits: Vec<SearchHitEnvelope<'a>>,
783    #[serde(skip_serializing_if = "Vec::is_empty")]
784    pub warnings: &'a Vec<crate::ops::WarningHint>,
785}
786
787/// Build the structured `memstead_entity` envelope. Identity fields
788/// (`_hash`, `id`, `mem`, `entity_type`, `title`, `_stub_kind`) come from the
789/// parsed `Entity` and live at the top level. Every schema-declared frontmatter
790/// key surfaces under a nested `metadata: {...}` map — its single home.
791/// Read a metadata
792/// value as `envelope.metadata.<key>`; generic consumers iterate the map
793/// without per-type branching. The prior shape additionally hoisted
794/// `level`/`stability`/`created_date`/`last_modified` to the top level,
795/// serialising those fields twice; that hoist is gone. The read-only
796/// identity triple (frontmatter `mem`/`id`/`type`, served as `mem`/`id`/`entity_type`) is excluded from the nested map
797/// — it appears only top-level — and underscore-prefixed internal keys
798/// (`_hash`, `_tokens*`, `_mem_schema`, `_stub_*`) live in dedicated
799/// top-level slots and never appear inside the nested map. `sections` and
800/// `relationships` round-trip the engine's internal IndexMap / Vec
801/// shapes verbatim. `_tokens` is computed from the rendered body
802/// (filter and opt-in inserts applied) so agents can pre-size before
803/// a follow-up `token_budget`-bounded read. `_mem_schema` rides
804/// when the workspace pinned a schema for the mem.
805///
806/// Per-section filtering applies — when `sections_filter` is
807/// `Some`, the structured `sections` map carries only the requested
808/// keys (matching the markdown projection). The unfiltered-base
809/// token cost surfaces as `_tokens_unfiltered_body` so agents can
810/// predict the cost of dropping the filter. The name avoids implying a
811/// monotonic relationship (`_tokens_unfiltered_body ≥ _tokens`) that the
812/// opt-in (`include_relations` / `include_context`) path can invert:
813/// opt-in inserts contribute to `_tokens` but not to this baseline. Stub
814/// entities ship every key with empty `sections` / `relationships`
815/// arrays.
816///
817/// The structured envelope is the contract for `memstead_entity`:
818/// agents read `_hash`, sections, and relations from typed fields
819/// rather than string-scraping the markdown frontmatter.
820#[allow(clippy::too_many_arguments)] // a pure builder: every arg is used, a params struct would churn 4 call sites for no clarity
821pub fn build_entity_envelope(
822    entity: &Entity,
823    rendered_body_tokens: usize,
824    full_tokens: Option<usize>,
825    sections_filter: Option<&[String]>,
826    schema_anchor: Option<&str>,
827    origin: OriginClass,
828    outgoing_edges: &[crate::store::Edge],
829    incoming_edges: Option<&[crate::store::InEdge]>,
830    signals: Option<&[crate::ops::signals::ComputedSignal]>,
831    labelling: Option<&crate::ops::labelling::LabellingView>,
832) -> serde_json::Value {
833    let mut envelope = serde_json::Map::new();
834    // Declared aggregate signals — present exactly when the entity's
835    // type declares any (the schema author opted in by declaring; a
836    // reader who must ask for the signal is a reader who forgets to).
837    // Undeclared types keep their byte-identical envelope.
838    if let Some(sigs) = signals
839        && !sigs.is_empty()
840    {
841        envelope.insert(
842            "_signals".to_string(),
843            crate::ops::signals::signals_json(sigs),
844        );
845    }
846    // Grounded labelling — present exactly when the mem's schema
847    // declares `relationships.labelling`; the label ships with its
848    // evidence, and the shape block exactly when `support` is
849    // declared.
850    if let Some(lab) = labelling {
851        envelope.insert("_labelling".to_string(), lab.to_json());
852    }
853    envelope.insert(
854        "_hash".to_string(),
855        serde_json::Value::String(entity.content_hash.clone()),
856    );
857    // Data-origin trust class, rendered at the shared envelope layer so
858    // no read surface can compose an entity read without it. It was
859    // previously inserted post-hoc by the MCP handler alone, which left
860    // the CLI's `--json` envelope silently unlabelled — a script
861    // branching on trust class treated third-party content as
862    // first-party there (cold-start 0-8-0, F9/F13).
863    envelope.insert(
864        "origin".to_string(),
865        serde_json::Value::String(origin.as_wire().to_string()),
866    );
867    // An entity whose body ends inside an unterminated fence has absorbed the
868    // sections after it; they come back as `""`, and a reader with no marker
869    // cannot tell that from a section the author left blank. Rendered at the
870    // shared envelope layer for the same reason `origin` is: it was first
871    // written on the conformance axis alone, which is opt-in, so the plain
872    // read — the one an agent actually makes — still reported them as merely
873    // empty (04/02, criterion 4, found by the plan's final grade).
874    //
875    // A marker, not a repair: the bytes are unchanged and no fence is closed.
876    // `_unread_sections` names the keys whose content is really sitting in
877    // `absorbed_into`, so a reader that branches on emptiness has something to
878    // branch on.
879    if let Some((absorbing, fence)) = entity.sections.iter().find_map(|(k, v)| {
880        crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
881    }) {
882        let unread: Vec<String> = entity
883            .sections
884            .iter()
885            .filter(|(k, v)| **k != absorbing && v.trim().is_empty())
886            .map(|(k, _)| k.clone())
887            .collect();
888        envelope.insert(
889            "_unread_sections".to_string(),
890            serde_json::json!({
891                "reason": "UNTERMINATED_FENCE",
892                "absorbed_into": absorbing,
893                "fence": fence,
894                "sections": unread,
895                "note": "these sections read as empty because an unterminated code fence in \
896                         `absorbed_into` swallowed them: their content is inside that section's \
897                         body. Repair through the engine by replacing that section; a write that \
898                         does not is refused.",
899            }),
900        );
901    }
902    envelope.insert(
903        "id".to_string(),
904        serde_json::Value::String(entity.id.to_string()),
905    );
906    envelope.insert(
907        "mem".to_string(),
908        serde_json::Value::String(entity.mem.clone()),
909    );
910    // `entity_type`, not `type`: one concept, one wire spelling. The wasm
911    // read surface has always served the serialized Entity's `entity_type`,
912    // and this envelope said `type` for the same read — the F7-adjacent
913    // split the 2026-08-28 wire batch closed. The FRONTMATTER key stays
914    // `type:` (on-disk storage format, not the wire), and the metadata
915    // read-only triple keeps refusing it under that name.
916    envelope.insert(
917        "entity_type".to_string(),
918        serde_json::Value::String(entity.entity_type.clone()),
919    );
920    // The `# H1` display title. Structural identity like `id`/`mem`/
921    // `entity_type`, so it lives top-level next to them; before this slot the
922    // structured envelope had no title at all and consumers had to
923    // parse the rendered markdown's H1 to recover it.
924    envelope.insert(
925        "title".to_string(),
926        serde_json::Value::String(entity.title.clone()),
927    );
928
929    // Metadata has exactly one home on the envelope — the nested
930    // `metadata` map. Scalars like `level`/`stability`/`created_date`/
931    // `last_modified` are NOT hoisted to the top level; agents read
932    // `envelope.metadata.<key>`. The nested map is authoritative because
933    // it carries every schema-declared frontmatter key (including
934    // type-specific fields a top-level hoist never covered).
935    //
936    // Identity keys stay top-level and are excluded here so they too
937    // appear exactly once: `_hash`, `id`, `mem`, `entity_type` are the
938    // entity's structural identity (inserted above), not free-form
939    // metadata. Frontmatter `mem`/`id`/`type` is the engine's read-only key triple
940    // (`READ_ONLY_METADATA_KEYS`); `_`-prefixed internal keys live in
941    // dedicated top-level slots (`_tokens*`, `_mem_schema`, `_stub_*`).
942    // Stub entities surface an empty `metadata: {}` so consumers don't
943    // branch on its presence.
944    let mut metadata = serde_json::Map::new();
945    for (key, value) in &entity.metadata {
946        if key.starts_with('_')
947            || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
948        {
949            continue;
950        }
951        metadata.insert(
952            key.clone(),
953            serde_json::Value::String(value.to_frontmatter_string()),
954        );
955    }
956    envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
957
958    envelope.insert(
959        "_tokens".to_string(),
960        serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
961    );
962    if let Some(t) = full_tokens {
963        // This measures the unfiltered base body cost without
964        // `include_relations` / `include_context` opt-in inserts.
965        // `_tokens` may exceed `_tokens_unfiltered_body` when opt-ins
966        // are active (the opt-in inserts contribute to `_tokens` but not
967        // to this baseline) — the field name avoids implying a monotonic
968        // relationship the opt-in path can invert.
969        envelope.insert(
970            "_tokens_unfiltered_body".to_string(),
971            serde_json::Value::Number(serde_json::Number::from(t)),
972        );
973    }
974    if let Some(s) = schema_anchor {
975        envelope.insert(
976            "_mem_schema".to_string(),
977            serde_json::Value::String(s.to_string()),
978        );
979    }
980
981    if let Some(kind) = &entity.stub_kind {
982        envelope.insert(
983            "_stub_kind".to_string(),
984            serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
985        );
986    }
987
988    let mut sections = serde_json::Map::new();
989    for (key, content) in &entity.sections {
990        if let Some(filter) = sections_filter
991            && !filter.iter().any(|f| f == key)
992        {
993            continue;
994        }
995        sections.insert(key.clone(), serde_json::Value::String(content.clone()));
996    }
997    envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
998
999    // Resolve each relationship's `source` label against the store's
1000    // outgoing-edge index. A hardcoded `"explicit"` would disagree
1001    // with the stub-adoption
1002    // response's `incoming[].source` for alias-synthesised
1003    // REFERENCES edges (and was actively misleading because
1004    // REFERENCES carries `manual_authoring: forbidden` — no edge of
1005    // that rel-type can be authored explicitly). The store's
1006    // `EdgeSource` is the single source of truth; the markdown
1007    // round-trip (which doesn't encode source) is no longer
1008    // consulted for this field.
1009    let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
1010        outgoing_edges
1011            .iter()
1012            .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
1013            .map(|e| match e.source {
1014                crate::store::EdgeSource::BodyLink => "body_link",
1015                crate::store::EdgeSource::Hierarchy => "hierarchy",
1016                crate::store::EdgeSource::Explicit => "explicit",
1017            })
1018            .unwrap_or("explicit")
1019    };
1020    // Every entry declares its direction explicitly. The authored
1021    // entries (the entity's own `## Relationships` section) are
1022    // outgoing; incoming edges — when the caller opted in — are
1023    // appended with `direction: "in"` and the other endpoint under
1024    // `from`. Before the marker existed the array was silently
1025    // one-directional: a consumer had no signal that "what depends on
1026    // this?" was unanswerable from the block (cold-start 0-8-0, F15).
1027    let mut relationships: Vec<serde_json::Value> = entity
1028        .relationships
1029        .iter()
1030        .map(|rel| {
1031            let mut obj = serde_json::Map::new();
1032            obj.insert(
1033                "rel_type".to_string(),
1034                serde_json::Value::String(rel.rel_type.clone()),
1035            );
1036            obj.insert(
1037                "target".to_string(),
1038                serde_json::Value::String(rel.target.to_string()),
1039            );
1040            obj.insert(
1041                "direction".to_string(),
1042                serde_json::Value::String("out".to_string()),
1043            );
1044            obj.insert(
1045                "source".to_string(),
1046                serde_json::Value::String(resolve_source(rel).to_string()),
1047            );
1048            if let Some(desc) = rel
1049                .description
1050                .as_deref()
1051                .map(str::trim)
1052                .filter(|s| !s.is_empty())
1053            {
1054                obj.insert(
1055                    "description".to_string(),
1056                    serde_json::Value::String(desc.to_string()),
1057                );
1058            }
1059            serde_json::Value::Object(obj)
1060        })
1061        .collect();
1062    if let Some(incoming) = incoming_edges {
1063        for e in incoming {
1064            let mut obj = serde_json::Map::new();
1065            obj.insert(
1066                "rel_type".to_string(),
1067                serde_json::Value::String(e.rel_type.clone()),
1068            );
1069            obj.insert(
1070                "from".to_string(),
1071                serde_json::Value::String(e.from.to_string()),
1072            );
1073            obj.insert(
1074                "direction".to_string(),
1075                serde_json::Value::String("in".to_string()),
1076            );
1077            obj.insert(
1078                "source".to_string(),
1079                serde_json::Value::String(
1080                    match e.source {
1081                        crate::store::EdgeSource::BodyLink => "body_link",
1082                        crate::store::EdgeSource::Hierarchy => "hierarchy",
1083                        crate::store::EdgeSource::Explicit => "explicit",
1084                    }
1085                    .to_string(),
1086                ),
1087            );
1088            relationships.push(serde_json::Value::Object(obj));
1089        }
1090    }
1091    envelope.insert(
1092        "relationships".to_string(),
1093        serde_json::Value::Array(relationships),
1094    );
1095
1096    serde_json::Value::Object(envelope)
1097}
1098
1099/// Build a `SearchResultEnvelope` borrowing from `result`.
1100pub fn build_search_envelope<'a>(
1101    result: &'a SearchResult,
1102    offset: usize,
1103) -> SearchResultEnvelope<'a> {
1104    SearchResultEnvelope {
1105        total: result.total,
1106        returned: result.returned,
1107        offset,
1108        total_tokens: result.total_tokens,
1109        hits: result.hits.iter().map(build_hit_envelope).collect(),
1110        facets: result.facets.as_ref(),
1111        warnings: &result.warnings,
1112    }
1113}
1114
1115/// Build a `ListResultEnvelope` borrowing from `result`.
1116pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
1117    ListResultEnvelope {
1118        total: result.total,
1119        returned: result.returned,
1120        offset: result.offset,
1121        total_tokens: result.total_tokens,
1122        hits: result.hits.iter().map(build_hit_envelope).collect(),
1123        warnings: &result.warnings,
1124    }
1125}
1126
1127fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
1128    let (heading, value) = hit_summary_pair(hit);
1129    SearchHitEnvelope {
1130        hit,
1131        summary_heading: heading,
1132        summary_value: value,
1133    }
1134}
1135
1136// ---------------------------------------------------------------------------
1137// Helpers
1138// ---------------------------------------------------------------------------
1139
1140/// Build the one-line summary for a search/list hit.
1141///
1142/// Resolves the hit's schema and uses its lead section (first required, or
1143/// first section if none are required) as the label. Never panics — unknown
1144/// schemas or schemas with no sections fall back to `**Summary**: —`.
1145fn hit_summary_line(hit: &SearchHit) -> String {
1146    let (heading, value) = hit_summary_pair(hit);
1147    format!("**{heading}**: {value}")
1148}
1149
1150/// Resolve `(heading, value)` for a hit's summary line — the single source of
1151/// truth for lead-section lookup. Used by both markdown rendering and the
1152/// structured-content envelope.
1153///
1154/// Prefers the engine-precomputed [`SearchHit::summary`] (resolved against the
1155/// hit's own mem schema at search time). Falls back to the global
1156/// `type_by_name` lookup only for hits built outside the search op (FFI/bridge
1157/// and test fixtures) — that fallback sees only the `default` schema, which is
1158/// why the engine resolves the pair where the per-mem schema is in hand.
1159fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
1160    if let Some(summary) = &hit.summary {
1161        return (summary.heading.clone(), summary.value.clone());
1162    }
1163    summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
1164}
1165
1166/// Resolve `(heading, value)` given a schema and the hit's section map.
1167fn summary_pair(
1168    schema: Option<&TypeDefinition>,
1169    sections: &HashMap<String, String>,
1170) -> (String, String) {
1171    match schema {
1172        Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
1173        None => ("Summary".to_string(), "—".to_string()),
1174    }
1175}
1176
1177/// The lead-section `(heading, value)` for a hit given its resolved schema:
1178/// the first required section (or the first section when none are required),
1179/// with its value pulled from `sections`. Returns `("Summary", "—")` when the
1180/// type declares no sections, and an honest `"—"` value when the lead section
1181/// is absent/empty in this hit. The single source of truth shared by the
1182/// render-time fallback ([`summary_pair`]) and the search op, which calls it
1183/// with each hit's correctly-resolved per-mem schema.
1184pub(crate) fn lead_section_pair<'a>(
1185    schema: &TypeDefinition,
1186    get_section: impl Fn(&str) -> Option<&'a str>,
1187) -> (String, String) {
1188    let Some(section) = schema
1189        .required_sections()
1190        .next()
1191        .or(schema.sections.first())
1192    else {
1193        return ("Summary".to_string(), "—".to_string());
1194    };
1195    let value = get_section(section.key.as_str()).unwrap_or("—");
1196    (section.heading.clone(), value.to_string())
1197}
1198
1199/// Convert a section key to a display heading via the simple
1200/// derivation: first char uppercased, underscores → spaces. Used as
1201/// a fallback when no schema-declared heading is available.
1202fn section_key_to_heading(key: &str) -> String {
1203    let mut chars = key.chars();
1204    match chars.next() {
1205        None => String::new(),
1206        Some(c) => {
1207            let first: String = c.to_uppercase().collect();
1208            let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
1209            format!("{first}{rest}")
1210        }
1211    }
1212}
1213
1214/// Resolve the heading for `key` from the type's declared sections;
1215/// fall back to the key-derivation when the type is unknown or the
1216/// key is not declared (e.g. the `relationships` virtual surface, or
1217/// catch-all extra keys). The schema-declared heading is the on-disk
1218/// truth — the renderer must echo it so rendered text matches the
1219/// markdown file content.
1220fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
1221    type_def
1222        .and_then(|t| t.sections.iter().find(|s| s.key == key))
1223        .map(|s| s.heading.clone())
1224        .unwrap_or_else(|| section_key_to_heading(key))
1225}
1226
1227/// Search every built-in schema for `name`, returning the first match.
1228/// Caches the loaded schema list via `OnceLock` so subsequent renders
1229/// pay only the HashMap lookup cost.
1230///
1231/// Distinct from `memstead_schema::type_by_name`, which is limited to the
1232/// `default` schema — that helper exists for legacy short-name lookups
1233/// and is left unchanged here. Custom workspace schemas (not embedded
1234/// in the binary) still fall through to the key-derivation path.
1235fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
1236    static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
1237    let schemas =
1238        CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
1239    for s in schemas {
1240        if let Some(t) = s.get_type(name) {
1241            return Some(t);
1242        }
1243    }
1244    None
1245}
1246
1247// ---------------------------------------------------------------------------
1248// Schema introspection rendering
1249// ---------------------------------------------------------------------------
1250
1251/// Render the full schema catalog as markdown — built-in default types.
1252pub fn render_type_catalog_markdown() -> String {
1253    render_type_catalog_lines(all_types())
1254}
1255
1256/// Render the type catalog for an arbitrary loaded [`Schema`].
1257/// Same shape as [`render_type_catalog_markdown`]; iterates the
1258/// schema's own types in name order so multi-mem workspaces can
1259/// describe the schema pinned by the writable mem, not the engine's
1260/// hard-coded built-in.
1261pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1262    let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1263    types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1264    render_type_catalog_lines(types)
1265}
1266
1267fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1268    let mut lines = vec![
1269        "# Available types".to_string(),
1270        String::new(),
1271        "Run `memstead type <name>` to see its metadata fields, sections, relationship types, and writing guidance — over MCP, `memstead_schema` takes the *schema* name and returns every type at once."
1272            .to_string(),
1273        String::new(),
1274    ];
1275    for schema in types {
1276        let required_sections = schema.required_sections().count();
1277        let total_sections = schema.sections.len();
1278        let metadata_count = schema.metadata_fields.len();
1279        lines.push(format!(
1280            "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1281            schema.name.as_str(),
1282            total_sections,
1283            required_sections,
1284            metadata_count,
1285            schema.staleness_threshold_days,
1286        ));
1287    }
1288    lines.push(String::new());
1289    lines.join("\n")
1290}
1291
1292/// Render a single type's definition as agent-friendly markdown.
1293pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1294    let mut lines = Vec::new();
1295    lines.push(format!("# Type: {}", schema.name.as_str()));
1296    lines.push(String::new());
1297    lines.push(format!(
1298        "Staleness threshold: {} days. Hierarchy: `{}`.",
1299        schema.staleness_threshold_days, schema.hierarchy_relationship,
1300    ));
1301    lines.push(String::new());
1302
1303    // Metadata fields
1304    lines.push("## Metadata fields".to_string());
1305    for field in &schema.metadata_fields {
1306        lines.push(format!("- {}", describe_metadata_field(field)));
1307    }
1308    lines.push(String::new());
1309
1310    // Sections
1311    lines.push("## Sections".to_string());
1312    for section in &schema.sections {
1313        let req = if section.required {
1314            "required"
1315        } else {
1316            "optional"
1317        };
1318        let catch_all = if section.catch_all { ", catch-all" } else { "" };
1319        lines.push(format!(
1320            "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1321            section.key, section.search_weight,
1322        ));
1323        for rule in &section.write_rules {
1324            lines.push(format!("  - Write rule: {rule}"));
1325        }
1326    }
1327    lines.push(String::new());
1328
1329    // Relationship types
1330    lines.push("## Relationship types (with edge weights)".to_string());
1331    for (rel_type, weight) in &schema.edge_weights {
1332        if rel_type == "_default" {
1333            continue;
1334        }
1335        let mut flags: Vec<&str> = Vec::new();
1336        if rel_type == &schema.hierarchy_relationship {
1337            flags.push("hierarchy");
1338        }
1339        if schema
1340            .no_self_loop_relationships
1341            .iter()
1342            .any(|r| r == rel_type)
1343        {
1344            flags.push("no-self-loop");
1345        }
1346        let flag_str = if flags.is_empty() {
1347            String::new()
1348        } else {
1349            format!(" ({})", flags.join(", "))
1350        };
1351        lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1352    }
1353    // Default weight
1354    if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1355        lines.push(format!(
1356            "- _default_ (any other relationship type): {default_weight}"
1357        ));
1358    }
1359    lines.push(String::new());
1360
1361    // Writing guidance (schema-level)
1362    if !schema.write_rules.is_empty() {
1363        lines.push("## Writing guidance".to_string());
1364        for rule in &schema.write_rules {
1365            lines.push(format!("- {rule}"));
1366        }
1367        lines.push(String::new());
1368    }
1369
1370    // System context
1371    let system_msg = schema.system_message_str();
1372    if !system_msg.is_empty() {
1373        lines.push("## System context".to_string());
1374        lines.push(system_msg.to_string());
1375        lines.push(String::new());
1376    }
1377
1378    // Canonical exemplar (agent-trust plan 09) — the engine-validated
1379    // few-shot entity, rendered in the mem markdown shape. The CLI's
1380    // full-depth type view matches `memstead_schema verbosity: full`.
1381    if let Some(ex) = &schema.exemplar {
1382        lines.push("## Exemplar (engine-validated)".to_string());
1383        lines.push(String::new());
1384        lines.push(format!("Title: {}", ex.title));
1385        if !ex.metadata.is_empty() {
1386            lines.push("Metadata:".to_string());
1387            for (k, v) in &ex.metadata {
1388                lines.push(format!("- {k}: {v}"));
1389            }
1390        }
1391        for (key, body) in &ex.sections {
1392            let heading = schema
1393                .section(key)
1394                .map(|s| s.heading.clone())
1395                .unwrap_or_else(|| key.clone());
1396            lines.push(format!("### {heading}"));
1397            lines.push(body.clone());
1398        }
1399        if !ex.relations.is_empty() {
1400            lines.push("Relations (placeholder targets):".to_string());
1401            for r in &ex.relations {
1402                match &r.description {
1403                    Some(d) => lines.push(format!(
1404                        "- {} → {} — {d}",
1405                        r.rel_type_name(),
1406                        r.target_slug()
1407                    )),
1408                    None => lines.push(format!("- {} → {}", r.rel_type_name(), r.target_slug())),
1409                }
1410            }
1411        }
1412        lines.push(String::new());
1413    }
1414
1415    lines.join("\n")
1416}
1417
1418/// Render a [`PerEdgeDescription`] to its wire literal — bit-identical to
1419/// what the schema YAML accepts so consumers can echo the value back
1420/// without case fiddling. `forbidden` (the default) is emitted explicitly
1421/// rather than omitted so a schema without an explicit declaration still
1422/// surfaces the resolved posture on the wire.
1423pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1424    match p {
1425        PerEdgeDescription::Forbidden => "forbidden",
1426        PerEdgeDescription::Optional => "optional",
1427        PerEdgeDescription::Required => "required",
1428    }
1429}
1430
1431/// Stable wire string for the `manual_authoring` posture.
1432pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1433    match p {
1434        ManualAuthoring::Allow => "allow",
1435        ManualAuthoring::Warn => "warn",
1436        ManualAuthoring::Forbidden => "forbidden",
1437    }
1438}
1439
1440/// Verbosity selector for [`build_schema_payload`].
1441///
1442/// `Full` is the complete payload — every description, `when_to_use`,
1443/// write-rule, and writing-guidance string. `Lite` drops that long-form
1444/// prose and returns a structural skeleton: entity-type names with their
1445/// section keys and metadata-field shapes, relationship names with their
1446/// allowed endpoints. The skeleton keeps every *flag* an agent needs to
1447/// author a legal write — the alias-model pointer, required-section and
1448/// required-field markers, endpoint constraints, the manual-authoring
1449/// posture, the `acyclic` flag, and the per-edge-description posture — so
1450/// a lite caller can plan a write without round-tripping to full and
1451/// without walking into a write-time refusal. Full and lite emit the two
1452/// heavy arrays under *distinct keys* (`types` / `relationships` vs.
1453/// `types_summary` / `relationships_summary`), so a consumer decodes by
1454/// key presence rather than by branching on the request shape.
1455#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1456pub enum SchemaVerbosity {
1457    #[default]
1458    Full,
1459    Lite,
1460}
1461
1462impl SchemaVerbosity {
1463    /// Parse the wire token (`"full"` / `"lite"`). Returns `None` for an
1464    /// unrecognized token so the calling surface can raise a typed error
1465    /// naming the bad value rather than silently defaulting. An absent
1466    /// parameter maps to `Full` at the call site, not here.
1467    pub fn from_wire(s: &str) -> Option<Self> {
1468        match s {
1469            "full" => Some(Self::Full),
1470            "lite" => Some(Self::Lite),
1471            _ => None,
1472        }
1473    }
1474
1475    /// The wire token for this verbosity.
1476    pub fn as_wire(self) -> &'static str {
1477        match self {
1478            Self::Full => "full",
1479            Self::Lite => "lite",
1480        }
1481    }
1482}
1483
1484/// Trust origin of a schema (or the mem that pins it), decided at
1485/// adopt/write time and reported — never re-derived — on the read path.
1486///
1487/// `FirstParty` is an engine built-in or a schema authored/explicitly
1488/// trusted in this workspace. Its prose-instruction fields
1489/// (`system_context`, `write_rules`, `writing_guidance`, `when_to_use`,
1490/// prose `description`, `default_writing_guidance`) guide *authoring* in
1491/// this workspace and are served in full.
1492///
1493/// `ThirdParty` is a schema that arrived from outside this workspace
1494/// (registry-installed or adopted from a foreign folder/clone) and has
1495/// not been explicitly trusted. Memstead's value proposition pulls a
1496/// mem's schema directly into a consuming agent's context, where the
1497/// schema's free-text fields are framed *as instructions* ("System
1498/// context", "Writing guidance"). A third-party schema is therefore
1499/// served structural-only: [`build_schema_payload`] forces the
1500/// [`SchemaVerbosity::Lite`] skeleton regardless of the requested
1501/// verbosity, omitting every prose-instruction field. This is lossless
1502/// for the legitimate use case — the omitted fields only guide writing,
1503/// and a write never targets a foreign mem.
1504///
1505/// The class is unforgeable by a publisher: it is decided by *how* the
1506/// schema entered the workspace, not by any content the schema carries.
1507/// An unknown/ambiguous origin classifies `ThirdParty` — the safe
1508/// default (a stranger's prose is never served as first-party
1509/// instructions on the strength of a missing label).
1510#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1511pub enum OriginClass {
1512    /// Engine built-in, or authored/explicitly trusted in this workspace.
1513    FirstParty,
1514    /// Arrived from outside this workspace and not explicitly trusted.
1515    /// The safe default for an unlabelled/ambiguous origin.
1516    #[default]
1517    ThirdParty,
1518}
1519
1520impl OriginClass {
1521    /// The wire token for this origin (`"first-party"` / `"third-party"`),
1522    /// emitted on every schema read so a consuming host can quarantine
1523    /// non-first-party content.
1524    pub fn as_wire(self) -> &'static str {
1525        match self {
1526            Self::FirstParty => "first-party",
1527            Self::ThirdParty => "third-party",
1528        }
1529    }
1530
1531    /// Whether this origin must have its schema served structural-only
1532    /// (prose-instruction fields omitted) on the read path.
1533    pub fn is_third_party(self) -> bool {
1534        matches!(self, Self::ThirdParty)
1535    }
1536}
1537
1538/// Build the transport-neutral, rmcp-free JSON payload for a schema read
1539/// (`memstead_schema`). Shared by the MCP server, the HTTP surface, and
1540/// the filesystem-mem MCP flavour so every surface emits identical
1541/// schema-read bytes from one source. `used_by` lists the writable mems
1542/// whose pinned schema resolves to this one; `verbosity` toggles the full
1543/// payload versus the lightweight skeleton (see [`SchemaVerbosity`]).
1544///
1545/// `origin` ([`OriginClass`]) is reported on the wire as `origin` and
1546/// governs de-framing: a [`OriginClass::ThirdParty`] schema is served
1547/// structural-only — the requested `verbosity` is overridden to
1548/// [`SchemaVerbosity::Lite`] so none of its prose-instruction fields
1549/// (`system_context`, `write_rules`, `writing_guidance`, `when_to_use`,
1550/// prose `description`, `default_writing_guidance`) reach a consuming
1551/// agent as instructions. A `full`-verbosity request on a third-party
1552/// schema therefore still omits them — the override is one-directional.
1553/// Append a section's format declaration (plan 08) to its rendered
1554/// object — only the declared keys, so undeclared sections keep their
1555/// exact pre-plan shape. `format_severity` renders whenever a
1556/// `content` declaration exists (the default `block` is a legality
1557/// fact, not noise).
1558fn append_section_format(
1559    obj: &mut serde_json::Map<String, serde_json::Value>,
1560    s: &memstead_schema::SectionDef,
1561) {
1562    if let Some(content) = &s.content {
1563        obj.insert("content".into(), serde_json::json!(content));
1564        obj.insert(
1565            "format_severity".into(),
1566            serde_json::json!(s.format_severity),
1567        );
1568    }
1569    if let Some(pattern) = &s.item_pattern {
1570        obj.insert("item_pattern".into(), serde_json::json!(pattern));
1571    }
1572    if let Some(table) = &s.table {
1573        obj.insert("table".into(), serde_json::json!(table));
1574    }
1575    if let Some(example) = &s.example {
1576        obj.insert("example".into(), serde_json::json!(example));
1577    }
1578}
1579
1580/// Unknown type names in a `types` selection passed to
1581/// [`build_schema_payload_scoped`] — the caller raises a typed refusal
1582/// naming the valid types (recovery-payload posture, never a silent
1583/// empty section).
1584#[derive(Debug, Clone)]
1585pub struct UnknownSchemaTypes {
1586    pub unknown: Vec<String>,
1587    pub known: Vec<String>,
1588}
1589
1590/// Token estimate for a serialized JSON payload — routed through the
1591/// house heuristic ([`crate::chunking::estimate_tokens`]) so "fits the
1592/// pipe" is judged by the same yardstick every budgeted surface uses.
1593fn estimate_payload_tokens(value: &serde_json::Value) -> usize {
1594    serde_json::to_string(value)
1595        .map(|s| estimate_tokens(&s))
1596        .unwrap_or(0)
1597}
1598
1599/// Default budget for the UNSCOPED full-verbosity schema reply, in
1600/// estimated (bytes/4) tokens — ~60 KB of JSON. Calibrated against the
1601/// primary client's ~25k real-token response cap: dense JSON tokenizes
1602/// well above bytes/4, so 15k estimated sits at the cap's edge. The
1603/// two measured packages land on the intended sides: `default@1.3.0`
1604/// (~52 KB) keeps serving in full — today's behaviour on today's reply
1605/// sizes — while `software@0.4.0` (60.2 KB, the observed harness spill,
1606/// 2026-08-18 WOENENN ingest) degrades visibly to the per-type steer
1607/// instead of overflowing the pipe.
1608pub const DEFAULT_SCHEMA_FULL_BUDGET: usize = 15_000;
1609
1610pub fn build_schema_payload(
1611    schema: &Arc<Schema>,
1612    used_by: Vec<String>,
1613    verbosity: SchemaVerbosity,
1614    origin: OriginClass,
1615) -> serde_json::Value {
1616    // Unscoped, unbudgeted — the classic shape every existing consumer
1617    // gets. Infallible by construction (no selection to refuse).
1618    build_schema_payload_scoped(schema, used_by, verbosity, origin, None, None)
1619        .expect("no type selection, no refusal")
1620}
1621
1622/// [`build_schema_payload`] with the serving-shape controls
1623/// (backlog-sweep plan 06a): `type_selection` scopes the heavy per-type
1624/// prose to the named types — the reply carries the full package-level
1625/// context, the selected types in full, and a `types_omitted` roster
1626/// naming what was not served (visible scope, never silent truncation).
1627/// An unknown name refuses with [`UnknownSchemaTypes`]. Under
1628/// [`SchemaVerbosity::Lite`] the selection filters the skeleton the
1629/// same way (coherent, though the full tier is the use case).
1630///
1631/// `token_budget` guards the UNSCOPED full reply: when the complete
1632/// payload's estimated tokens exceed the budget, the reply degrades
1633/// visibly — per-type prose drops to the lite `types_summary` skeleton,
1634/// `_schema_mode: "reduced"` is stamped, and `_hint` steers the caller
1635/// to per-type retrieval via `types`. A scoped request is what the
1636/// budget steers TOWARD, so the selection path is never re-degraded.
1637pub fn build_schema_payload_scoped(
1638    schema: &Arc<Schema>,
1639    used_by: Vec<String>,
1640    verbosity: SchemaVerbosity,
1641    origin: OriginClass,
1642    type_selection: Option<&[String]>,
1643    token_budget: Option<usize>,
1644) -> Result<serde_json::Value, UnknownSchemaTypes> {
1645    let manifest = &schema.manifest;
1646
1647    // Validate the selection against the manifest roster before any
1648    // rendering — refuse-with-the-known-names beats a silent empty
1649    // `types` array.
1650    if let Some(sel) = type_selection {
1651        let unknown: Vec<String> = sel
1652            .iter()
1653            .filter(|t| !manifest.types.iter().any(|m| m == *t))
1654            .cloned()
1655            .collect();
1656        if !unknown.is_empty() {
1657            return Err(UnknownSchemaTypes {
1658                unknown,
1659                known: manifest.types.clone(),
1660            });
1661        }
1662    }
1663    // De-frame third-party schemas: their prose-instruction fields only
1664    // guide authoring (which never targets a foreign mem), so omitting
1665    // them is lossless — and serving them would place a stranger's
1666    // free-text in the consuming agent's instruction context. The Lite
1667    // skeleton keeps every structural flag an agent needs to understand
1668    // and query the mem. The override is one-directional: a `full`
1669    // request cannot re-admit the prose for a third-party schema.
1670    let verbosity = if origin.is_third_party() {
1671        SchemaVerbosity::Lite
1672    } else {
1673        verbosity
1674    };
1675
1676    // `_default` is the schema's internal weight-fallback knob — it
1677    // sets the edge weight every `_default`-less rel-type inherits and
1678    // is *not* a usable rel-type on `memstead_relate` (the relate path
1679    // rejects it with `INVALID_REL_TYPE`). Surfacing it in the agent-
1680    // facing vocabulary cost one round-trip per
1681    // session as agents tried it and learned the asymmetry by trial,
1682    // so it is suppressed here: the schema response advertises only
1683    // the rel-types `memstead_relate` actually accepts. Schemas that
1684    // declare `_default` for weight purposes are unaffected — the
1685    // engine still consults it for `edge_weight` fallback.
1686    let relationships: Vec<serde_json::Value> = manifest
1687        .relationships
1688        .definitions
1689        .iter()
1690        .filter(|d| d.name != "_default")
1691        .map(|d| {
1692            // Surface the `acyclic` flag so agents can predict cycle-check
1693            // refusal from introspection without trial-and-error.
1694            // Combined with each type's `no_self_loop_relationships`
1695            // list (below), the schema response fully describes the
1696            // self-loop / long-cycle gates.
1697            //
1698            // Surface the `manual_authoring` posture so agents see at
1699            // introspection time which rel-types refuse explicit
1700            // `memstead_relate` (forbidden), warn softly (warn), or
1701            // admit explicit authoring (allow, default).
1702            //
1703            // Surface the source/target type pinning declared on the
1704            // schema's `RelationshipDefinition` so agents can pre-filter
1705            // rel-types for their `(from_type, to_type)` pair from
1706            // introspection instead of trial-and-error against
1707            // `INVALID_REL_SHAPE`. Field names mirror the
1708            // `INVALID_REL_SHAPE` `details.allowed_source_types` /
1709            // `details.allowed_target_types` payload so the agent
1710            // learns the contract once. Empty arrays = "any type
1711            // admitted" (no pinning).
1712            let mut o = serde_json::json!({
1713                "name": d.name,
1714                "description": d.description,
1715                "when_to_use": d.when_to_use,
1716                "default_weight": d.default_weight,
1717                "acyclic": d.acyclic,
1718                "per_edge_description": per_edge_description_str(d.per_edge_description),
1719                "manual_authoring": manual_authoring_str(d.manual_authoring),
1720                "allowed_sources": d.source_types,
1721                "allowed_targets": d.target_types,
1722            });
1723            // Derivation declaration (agent-trust plan 12) — a
1724            // behaviour-bearing flag (baseline recording, the
1725            // stale_derivations axis, duplicate-add re-baseline), so
1726            // it must be visible at introspection time. Emitted only
1727            // when true so undeclared schemas keep their bytes.
1728            if d.derivation {
1729                o["derivation"] = serde_json::json!(true);
1730            }
1731            o
1732        })
1733        .collect();
1734
1735    // Outbound cross-mem vocabulary, one entry per target schema.
1736    // Same shape as the YAML — `{ to_schema, definitions: [...] }` —
1737    // so consumers can decode the section symmetrically with the
1738    // intra-mem `relationships` array. `_default` filtering mirrors
1739    // the intra-mem block; the rest of the per-definition shape is
1740    // identical so a single decoder handles both.
1741    let cross_mem_relationships: Vec<serde_json::Value> = manifest
1742        .cross_mem_relationships
1743        .iter()
1744        .map(|entry| {
1745            let definitions: Vec<serde_json::Value> = entry
1746                .definitions
1747                .iter()
1748                .filter(|d| d.name != "_default")
1749                .map(|d| {
1750                    serde_json::json!({
1751                        "name": d.name,
1752                        "description": d.description,
1753                        "when_to_use": d.when_to_use,
1754                        "default_weight": d.default_weight,
1755                        "source_types": d.source_types,
1756                        "target_types": d.target_types,
1757                        "per_edge_description": per_edge_description_str(d.per_edge_description),
1758                    })
1759                })
1760                .collect();
1761            serde_json::json!({
1762                "to_schema": entry.to_schema,
1763                "definitions": definitions,
1764            })
1765        })
1766        .collect();
1767
1768    // Iterate type names in manifest-declared order so the output is
1769    // deterministic and matches the schema author's intent.
1770    let types_full: Vec<serde_json::Value> = manifest
1771        .types
1772        .iter()
1773        .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1774        .map(|(_, td)| {
1775            let sections: Vec<serde_json::Value> = td
1776                .sections
1777                .iter()
1778                .map(|s| {
1779                    let mut obj = serde_json::json!({
1780                        "key": s.key,
1781                        "heading": s.heading,
1782                        "required": s.required,
1783                        "write_rules": s.write_rules,
1784                    });
1785                    // Section-format declarations (plan 08) — a
1786                    // legality condition, so it must never be
1787                    // invisible in the schema response (rendered at
1788                    // BOTH verbosity levels via the lite projection
1789                    // below).
1790                    append_section_format(obj.as_object_mut().unwrap(), s);
1791                    obj
1792                })
1793                .collect();
1794
1795            let fields: Vec<serde_json::Value> = td
1796                .metadata_fields
1797                .iter()
1798                .map(|f| {
1799                    let mut obj = serde_json::json!({
1800                        "name": f.key,
1801                        "description": f.description,
1802                        "required": f.is_required(),
1803                    });
1804                    if let Some(enum_values) = &f.enum_values {
1805                        obj.as_object_mut()
1806                            .unwrap()
1807                            .insert("enum".into(), serde_json::json!(enum_values));
1808                    }
1809                    // Surface schema-declared `default_value` so agents
1810                    // see what the create path fills in when a required
1811                    // field is omitted. Without this, the engine appears
1812                    // to silently default — `priority: mid` on a
1813                    // `coverage_gap` would land with no schema-side
1814                    // explanation of where the value came from.
1815                    if let Some(default) = &f.default_value {
1816                        obj.as_object_mut()
1817                            .unwrap()
1818                            .insert("default".into(), serde_json::json!(default));
1819                    }
1820                    // Surface the `filterable` posture so an agent constructs
1821                    // valid `filters` / `range_filters` from the schema body
1822                    // in one shot. Always present: `"equality"` accepts
1823                    // `filters`, `"range"` accepts `range_filters`, `null`
1824                    // means not filterable.
1825                    obj.as_object_mut().unwrap().insert(
1826                        "filterable".into(),
1827                        match f.filterable.as_wire_str() {
1828                            Some(s) => serde_json::json!(s),
1829                            None => serde_json::Value::Null,
1830                        },
1831                    );
1832                    obj
1833                })
1834                .collect();
1835
1836            // Expose the per-type `no_self_loop_relationships` list so agents
1837            // can predict self-loop refusal. The engine refuses
1838            // `memstead_relate type=R from=X(type=T) to=X` whenever R
1839            // appears here, independent of R's `acyclic` flag.
1840            //
1841            // `required_outgoing` is the only declared legality condition
1842            // on an entity's outgoing edges: each block lists the
1843            // relationship-name alternatives and the cardinality bound,
1844            // in declaration order. Always present — a type with no
1845            // blocks emits an empty list, because an absent key would
1846            // read as "unknown" and send agents back to the authoring
1847            // YAML. Cardinality is rendered exactly as declared
1848            // (`at_least_one` — an open upper bound stays open, never
1849            // normalised into a number).
1850            let required_outgoing: Vec<serde_json::Value> = td
1851                .required_outgoing
1852                .iter()
1853                .map(|block| {
1854                    let mut b = serde_json::json!({
1855                        "relationships": block.relationships,
1856                        "cardinality": block.cardinality.to_string(),
1857                        "severity": block.severity,
1858                    });
1859                    // Conditional blocks carry their trigger at both
1860                    // verbosity levels (the lite skeleton projects this
1861                    // object unchanged); unconditional blocks keep
1862                    // their byte-identical three-key shape.
1863                    if let (Some(wf), Some(wv)) = (&block.when_field, &block.when_value) {
1864                        b["when_field"] = serde_json::json!(wf);
1865                        b["when_value"] = serde_json::json!(wv);
1866                    }
1867                    b
1868                })
1869                .collect();
1870
1871            // Declared `constraints` — like `required_outgoing`, a
1872            // legality/health condition that must never be invisible
1873            // in the schema response (a hidden legality condition is
1874            // a defect class of its own). Always present, empty list
1875            // for a type declaring none; each entry restates the
1876            // declaration with its `severity` (`warn` = health
1877            // finding, `block` = write-time refusal), in declaration
1878            // order, at BOTH verbosity levels.
1879            let constraints: Vec<serde_json::Value> = td
1880                .constraints
1881                .iter()
1882                .map(|c| match c {
1883                    memstead_schema::ConstraintDef::RequiresWhen {
1884                        field,
1885                        when_field,
1886                        when_value,
1887                        severity,
1888                    } => serde_json::json!({
1889                        "kind": "requires_when",
1890                        "field": field,
1891                        "when_field": when_field,
1892                        "when_value": when_value,
1893                        "severity": severity,
1894                    }),
1895                    memstead_schema::ConstraintDef::Unique { fields, severity } => {
1896                        serde_json::json!({
1897                            "kind": "unique",
1898                            "fields": fields,
1899                            "severity": severity,
1900                        })
1901                    }
1902                    memstead_schema::ConstraintDef::EnumFromNeighbour {
1903                        field,
1904                        rel_type,
1905                        section,
1906                        severity,
1907                    } => serde_json::json!({
1908                        "kind": "enum_from_neighbour",
1909                        "field": field,
1910                        "rel_type": rel_type,
1911                        "section": section,
1912                        "severity": severity,
1913                    }),
1914                    memstead_schema::ConstraintDef::StatusPropagation {
1915                        field,
1916                        value,
1917                        rel_type,
1918                        rel_types,
1919                        direction,
1920                        severity,
1921                    } => {
1922                        let mut c = serde_json::json!({
1923                            "kind": "status_propagation",
1924                            "field": field,
1925                            "value": value,
1926                            "direction": direction,
1927                            "severity": severity,
1928                        });
1929                        // Echo the declaration's own shape: the
1930                        // single-name key stays byte-identical, a
1931                        // relation set rides under `rel_types`.
1932                        if let Some(single) = rel_type {
1933                            c["rel_type"] = serde_json::json!(single);
1934                        }
1935                        if let Some(set) = rel_types {
1936                            c["rel_types"] = serde_json::json!(set);
1937                        }
1938                        c
1939                    }
1940                    memstead_schema::ConstraintDef::TransitionRequiresChecks {
1941                        field,
1942                        to_value,
1943                        relationships,
1944                        direction,
1945                        severity,
1946                    } => serde_json::json!({
1947                        "kind": "transition_requires_checks",
1948                        "field": field,
1949                        "to_value": to_value,
1950                        "relationships": relationships,
1951                        "direction": direction,
1952                        "severity": severity,
1953                    }),
1954                })
1955                .collect();
1956            let mut obj = serde_json::json!({
1957                "name": td.name,
1958                "description": td.description,
1959                "when_to_use": td.when_to_use,
1960                "sections": sections,
1961                "fields": fields,
1962                "writing_guidance": td.write_rules,
1963                "system_context": td.system_message_str(),
1964                "staleness_threshold_days": td.staleness_threshold_days,
1965                "no_self_loop_relationships": td.no_self_loop_relationships,
1966                "required_outgoing": required_outgoing,
1967                "constraints": constraints,
1968            });
1969            // Reachability obligations — like `required_outgoing`, a
1970            // health condition the schema response must not hide; the
1971            // declaration is echoed in its YAML shape. Emitted only
1972            // when declared so undeclared schemas keep their payload
1973            // bytes unchanged.
1974            if !td.must_reach.is_empty() {
1975                obj["must_reach"] = serde_json::to_value(&td.must_reach)
1976                    .expect("must_reach declarations serialize");
1977            }
1978            // Aggregate-signal declarations — served behaviour (the
1979            // `_signals` read insert, the health axis, the crossing
1980            // warning) an agent must see at introspection time; the
1981            // declaration is echoed in its YAML shape. Emitted only
1982            // when declared.
1983            if !td.signals.is_empty() {
1984                obj["signals"] =
1985                    serde_json::to_value(&td.signals).expect("signal declarations serialize");
1986            }
1987            // Leaf declaration — a legality-relevant fact an agent
1988            // planning writes must see; emitted only when true so
1989            // undeclared schemas keep their payload bytes unchanged.
1990            if td.leaf {
1991                obj["leaf"] = serde_json::json!(true);
1992            }
1993            // The type's canonical exemplar (agent-trust plan 09) —
1994            // engine-validated at install/seal, so what it teaches is
1995            // exactly what the validator accepts. Rides FULL mode only
1996            // (this array); the lite projection below drops it by
1997            // allowlist, so the per-session skeleton stays unchanged.
1998            // Relation targets are placeholder slugs by contract.
1999            //
2000            // The relation entries are emitted in the MUTATION
2001            // vocabulary (`target` / `rel_type`) — since the
2002            // 05-front-door/08 rider landed, that is also the authoring
2003            // spelling (legacy sealed content is translated at load),
2004            // so an agent copying this payload into `memstead_create`
2005            // gets a shape the write gate accepts.
2006            if let Some(ex) = &td.exemplar {
2007                let relations: Vec<serde_json::Value> = ex
2008                    .relations
2009                    .iter()
2010                    .map(|r| {
2011                        let mut o = serde_json::json!({
2012                            "target": r.target_slug(),
2013                            "rel_type": r.rel_type_name(),
2014                        });
2015                        if let Some(d) = &r.description {
2016                            o["description"] = serde_json::json!(d);
2017                        }
2018                        o
2019                    })
2020                    .collect();
2021                obj["exemplar"] = serde_json::json!({
2022                    "title": ex.title,
2023                    "metadata": ex.metadata,
2024                    "sections": ex.sections,
2025                    "relations": relations,
2026                });
2027            }
2028            obj
2029        })
2030        .collect();
2031
2032    let mode = match manifest.relationships.mode {
2033        RelationshipMode::Strict => "strict",
2034        RelationshipMode::Open => "open",
2035    };
2036
2037    let full = verbosity == SchemaVerbosity::Full;
2038
2039    // Scalar fields present in BOTH modes. `ref` names the schema even
2040    // in the lite skeleton; `relationship_mode`, `community`, and
2041    // `used_by` are bounded and cheap.
2042    let mut payload = serde_json::json!({
2043        "ref": format!("{}@{}", manifest.name, schema.version),
2044        "relationship_mode": mode,
2045        "community": {
2046            "resolution": manifest.community.resolution,
2047            "seed": manifest.community.seed,
2048        },
2049        "used_by": used_by,
2050        // Machine-readable trust origin, present in both modes. A
2051        // consuming host reads this to decide whether to treat the
2052        // schema as workspace instructions (`first-party`) or quarantine
2053        // it as untrusted (`third-party`). Additive — a client that
2054        // ignores it still decodes the rest of the payload unchanged.
2055        "origin": origin.as_wire(),
2056    });
2057    let obj = payload.as_object_mut().unwrap();
2058
2059    // Declared acyclicity sets — a legality condition on the relate
2060    // path (a cycle in a set's union subgraph refuses), so it ships in
2061    // BOTH modes; emitted only when declared so undeclared schemas
2062    // keep their payload bytes unchanged.
2063    if !manifest.relationships.acyclic_sets.is_empty() {
2064        obj.insert(
2065            "acyclic_sets".into(),
2066            serde_json::to_value(&manifest.relationships.acyclic_sets)
2067                .expect("acyclic_sets serialize"),
2068        );
2069    }
2070    // Grounded-labelling declaration — served behaviour (the
2071    // `_labelling` read insert and the `labelling` health axis) an
2072    // agent must see at introspection time; echoed in its YAML shape,
2073    // in BOTH modes, only when declared.
2074    if let Some(lab) = &manifest.relationships.labelling {
2075        obj.insert(
2076            "labelling".into(),
2077            serde_json::to_value(lab).expect("labelling declaration serializes"),
2078        );
2079    }
2080
2081    // Schema-level prose — FULL mode only. An agent that asked for the
2082    // lite skeleton is orienting on structure; the human-readable
2083    // `description` / `when_to_use` is exactly the weight the lite cut
2084    // exists to drop. The schema `ref` still identifies the schema.
2085    if full {
2086        obj.insert(
2087            "description".into(),
2088            serde_json::Value::String(manifest.description.clone()),
2089        );
2090        obj.insert(
2091            "when_to_use".into(),
2092            serde_json::Value::String(manifest.when_to_use.clone()),
2093        );
2094        // Schema-level `system_message`, wire-named `system_context` to
2095        // match the per-type key. Without this the manifest's voice/
2096        // posture prose is unreachable from the agent surface entirely
2097        // (its only other consumer is the `memstead type` CLI markdown).
2098        // Omitted when undeclared so existing schemas render unchanged.
2099        if let Some(msg) = &manifest.system_message {
2100            obj.insert(
2101                "system_context".into(),
2102                serde_json::Value::String(msg.clone()),
2103            );
2104        }
2105    }
2106
2107    // One-line effect note for the per-type `no_self_loop_relationships`
2108    // arrays — present in BOTH modes, right where the field is read.
2109    // The retired `propagating_relationships` name misled outside
2110    // schema authors into declaring impact propagation; the renamed
2111    // key states the single functional effect. Top-level (not
2112    // per-type) so the note costs one key, not one per type.
2113    obj.insert(
2114        "no_self_loop_relationships_effect".into(),
2115        serde_json::Value::String(
2116            "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
2117             memstead_relate refuses a self-loop (from == to) on a rel-type the \
2118             source type lists here. It does not propagate impact, imply an \
2119             evidence obligation, or have any other effect (the name says it \
2120             all). To declare real impact propagation, use the \
2121             `status_propagation` constraint (`constraints:` on the type), which \
2122             taints dependents of a terminal status value via a named rel-type \
2123             and direction and surfaces them as health findings."
2124                .to_string(),
2125        ),
2126    );
2127
2128    // Schema-level `alias_target_rel_type` pointer — names the rel-type
2129    // that body wiki-links `[[target]]` auto-emit through the
2130    // alias-synthesis pass. Present in BOTH modes: it governs whether an
2131    // unbacked wiki-link bakes an edge or refuses with
2132    // `WIKILINK_WITHOUT_RELATION`, so dropping it from lite would leave a
2133    // caller one round-trip from a write-time refusal. Schemas omitting
2134    // the field render with the key absent so existing agents don't see
2135    // a noisy `null`.
2136    if let Some(target) = &manifest.alias_target_rel_type {
2137        obj.insert(
2138            "alias_target_rel_type".into(),
2139            serde_json::Value::String(target.clone()),
2140        );
2141    }
2142
2143    // Surface `default_writing_guidance` at the top level so plugin-side
2144    // resolvers can concatenate the schema-generic prose with per-mem
2145    // additions without parsing schema YAML themselves. FULL mode only —
2146    // it is guidance prose. Field-by-field omission — a schema with
2147    // neither `avoid` nor `goal` declared emits no key at all (both
2148    // `Option<String>` inside an `Option<DefaultWritingGuidance>`).
2149    if full && let Some(dwg) = &manifest.default_writing_guidance {
2150        let mut block = serde_json::Map::new();
2151        if let Some(avoid) = &dwg.avoid {
2152            block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
2153        }
2154        if let Some(goal) = &dwg.goal {
2155            block.insert("goal".into(), serde_json::Value::String(goal.clone()));
2156        }
2157        if !block.is_empty() {
2158            obj.insert(
2159                "default_writing_guidance".into(),
2160                serde_json::Value::Object(block),
2161            );
2162        }
2163    }
2164
2165    // The selection partitions the manifest-ordered type roster into
2166    // served and omitted halves. `types_omitted` is emitted whenever
2167    // any type was NOT served in the requested tier — the visible-scope
2168    // guarantee (a reader always sees what a reply does not carry).
2169    let selected = |name: &serde_json::Value| -> bool {
2170        match type_selection {
2171            None => true,
2172            Some(sel) => name.as_str().is_some_and(|n| sel.iter().any(|s| s == n)),
2173        }
2174    };
2175    let omitted_names: Vec<serde_json::Value> = types_full
2176        .iter()
2177        .filter(|t| !selected(&t["name"]))
2178        .map(|t| t["name"].clone())
2179        .collect();
2180
2181    if full {
2182        obj.insert(
2183            "relationships".into(),
2184            serde_json::Value::Array(relationships),
2185        );
2186        // Only surface the cross-mem block when the schema declares
2187        // outbound entries — keeps the response minimal for schemas
2188        // that don't speak cross-mem vocabulary.
2189        if !cross_mem_relationships.is_empty() {
2190            obj.insert(
2191                "cross_mem_relationships".into(),
2192                serde_json::Value::Array(cross_mem_relationships),
2193            );
2194        }
2195        match type_selection {
2196            Some(_) => {
2197                let served: Vec<serde_json::Value> = types_full
2198                    .iter()
2199                    .filter(|t| selected(&t["name"]))
2200                    .cloned()
2201                    .collect();
2202                obj.insert("types".into(), serde_json::Value::Array(served));
2203                if !omitted_names.is_empty() {
2204                    obj.insert(
2205                        "types_omitted".into(),
2206                        serde_json::Value::Array(omitted_names),
2207                    );
2208                }
2209            }
2210            None => {
2211                obj.insert("types".into(), serde_json::Value::Array(types_full.clone()));
2212                // Budget guard on the UNSCOPED full reply: when the
2213                // assembled payload exceeds the budget, degrade
2214                // visibly — the per-type prose drops to the lite
2215                // skeleton, the mode is stamped, and the hint steers
2216                // to per-type retrieval. Never silent truncation: the
2217                // caller sees `_schema_mode: "reduced"` plus the full
2218                // roster in `types_omitted`.
2219                if let Some(budget) = token_budget {
2220                    let estimated = estimate_payload_tokens(&payload);
2221                    if estimated > budget {
2222                        let obj = payload.as_object_mut().unwrap();
2223                        obj.remove("types");
2224                        let all_names: Vec<serde_json::Value> =
2225                            types_full.iter().map(|t| t["name"].clone()).collect();
2226                        obj.insert(
2227                            "types_summary".into(),
2228                            serde_json::Value::Array(lite_types_projection(&types_full)),
2229                        );
2230                        obj.insert("types_omitted".into(), serde_json::Value::Array(all_names));
2231                        obj.insert(
2232                            "_schema_mode".into(),
2233                            serde_json::Value::String("reduced".into()),
2234                        );
2235                        obj.insert("_estimated_tokens".into(), serde_json::json!(estimated));
2236                        obj.insert("_token_budget".into(), serde_json::json!(budget));
2237                        obj.insert(
2238                            "_hint".into(),
2239                            serde_json::Value::String(format!(
2240                                "the full prose for all {} types (~{estimated} tokens) exceeds \
2241                                 the response budget ({budget}); per-type prose is served as the \
2242                                 lite skeleton here — request the full prose for exactly the \
2243                                 types you will write via `types: [\"<name>\", …]` (valid names \
2244                                 in `types_omitted`)",
2245                                types_full.len(),
2246                            )),
2247                        );
2248                    }
2249                }
2250            }
2251        }
2252    } else {
2253        // Lite relationship form: name + endpoint constraints
2254        // (`allowed_sources`/`allowed_targets`) + manual-authoring
2255        // posture + `acyclic` + per-edge-description posture — every flag
2256        // that governs a relate-path refusal (`INVALID_REL_SHAPE`,
2257        // `RELATION_MANUAL_AUTHORING_FORBIDDEN`, cycle check,
2258        // `MISSING_REQUIRED_DESCRIPTION`) — with the description /
2259        // when_to_use / weight prose dropped. The ~42 rel-types carry the
2260        // bulk of the bytes, so this is the load-bearing half of the cut.
2261        // Projected from the rich array so each field value has one source.
2262        let relationships_summary: Vec<serde_json::Value> = relationships
2263            .iter()
2264            .map(|r| {
2265                let mut o = serde_json::json!({
2266                    "name": r["name"],
2267                    "allowed_sources": r["allowed_sources"],
2268                    "allowed_targets": r["allowed_targets"],
2269                    "manual_authoring": r["manual_authoring"],
2270                    "acyclic": r["acyclic"],
2271                    "per_edge_description": r["per_edge_description"],
2272                });
2273                if r.get("derivation") == Some(&serde_json::json!(true)) {
2274                    o["derivation"] = serde_json::json!(true);
2275                }
2276                o
2277            })
2278            .collect();
2279        obj.insert(
2280            "relationships_summary".into(),
2281            serde_json::Value::Array(relationships_summary),
2282        );
2283
2284        // Lite cross-mem form mirrors the intra-mem lite shape:
2285        // name + endpoint pinning, prose dropped. Same emit-when-non-empty
2286        // rule as full mode.
2287        if !cross_mem_relationships.is_empty() {
2288            let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
2289                .iter()
2290                .map(|e| {
2291                    let definitions: Vec<serde_json::Value> = e["definitions"]
2292                        .as_array()
2293                        .map(|defs| {
2294                            defs.iter()
2295                                .map(|d| {
2296                                    serde_json::json!({
2297                                        "name": d["name"],
2298                                        "source_types": d["source_types"],
2299                                        "target_types": d["target_types"],
2300                                    })
2301                                })
2302                                .collect()
2303                        })
2304                        .unwrap_or_default();
2305                    serde_json::json!({
2306                        "to_schema": e["to_schema"],
2307                        "definitions": definitions,
2308                    })
2309                })
2310                .collect();
2311            obj.insert(
2312                "cross_mem_relationships_summary".into(),
2313                serde_json::Value::Array(cross_summary),
2314            );
2315        }
2316
2317        // Lite entity-type form — see [`lite_types_projection`]. The
2318        // selection filters the skeleton the same way it filters the
2319        // full tier, with the same visible `types_omitted` roster.
2320        let served: Vec<serde_json::Value> = types_full
2321            .iter()
2322            .filter(|t| selected(&t["name"]))
2323            .cloned()
2324            .collect();
2325        obj.insert(
2326            "types_summary".into(),
2327            serde_json::Value::Array(lite_types_projection(&served)),
2328        );
2329        if !omitted_names.is_empty() {
2330            obj.insert(
2331                "types_omitted".into(),
2332                serde_json::Value::Array(omitted_names),
2333            );
2334        }
2335    }
2336
2337    Ok(payload)
2338}
2339
2340/// Lite entity-type form: name + section keys (each with its
2341/// `required` marker) + metadata-field shapes (name, required,
2342/// `enum`, `default`) + `no_self_loop_relationships` +
2343/// `required_outgoing` — the structural minimum to author a
2344/// legal write — with the type/section prose (descriptions,
2345/// write_rules, writing_guidance, system_context) dropped.
2346/// `no_self_loop_relationships` rides along because it governs
2347/// the self-loop relate refusal (relate R X→X when type T lists
2348/// R), one of the refusals the lite view must let an
2349/// agent avoid. `required_outgoing` rides along because it is
2350/// the only declared legality condition on outgoing edges —
2351/// dropping it would make "enough to plan a legal write" false.
2352/// Projected from the rich array so each field value has one
2353/// source; also the degrade target for an over-budget unscoped
2354/// full reply.
2355fn lite_types_projection(types_full: &[serde_json::Value]) -> Vec<serde_json::Value> {
2356    types_full
2357        .iter()
2358        .map(|t| {
2359            let sections: Vec<serde_json::Value> = t["sections"]
2360                .as_array()
2361                .map(|secs| {
2362                    secs.iter()
2363                        .map(|s| {
2364                            let mut o = serde_json::Map::new();
2365                            o.insert("key".into(), s["key"].clone());
2366                            o.insert("required".into(), s["required"].clone());
2367                            // The format declaration is a
2368                            // legality condition — the lite
2369                            // skeleton carries it in full.
2370                            for k in [
2371                                "content",
2372                                "item_pattern",
2373                                "table",
2374                                "example",
2375                                "format_severity",
2376                            ] {
2377                                if let Some(v) = s.get(k) {
2378                                    o.insert(k.into(), v.clone());
2379                                }
2380                            }
2381                            serde_json::Value::Object(o)
2382                        })
2383                        .collect()
2384                })
2385                .unwrap_or_default();
2386            let fields: Vec<serde_json::Value> = t["fields"]
2387                .as_array()
2388                .map(|fs| {
2389                    fs.iter()
2390                        .map(|f| {
2391                            let mut o = serde_json::Map::new();
2392                            o.insert("name".into(), f["name"].clone());
2393                            o.insert("required".into(), f["required"].clone());
2394                            if let Some(e) = f.get("enum") {
2395                                o.insert("enum".into(), e.clone());
2396                            }
2397                            if let Some(d) = f.get("default") {
2398                                o.insert("default".into(), d.clone());
2399                            }
2400                            serde_json::Value::Object(o)
2401                        })
2402                        .collect()
2403                })
2404                .unwrap_or_default();
2405            let mut o = serde_json::json!({
2406                "name": t["name"],
2407                "sections": sections,
2408                "fields": fields,
2409                "no_self_loop_relationships": t["no_self_loop_relationships"],
2410                "required_outgoing": t["required_outgoing"],
2411                "constraints": t["constraints"],
2412            });
2413            // Leaf declaration rides the lite skeleton too — it is
2414            // a legality-relevant per-type fact.
2415            if t.get("leaf") == Some(&serde_json::json!(true)) {
2416                o["leaf"] = serde_json::json!(true);
2417            }
2418            // Reachability obligations ride whole — a health condition
2419            // the skeleton must not hide; key present only when the
2420            // full payload carries it.
2421            if let Some(mr) = t.get("must_reach") {
2422                o["must_reach"] = mr.clone();
2423            }
2424            // Signal declarations ride whole for the same reason.
2425            if let Some(sig) = t.get("signals") {
2426                o["signals"] = sig.clone();
2427            }
2428            o
2429        })
2430        .collect()
2431}
2432
2433/// Format a metadata field definition as a single bullet line.
2434fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
2435    let type_str = match field.field_type {
2436        FieldType::String => "String",
2437        FieldType::Number => "Number",
2438        FieldType::Date => "Date",
2439        FieldType::Boolean => "Boolean",
2440    };
2441
2442    let mut flags: Vec<&str> = Vec::new();
2443    if !field.is_required() {
2444        flags.push("optional");
2445    } else {
2446        flags.push("required");
2447    }
2448    if field.init_timestamp {
2449        flags.push("auto-init");
2450    }
2451    if field.auto_timestamp {
2452        flags.push("auto-update");
2453    }
2454    match field.serialization {
2455        Serialization::CsvArray => flags.push("csv array"),
2456        Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
2457        Serialization::Default => {}
2458    }
2459
2460    let mut extras: Vec<String> = Vec::new();
2461    if let Some(values) = &field.enum_values {
2462        extras.push(format!("enum: {}", values.join(", ")));
2463    }
2464    if let Some(default) = &field.default_value {
2465        extras.push(format!("default: {default}"));
2466    }
2467    let filterable_str = match field.filterable {
2468        Filterable::None => None,
2469        Filterable::Equality => Some("filterable: equality"),
2470        Filterable::Range => Some("filterable: range"),
2471    };
2472    if let Some(f) = filterable_str {
2473        extras.push(f.to_string());
2474    }
2475
2476    let extras_str = if extras.is_empty() {
2477        String::new()
2478    } else {
2479        format!(" — {}", extras.join(" — "))
2480    };
2481
2482    format!(
2483        "**{key}**: {type_str} ({flags}){extras_str}",
2484        key = field.key,
2485        flags = flags.join(", "),
2486    )
2487}
2488
2489#[cfg(test)]
2490mod tests {
2491    use super::*;
2492    use crate::{Entity, EntityId, ListResult, SearchResult};
2493    use indexmap::IndexMap;
2494    use std::collections::HashMap;
2495
2496    fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
2497        SearchHit {
2498            id: EntityId(id.to_string()),
2499            last_modified: None,
2500            title: title.to_string(),
2501            mem: id.split("--").next().unwrap_or("").to_string(),
2502            entity_type: entity_type.to_string(),
2503            stub: false,
2504            score: 1.0,
2505            tokens: 10,
2506            snippet: None,
2507            sections: sections
2508                .iter()
2509                .map(|(k, v)| (k.to_string(), v.to_string()))
2510                .collect(),
2511            score_breakdown: None,
2512            matched_terms: None,
2513            expansion: None,
2514            // Test fixtures exercise the render-time fallback (default-schema
2515            // lookup); the engine-precomputed path is set in the search op.
2516            summary: None,
2517        }
2518    }
2519
2520    fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2521        let returned = hits.len();
2522        let total_tokens = hits.iter().map(|h| h.tokens).sum();
2523        SearchResult {
2524            total: returned,
2525            returned,
2526            offset: 0,
2527            total_tokens,
2528            hits,
2529            facets: None,
2530            warnings: vec![],
2531        }
2532    }
2533
2534    fn list_result(hits: Vec<SearchHit>) -> ListResult {
2535        let returned = hits.len();
2536        ListResult {
2537            total: returned,
2538            returned,
2539            offset: 0,
2540            total_tokens: hits.iter().map(|h| h.tokens).sum(),
2541            hits,
2542            warnings: vec![],
2543        }
2544    }
2545
2546    fn test_entity() -> Entity {
2547        Entity {
2548            id: EntityId("specs--test-entity".to_string()),
2549            title: "Test Entity".to_string(),
2550            entity_type: "spec".to_string(),
2551            mem: "specs".to_string(),
2552            file_path: "test-entity.md".to_string(),
2553            metadata: IndexMap::new(),
2554            sections: IndexMap::from([
2555                ("identity".to_string(), "A test entity for unit tests.".to_string()),
2556                ("purpose".to_string(), "Validates render logic.".to_string()),
2557                ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2558            ]),
2559            relationships: vec![],
2560            content_hash: "abc123".to_string(),
2561            stub: false,
2562            stub_kind: None,
2563            heading_spans: std::collections::HashMap::new(),
2564            raw_section_headings: Vec::new(),
2565        }
2566    }
2567
2568    #[test]
2569    fn section_key_to_heading_basic() {
2570        assert_eq!(section_key_to_heading("identity"), "Identity");
2571        assert_eq!(section_key_to_heading("current_state"), "Current state");
2572    }
2573
2574    #[test]
2575    fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2576        // The `ingest.inconsistency` schema declares `claim_a` with
2577        // heading "Claim A" — the simple key-derivation would produce
2578        // "Claim a", which would disagree with the on-disk markdown
2579        // emitted by the generator. The renderer must echo the
2580        // schema's declared heading verbatim.
2581        let mut sections: IndexMap<String, String> = IndexMap::new();
2582        sections.insert("claim_a".to_string(), "Body A.".to_string());
2583        sections.insert("claim_b".to_string(), "Body B.".to_string());
2584
2585        let entity = Entity {
2586            id: EntityId("ingest--example".to_string()),
2587            title: "Example".to_string(),
2588            entity_type: "inconsistency".to_string(),
2589            mem: "ingest".to_string(),
2590            file_path: "example.md".to_string(),
2591            metadata: IndexMap::new(),
2592            sections,
2593            relationships: vec![],
2594            content_hash: "h".to_string(),
2595            stub: false,
2596            stub_kind: None,
2597            heading_spans: std::collections::HashMap::new(),
2598            raw_section_headings: Vec::new(),
2599        };
2600
2601        let md = render_entity_markdown(&entity, None);
2602        assert!(
2603            md.contains("## Claim A"),
2604            "expected schema-declared `## Claim A` heading; got:\n{md}"
2605        );
2606        assert!(
2607            md.contains("## Claim B"),
2608            "expected schema-declared `## Claim B` heading; got:\n{md}"
2609        );
2610        // The naive derivation would have produced lower-case `a`/`b`.
2611        assert!(
2612            !md.contains("## Claim a"),
2613            "renderer must not fall back to key-derivation when the \
2614             schema declares a heading; got:\n{md}"
2615        );
2616    }
2617
2618    #[test]
2619    fn render_falls_back_to_key_derivation_for_unknown_types() {
2620        // When the entity_type is not in any built-in schema (custom
2621        // workspace schemas, legacy entities), the renderer falls back
2622        // to the simple key→heading derivation.
2623        let mut sections: IndexMap<String, String> = IndexMap::new();
2624        sections.insert("identity".to_string(), "body".to_string());
2625
2626        let entity = Entity {
2627            id: EntityId("custom--example".to_string()),
2628            title: "Example".to_string(),
2629            entity_type: "not-a-builtin-type".to_string(),
2630            mem: "custom".to_string(),
2631            file_path: "example.md".to_string(),
2632            metadata: IndexMap::new(),
2633            sections,
2634            relationships: vec![],
2635            content_hash: "h".to_string(),
2636            stub: false,
2637            stub_kind: None,
2638            heading_spans: std::collections::HashMap::new(),
2639            raw_section_headings: Vec::new(),
2640        };
2641
2642        let md = render_entity_markdown(&entity, None);
2643        assert!(
2644            md.contains("## Identity"),
2645            "fallback derivation must produce `## Identity`; got:\n{md}"
2646        );
2647    }
2648
2649    // Regression lock for deterministic section order. The invariant:
2650    // render_entity_body walks `entity.sections` in IndexMap insertion order,
2651    // so whatever order the parser/caller inserts is what ships. The parser
2652    // inserts in schema-declared order; this test deliberately inserts in
2653    // REVERSE schema order to prove the renderer honors insertion order
2654    // (not the schema's declared order directly).
2655    #[test]
2656    fn render_entity_sections_follow_indexmap_insertion_order() {
2657        let mut sections: IndexMap<String, String> = IndexMap::new();
2658        sections.insert("specifies".to_string(), "S content.".to_string());
2659        sections.insert("purpose".to_string(), "P content.".to_string());
2660        sections.insert("identity".to_string(), "I content.".to_string());
2661
2662        let entity = Entity {
2663            id: EntityId("specs--order-test".to_string()),
2664            title: "Order Test".to_string(),
2665            entity_type: "spec".to_string(),
2666            mem: "specs".to_string(),
2667            file_path: "order-test.md".to_string(),
2668            metadata: IndexMap::new(),
2669            sections,
2670            relationships: vec![],
2671            content_hash: "abc123".to_string(),
2672            stub: false,
2673            stub_kind: None,
2674            heading_spans: std::collections::HashMap::new(),
2675            raw_section_headings: Vec::new(),
2676        };
2677
2678        let md = render_entity_markdown(&entity, None);
2679        let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2680        let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2681        let identity_pos = md.find("## Identity").expect("## Identity must appear");
2682
2683        assert!(
2684            specifies_pos < purpose_pos,
2685            "Specifies (inserted first) must render before Purpose; got:\n{md}"
2686        );
2687        assert!(
2688            purpose_pos < identity_pos,
2689            "Purpose (inserted second) must render before Identity; got:\n{md}"
2690        );
2691    }
2692
2693    /// `_tokens_unfiltered_body` rides only when a section filter
2694    /// narrows the rendered output; it carries the unfiltered-base
2695    /// cost so agents can predict the cost of dropping the filter. The
2696    /// name avoids a monotonic-relationship implication
2697    /// that the opt-in path could invert.
2698    #[test]
2699    fn tokens_reflect_filtered_output() {
2700        let entity = test_entity();
2701
2702        // Full render — no filter
2703        let full = render_entity_markdown(&entity, None);
2704        assert!(full.contains("_tokens:"), "should have _tokens");
2705        assert!(
2706            !full.contains("_tokens_unfiltered_body:"),
2707            "should NOT have _tokens_unfiltered_body when unfiltered"
2708        );
2709        assert!(
2710            !full.contains("_tokens_full:"),
2711            "old _tokens_full name must not survive — rename is one-way"
2712        );
2713
2714        // Filtered render — request only "identity"
2715        let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2716        assert!(filtered.contains("_tokens:"), "should have _tokens");
2717        assert!(
2718            filtered.contains("_tokens_unfiltered_body:"),
2719            "should have _tokens_unfiltered_body when filtered"
2720        );
2721        assert!(
2722            !filtered.contains("_tokens_full:"),
2723            "old _tokens_full name must not survive — rename is one-way"
2724        );
2725
2726        // Extract token values
2727        let full_tokens: usize = full
2728            .lines()
2729            .find(|l| l.starts_with("_tokens:"))
2730            .unwrap()
2731            .trim_start_matches("_tokens: ")
2732            .parse()
2733            .unwrap();
2734        let filtered_tokens: usize = filtered
2735            .lines()
2736            .find(|l| l.starts_with("_tokens:"))
2737            .unwrap()
2738            .trim_start_matches("_tokens: ")
2739            .parse()
2740            .unwrap();
2741        let tokens_unfiltered_body: usize = filtered
2742            .lines()
2743            .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2744            .unwrap()
2745            .trim_start_matches("_tokens_unfiltered_body: ")
2746            .parse()
2747            .unwrap();
2748
2749        assert!(
2750            filtered_tokens < full_tokens,
2751            "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2752        );
2753        assert!(
2754            tokens_unfiltered_body >= full_tokens,
2755            "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2756        );
2757    }
2758
2759    // -----------------------------------------------------------------------
2760    // Summary line — search rendering
2761    // -----------------------------------------------------------------------
2762
2763    #[test]
2764    fn render_search_uses_first_required_section_for_spec() {
2765        let hit = make_hit(
2766            "specs--demo",
2767            "Demo Spec",
2768            "spec",
2769            &[
2770                ("identity", "A demo spec."),
2771                ("purpose", "Verifies rendering."),
2772            ],
2773        );
2774        let out = render_search_markdown(&search_result(vec![hit]), 0);
2775        assert!(
2776            out.contains("**Identity**: A demo spec."),
2777            "expected Identity line for spec hit, got:\n{out}"
2778        );
2779    }
2780
2781    #[test]
2782    fn render_search_uses_first_required_section_for_memo() {
2783        let hit = make_hit(
2784            "memos--d1",
2785            "Memo One",
2786            "memo",
2787            &[("claim", "Some claim."), ("context", "Some context.")],
2788        );
2789        let out = render_search_markdown(&search_result(vec![hit]), 0);
2790        assert!(
2791            out.contains("**Claim**: Some claim."),
2792            "expected Claim line for memo hit, got:\n{out}"
2793        );
2794        assert!(
2795            !out.contains("**Identity**"),
2796            "memo hit must not render Identity label"
2797        );
2798        assert!(
2799            !out.contains("**Purpose**"),
2800            "memo hit must not render Purpose label"
2801        );
2802    }
2803
2804    #[test]
2805    fn render_search_uses_first_required_section_for_concept() {
2806        let hit = make_hit(
2807            "concepts--thing",
2808            "Thing",
2809            "concept",
2810            &[("definition", "A thing."), ("explanation", "Details.")],
2811        );
2812        let out = render_search_markdown(&search_result(vec![hit]), 0);
2813        assert!(
2814            out.contains("**Definition**: A thing."),
2815            "expected Definition line for concept hit, got:\n{out}"
2816        );
2817    }
2818
2819    #[test]
2820    fn render_search_missing_summary_section_shows_dash() {
2821        // Memo hit with no "claim" section — renderer falls back to em-dash.
2822        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2823        let out = render_search_markdown(&search_result(vec![hit]), 0);
2824        assert!(
2825            out.contains("**Claim**: —"),
2826            "expected Claim dash fallback, got:\n{out}"
2827        );
2828    }
2829
2830    #[test]
2831    fn render_search_mixes_schemas_in_one_result() {
2832        let spec_hit = make_hit(
2833            "specs--s1",
2834            "Spec One",
2835            "spec",
2836            &[("identity", "Spec body.")],
2837        );
2838        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2839        let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2840        assert!(
2841            out.contains("**Identity**: Spec body."),
2842            "spec hit should still render Identity, got:\n{out}"
2843        );
2844        assert!(
2845            out.contains("**Claim**: Memo claim."),
2846            "memo hit should render Claim in the same output, got:\n{out}"
2847        );
2848    }
2849
2850    #[test]
2851    fn render_search_unknown_schema_shows_summary_dash() {
2852        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2853        let out = render_search_markdown(&search_result(vec![hit]), 0);
2854        assert!(
2855            out.contains("**Summary**: —"),
2856            "unknown schema should render Summary dash, got:\n{out}"
2857        );
2858    }
2859
2860    #[test]
2861    fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2862        use memstead_schema::{SectionDef, TypeDefinition};
2863
2864        let schema = TypeDefinition {
2865            name: "spec".to_string(),
2866            description: "test".to_string(),
2867            when_to_use: "test".to_string(),
2868            boundaries: vec![],
2869            exemplar: None,
2870            legacy_examples: None,
2871            system_message: None,
2872            sections: vec![SectionDef {
2873                key: "note".to_string(),
2874                heading: "Note".to_string(),
2875                required: false,
2876                load_bearing: None,
2877                search_weight: 1.0,
2878                catch_all: false,
2879                write_rules: vec![],
2880                description: None,
2881                content: None,
2882                item_pattern: None,
2883                table: None,
2884                example: None,
2885                format_severity: memstead_schema::ConstraintSeverity::Block,
2886                compiled_content: None,
2887                format_problems: Vec::new(),
2888            }],
2889            metadata_fields: vec![],
2890            title_weight: 1.0,
2891            text_fields: vec![],
2892            hierarchy_relationship: "PART_OF".to_string(),
2893            edge_weight_overrides: indexmap::IndexMap::new(),
2894            edge_weights: indexmap::IndexMap::new(),
2895            no_self_loop_relationships: vec![],
2896            legacy_propagating_relationships: None,
2897            due: None,
2898            leaf: false,
2899            updatable_fields: vec![],
2900            health_required_fields: vec![],
2901            staleness_threshold_days: 90,
2902            write_rules: vec![],
2903            required_outgoing: vec![],
2904            must_reach: vec![],
2905            signals: vec![],
2906            constraints: vec![],
2907            declared_metadata_keys: vec![],
2908        };
2909
2910        let mut sections = HashMap::new();
2911        sections.insert("note".to_string(), "a note".to_string());
2912        assert_eq!(
2913            summary_pair(Some(&schema), &sections),
2914            ("Note".to_string(), "a note".to_string()),
2915        );
2916
2917        assert_eq!(
2918            summary_pair(Some(&schema), &HashMap::new()),
2919            ("Note".to_string(), "—".to_string()),
2920        );
2921    }
2922
2923    // -----------------------------------------------------------------------
2924    // Summary line — list rendering (symmetric)
2925    // -----------------------------------------------------------------------
2926
2927    #[test]
2928    fn render_list_uses_first_required_section_for_spec() {
2929        let hit = make_hit(
2930            "specs--demo",
2931            "Demo Spec",
2932            "spec",
2933            &[
2934                ("identity", "A demo spec."),
2935                ("purpose", "Verifies rendering."),
2936            ],
2937        );
2938        let out = render_list_markdown(&list_result(vec![hit]));
2939        assert!(
2940            out.contains("**Identity**: A demo spec."),
2941            "expected Identity line for spec hit, got:\n{out}"
2942        );
2943    }
2944
2945    #[test]
2946    fn render_list_uses_first_required_section_for_memo() {
2947        let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2948        let out = render_list_markdown(&list_result(vec![hit]));
2949        assert!(
2950            out.contains("**Claim**: Some claim."),
2951            "expected Claim line for memo hit, got:\n{out}"
2952        );
2953        assert!(
2954            !out.contains("**Identity**"),
2955            "memo hit must not render Identity label in list output"
2956        );
2957    }
2958
2959    #[test]
2960    fn render_list_uses_first_required_section_for_concept() {
2961        let hit = make_hit(
2962            "concepts--thing",
2963            "Thing",
2964            "concept",
2965            &[("definition", "A thing.")],
2966        );
2967        let out = render_list_markdown(&list_result(vec![hit]));
2968        assert!(
2969            out.contains("**Definition**: A thing."),
2970            "expected Definition line for concept hit, got:\n{out}"
2971        );
2972    }
2973
2974    #[test]
2975    fn render_list_missing_summary_section_shows_dash() {
2976        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2977        let out = render_list_markdown(&list_result(vec![hit]));
2978        assert!(
2979            out.contains("**Claim**: —"),
2980            "expected Claim dash fallback in list output, got:\n{out}"
2981        );
2982    }
2983
2984    #[test]
2985    fn render_list_mixes_schemas_in_one_result() {
2986        let spec_hit = make_hit(
2987            "specs--s1",
2988            "Spec One",
2989            "spec",
2990            &[("identity", "Spec body.")],
2991        );
2992        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2993        let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2994        assert!(
2995            out.contains("**Identity**: Spec body."),
2996            "spec hit should still render Identity in list output, got:\n{out}"
2997        );
2998        assert!(
2999            out.contains("**Claim**: Memo claim."),
3000            "memo hit should render Claim in list output, got:\n{out}"
3001        );
3002    }
3003
3004    #[test]
3005    fn render_list_unknown_schema_shows_summary_dash() {
3006        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
3007        let out = render_list_markdown(&list_result(vec![hit]));
3008        assert!(
3009            out.contains("**Summary**: —"),
3010            "unknown schema should render Summary dash in list output, got:\n{out}"
3011        );
3012    }
3013
3014    // -----------------------------------------------------------------------
3015    // summary_pair — structured-content source of truth
3016    // -----------------------------------------------------------------------
3017
3018    #[test]
3019    fn summary_pair_for_spec_returns_identity() {
3020        let schema = type_by_name("spec");
3021        let mut sections = HashMap::new();
3022        sections.insert("identity".to_string(), "A demo spec.".to_string());
3023        assert_eq!(
3024            summary_pair(schema.as_deref(), &sections),
3025            ("Identity".to_string(), "A demo spec.".to_string()),
3026        );
3027    }
3028
3029    #[test]
3030    fn summary_pair_for_memo_returns_claim() {
3031        let schema = type_by_name("memo");
3032        let mut sections = HashMap::new();
3033        sections.insert("claim".to_string(), "Memos matter.".to_string());
3034        assert_eq!(
3035            summary_pair(schema.as_deref(), &sections),
3036            ("Claim".to_string(), "Memos matter.".to_string()),
3037        );
3038    }
3039
3040    #[test]
3041    fn summary_pair_missing_section_returns_dash() {
3042        let schema = type_by_name("memo");
3043        assert_eq!(
3044            summary_pair(schema.as_deref(), &HashMap::new()),
3045            ("Claim".to_string(), "—".to_string()),
3046        );
3047    }
3048
3049    #[test]
3050    fn summary_pair_unknown_schema_returns_summary_dash() {
3051        assert_eq!(
3052            summary_pair(None, &HashMap::new()),
3053            ("Summary".to_string(), "—".to_string()),
3054        );
3055    }
3056
3057    // -----------------------------------------------------------------------
3058    // Envelope serialization — structured-content sidecar
3059    // -----------------------------------------------------------------------
3060
3061    #[test]
3062    fn envelope_serializes_summary_fields() {
3063        let hit = make_hit(
3064            "memos--d1",
3065            "Memo One",
3066            "memo",
3067            &[("claim", "Memos matter.")],
3068        );
3069        let result = search_result(vec![hit]);
3070        let envelope = build_search_envelope(&result, 0);
3071        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3072
3073        // The top-level counters use the `_-prefixed` engine-emitted
3074        // shape so the wire signals "engine-authored metadata, not
3075        // user data".
3076        assert_eq!(value["_total"], 1);
3077        assert_eq!(value["_returned"], 1);
3078        assert_eq!(value["_offset"], 0);
3079        // Warnings field is omitted when empty (skip_serializing_if).
3080        assert!(
3081            value.get("warnings").is_none(),
3082            "empty warnings must be elided, got: {value}"
3083        );
3084
3085        let hit0 = &value["hits"][0];
3086        assert_eq!(hit0["summary_heading"], "Claim");
3087        assert_eq!(hit0["summary_value"], "Memos matter.");
3088        // Flattened SearchHit fields present.
3089        assert_eq!(hit0["id"], "memos--d1");
3090        assert_eq!(hit0["title"], "Memo One");
3091        assert_eq!(hit0["entity_type"], "memo");
3092        assert_eq!(hit0["mem"], "memos");
3093        assert_eq!(hit0["stub"], false);
3094        assert_eq!(hit0["tokens"], 10);
3095        assert!(hit0["sections"].is_object());
3096    }
3097
3098    #[test]
3099    fn envelope_roundtrips_through_structured_content() {
3100        // Mixed-schema result: one spec hit, one memo hit. Both summary pairs
3101        // must match what summary_pair produces for each schema.
3102        let spec_hit = make_hit(
3103            "specs--s1",
3104            "Spec One",
3105            "spec",
3106            &[("identity", "Spec body.")],
3107        );
3108        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3109        let result = search_result(vec![spec_hit, memo_hit]);
3110        let envelope = build_search_envelope(&result, 0);
3111        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3112
3113        let hits = value["hits"].as_array().expect("hits must be array");
3114        assert_eq!(hits.len(), 2);
3115        assert_eq!(hits[0]["summary_heading"], "Identity");
3116        assert_eq!(hits[0]["summary_value"], "Spec body.");
3117        assert_eq!(hits[1]["summary_heading"], "Claim");
3118        assert_eq!(hits[1]["summary_value"], "Memo claim.");
3119    }
3120
3121    #[test]
3122    fn list_envelope_includes_total_tokens() {
3123        let hit = make_hit(
3124            "concepts--c1",
3125            "Thing",
3126            "concept",
3127            &[("definition", "A thing.")],
3128        );
3129        let result = list_result(vec![hit]);
3130        let envelope = build_list_envelope(&result);
3131        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3132
3133        // `_`-prefixed engine-meta keys, matching the search envelope.
3134        assert_eq!(value["_total"], 1);
3135        assert_eq!(value["_total_tokens"], 10);
3136        assert!(value.get("total").is_none(), "unprefixed keys retired");
3137        assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3138        assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3139    }
3140
3141    #[test]
3142    fn envelope_emits_warnings_when_present() {
3143        let mut result = search_result(vec![]);
3144        // Search warnings ship as typed `WarningHint` entries (same
3145        // `{code, details, message}` envelope every other tool uses).
3146        result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3147            field: "foo".to_string(),
3148        }];
3149        let envelope = build_search_envelope(&result, 0);
3150        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3151        assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3152        assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3153        assert!(
3154            value["warnings"][0]["message"]
3155                .as_str()
3156                .is_some_and(|m| m.contains("not filterable"))
3157        );
3158    }
3159
3160    // -----------------------------------------------------------------------
3161    // Per-hit and per-result fields that must appear in the Markdown body.
3162    // -----------------------------------------------------------------------
3163
3164    fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3165        TermMatch {
3166            field: field.to_string(),
3167            snippet: snippet.to_string(),
3168            heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3169        }
3170    }
3171
3172    fn sample_facets() -> Facets {
3173        use crate::ops::SubsectionFacet;
3174        Facets {
3175            by_type: HashMap::from([
3176                ("spec".to_string(), 7),
3177                ("memo".to_string(), 3),
3178                ("decision".to_string(), 2),
3179            ]),
3180            by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3181            by_level: HashMap::from([("high".to_string(), 4)]),
3182            by_status: HashMap::from([("active".to_string(), 6)]),
3183            by_confidence: HashMap::from([("medium".to_string(), 3)]),
3184            by_subsection: vec![
3185                SubsectionFacet {
3186                    path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3187                    count: 4,
3188                },
3189                SubsectionFacet {
3190                    path: vec!["purpose".to_string(), "Rationale".to_string()],
3191                    count: 2,
3192                },
3193            ],
3194            by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3195        }
3196    }
3197
3198    #[test]
3199    fn render_search_emits_matched_terms_line() {
3200        let mut hit = make_hit(
3201            "specs--e1",
3202            "Entity One",
3203            "spec",
3204            &[("identity", "Body text.")],
3205        );
3206        hit.matched_terms = Some(HashMap::from([
3207            (
3208                "entity".to_string(),
3209                vec![
3210                    tm("title", "...entity...", None),
3211                    tm("purpose", "...entity...", None),
3212                    tm("purpose", "...entity two...", None),
3213                ],
3214            ),
3215            ("one".to_string(), vec![tm("title", "...one...", None)]),
3216        ]));
3217        let out = render_search_markdown(&search_result(vec![hit]), 0);
3218        assert!(
3219            out.contains("**Matched terms:**"),
3220            "missing Matched terms line; got:\n{out}"
3221        );
3222        assert!(
3223            out.contains("`entity` (purpose×2, title×1)"),
3224            "entity term grouping wrong; got:\n{out}"
3225        );
3226        assert!(
3227            out.contains("`one` (title×1)"),
3228            "one term grouping wrong; got:\n{out}"
3229        );
3230    }
3231
3232    #[test]
3233    fn render_search_emits_score_breakdown_line() {
3234        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3235        hit.score_breakdown = Some(ScoreBreakdown {
3236            bm25: 2.5,
3237            title_boost: 2.0,
3238            field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3239            expansion_decay: Some(0.5),
3240        });
3241        let out = render_search_markdown(&search_result(vec![hit]), 0);
3242        assert!(
3243            out.contains(
3244                "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3245            ),
3246            "score breakdown line wrong; got:\n{out}"
3247        );
3248    }
3249
3250    #[test]
3251    fn render_search_omits_expansion_decay_when_none() {
3252        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3253        hit.score_breakdown = Some(ScoreBreakdown {
3254            bm25: 1.5,
3255            title_boost: 1.0,
3256            field_weights: HashMap::new(),
3257            expansion_decay: None,
3258        });
3259        let out = render_search_markdown(&search_result(vec![hit]), 0);
3260        assert!(
3261            out.contains("**Score:** bm25 1.5 + title 1.0"),
3262            "base score wrong; got:\n{out}"
3263        );
3264        assert!(
3265            !out.contains("expansion_decay"),
3266            "expansion_decay must be absent when None; got:\n{out}"
3267        );
3268    }
3269
3270    #[test]
3271    fn render_search_emits_heading_path_line() {
3272        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3273        hit.matched_terms = Some(HashMap::from([(
3274            "x".to_string(),
3275            vec![
3276                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3277                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), // duplicate, dedupe
3278                tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3279            ],
3280        )]));
3281        let out = render_search_markdown(&search_result(vec![hit]), 0);
3282        assert!(
3283            out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3284            "heading path line wrong; got:\n{out}"
3285        );
3286    }
3287
3288    #[test]
3289    fn render_search_emits_expansion_line() {
3290        let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3291        hit.expansion = Some(ExpansionInfo {
3292            of: EntityId("specs--seed".to_string()),
3293            via_edge: "refines".to_string(),
3294            via_direction: crate::graph::query::TraversalDirection::Out,
3295            depth: 1,
3296        });
3297        let out = render_search_markdown(&search_result(vec![hit]), 0);
3298        assert!(
3299            out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3300            "expansion line reports the traversal direction beside the label; got:\n{out}"
3301        );
3302    }
3303
3304    #[test]
3305    fn render_search_emits_facets_block() {
3306        let mut result = search_result(vec![]);
3307        result.facets = Some(sample_facets());
3308        let out = render_search_markdown(&result, 0);
3309        assert!(
3310            out.contains("## Facets"),
3311            "facets header missing; got:\n{out}"
3312        );
3313        assert!(
3314            out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3315            "by_type bucket wrong; got:\n{out}"
3316        );
3317        assert!(
3318            out.contains("- **by_mem:** specs=10, memos=2"),
3319            "by_mem bucket wrong; got:\n{out}"
3320        );
3321        assert!(
3322            out.contains("- **by_level:** high=4"),
3323            "by_level bucket wrong; got:\n{out}"
3324        );
3325        assert!(
3326            out.contains("- **by_status:** active=6"),
3327            "by_status bucket wrong; got:\n{out}"
3328        );
3329        assert!(
3330            out.contains("- **by_confidence:** medium=3"),
3331            "by_confidence bucket wrong; got:\n{out}"
3332        );
3333        assert!(
3334            out.contains("- **by_expansion:** primary=8, expanded=4"),
3335            "by_expansion bucket wrong; got:\n{out}"
3336        );
3337        assert!(
3338            out.contains("- **by_subsection:**"),
3339            "by_subsection header missing; got:\n{out}"
3340        );
3341        assert!(
3342            out.contains("`specifies › Response Shapes`: 4"),
3343            "subsection facet wrong; got:\n{out}"
3344        );
3345    }
3346
3347    #[test]
3348    fn render_search_omits_facets_block_when_all_empty() {
3349        let mut result = search_result(vec![]);
3350        result.facets = Some(Facets::default());
3351        let out = render_search_markdown(&result, 0);
3352        assert!(
3353            !out.contains("## Facets"),
3354            "empty facets must not emit header; got:\n{out}"
3355        );
3356    }
3357
3358    /// Every field the search-tool description promises must be rendered
3359    /// in Markdown. This test exercises all of them in one result and
3360    /// asserts they all appear.
3361    #[test]
3362    fn search_markdown_covers_every_sidecar_field() {
3363        let mut hit = make_hit(
3364            "specs--e1",
3365            "Entity One",
3366            "spec",
3367            &[("identity", "Body text.")],
3368        );
3369        hit.matched_terms = Some(HashMap::from([(
3370            "entity".to_string(),
3371            vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3372        )]));
3373        hit.score_breakdown = Some(ScoreBreakdown {
3374            bm25: 1.5,
3375            title_boost: 1.0,
3376            field_weights: HashMap::from([("body".to_string(), 0.4)]),
3377            expansion_decay: Some(0.5),
3378        });
3379        hit.expansion = Some(ExpansionInfo {
3380            of: EntityId("specs--seed".to_string()),
3381            via_edge: "refines".to_string(),
3382            via_direction: crate::graph::query::TraversalDirection::Out,
3383            depth: 2,
3384        });
3385
3386        let mut result = search_result(vec![hit]);
3387        result.facets = Some(sample_facets());
3388
3389        let out = render_search_markdown(&result, 0);
3390        for marker in [
3391            "## Facets",
3392            "- **by_type:**",
3393            "- **by_mem:**",
3394            "- **by_level:**",
3395            "- **by_status:**",
3396            "- **by_confidence:**",
3397            "- **by_expansion:**",
3398            "- **by_subsection:**",
3399            "**Matched terms:**",
3400            "**Score:**",
3401            "**Heading path:**",
3402            "**Expansion:**",
3403        ] {
3404            assert!(
3405                out.contains(marker),
3406                "lockstep marker `{marker}` missing from search markdown; \
3407                 update render_search_markdown when adding sidecar fields. got:\n{out}"
3408            );
3409        }
3410    }
3411
3412    /// The envelope's `relationships[].source` field reads the store's
3413    /// `EdgeSource` discriminator rather than a hardcoded `"explicit"`,
3414    /// which would disagree with the stub-adoption
3415    /// response for alias-synthesised edges (and would be
3416    /// misleading because REFERENCES carries `manual_authoring:
3417    /// forbidden`).
3418    #[test]
3419    fn build_entity_envelope_source_field_reads_edge_source() {
3420        let mut entity = test_entity();
3421        let body_link_target = EntityId("specs--body-link-target".to_string());
3422        let explicit_target = EntityId("specs--explicit-target".to_string());
3423        entity.relationships = vec![
3424            crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3425            crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3426        ];
3427
3428        let edges = vec![
3429            crate::store::Edge {
3430                rel_type: "REFERENCES".to_string(),
3431                target: body_link_target.clone(),
3432                source: crate::store::EdgeSource::BodyLink,
3433            },
3434            crate::store::Edge {
3435                rel_type: "USES".to_string(),
3436                target: explicit_target.clone(),
3437                source: crate::store::EdgeSource::Explicit,
3438            },
3439        ];
3440
3441        let env = build_entity_envelope(
3442            &entity,
3443            0,
3444            None,
3445            None,
3446            None,
3447            OriginClass::FirstParty,
3448            &edges,
3449            None,
3450            None,
3451            None,
3452        );
3453        let relationships = env["relationships"].as_array().expect("array");
3454        let refs = relationships
3455            .iter()
3456            .find(|r| r["rel_type"] == "REFERENCES")
3457            .expect("REFERENCES present");
3458        assert_eq!(
3459            refs["source"], "body_link",
3460            "alias-synthesised edge must label body_link"
3461        );
3462        let uses = relationships
3463            .iter()
3464            .find(|r| r["rel_type"] == "USES")
3465            .expect("USES present");
3466        assert_eq!(
3467            uses["source"], "explicit",
3468            "explicit-authored edge must label explicit"
3469        );
3470    }
3471
3472    /// The envelope's read contract is structural (cold-start 0-8-0,
3473    /// F9/F13/F15): `origin` is present on every envelope, every
3474    /// relationship entry declares its `direction`, and incoming edges
3475    /// — when the caller passes them — appear as `direction: "in"`
3476    /// entries carrying the other endpoint under `from`. A consumer
3477    /// can therefore always tell whether the block is one-directional.
3478    #[test]
3479    fn build_entity_envelope_carries_origin_direction_and_incoming() {
3480        let mut entity = test_entity();
3481        let out_target = EntityId("specs--downstream".to_string());
3482        entity.relationships = vec![crate::entity::Relationship::new(
3483            "USES".to_string(),
3484            out_target.clone(),
3485        )];
3486        let edges = vec![crate::store::Edge {
3487            rel_type: "USES".to_string(),
3488            target: out_target,
3489            source: crate::store::EdgeSource::Explicit,
3490        }];
3491        let incoming = vec![crate::store::InEdge {
3492            rel_type: "MANAGES".to_string(),
3493            from: EntityId("specs--upstream".to_string()),
3494            source: crate::store::EdgeSource::Explicit,
3495        }];
3496
3497        // Without incoming: outgoing entries are direction-labelled.
3498        let env = build_entity_envelope(
3499            &entity,
3500            0,
3501            None,
3502            None,
3503            None,
3504            OriginClass::ThirdParty,
3505            &edges,
3506            None,
3507            None,
3508            None,
3509        );
3510        assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3511        let rels = env["relationships"].as_array().expect("array");
3512        assert_eq!(rels.len(), 1);
3513        assert_eq!(rels[0]["direction"], "out");
3514
3515        // With incoming: the other half of the neighbourhood appears,
3516        // direction-labelled, endpoint under `from`.
3517        let env = build_entity_envelope(
3518            &entity,
3519            0,
3520            None,
3521            None,
3522            None,
3523            OriginClass::FirstParty,
3524            &edges,
3525            Some(&incoming),
3526            None,
3527            None,
3528        );
3529        assert_eq!(env["origin"], "first-party");
3530        let rels = env["relationships"].as_array().expect("array");
3531        assert_eq!(rels.len(), 2);
3532        let inc = rels
3533            .iter()
3534            .find(|r| r["direction"] == "in")
3535            .expect("incoming entry present");
3536        assert_eq!(inc["rel_type"], "MANAGES");
3537        assert_eq!(inc["from"], "specs--upstream");
3538        assert!(
3539            inc.get("target").is_none(),
3540            "incoming carries from, not target"
3541        );
3542    }
3543
3544    /// A relationship whose store edge is missing
3545    /// (transitional drift, store-rebuild lag) falls back to
3546    /// `"explicit"` so the envelope doesn't crash. The fallback is
3547    /// the conservative label — agents already branch on it.
3548    #[test]
3549    fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3550        let mut entity = test_entity();
3551        let target = EntityId("specs--unmapped".to_string());
3552        entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3553        let edges: Vec<crate::store::Edge> = Vec::new();
3554        let env = build_entity_envelope(
3555            &entity,
3556            0,
3557            None,
3558            None,
3559            None,
3560            OriginClass::FirstParty,
3561            &edges,
3562            None,
3563            None,
3564            None,
3565        );
3566        let relationships = env["relationships"].as_array().expect("array");
3567        assert_eq!(relationships[0]["source"], "explicit");
3568    }
3569
3570    /// Every schema-declared frontmatter key surfaces under the nested
3571    /// `metadata` map — its single home. The four
3572    /// formerly-hoisted scalars are not at the top level; the
3573    /// read-only identity triple (mem/id/type) and underscore-prefixed
3574    /// internal keys are excluded from the nested map.
3575    #[test]
3576    fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3577        use crate::entity::MetadataValue;
3578        let mut entity = test_entity();
3579        entity.entity_type = "contract".to_string();
3580        // Pre-fix the envelope dropped every non-promoted key.
3581        entity.metadata = IndexMap::from([
3582            ("level".to_string(), MetadataValue::String("M0".to_string())),
3583            (
3584                "stability".to_string(),
3585                MetadataValue::String("stable".to_string()),
3586            ),
3587            (
3588                "created_date".to_string(),
3589                MetadataValue::String("2026-01-01".to_string()),
3590            ),
3591            (
3592                "last_modified".to_string(),
3593                MetadataValue::String("2026-05-19".to_string()),
3594            ),
3595            (
3596                "protocol".to_string(),
3597                MetadataValue::String("https".to_string()),
3598            ),
3599            (
3600                "version".to_string(),
3601                MetadataValue::String("0.1.0".to_string()),
3602            ),
3603            (
3604                "deprecation_status".to_string(),
3605                MetadataValue::String("none".to_string()),
3606            ),
3607        ]);
3608
3609        let env = build_entity_envelope(
3610            &entity,
3611            0,
3612            None,
3613            None,
3614            None,
3615            OriginClass::FirstParty,
3616            &[],
3617            None,
3618            None,
3619            None,
3620        );
3621
3622        // Metadata scalars are NOT hoisted to the top level — the
3623        // nested map is their single home.
3624        assert!(
3625            env.get("level").is_none(),
3626            "level must not be hoisted top-level"
3627        );
3628        assert!(
3629            env.get("stability").is_none(),
3630            "stability must not be hoisted"
3631        );
3632        assert!(
3633            env.get("created_date").is_none(),
3634            "created_date must not be hoisted"
3635        );
3636        assert!(
3637            env.get("last_modified").is_none(),
3638            "last_modified must not be hoisted"
3639        );
3640        // The entity's type stays top-level as identity, spelled
3641        // `entity_type` on the wire (2026-08-28 batch); the retired `type`
3642        // key is gone, not aliased.
3643        assert_eq!(env["entity_type"], "contract");
3644        assert!(
3645            env.get("type").is_none(),
3646            "the retired wire key must not survive"
3647        );
3648
3649        // Nested map carries every non-internal, non-identity frontmatter key.
3650        let metadata = env["metadata"].as_object().expect("metadata map");
3651        assert_eq!(metadata["level"], "M0");
3652        assert_eq!(metadata["stability"], "stable");
3653        assert_eq!(metadata["created_date"], "2026-01-01");
3654        assert_eq!(metadata["last_modified"], "2026-05-19");
3655        assert_eq!(metadata["protocol"], "https");
3656        assert_eq!(metadata["version"], "0.1.0");
3657        assert_eq!(metadata["deprecation_status"], "none");
3658
3659        // Internal underscore-prefixed keys and the read-only identity
3660        // triple (mem/id/type) do NOT appear inside the nested map.
3661        for k in metadata.keys() {
3662            assert!(
3663                !k.starts_with('_'),
3664                "metadata map must not carry underscore-prefixed key `{k}`"
3665            );
3666            assert!(
3667                !["mem", "id", "type"].contains(&k.as_str()),
3668                "metadata map must not carry identity key `{k}` (it lives top-level)"
3669            );
3670        }
3671    }
3672
3673    /// Stub envelopes carry an
3674    /// empty `metadata: {}` map so consumers don't branch on the
3675    /// map's presence.
3676    #[test]
3677    fn build_entity_envelope_stub_carries_empty_metadata_map() {
3678        let mut entity = test_entity();
3679        entity.stub = true;
3680        entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3681        entity.metadata = IndexMap::new();
3682        let env = build_entity_envelope(
3683            &entity,
3684            0,
3685            None,
3686            None,
3687            None,
3688            OriginClass::FirstParty,
3689            &[],
3690            None,
3691            None,
3692            None,
3693        );
3694        let metadata = env["metadata"]
3695            .as_object()
3696            .expect("metadata key present even on stubs");
3697        assert!(metadata.is_empty(), "stub metadata map must be empty");
3698    }
3699
3700    /// A user-defined schema names a
3701    /// metadata field colliding with structured envelope slots
3702    /// (`sections`, `relationships`). The colliding name surfaces
3703    /// under `metadata.sections` / `metadata.relationships` without
3704    /// disturbing the top-level structured arrays — the nested map
3705    /// decouples user namespace from engine namespace.
3706    #[test]
3707    fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3708        use crate::entity::MetadataValue;
3709        let mut entity = test_entity();
3710        entity.metadata = IndexMap::from([
3711            (
3712                "sections".to_string(),
3713                MetadataValue::String("user-supplied-shadow".to_string()),
3714            ),
3715            (
3716                "relationships".to_string(),
3717                MetadataValue::String("also-shadowed".to_string()),
3718            ),
3719        ]);
3720        let env = build_entity_envelope(
3721            &entity,
3722            0,
3723            None,
3724            None,
3725            None,
3726            OriginClass::FirstParty,
3727            &[],
3728            None,
3729            None,
3730            None,
3731        );
3732        // Top-level structured slots stay structured.
3733        assert!(
3734            env["sections"].is_object(),
3735            "top-level sections stays a map"
3736        );
3737        assert!(
3738            env["relationships"].is_array(),
3739            "top-level relationships stays an array"
3740        );
3741        // User-supplied collisions land inside the nested map.
3742        let metadata = env["metadata"].as_object().expect("metadata map");
3743        assert_eq!(metadata["sections"], "user-supplied-shadow");
3744        assert_eq!(metadata["relationships"], "also-shadowed");
3745    }
3746
3747    /// `_tokens_unfiltered_body` on the structured envelope rides only
3748    /// when `full_tokens` is supplied (a section filter was active);
3749    /// the legacy `_tokens_full` name is not present as an alias.
3750    #[test]
3751    fn build_entity_envelope_unfiltered_body_token_field_name() {
3752        let entity = test_entity();
3753        // Filter-active path — field present under new name.
3754        let env_filtered = build_entity_envelope(
3755            &entity,
3756            10,
3757            Some(42),
3758            None,
3759            None,
3760            OriginClass::FirstParty,
3761            &[],
3762            None,
3763            None,
3764            None,
3765        );
3766        assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3767        assert!(
3768            env_filtered.get("_tokens_full").is_none(),
3769            "_tokens_full must not survive — rename is one-way"
3770        );
3771        // No-filter path — field absent under both names.
3772        let env_unfiltered = build_entity_envelope(
3773            &entity,
3774            10,
3775            None,
3776            None,
3777            None,
3778            OriginClass::FirstParty,
3779            &[],
3780            None,
3781            None,
3782            None,
3783        );
3784        assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3785        assert!(env_unfiltered.get("_tokens_full").is_none());
3786    }
3787
3788    // ------------------------------------------------------------------
3789    // Schema verbosity (lite vs. full) — Plan 01.
3790    // ------------------------------------------------------------------
3791
3792    /// Load the embedded `software` schema (~42 rel-types, 9 entity
3793    /// types, `alias_target_rel_type: REFERENCES`) — the heaviest builtin,
3794    /// so the lite cut has something to bite into.
3795    fn software_schema() -> Arc<Schema> {
3796        memstead_schema::builtins::load_builtin_schemas()
3797            .expect("builtins load")
3798            .into_iter()
3799            .find(|s| s.manifest.name == "software")
3800            .expect("software schema is a builtin")
3801    }
3802
3803    #[test]
3804    fn schema_verbosity_wire_round_trips() {
3805        assert_eq!(
3806            SchemaVerbosity::from_wire("full"),
3807            Some(SchemaVerbosity::Full)
3808        );
3809        assert_eq!(
3810            SchemaVerbosity::from_wire("lite"),
3811            Some(SchemaVerbosity::Lite)
3812        );
3813        assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3814        assert_eq!(SchemaVerbosity::from_wire(""), None);
3815        assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3816        assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3817        assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3818    }
3819
3820    /// Exemplar serving (agent-trust plan 09): `verbosity: full`
3821    /// carries each type's exemplar (title, metadata, sections,
3822    /// relations with placeholder targets); the lite skeleton is
3823    /// BYTE-unchanged between the same schema with and without an
3824    /// exemplar — the per-session lite fetch never grows.
3825    #[test]
3826    fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3827        let manifest = r#"name: servefix
3828version: 1.0.0
3829description: serving fixture
3830when_to_use: tests
3831types:
3832  - sample
3833relationships:
3834  mode: strict
3835  definitions:
3836    - name: PART_OF
3837      description: hier
3838      default_weight: 3.0
3839    - name: _default
3840      description: fallback
3841      default_weight: 1.0
3842community:
3843  resolution: 1.0
3844  seed: 42
3845"#;
3846        let base_type = r#"name: sample
3847description: t
3848when_to_use: tests
3849sections:
3850  - key: body
3851    heading: Body
3852    required: true
3853    search_weight: 10.0
3854    catch_all: true
3855    write_rules: []
3856metadata_fields:
3857  - key: status
3858    description: state
3859    field_type: string
3860    enum_values: [draft, final]
3861    optional: true
3862title_weight: 100.0
3863text_fields:
3864  - body
3865hierarchy_relationship: PART_OF
3866no_self_loop_relationships: []
3867updatable_fields:
3868  - title
3869  - body
3870health_required_fields:
3871  - body
3872staleness_threshold_days: 90
3873write_rules: []
3874"#;
3875        let with_exemplar = format!(
3876            "{base_type}exemplar:\n  title: A Conforming Sample\n  metadata:\n    status: draft\n  sections:\n    body: \"One canonical body paragraph.\"\n  relations:\n    - to: parent-placeholder\n      type: PART_OF\n"
3877        );
3878
3879        let plain = Arc::new(
3880            memstead_schema::loader::load_schema_from_memory(
3881                manifest,
3882                &[("sample".to_string(), base_type.to_string())],
3883            )
3884            .expect("fixture loads"),
3885        );
3886        let exemplary = Arc::new(
3887            memstead_schema::loader::load_schema_from_memory(
3888                manifest,
3889                &[("sample".to_string(), with_exemplar)],
3890            )
3891            .expect("fixture loads"),
3892        );
3893
3894        // FULL serves the exemplar with the type.
3895        let full = build_schema_payload(
3896            &exemplary,
3897            vec![],
3898            SchemaVerbosity::Full,
3899            OriginClass::FirstParty,
3900        );
3901        let ex = &full["types"][0]["exemplar"];
3902        assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3903        assert_eq!(ex["metadata"]["status"], "draft");
3904        assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3905        assert_eq!(ex["relations"][0]["target"], "parent-placeholder");
3906        assert_eq!(ex["relations"][0]["rel_type"], "PART_OF");
3907
3908        // FULL without an exemplar: no key (absent, not null).
3909        let full_plain = build_schema_payload(
3910            &plain,
3911            vec![],
3912            SchemaVerbosity::Full,
3913            OriginClass::FirstParty,
3914        );
3915        assert!(full_plain["types"][0].get("exemplar").is_none());
3916
3917        // LITE is byte-identical with and without the exemplar — the
3918        // skeleton every session fetches does not grow.
3919        let lite_with = build_schema_payload(
3920            &exemplary,
3921            vec![],
3922            SchemaVerbosity::Lite,
3923            OriginClass::FirstParty,
3924        );
3925        let lite_without = build_schema_payload(
3926            &plain,
3927            vec![],
3928            SchemaVerbosity::Lite,
3929            OriginClass::FirstParty,
3930        );
3931        assert_eq!(
3932            serde_json::to_string(&lite_with).unwrap(),
3933            serde_json::to_string(&lite_without).unwrap(),
3934            "lite must not change when an exemplar exists"
3935        );
3936        assert!(
3937            !serde_json::to_string(&lite_with)
3938                .unwrap()
3939                .contains("exemplar"),
3940            "lite must not mention exemplars at all"
3941        );
3942    }
3943
3944    /// A first-party schema labels its origin and serves its full prose
3945    /// under `full`. The origin field is additive and present in both
3946    /// verbosities so a consuming host can always read it.
3947    #[test]
3948    fn first_party_origin_is_labelled_and_keeps_prose() {
3949        let schema = software_schema();
3950        let full = build_schema_payload(
3951            &schema,
3952            vec!["v".into()],
3953            SchemaVerbosity::Full,
3954            OriginClass::FirstParty,
3955        );
3956        assert_eq!(full["origin"], "first-party");
3957        // First-party full keeps the prose-instruction fields.
3958        assert!(full["description"].is_string());
3959        let t = &full["types"].as_array().unwrap()[0];
3960        assert!(t.get("system_context").is_some());
3961        assert!(t.get("writing_guidance").is_some());
3962
3963        // The origin label rides the lite skeleton too.
3964        let lite = build_schema_payload(
3965            &schema,
3966            vec!["v".into()],
3967            SchemaVerbosity::Lite,
3968            OriginClass::FirstParty,
3969        );
3970        assert_eq!(lite["origin"], "first-party");
3971    }
3972
3973    /// Declared constraints and `required_outgoing` severities are
3974    /// visible at BOTH verbosity levels — no legality condition may
3975    /// exist that the schema response omits. Complement: a type
3976    /// declaring none renders `constraints: []`, never an absent key.
3977    #[test]
3978    fn constraints_and_severity_render_at_both_verbosities() {
3979        let manifest = r#"name: constrained
3980version: 1.0.0
3981description: constraint render fixture
3982when_to_use: render tests
3983types:
3984  - sample
3985relationships:
3986  mode: strict
3987  definitions:
3988    - name: PART_OF
3989      description: hier
3990      default_weight: 3.0
3991    - name: _default
3992      description: fallback
3993      default_weight: 1.0
3994community:
3995  resolution: 1.0
3996  seed: 42
3997"#;
3998        let type_yaml = r#"name: sample
3999description: t
4000when_to_use: tests
4001sections:
4002  - key: body
4003    heading: Body
4004    required: true
4005    search_weight: 10.0
4006    catch_all: true
4007    write_rules: []
4008metadata_fields:
4009  - key: status
4010    description: state
4011    field_type: string
4012    enum_values: [open, checked]
4013    optional: true
4014  - key: checked_by
4015    description: who
4016    field_type: string
4017    optional: true
4018title_weight: 100.0
4019text_fields:
4020  - body
4021hierarchy_relationship: PART_OF
4022no_self_loop_relationships: []
4023updatable_fields:
4024  - title
4025  - body
4026health_required_fields:
4027  - body
4028staleness_threshold_days: 90
4029required_outgoing:
4030  - relationships: [PART_OF]
4031    cardinality: at_least_one
4032    severity: block
4033constraints:
4034  - kind: requires_when
4035    field: checked_by
4036    when_field: status
4037    when_value: checked
4038  - kind: unique
4039    fields: [status, checked_by]
4040  - kind: enum_from_neighbour
4041    field: status
4042    rel_type: PART_OF
4043    section: body
4044  - kind: status_propagation
4045    field: status
4046    value: checked
4047    rel_type: PART_OF
4048    direction: incoming
4049write_rules: []
4050"#;
4051        let schema = Arc::new(
4052            memstead_schema::loader::load_schema_from_memory(
4053                manifest,
4054                &[("sample".to_string(), type_yaml.to_string())],
4055            )
4056            .expect("fixture loads"),
4057        );
4058
4059        // All five constraint forms (requires_when, unique,
4060        // enum_from_neighbour, status_propagation here; form 4 is the
4061        // required_outgoing severity) must be visible with their
4062        // severity at both verbosity levels.
4063        let expected_constraints = serde_json::json!([
4064            {
4065                "kind": "requires_when",
4066                "field": "checked_by",
4067                "when_field": "status",
4068                "when_value": "checked",
4069                "severity": "warn",
4070            },
4071            {
4072                "kind": "unique",
4073                "fields": ["status", "checked_by"],
4074                "severity": "block",
4075            },
4076            {
4077                "kind": "enum_from_neighbour",
4078                "field": "status",
4079                "rel_type": "PART_OF",
4080                "section": "body",
4081                "severity": "warn",
4082            },
4083            {
4084                "kind": "status_propagation",
4085                "field": "status",
4086                "value": "checked",
4087                "rel_type": "PART_OF",
4088                "direction": "incoming",
4089                "severity": "warn",
4090            },
4091        ]);
4092
4093        let full = build_schema_payload(
4094            &schema,
4095            vec![],
4096            SchemaVerbosity::Full,
4097            OriginClass::FirstParty,
4098        );
4099        let t = &full["types"].as_array().unwrap()[0];
4100        assert_eq!(t["constraints"], expected_constraints);
4101        assert_eq!(t["required_outgoing"][0]["severity"], "block");
4102
4103        let lite = build_schema_payload(
4104            &schema,
4105            vec![],
4106            SchemaVerbosity::Lite,
4107            OriginClass::FirstParty,
4108        );
4109        let ts = &lite["types_summary"].as_array().unwrap()[0];
4110        assert_eq!(ts["constraints"], expected_constraints);
4111        assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4112
4113        // Section-format declarations render at BOTH verbosity
4114        // levels (plan 08 shares plan 07's no-hidden-legality rule).
4115        let fmt_manifest = r#"name: formatted
4116version: 1.0.0
4117description: format render fixture
4118when_to_use: render tests
4119types:
4120  - plan
4121relationships:
4122  mode: strict
4123  definitions:
4124    - name: PART_OF
4125      description: hier
4126      default_weight: 1.0
4127    - name: _default
4128      description: fallback
4129      default_weight: 1.0
4130community:
4131  resolution: 1.0
4132  seed: 42
4133"#;
4134        let fmt_type = r#"name: plan
4135description: t
4136when_to_use: tests
4137sections:
4138  - key: body
4139    heading: Body
4140    required: true
4141    search_weight: 10.0
4142    catch_all: true
4143    write_rules: []
4144  - key: meilensteine
4145    heading: Meilensteine
4146    required: false
4147    search_weight: 5.0
4148    catch_all: false
4149    write_rules: []
4150    content: "(heading(3) list(bullet))+"
4151    item_pattern: '\*\*(?<name>[^*]+)\*\*'
4152    example: |
4153      ### Phase 1
4154      - **Kickoff**
4155    format_severity: warn
4156  - key: tabelle
4157    heading: Tabelle
4158    required: false
4159    search_weight: 5.0
4160    catch_all: false
4161    write_rules: []
4162    content: "table"
4163    table:
4164      columns: [Name, Datum]
4165      column_patterns:
4166        Datum: '\d{4}-\d{2}-\d{2}'
4167  - key: belege
4168    heading: Belege
4169    required: false
4170    search_weight: 5.0
4171    catch_all: false
4172    write_rules: []
4173    content: "paragraph+"
4174    item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4175metadata_fields: []
4176title_weight: 100.0
4177text_fields:
4178  - body
4179hierarchy_relationship: PART_OF
4180no_self_loop_relationships: []
4181updatable_fields:
4182  - title
4183  - body
4184health_required_fields:
4185  - body
4186staleness_threshold_days: 90
4187write_rules: []
4188"#;
4189        let fmt_schema = Arc::new(
4190            memstead_schema::loader::load_schema_from_memory(
4191                fmt_manifest,
4192                &[("plan".to_string(), fmt_type.to_string())],
4193            )
4194            .expect("format fixture loads"),
4195        );
4196        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4197            let payload =
4198                build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4199            let sections_key = match verbosity {
4200                SchemaVerbosity::Full => &payload["types"][0]["sections"],
4201                SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4202            };
4203            let secs = sections_key.as_array().unwrap();
4204            let meilensteine = secs
4205                .iter()
4206                .find(|s| s["key"] == "meilensteine")
4207                .expect("declared section present");
4208            assert_eq!(
4209                meilensteine["content"], "(heading(3) list(bullet))+",
4210                "{verbosity:?} carries content"
4211            );
4212            assert!(
4213                meilensteine["item_pattern"]
4214                    .as_str()
4215                    .unwrap()
4216                    .contains("name")
4217            );
4218            assert!(
4219                meilensteine["example"]
4220                    .as_str()
4221                    .unwrap()
4222                    .contains("Kickoff")
4223            );
4224            assert_eq!(meilensteine["format_severity"], "warn");
4225            let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4226            assert_eq!(tabelle["format_severity"], "block", "default renders");
4227            assert_eq!(tabelle["table"]["columns"][0], "Name");
4228            assert!(
4229                tabelle["table"]["column_patterns"]["Datum"]
4230                    .as_str()
4231                    .is_some()
4232            );
4233            let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4234            assert_eq!(belege["content"], "paragraph+");
4235            assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4236            let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4237            assert!(
4238                body.get("content").is_none() && body.get("format_severity").is_none(),
4239                "undeclared section keeps its pre-plan shape"
4240            );
4241        }
4242
4243        // Complement: a constraint-free builtin renders the
4244        // always-present empty list at both levels.
4245        let plain_full = build_schema_payload(
4246            &software_schema(),
4247            vec![],
4248            SchemaVerbosity::Full,
4249            OriginClass::FirstParty,
4250        );
4251        let pt = &plain_full["types"].as_array().unwrap()[0];
4252        assert_eq!(pt["constraints"], serde_json::json!([]));
4253        let plain_lite = build_schema_payload(
4254            &software_schema(),
4255            vec![],
4256            SchemaVerbosity::Lite,
4257            OriginClass::FirstParty,
4258        );
4259        let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4260        assert_eq!(pts["constraints"], serde_json::json!([]));
4261    }
4262
4263    /// A third-party schema is de-framed: a `full`-verbosity request is
4264    /// overridden to the structural-only skeleton, so NONE of the
4265    /// prose-instruction fields (`system_context`, `writing_guidance`,
4266    /// section `write_rules`, schema `description` / `when_to_use`,
4267    /// `default_writing_guidance`, rel `description` / `when_to_use`)
4268    /// reach a consuming agent — even though `full` was asked for. The
4269    /// structural skeleton (type/section/field/rel shape) survives so the
4270    /// mem stays understandable and queryable. This is the refusal
4271    /// complement: a `full` request cannot re-admit the prose.
4272    #[test]
4273    fn third_party_origin_forces_structural_only_even_under_full() {
4274        let schema = software_schema();
4275        let full_requested = build_schema_payload(
4276            &schema,
4277            vec!["v".into()],
4278            SchemaVerbosity::Full,
4279            OriginClass::ThirdParty,
4280        );
4281
4282        // Origin label.
4283        assert_eq!(full_requested["origin"], "third-party");
4284
4285        // Prose-bearing rich arrays are GONE despite the full request;
4286        // the structural-only summaries are present instead.
4287        assert!(
4288            full_requested.get("types").is_none(),
4289            "third-party omits the rich `types` array even under full"
4290        );
4291        assert!(
4292            full_requested.get("relationships").is_none(),
4293            "third-party omits the rich `relationships` array even under full"
4294        );
4295        assert!(
4296            full_requested["types_summary"].is_array(),
4297            "third-party serves the structural `types_summary` skeleton"
4298        );
4299        assert!(
4300            full_requested["relationships_summary"].is_array(),
4301            "third-party serves the structural `relationships_summary` skeleton"
4302        );
4303
4304        // Schema-level prose-instruction fields dropped.
4305        assert!(
4306            full_requested.get("description").is_none(),
4307            "third-party drops schema description prose"
4308        );
4309        assert!(
4310            full_requested.get("when_to_use").is_none(),
4311            "third-party drops schema when_to_use prose"
4312        );
4313        assert!(
4314            full_requested.get("default_writing_guidance").is_none(),
4315            "third-party drops default_writing_guidance prose"
4316        );
4317
4318        // Per-type prose-instruction fields dropped.
4319        for t in full_requested["types_summary"].as_array().unwrap() {
4320            assert!(
4321                t.get("system_context").is_none(),
4322                "third-party drops system_context"
4323            );
4324            assert!(
4325                t.get("writing_guidance").is_none(),
4326                "third-party drops writing_guidance"
4327            );
4328            assert!(
4329                t.get("description").is_none(),
4330                "third-party drops type description"
4331            );
4332            for s in t["sections"].as_array().unwrap() {
4333                assert!(
4334                    s.get("write_rules").is_none(),
4335                    "third-party drops section write_rules"
4336                );
4337            }
4338        }
4339        // Per-rel prose dropped.
4340        for r in full_requested["relationships_summary"].as_array().unwrap() {
4341            assert!(
4342                r.get("description").is_none(),
4343                "third-party drops rel description"
4344            );
4345            assert!(
4346                r.get("when_to_use").is_none(),
4347                "third-party drops rel when_to_use"
4348            );
4349        }
4350
4351        // A third-party schema served under `full` is byte-identical to
4352        // the same schema served under `lite` (modulo the origin label,
4353        // which is identical here) — the override fully collapses to Lite.
4354        let lite_requested = build_schema_payload(
4355            &schema,
4356            vec!["v".into()],
4357            SchemaVerbosity::Lite,
4358            OriginClass::ThirdParty,
4359        );
4360        assert_eq!(
4361            full_requested, lite_requested,
4362            "third-party full must collapse to the lite skeleton"
4363        );
4364    }
4365
4366    #[test]
4367    fn full_payload_carries_the_rich_arrays_and_prose() {
4368        let schema = software_schema();
4369        let full = build_schema_payload(
4370            &schema,
4371            vec!["v".into()],
4372            SchemaVerbosity::Full,
4373            OriginClass::FirstParty,
4374        );
4375
4376        // Full keeps today's contract: rich arrays + schema-level prose.
4377        assert!(full["types"].is_array(), "full has `types`");
4378        assert!(full["relationships"].is_array(), "full has `relationships`");
4379        assert!(
4380            full.get("types_summary").is_none(),
4381            "full omits `types_summary`"
4382        );
4383        assert!(
4384            full.get("relationships_summary").is_none(),
4385            "full omits `relationships_summary`"
4386        );
4387        assert!(
4388            full["description"].is_string(),
4389            "full keeps schema description"
4390        );
4391        assert!(
4392            full["when_to_use"].is_string(),
4393            "full keeps schema when_to_use"
4394        );
4395        assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4396
4397        // A full type entry keeps the prose the lite cut drops.
4398        let t = &full["types"].as_array().unwrap()[0];
4399        assert!(t["description"].is_string());
4400        assert!(t.get("writing_guidance").is_some());
4401        assert!(t.get("system_context").is_some());
4402        // A full rel entry keeps its prose.
4403        let r = &full["relationships"].as_array().unwrap()[0];
4404        assert!(r["description"].is_string());
4405        assert!(r.get("when_to_use").is_some());
4406        assert!(r.get("default_weight").is_some());
4407    }
4408
4409    /// The declared `required_outgoing` blocks appear per type — with
4410    /// their relationship lists and cardinality, in declaration order —
4411    /// at BOTH verbosity levels, and a type declaring none reports an
4412    /// empty list (never a missing key). The `project` built-in is the
4413    /// live fixture: `evidence` declares one block, `decision` (among
4414    /// others) declares none. The `no_self_loop_relationships_effect`
4415    /// note ships at both levels and claims nothing beyond the
4416    /// self-loop refusal.
4417    #[test]
4418    fn required_outgoing_reported_with_cardinality_at_both_levels() {
4419        let reg = memstead_schema::SchemaRegistry::builtin();
4420        let project = reg
4421            .get("project", &semver::Version::new(0, 2, 0))
4422            .expect("project is a built-in");
4423
4424        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4425            let payload =
4426                build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4427            let types_key = if verbosity == SchemaVerbosity::Full {
4428                "types"
4429            } else {
4430                "types_summary"
4431            };
4432            let types = payload[types_key].as_array().expect("types array");
4433
4434            let mut saw_evidence = false;
4435            let mut saw_memo = false;
4436            for t in types {
4437                let ro = t
4438                    .get("required_outgoing")
4439                    .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4440                    .as_array()
4441                    .expect("required_outgoing is an array for every type");
4442                if t["name"] == "evidence" {
4443                    saw_evidence = true;
4444                    assert_eq!(ro.len(), 1, "evidence declares one block");
4445                    assert_eq!(
4446                        ro[0]["relationships"],
4447                        serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4448                        "relationship alternatives in declaration order"
4449                    );
4450                    assert_eq!(
4451                        ro[0]["cardinality"], "at_least_one",
4452                        "cardinality rendered as declared — the open upper bound \
4453                         stays open, never a finite number"
4454                    );
4455                } else if t["name"] == "memo" {
4456                    // A type declaring no blocks reports the empty
4457                    // list, not a missing key.
4458                    saw_memo = true;
4459                    assert!(ro.is_empty(), "memo declares no blocks → empty list");
4460                }
4461            }
4462            assert!(saw_evidence, "project schema carries the evidence type");
4463            assert!(saw_memo, "project schema carries the memo type");
4464
4465            // The effect note for no_self_loop_relationships ships at both
4466            // levels and states the single real effect.
4467            let note = payload["no_self_loop_relationships_effect"]
4468                .as_str()
4469                .expect("effect note present at both verbosity levels");
4470            assert!(note.contains("self-loop"), "names the actual effect");
4471            assert!(
4472                !note.contains("propagates impact") || note.contains("does not propagate"),
4473                "claims no propagation behaviour beyond the self-loop refusal"
4474            );
4475            assert!(
4476                note.contains("status_propagation"),
4477                "deprecation pointer names the real propagation declaration"
4478            );
4479        }
4480    }
4481
4482    /// A conditional `required_outgoing` block's trigger (`when_field`
4483    /// / `when_value`) is visible at BOTH verbosity levels — no
4484    /// legality condition the schema response omits — while an
4485    /// unconditional block keeps its byte-identical three-key shape
4486    /// (no `when_*` keys at all).
4487    #[test]
4488    fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4489        let manifest = r#"name: condro-render
4490version: 0.1.0
4491description: conditional required_outgoing render fixture
4492when_to_use: tests
4493types:
4494  - task
4495relationships:
4496  mode: strict
4497  definitions:
4498    - name: PART_OF
4499      description: hier
4500      default_weight: 3.0
4501    - name: _default
4502      description: fallback
4503      default_weight: 1.0
4504community:
4505  resolution: 1.0
4506  seed: 42
4507"#;
4508        let task_yaml = "name: task\ndescription: t\nwhen_to_use: tests\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: status\n    description: workflow state\n    field_type: string\n    enum_values: [open, checked]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\n  - status\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\nrequired_outgoing:\n  - relationships: [PART_OF]\n    cardinality: at_least_one\n  - relationships: [PART_OF]\n    cardinality: at_least_one\n    severity: block\n    when_field: status\n    when_value: checked\n";
4509        let schema = Arc::new(
4510            memstead_schema::load_schema_from_memory(
4511                manifest,
4512                &[("task".to_string(), task_yaml.to_string())],
4513            )
4514            .expect("render fixture schema must parse"),
4515        );
4516
4517        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4518            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4519            let types_key = if verbosity == SchemaVerbosity::Full {
4520                "types"
4521            } else {
4522                "types_summary"
4523            };
4524            let task = &payload[types_key].as_array().expect("types array")[0];
4525            let ro = task["required_outgoing"].as_array().expect("blocks array");
4526            assert_eq!(ro.len(), 2);
4527            assert!(
4528                ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4529                "unconditional block carries no when_* keys: {:?}",
4530                ro[0]
4531            );
4532            assert_eq!(ro[1]["when_field"], "status");
4533            assert_eq!(ro[1]["when_value"], "checked");
4534        }
4535    }
4536
4537    /// Declared `acyclic_sets` and a `status_propagation` relation
4538    /// set are visible at BOTH verbosity levels; a single-name
4539    /// propagation declaration keeps its `rel_type` key with no
4540    /// `rel_types`, and a schema without sets carries no
4541    /// `acyclic_sets` key at all.
4542    #[test]
4543    fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4544        let manifest = r#"name: relsets-render
4545version: 0.1.0
4546description: relation-set render fixture
4547when_to_use: tests
4548types:
4549  - claim
4550relationships:
4551  mode: strict
4552  acyclic_sets:
4553    - [GROUNDS, CONCLUDES]
4554  definitions:
4555    - name: GROUNDS
4556      description: g
4557      default_weight: 3.0
4558    - name: CONCLUDES
4559      description: c
4560      default_weight: 3.0
4561    - name: PART_OF
4562      description: hier
4563      default_weight: 1.0
4564    - name: _default
4565      description: fallback
4566      default_weight: 1.0
4567community:
4568  resolution: 1.0
4569  seed: 42
4570"#;
4571        let claim = "name: claim\ndescription: t\nwhen_to_use: tests\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields:\n  - key: standing\n    description: s\n    field_type: string\n    enum_values: [active, withdrawn]\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\n  - standing\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n  - kind: status_propagation\n    field: standing\n    value: withdrawn\n    rel_types: [GROUNDS, CONCLUDES]\n    direction: incoming\n  - kind: status_propagation\n    field: standing\n    value: withdrawn\n    rel_type: PART_OF\n    direction: outgoing\n";
4572        let schema = Arc::new(
4573            memstead_schema::load_schema_from_memory(
4574                manifest,
4575                &[("claim".to_string(), claim.to_string())],
4576            )
4577            .expect("render fixture schema must parse"),
4578        );
4579
4580        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4581            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4582            assert_eq!(
4583                payload["acyclic_sets"],
4584                serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4585                "acyclic_sets present at {verbosity:?}"
4586            );
4587            let types_key = if verbosity == SchemaVerbosity::Full {
4588                "types"
4589            } else {
4590                "types_summary"
4591            };
4592            let claim = &payload[types_key].as_array().expect("types array")[0];
4593            let constraints = claim["constraints"].as_array().expect("constraints array");
4594            assert_eq!(
4595                constraints[0]["rel_types"],
4596                serde_json::json!(["GROUNDS", "CONCLUDES"])
4597            );
4598            assert!(
4599                constraints[0].get("rel_type").is_none(),
4600                "set declaration carries no single-name key: {:?}",
4601                constraints[0]
4602            );
4603            assert_eq!(constraints[1]["rel_type"], "PART_OF");
4604            assert!(
4605                constraints[1].get("rel_types").is_none(),
4606                "single-name declaration stays byte-identical: {:?}",
4607                constraints[1]
4608            );
4609        }
4610
4611        // A schema without sets carries no `acyclic_sets` key.
4612        let plain = software_schema();
4613        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4614            let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4615            assert!(
4616                payload.get("acyclic_sets").is_none(),
4617                "undeclared schema carries no acyclic_sets key"
4618            );
4619        }
4620    }
4621
4622    /// The labelling declaration is visible at BOTH verbosity levels
4623    /// with attack set and support walk echoed whole; a schema
4624    /// declaring none carries no `labelling` key at all.
4625    #[test]
4626    fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4627        let manifest = r#"name: labelling-render
4628version: 0.1.0
4629description: labelling render fixture
4630when_to_use: tests
4631types:
4632  - claim
4633relationships:
4634  mode: strict
4635  labelling:
4636    attack: [REBUTS]
4637    support:
4638      relationships: [GROUNDS]
4639      direction: out
4640      terminal_types: [claim]
4641  definitions:
4642    - name: REBUTS
4643      description: attack
4644      default_weight: 3.0
4645    - name: GROUNDS
4646      description: support
4647      default_weight: 3.0
4648    - name: PART_OF
4649      description: hier
4650      default_weight: 1.0
4651    - name: _default
4652      description: fallback
4653      default_weight: 1.0
4654community:
4655  resolution: 1.0
4656  seed: 42
4657"#;
4658        let claim = "name: claim\ndescription: t\nwhen_to_use: tests\nmetadata_fields: []\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4659        let schema = Arc::new(
4660            memstead_schema::load_schema_from_memory(
4661                manifest,
4662                &[("claim".to_string(), claim.to_string())],
4663            )
4664            .expect("render fixture schema must parse"),
4665        );
4666
4667        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4668            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4669            assert_eq!(
4670                payload["labelling"]["attack"],
4671                serde_json::json!(["REBUTS"]),
4672                "attack set present at {verbosity:?}"
4673            );
4674            assert_eq!(
4675                payload["labelling"]["support"]["relationships"],
4676                serde_json::json!(["GROUNDS"])
4677            );
4678            assert_eq!(payload["labelling"]["support"]["direction"], "out");
4679        }
4680
4681        let plain = software_schema();
4682        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4683            let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4684            assert!(
4685                payload.get("labelling").is_none(),
4686                "undeclared schema carries no labelling key"
4687            );
4688        }
4689    }
4690
4691    /// Declared signals are visible at BOTH verbosity levels with the
4692    /// declaration echoed whole; a type declaring none carries no
4693    /// `signals` key at all.
4694    #[test]
4695    fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4696        let manifest = r#"name: signals-render
4697version: 0.1.0
4698description: signal render fixture
4699when_to_use: tests
4700types:
4701  - claim
4702  - objection
4703relationships:
4704  mode: strict
4705  definitions:
4706    - name: REBUTS
4707      description: r
4708      default_weight: 3.0
4709    - name: PART_OF
4710      description: hier
4711      default_weight: 1.0
4712    - name: _default
4713      description: fallback
4714      default_weight: 1.0
4715community:
4716  resolution: 1.0
4717  seed: 42
4718"#;
4719        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4720        let claim = format!(
4721            "name: claim\ndescription: t\nwhen_to_use: tests\nmetadata_fields: []\n{body}signals:\n  - name: attack_load\n    kind: edge_load\n    relationships: [REBUTS]\n    direction: in\n    thresholds:\n      - at_least: 1\n        level: notice\n      - at_least: 3\n        level: warn\n"
4722        );
4723        let objection = format!(
4724            "name: objection\ndescription: t\nwhen_to_use: tests\nmetadata_fields:\n  - key: state\n    description: s\n    field_type: string\n    enum_values: [open, closed]\n{body}"
4725        );
4726        let schema = Arc::new(
4727            memstead_schema::load_schema_from_memory(
4728                manifest,
4729                &[
4730                    ("claim".to_string(), claim),
4731                    ("objection".to_string(), objection),
4732                ],
4733            )
4734            .expect("render fixture schema must parse"),
4735        );
4736
4737        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4738            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4739            let types_key = if verbosity == SchemaVerbosity::Full {
4740                "types"
4741            } else {
4742                "types_summary"
4743            };
4744            let types = payload[types_key].as_array().expect("types array");
4745            let claim = types
4746                .iter()
4747                .find(|t| t["name"] == "claim")
4748                .expect("claim type present");
4749            let sigs = claim["signals"].as_array().expect("signals array");
4750            assert_eq!(sigs[0]["name"], "attack_load");
4751            assert_eq!(sigs[0]["kind"], "edge_load");
4752            assert_eq!(sigs[0]["direction"], "in");
4753            assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4754            assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4755            let objection = types
4756                .iter()
4757                .find(|t| t["name"] == "objection")
4758                .expect("objection type present");
4759            assert!(
4760                objection.get("signals").is_none(),
4761                "undeclared type carries no signals key"
4762            );
4763        }
4764    }
4765
4766    /// A declared `must_reach` obligation is visible at BOTH verbosity
4767    /// levels with the declaration echoed (relation set, direction,
4768    /// terminal types, depth); a type declaring none carries no
4769    /// `must_reach` key at all (undeclared schemas keep their payload
4770    /// bytes unchanged).
4771    #[test]
4772    fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4773        let manifest = r#"name: mustreach-render
4774version: 0.1.0
4775description: must_reach render fixture
4776when_to_use: tests
4777types:
4778  - claim
4779  - evidence
4780relationships:
4781  mode: strict
4782  definitions:
4783    - name: GROUNDS
4784      description: g
4785      default_weight: 3.0
4786    - name: PART_OF
4787      description: hier
4788      default_weight: 1.0
4789    - name: _default
4790      description: fallback
4791      default_weight: 1.0
4792community:
4793  resolution: 1.0
4794  seed: 42
4795"#;
4796        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4797        let claim = format!(
4798            "name: claim\ndescription: t\nwhen_to_use: tests\n{body}must_reach:\n  - relationships: [GROUNDS]\n    direction: out\n    terminal_types: [evidence]\n    max_depth: 12\n"
4799        );
4800        let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4801        let schema = Arc::new(
4802            memstead_schema::load_schema_from_memory(
4803                manifest,
4804                &[
4805                    ("claim".to_string(), claim),
4806                    ("evidence".to_string(), evidence),
4807                ],
4808            )
4809            .expect("render fixture schema must parse"),
4810        );
4811
4812        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4813            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4814            let types_key = if verbosity == SchemaVerbosity::Full {
4815                "types"
4816            } else {
4817                "types_summary"
4818            };
4819            let types = payload[types_key].as_array().expect("types array");
4820            let claim = types
4821                .iter()
4822                .find(|t| t["name"] == "claim")
4823                .expect("claim type present");
4824            let mr = claim["must_reach"].as_array().expect("obligations array");
4825            assert_eq!(mr.len(), 1);
4826            assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4827            assert_eq!(mr[0]["direction"], "out");
4828            assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4829            assert_eq!(mr[0]["max_depth"], 12);
4830            let evidence = types
4831                .iter()
4832                .find(|t| t["name"] == "evidence")
4833                .expect("evidence type present");
4834            assert!(
4835                evidence.get("must_reach").is_none(),
4836                "undeclared type carries no must_reach key: {evidence:?}"
4837            );
4838        }
4839    }
4840
4841    #[test]
4842    fn lite_payload_is_the_structural_skeleton_without_prose() {
4843        let schema = software_schema();
4844        let lite = build_schema_payload(
4845            &schema,
4846            vec!["v".into()],
4847            SchemaVerbosity::Lite,
4848            OriginClass::FirstParty,
4849        );
4850
4851        // Heavy arrays under the distinct lite keys; rich keys absent.
4852        let types = lite["types_summary"]
4853            .as_array()
4854            .expect("lite has `types_summary`");
4855        let rels = lite["relationships_summary"]
4856            .as_array()
4857            .expect("lite has `relationships_summary`");
4858        assert!(lite.get("types").is_none(), "lite omits rich `types`");
4859        assert!(
4860            lite.get("relationships").is_none(),
4861            "lite omits rich `relationships`"
4862        );
4863
4864        // Alias pointer + endpoint constraints survive the cut — every
4865        // flag an agent needs to author a legal write.
4866        assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4867
4868        // Schema-level prose dropped.
4869        assert!(
4870            lite.get("description").is_none(),
4871            "lite drops schema description"
4872        );
4873        assert!(
4874            lite.get("when_to_use").is_none(),
4875            "lite drops schema when_to_use"
4876        );
4877        assert!(
4878            lite.get("default_writing_guidance").is_none(),
4879            "lite drops default_writing_guidance"
4880        );
4881
4882        // Every entity-type name carries its section keys (with `required`)
4883        // and field shapes — and NO type/section prose.
4884        for t in types {
4885            assert!(t["name"].is_string());
4886            let sections = t["sections"].as_array().expect("lite type has sections");
4887            for s in sections {
4888                assert!(s["key"].is_string(), "section carries its key");
4889                assert!(s["required"].is_boolean(), "section carries required flag");
4890                assert!(
4891                    s.get("write_rules").is_none(),
4892                    "lite section drops write_rules prose"
4893                );
4894                assert!(s.get("heading").is_none(), "lite section drops heading");
4895            }
4896            assert!(
4897                t.get("description").is_none(),
4898                "lite type drops description"
4899            );
4900            assert!(
4901                t.get("writing_guidance").is_none(),
4902                "lite type drops writing_guidance"
4903            );
4904            assert!(
4905                t.get("system_context").is_none(),
4906                "lite type drops system_context"
4907            );
4908            // `no_self_loop_relationships` rides along — it governs the
4909            // self-loop relate refusal, a write-time refusal lite must let
4910            // an agent avoid.
4911            assert!(
4912                t.get("no_self_loop_relationships").is_some(),
4913                "lite type keeps no_self_loop_relationships"
4914            );
4915            // `required_outgoing` rides along — the only declared
4916            // legality condition on outgoing edges. Always an array,
4917            // never an absent key (absence would read as "unknown").
4918            assert!(
4919                t.get("required_outgoing").is_some_and(|v| v.is_array()),
4920                "lite type keeps required_outgoing as an array"
4921            );
4922            // Field shapes present (name + required), prose absent.
4923            if let Some(fields) = t["fields"].as_array() {
4924                for f in fields {
4925                    assert!(f["name"].is_string());
4926                    assert!(f["required"].is_boolean());
4927                    assert!(
4928                        f.get("description").is_none(),
4929                        "lite field drops description"
4930                    );
4931                }
4932            }
4933        }
4934
4935        // Every relationship name carries its allowed endpoints and the
4936        // refusal-governing flags — and NO description/when_to_use prose.
4937        for r in rels {
4938            assert!(r["name"].is_string());
4939            assert!(
4940                r.get("allowed_sources").is_some(),
4941                "lite rel has allowed_sources"
4942            );
4943            assert!(
4944                r.get("allowed_targets").is_some(),
4945                "lite rel has allowed_targets"
4946            );
4947            assert!(
4948                r.get("manual_authoring").is_some(),
4949                "lite rel keeps manual_authoring"
4950            );
4951            assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
4952            assert!(
4953                r.get("per_edge_description").is_some(),
4954                "lite rel keeps per_edge_description"
4955            );
4956            assert!(r.get("description").is_none(), "lite rel drops description");
4957            assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
4958            assert!(
4959                r.get("default_weight").is_none(),
4960                "lite rel drops default_weight"
4961            );
4962        }
4963    }
4964
4965    #[test]
4966    fn lite_is_measurably_smaller_than_full() {
4967        let schema = software_schema();
4968        let full = build_schema_payload(
4969            &schema,
4970            vec!["v".into()],
4971            SchemaVerbosity::Full,
4972            OriginClass::FirstParty,
4973        );
4974        let lite = build_schema_payload(
4975            &schema,
4976            vec!["v".into()],
4977            SchemaVerbosity::Lite,
4978            OriginClass::FirstParty,
4979        );
4980        let full_len = serde_json::to_string(&full).unwrap().len();
4981        let lite_len = serde_json::to_string(&lite).unwrap().len();
4982        assert!(
4983            lite_len * 2 < full_len,
4984            "lite ({lite_len} B) must be well under half of full ({full_len} B)"
4985        );
4986    }
4987
4988    #[test]
4989    fn lite_full_carry_the_same_type_and_rel_names() {
4990        // The cut drops prose, never an entity type or a rel-type — an
4991        // agent orienting on lite sees the full vocabulary.
4992        let schema = software_schema();
4993        let full = build_schema_payload(
4994            &schema,
4995            vec!["v".into()],
4996            SchemaVerbosity::Full,
4997            OriginClass::FirstParty,
4998        );
4999        let lite = build_schema_payload(
5000            &schema,
5001            vec!["v".into()],
5002            SchemaVerbosity::Lite,
5003            OriginClass::FirstParty,
5004        );
5005
5006        let names = |arr: &serde_json::Value| -> Vec<String> {
5007            arr.as_array()
5008                .unwrap()
5009                .iter()
5010                .map(|v| v["name"].as_str().unwrap().to_string())
5011                .collect()
5012        };
5013        assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
5014        assert_eq!(
5015            names(&full["relationships"]),
5016            names(&lite["relationships_summary"])
5017        );
5018    }
5019
5020    /// Criterion 4 of 04/02, on the surface an agent actually reads. The
5021    /// conformance axis knew, but it is opt-in; a plain `memstead_entity` /
5022    /// `memstead entity --json` returned the swallowed sections as `""` with
5023    /// nothing to distinguish them from sections the author left blank.
5024    #[test]
5025    fn swallowed_sections_carry_a_marker_on_the_plain_read() {
5026        let mut e = test_entity();
5027        e.sections.insert(
5028            "identity".to_string(),
5029            "intro\n\n```rust\nfn main() {}".to_string(),
5030        );
5031        e.sections.insert("purpose".to_string(), String::new());
5032        let env = build_entity_envelope(
5033            &e,
5034            10,
5035            None,
5036            None,
5037            None,
5038            OriginClass::FirstParty,
5039            &[],
5040            None,
5041            None,
5042            None,
5043        );
5044        let marker = &env["_unread_sections"];
5045        assert_eq!(marker["reason"], "UNTERMINATED_FENCE");
5046        assert_eq!(marker["absorbed_into"], "identity");
5047        assert_eq!(marker["sections"], serde_json::json!(["purpose"]));
5048    }
5049
5050    #[test]
5051    fn an_ordinary_entity_carries_no_unread_marker() {
5052        // Including one whose empty section is genuinely just empty, and one
5053        // carrying a CLOSED fence: a marker that fired on either would make
5054        // every blank section look like data loss.
5055        for body in ["plain prose", "```rust\nfn main() {}\n```"] {
5056            let mut e = test_entity();
5057            e.sections.insert("identity".to_string(), body.to_string());
5058            e.sections.insert("purpose".to_string(), String::new());
5059            let env = build_entity_envelope(
5060                &e,
5061                10,
5062                None,
5063                None,
5064                None,
5065                OriginClass::FirstParty,
5066                &[],
5067                None,
5068                None,
5069                None,
5070            );
5071            assert!(
5072                env.get("_unread_sections").is_none(),
5073                "body {body:?} produced a marker"
5074            );
5075        }
5076    }
5077}