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