Skip to main content

memstead_base/
render.rs

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