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