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