Skip to main content

memstead_base/
render.rs

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