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    lines.push(String::new());
1325
1326    // Metadata fields
1327    lines.push("## Metadata fields".to_string());
1328    for field in &schema.metadata_fields {
1329        lines.push(format!("- {}", describe_metadata_field(field)));
1330    }
1331    lines.push(String::new());
1332
1333    // Sections
1334    lines.push("## Sections".to_string());
1335    for section in &schema.sections {
1336        let req = if section.required {
1337            "required"
1338        } else {
1339            "optional"
1340        };
1341        let catch_all = if section.catch_all { ", catch-all" } else { "" };
1342        lines.push(format!(
1343            "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1344            section.key, section.search_weight,
1345        ));
1346        for rule in &section.write_rules {
1347            lines.push(format!("  - Write rule: {rule}"));
1348        }
1349    }
1350    lines.push(String::new());
1351
1352    // Relationship types
1353    lines.push("## Relationship types (with edge weights)".to_string());
1354    for (rel_type, weight) in &schema.edge_weights {
1355        if rel_type == "_default" {
1356            continue;
1357        }
1358        let mut flags: Vec<&str> = Vec::new();
1359        if rel_type == &schema.hierarchy_relationship {
1360            flags.push("hierarchy");
1361        }
1362        if schema
1363            .no_self_loop_relationships
1364            .iter()
1365            .any(|r| r == rel_type)
1366        {
1367            flags.push("no-self-loop");
1368        }
1369        // Schema-level authoring posture, when the parent schema is in
1370        // hand: a rel-type the alias machinery owns (e.g. REFERENCES)
1371        // is marked here, BEFORE a write, instead of only refusing
1372        // after a batch is composed.
1373        if let Some(p) = parent {
1374            match p.relationship_manual_authoring(rel_type) {
1375                memstead_schema::ManualAuthoring::Forbidden => {
1376                    flags.push("manual authoring FORBIDDEN — emitted from body wiki-links only");
1377                }
1378                memstead_schema::ManualAuthoring::Warn => {
1379                    flags.push("manual authoring warns");
1380                }
1381                memstead_schema::ManualAuthoring::Allow => {}
1382            }
1383        }
1384        let flag_str = if flags.is_empty() {
1385            String::new()
1386        } else {
1387            format!(" ({})", flags.join(", "))
1388        };
1389        lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1390    }
1391    // Default weight
1392    if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1393        lines.push(format!(
1394            "- _default_ (any other relationship type): {default_weight}"
1395        ));
1396    }
1397    lines.push(String::new());
1398
1399    // Writing guidance (schema-level)
1400    if !schema.write_rules.is_empty() {
1401        lines.push("## Writing guidance".to_string());
1402        for rule in &schema.write_rules {
1403            lines.push(format!("- {rule}"));
1404        }
1405        lines.push(String::new());
1406    }
1407
1408    // System context
1409    let system_msg = schema.system_message_str();
1410    if !system_msg.is_empty() {
1411        lines.push("## System context".to_string());
1412        lines.push(system_msg.to_string());
1413        lines.push(String::new());
1414    }
1415
1416    // Canonical exemplar (agent-trust plan 09) — the engine-validated
1417    // few-shot entity, rendered in the mem markdown shape. The CLI's
1418    // full-depth type view matches `memstead_schema verbosity: full`.
1419    if let Some(ex) = &schema.exemplar {
1420        lines.push("## Exemplar (engine-validated)".to_string());
1421        lines.push(String::new());
1422        lines.push(format!("Title: {}", ex.title));
1423        if !ex.metadata.is_empty() {
1424            lines.push("Metadata:".to_string());
1425            for (k, v) in &ex.metadata {
1426                lines.push(format!("- {k}: {v}"));
1427            }
1428        }
1429        for (key, body) in &ex.sections {
1430            let heading = schema
1431                .section(key)
1432                .map(|s| s.heading.clone())
1433                .unwrap_or_else(|| key.clone());
1434            lines.push(format!("### {heading}"));
1435            lines.push(body.clone());
1436        }
1437        if !ex.relations.is_empty() {
1438            lines.push("Relations (placeholder targets):".to_string());
1439            for r in &ex.relations {
1440                match &r.description {
1441                    Some(d) => lines.push(format!(
1442                        "- {} → {} — {d}",
1443                        r.rel_type_name(),
1444                        r.target_slug()
1445                    )),
1446                    None => lines.push(format!("- {} → {}", r.rel_type_name(), r.target_slug())),
1447                }
1448            }
1449        }
1450        lines.push(String::new());
1451    }
1452
1453    lines.join("\n")
1454}
1455
1456/// Render a [`PerEdgeDescription`] to its wire literal — bit-identical to
1457/// what the schema YAML accepts so consumers can echo the value back
1458/// without case fiddling. `forbidden` (the default) is emitted explicitly
1459/// rather than omitted so a schema without an explicit declaration still
1460/// surfaces the resolved posture on the wire.
1461pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1462    match p {
1463        PerEdgeDescription::Forbidden => "forbidden",
1464        PerEdgeDescription::Optional => "optional",
1465        PerEdgeDescription::Required => "required",
1466    }
1467}
1468
1469/// Stable wire string for the `manual_authoring` posture.
1470pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1471    match p {
1472        ManualAuthoring::Allow => "allow",
1473        ManualAuthoring::Warn => "warn",
1474        ManualAuthoring::Forbidden => "forbidden",
1475    }
1476}
1477
1478/// Verbosity selector for [`build_schema_payload`].
1479///
1480/// `Full` is the complete payload — every description, `when_to_use`,
1481/// write-rule, and writing-guidance string. `Lite` drops that long-form
1482/// prose and returns a structural skeleton: entity-type names with their
1483/// section keys and metadata-field shapes, relationship names with their
1484/// allowed endpoints. The skeleton keeps every *flag* an agent needs to
1485/// author a legal write — the alias-model pointer, required-section and
1486/// required-field markers, endpoint constraints, the manual-authoring
1487/// posture, the `acyclic` flag, and the per-edge-description posture — so
1488/// a lite caller can plan a write without round-tripping to full and
1489/// without walking into a write-time refusal. Full and lite emit the two
1490/// heavy arrays under *distinct keys* (`types` / `relationships` vs.
1491/// `types_summary` / `relationships_summary`), so a consumer decodes by
1492/// key presence rather than by branching on the request shape.
1493#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1494pub enum SchemaVerbosity {
1495    #[default]
1496    Full,
1497    Lite,
1498}
1499
1500impl SchemaVerbosity {
1501    /// Parse the wire token (`"full"` / `"lite"`). Returns `None` for an
1502    /// unrecognized token so the calling surface can raise a typed error
1503    /// naming the bad value rather than silently defaulting. An absent
1504    /// parameter maps to `Full` at the call site, not here.
1505    pub fn from_wire(s: &str) -> Option<Self> {
1506        match s {
1507            "full" => Some(Self::Full),
1508            "lite" => Some(Self::Lite),
1509            _ => None,
1510        }
1511    }
1512
1513    /// The wire token for this verbosity.
1514    pub fn as_wire(self) -> &'static str {
1515        match self {
1516            Self::Full => "full",
1517            Self::Lite => "lite",
1518        }
1519    }
1520}
1521
1522/// Trust origin of a schema (or the mem that pins it), decided at
1523/// adopt/write time and reported — never re-derived — on the read path.
1524///
1525/// `FirstParty` is an engine built-in or a schema authored/explicitly
1526/// trusted in this workspace. Its prose-instruction fields
1527/// (`system_context`, `write_rules`, `writing_guidance`, `when_to_use`,
1528/// prose `description`, `default_writing_guidance`) guide *authoring* in
1529/// this workspace and are served in full.
1530///
1531/// `ThirdParty` is a schema that arrived from outside this workspace
1532/// (registry-installed or adopted from a foreign folder/clone) and has
1533/// not been explicitly trusted. Memstead's value proposition pulls a
1534/// mem's schema directly into a consuming agent's context, where the
1535/// schema's free-text fields are framed *as instructions* ("System
1536/// context", "Writing guidance"). A third-party schema is therefore
1537/// served structural-only: [`build_schema_payload`] forces the
1538/// [`SchemaVerbosity::Lite`] skeleton regardless of the requested
1539/// verbosity, omitting every prose-instruction field. This is lossless
1540/// for the legitimate use case — the omitted fields only guide writing,
1541/// and a write never targets a foreign mem.
1542///
1543/// The class is unforgeable by a publisher: it is decided by *how* the
1544/// schema entered the workspace, not by any content the schema carries.
1545/// An unknown/ambiguous origin classifies `ThirdParty` — the safe
1546/// default (a stranger's prose is never served as first-party
1547/// instructions on the strength of a missing label).
1548#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1549pub enum OriginClass {
1550    /// Engine built-in, or authored/explicitly trusted in this workspace.
1551    FirstParty,
1552    /// Arrived from outside this workspace and not explicitly trusted.
1553    /// The safe default for an unlabelled/ambiguous origin.
1554    #[default]
1555    ThirdParty,
1556}
1557
1558impl OriginClass {
1559    /// The wire token for this origin (`"first-party"` / `"third-party"`),
1560    /// emitted on every schema read so a consuming host can quarantine
1561    /// non-first-party content.
1562    pub fn as_wire(self) -> &'static str {
1563        match self {
1564            Self::FirstParty => "first-party",
1565            Self::ThirdParty => "third-party",
1566        }
1567    }
1568
1569    /// Whether this origin must have its schema served structural-only
1570    /// (prose-instruction fields omitted) on the read path.
1571    pub fn is_third_party(self) -> bool {
1572        matches!(self, Self::ThirdParty)
1573    }
1574}
1575
1576/// Build the transport-neutral, rmcp-free JSON payload for a schema read
1577/// (`memstead_schema`). Shared by the MCP server, the HTTP surface, and
1578/// the filesystem-mem MCP flavour so every surface emits identical
1579/// schema-read bytes from one source. `used_by` lists the writable mems
1580/// whose pinned schema resolves to this one; `verbosity` toggles the full
1581/// payload versus the lightweight skeleton (see [`SchemaVerbosity`]).
1582///
1583/// `origin` ([`OriginClass`]) is reported on the wire as `origin` and
1584/// governs de-framing: a [`OriginClass::ThirdParty`] schema is served
1585/// structural-only — the requested `verbosity` is overridden to
1586/// [`SchemaVerbosity::Lite`] so none of its prose-instruction fields
1587/// (`system_context`, `write_rules`, `writing_guidance`, `when_to_use`,
1588/// prose `description`, `default_writing_guidance`) reach a consuming
1589/// agent as instructions. A `full`-verbosity request on a third-party
1590/// schema therefore still omits them — the override is one-directional.
1591/// Append a section's format declaration (plan 08) to its rendered
1592/// object — only the declared keys, so undeclared sections keep their
1593/// exact pre-plan shape. `format_severity` renders whenever a
1594/// `content` declaration exists (the default `block` is a legality
1595/// fact, not noise).
1596fn append_section_format(
1597    obj: &mut serde_json::Map<String, serde_json::Value>,
1598    s: &memstead_schema::SectionDef,
1599) {
1600    if let Some(content) = &s.content {
1601        obj.insert("content".into(), serde_json::json!(content));
1602        obj.insert(
1603            "format_severity".into(),
1604            serde_json::json!(s.format_severity),
1605        );
1606    }
1607    if let Some(pattern) = &s.item_pattern {
1608        obj.insert("item_pattern".into(), serde_json::json!(pattern));
1609    }
1610    if let Some(table) = &s.table {
1611        obj.insert("table".into(), serde_json::json!(table));
1612    }
1613    if let Some(example) = &s.example {
1614        obj.insert("example".into(), serde_json::json!(example));
1615    }
1616}
1617
1618/// Unknown type names in a `types` selection passed to
1619/// [`build_schema_payload_scoped`] — the caller raises a typed refusal
1620/// naming the valid types (recovery-payload posture, never a silent
1621/// empty section).
1622#[derive(Debug, Clone)]
1623pub struct UnknownSchemaTypes {
1624    pub unknown: Vec<String>,
1625    pub known: Vec<String>,
1626}
1627
1628/// Token estimate for a serialized JSON payload — routed through the
1629/// house heuristic ([`crate::chunking::estimate_tokens`]) so "fits the
1630/// pipe" is judged by the same yardstick every budgeted surface uses.
1631fn estimate_payload_tokens(value: &serde_json::Value) -> usize {
1632    serde_json::to_string(value)
1633        .map(|s| estimate_tokens(&s))
1634        .unwrap_or(0)
1635}
1636
1637/// Default budget for the UNSCOPED full-verbosity schema reply, in
1638/// estimated (bytes/4) tokens — ~60 KB of JSON. Calibrated against the
1639/// primary client's ~25k real-token response cap: dense JSON tokenizes
1640/// well above bytes/4, so 15k estimated sits at the cap's edge. The
1641/// two measured packages land on the intended sides: `default@1.3.0`
1642/// (~52 KB) keeps serving in full — today's behaviour on today's reply
1643/// sizes — while `software@0.4.0` (60.2 KB, the observed harness spill,
1644/// 2026-08-18 WOENENN ingest) degrades visibly to the per-type steer
1645/// instead of overflowing the pipe.
1646pub const DEFAULT_SCHEMA_FULL_BUDGET: usize = 15_000;
1647
1648pub fn build_schema_payload(
1649    schema: &Arc<Schema>,
1650    used_by: Vec<String>,
1651    verbosity: SchemaVerbosity,
1652    origin: OriginClass,
1653) -> serde_json::Value {
1654    // Unscoped, unbudgeted — the classic shape every existing consumer
1655    // gets. Infallible by construction (no selection to refuse).
1656    build_schema_payload_scoped(schema, used_by, verbosity, origin, None, None)
1657        .expect("no type selection, no refusal")
1658}
1659
1660/// [`build_schema_payload`] with the serving-shape controls
1661/// (backlog-sweep plan 06a): `type_selection` scopes the heavy per-type
1662/// prose to the named types — the reply carries the full package-level
1663/// context, the selected types in full, and a `types_omitted` roster
1664/// naming what was not served (visible scope, never silent truncation).
1665/// An unknown name refuses with [`UnknownSchemaTypes`]. Under
1666/// [`SchemaVerbosity::Lite`] the selection filters the skeleton the
1667/// same way (coherent, though the full tier is the use case).
1668///
1669/// `token_budget` guards the UNSCOPED full reply: when the complete
1670/// payload's estimated tokens exceed the budget, the reply degrades
1671/// visibly — per-type prose drops to the lite `types_summary` skeleton,
1672/// `_schema_mode: "reduced"` is stamped, and `_hint` steers the caller
1673/// to per-type retrieval via `types`. A scoped request is what the
1674/// budget steers TOWARD, so the selection path is never re-degraded.
1675pub fn build_schema_payload_scoped(
1676    schema: &Arc<Schema>,
1677    used_by: Vec<String>,
1678    verbosity: SchemaVerbosity,
1679    origin: OriginClass,
1680    type_selection: Option<&[String]>,
1681    token_budget: Option<usize>,
1682) -> Result<serde_json::Value, UnknownSchemaTypes> {
1683    let manifest = &schema.manifest;
1684
1685    // Validate the selection against the manifest roster before any
1686    // rendering — refuse-with-the-known-names beats a silent empty
1687    // `types` array.
1688    if let Some(sel) = type_selection {
1689        let unknown: Vec<String> = sel
1690            .iter()
1691            .filter(|t| !manifest.types.iter().any(|m| m == *t))
1692            .cloned()
1693            .collect();
1694        if !unknown.is_empty() {
1695            return Err(UnknownSchemaTypes {
1696                unknown,
1697                known: manifest.types.clone(),
1698            });
1699        }
1700    }
1701    // De-frame third-party schemas: their prose-instruction fields only
1702    // guide authoring (which never targets a foreign mem), so omitting
1703    // them is lossless — and serving them would place a stranger's
1704    // free-text in the consuming agent's instruction context. The Lite
1705    // skeleton keeps every structural flag an agent needs to understand
1706    // and query the mem. The override is one-directional: a `full`
1707    // request cannot re-admit the prose for a third-party schema.
1708    let verbosity = if origin.is_third_party() {
1709        SchemaVerbosity::Lite
1710    } else {
1711        verbosity
1712    };
1713
1714    // `_default` is the schema's internal weight-fallback knob — it
1715    // sets the edge weight every `_default`-less rel-type inherits and
1716    // is *not* a usable rel-type on `memstead_relate` (the relate path
1717    // rejects it with `INVALID_REL_TYPE`). Surfacing it in the agent-
1718    // facing vocabulary cost one round-trip per
1719    // session as agents tried it and learned the asymmetry by trial,
1720    // so it is suppressed here: the schema response advertises only
1721    // the rel-types `memstead_relate` actually accepts. Schemas that
1722    // declare `_default` for weight purposes are unaffected — the
1723    // engine still consults it for `edge_weight` fallback.
1724    let relationships: Vec<serde_json::Value> = manifest
1725        .relationships
1726        .definitions
1727        .iter()
1728        .filter(|d| d.name != "_default")
1729        .map(|d| {
1730            // Surface the `acyclic` flag so agents can predict cycle-check
1731            // refusal from introspection without trial-and-error.
1732            // Combined with each type's `no_self_loop_relationships`
1733            // list (below), the schema response fully describes the
1734            // self-loop / long-cycle gates.
1735            //
1736            // Surface the `manual_authoring` posture so agents see at
1737            // introspection time which rel-types refuse explicit
1738            // `memstead_relate` (forbidden), warn softly (warn), or
1739            // admit explicit authoring (allow, default).
1740            //
1741            // Surface the source/target type pinning declared on the
1742            // schema's `RelationshipDefinition` so agents can pre-filter
1743            // rel-types for their `(from_type, to_type)` pair from
1744            // introspection instead of trial-and-error against
1745            // `INVALID_REL_SHAPE`. Field names mirror the
1746            // `INVALID_REL_SHAPE` `details.allowed_source_types` /
1747            // `details.allowed_target_types` payload so the agent
1748            // learns the contract once. Empty arrays = "any type
1749            // admitted" (no pinning).
1750            let mut o = serde_json::json!({
1751                "name": d.name,
1752                "description": d.description,
1753                "when_to_use": d.when_to_use,
1754                "default_weight": d.default_weight,
1755                "acyclic": d.acyclic,
1756                "per_edge_description": per_edge_description_str(d.per_edge_description),
1757                "manual_authoring": manual_authoring_str(d.manual_authoring),
1758                "allowed_sources": d.source_types,
1759                "allowed_targets": d.target_types,
1760            });
1761            // Derivation declaration (agent-trust plan 12) — a
1762            // behaviour-bearing flag (baseline recording, the
1763            // stale_derivations axis, duplicate-add re-baseline), so
1764            // it must be visible at introspection time. Emitted only
1765            // when true so undeclared schemas keep their bytes.
1766            if d.derivation {
1767                o["derivation"] = serde_json::json!(true);
1768            }
1769            o
1770        })
1771        .collect();
1772
1773    // Outbound cross-mem vocabulary, one entry per target schema.
1774    // Same shape as the YAML — `{ to_schema, definitions: [...] }` —
1775    // so consumers can decode the section symmetrically with the
1776    // intra-mem `relationships` array. `_default` filtering mirrors
1777    // the intra-mem block; the rest of the per-definition shape is
1778    // identical so a single decoder handles both.
1779    let cross_mem_relationships: Vec<serde_json::Value> = manifest
1780        .cross_mem_relationships
1781        .iter()
1782        .map(|entry| {
1783            let definitions: Vec<serde_json::Value> = entry
1784                .definitions
1785                .iter()
1786                .filter(|d| d.name != "_default")
1787                .map(|d| {
1788                    serde_json::json!({
1789                        "name": d.name,
1790                        "description": d.description,
1791                        "when_to_use": d.when_to_use,
1792                        "default_weight": d.default_weight,
1793                        "source_types": d.source_types,
1794                        "target_types": d.target_types,
1795                        "per_edge_description": per_edge_description_str(d.per_edge_description),
1796                    })
1797                })
1798                .collect();
1799            serde_json::json!({
1800                "to_schema": entry.to_schema,
1801                "definitions": definitions,
1802            })
1803        })
1804        .collect();
1805
1806    // Iterate type names in manifest-declared order so the output is
1807    // deterministic and matches the schema author's intent.
1808    let types_full: Vec<serde_json::Value> = manifest
1809        .types
1810        .iter()
1811        .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1812        .map(|(_, td)| {
1813            let sections: Vec<serde_json::Value> = td
1814                .sections
1815                .iter()
1816                .map(|s| {
1817                    let mut obj = serde_json::json!({
1818                        "key": s.key,
1819                        "heading": s.heading,
1820                        "required": s.required,
1821                        "write_rules": s.write_rules,
1822                    });
1823                    // Section-format declarations (plan 08) — a
1824                    // legality condition, so it must never be
1825                    // invisible in the schema response (rendered at
1826                    // BOTH verbosity levels via the lite projection
1827                    // below).
1828                    append_section_format(obj.as_object_mut().unwrap(), s);
1829                    obj
1830                })
1831                .collect();
1832
1833            let fields: Vec<serde_json::Value> = td
1834                .metadata_fields
1835                .iter()
1836                .map(|f| {
1837                    let mut obj = serde_json::json!({
1838                        "name": f.key,
1839                        "description": f.description,
1840                        "required": f.is_required(),
1841                    });
1842                    if let Some(enum_values) = &f.enum_values {
1843                        obj.as_object_mut()
1844                            .unwrap()
1845                            .insert("enum".into(), serde_json::json!(enum_values));
1846                    }
1847                    // Surface schema-declared `default_value` so agents
1848                    // see what the create path fills in when a required
1849                    // field is omitted. Without this, the engine appears
1850                    // to silently default — `priority: mid` on a
1851                    // `coverage_gap` would land with no schema-side
1852                    // explanation of where the value came from.
1853                    if let Some(default) = &f.default_value {
1854                        obj.as_object_mut()
1855                            .unwrap()
1856                            .insert("default".into(), serde_json::json!(default));
1857                    }
1858                    // Surface the `filterable` posture so an agent constructs
1859                    // valid `filters` / `range_filters` from the schema body
1860                    // in one shot. Always present: `"equality"` accepts
1861                    // `filters`, `"range"` accepts `range_filters`, `null`
1862                    // means not filterable.
1863                    obj.as_object_mut().unwrap().insert(
1864                        "filterable".into(),
1865                        match f.filterable.as_wire_str() {
1866                            Some(s) => serde_json::json!(s),
1867                            None => serde_json::Value::Null,
1868                        },
1869                    );
1870                    obj
1871                })
1872                .collect();
1873
1874            // Expose the per-type `no_self_loop_relationships` list so agents
1875            // can predict self-loop refusal. The engine refuses
1876            // `memstead_relate type=R from=X(type=T) to=X` whenever R
1877            // appears here, independent of R's `acyclic` flag.
1878            //
1879            // `required_outgoing` is the only declared legality condition
1880            // on an entity's outgoing edges: each block lists the
1881            // relationship-name alternatives and the cardinality bound,
1882            // in declaration order. Always present — a type with no
1883            // blocks emits an empty list, because an absent key would
1884            // read as "unknown" and send agents back to the authoring
1885            // YAML. Cardinality is rendered exactly as declared
1886            // (`at_least_one` — an open upper bound stays open, never
1887            // normalised into a number).
1888            let required_outgoing: Vec<serde_json::Value> = td
1889                .required_outgoing
1890                .iter()
1891                .map(|block| {
1892                    let mut b = serde_json::json!({
1893                        "relationships": block.relationships,
1894                        "cardinality": block.cardinality.to_string(),
1895                        "severity": block.severity,
1896                    });
1897                    // Conditional blocks carry their trigger at both
1898                    // verbosity levels (the lite skeleton projects this
1899                    // object unchanged); unconditional blocks keep
1900                    // their byte-identical three-key shape.
1901                    if let (Some(wf), Some(wv)) = (&block.when_field, &block.when_value) {
1902                        b["when_field"] = serde_json::json!(wf);
1903                        b["when_value"] = serde_json::json!(wv);
1904                    }
1905                    b
1906                })
1907                .collect();
1908
1909            // Declared `constraints` — like `required_outgoing`, a
1910            // legality/health condition that must never be invisible
1911            // in the schema response (a hidden legality condition is
1912            // a defect class of its own). Always present, empty list
1913            // for a type declaring none; each entry restates the
1914            // declaration with its `severity` (`warn` = health
1915            // finding, `block` = write-time refusal), in declaration
1916            // order, at BOTH verbosity levels.
1917            let constraints: Vec<serde_json::Value> = td
1918                .constraints
1919                .iter()
1920                .map(|c| match c {
1921                    memstead_schema::ConstraintDef::RequiresWhen {
1922                        field,
1923                        when_field,
1924                        when_value,
1925                        severity,
1926                    } => serde_json::json!({
1927                        "kind": "requires_when",
1928                        "field": field,
1929                        "when_field": when_field,
1930                        "when_value": when_value,
1931                        "severity": severity,
1932                    }),
1933                    memstead_schema::ConstraintDef::Unique { fields, severity } => {
1934                        serde_json::json!({
1935                            "kind": "unique",
1936                            "fields": fields,
1937                            "severity": severity,
1938                        })
1939                    }
1940                    memstead_schema::ConstraintDef::EnumFromNeighbour {
1941                        field,
1942                        rel_type,
1943                        section,
1944                        severity,
1945                    } => serde_json::json!({
1946                        "kind": "enum_from_neighbour",
1947                        "field": field,
1948                        "rel_type": rel_type,
1949                        "section": section,
1950                        "severity": severity,
1951                    }),
1952                    memstead_schema::ConstraintDef::StatusPropagation {
1953                        field,
1954                        value,
1955                        rel_type,
1956                        rel_types,
1957                        direction,
1958                        severity,
1959                    } => {
1960                        let mut c = serde_json::json!({
1961                            "kind": "status_propagation",
1962                            "field": field,
1963                            "value": value,
1964                            "direction": direction,
1965                            "severity": severity,
1966                        });
1967                        // Echo the declaration's own shape: the
1968                        // single-name key stays byte-identical, a
1969                        // relation set rides under `rel_types`.
1970                        if let Some(single) = rel_type {
1971                            c["rel_type"] = serde_json::json!(single);
1972                        }
1973                        if let Some(set) = rel_types {
1974                            c["rel_types"] = serde_json::json!(set);
1975                        }
1976                        c
1977                    }
1978                    memstead_schema::ConstraintDef::TransitionRequiresChecks {
1979                        field,
1980                        to_value,
1981                        relationships,
1982                        direction,
1983                        severity,
1984                    } => serde_json::json!({
1985                        "kind": "transition_requires_checks",
1986                        "field": field,
1987                        "to_value": to_value,
1988                        "relationships": relationships,
1989                        "direction": direction,
1990                        "severity": severity,
1991                    }),
1992                })
1993                .collect();
1994            let mut obj = serde_json::json!({
1995                "name": td.name,
1996                "description": td.description,
1997                "when_to_use": td.when_to_use,
1998                "sections": sections,
1999                "fields": fields,
2000                "writing_guidance": td.write_rules,
2001                "system_context": td.system_message_str(),
2002                "staleness_threshold_days": td.staleness_threshold_days,
2003                "no_self_loop_relationships": td.no_self_loop_relationships,
2004                "required_outgoing": required_outgoing,
2005                "constraints": constraints,
2006            });
2007            // Reachability obligations — like `required_outgoing`, a
2008            // health condition the schema response must not hide; the
2009            // declaration is echoed in its YAML shape. Emitted only
2010            // when declared so undeclared schemas keep their payload
2011            // bytes unchanged.
2012            if !td.must_reach.is_empty() {
2013                obj["must_reach"] = serde_json::to_value(&td.must_reach)
2014                    .expect("must_reach declarations serialize");
2015            }
2016            // Aggregate-signal declarations — served behaviour (the
2017            // `_signals` read insert, the health axis, the crossing
2018            // warning) an agent must see at introspection time; the
2019            // declaration is echoed in its YAML shape. Emitted only
2020            // when declared.
2021            if !td.signals.is_empty() {
2022                obj["signals"] =
2023                    serde_json::to_value(&td.signals).expect("signal declarations serialize");
2024            }
2025            // Leaf declaration — a legality-relevant fact an agent
2026            // planning writes must see; emitted only when true so
2027            // undeclared schemas keep their payload bytes unchanged.
2028            if td.leaf {
2029                obj["leaf"] = serde_json::json!(true);
2030            }
2031            // The type's canonical exemplar (agent-trust plan 09) —
2032            // engine-validated at install/seal, so what it teaches is
2033            // exactly what the validator accepts. Rides FULL mode only
2034            // (this array); the lite projection below drops it by
2035            // allowlist, so the per-session skeleton stays unchanged.
2036            // Relation targets are placeholder slugs by contract.
2037            //
2038            // The relation entries are emitted in the MUTATION
2039            // vocabulary (`target` / `rel_type`) — since the
2040            // 05-front-door/08 rider landed, that is also the authoring
2041            // spelling (legacy sealed content is translated at load),
2042            // so an agent copying this payload into `memstead_create`
2043            // gets a shape the write gate accepts.
2044            if let Some(ex) = &td.exemplar {
2045                let relations: Vec<serde_json::Value> = ex
2046                    .relations
2047                    .iter()
2048                    .map(|r| {
2049                        let mut o = serde_json::json!({
2050                            "target": r.target_slug(),
2051                            "rel_type": r.rel_type_name(),
2052                        });
2053                        if let Some(d) = &r.description {
2054                            o["description"] = serde_json::json!(d);
2055                        }
2056                        o
2057                    })
2058                    .collect();
2059                obj["exemplar"] = serde_json::json!({
2060                    "title": ex.title,
2061                    "metadata": ex.metadata,
2062                    "sections": ex.sections,
2063                    "relations": relations,
2064                });
2065            }
2066            obj
2067        })
2068        .collect();
2069
2070    let mode = match manifest.relationships.mode {
2071        RelationshipMode::Strict => "strict",
2072        RelationshipMode::Open => "open",
2073    };
2074
2075    let full = verbosity == SchemaVerbosity::Full;
2076
2077    // Scalar fields present in BOTH modes. `ref` names the schema even
2078    // in the lite skeleton; `relationship_mode`, `community`, and
2079    // `used_by` are bounded and cheap.
2080    let mut payload = serde_json::json!({
2081        "ref": format!("{}@{}", manifest.name, schema.version),
2082        "relationship_mode": mode,
2083        "community": {
2084            "resolution": manifest.community.resolution,
2085            "seed": manifest.community.seed,
2086        },
2087        "used_by": used_by,
2088        // Machine-readable trust origin, present in both modes. A
2089        // consuming host reads this to decide whether to treat the
2090        // schema as workspace instructions (`first-party`) or quarantine
2091        // it as untrusted (`third-party`). Additive — a client that
2092        // ignores it still decodes the rest of the payload unchanged.
2093        "origin": origin.as_wire(),
2094    });
2095    let obj = payload.as_object_mut().unwrap();
2096
2097    // Declared acyclicity sets — a legality condition on the relate
2098    // path (a cycle in a set's union subgraph refuses), so it ships in
2099    // BOTH modes; emitted only when declared so undeclared schemas
2100    // keep their payload bytes unchanged.
2101    if !manifest.relationships.acyclic_sets.is_empty() {
2102        obj.insert(
2103            "acyclic_sets".into(),
2104            serde_json::to_value(&manifest.relationships.acyclic_sets)
2105                .expect("acyclic_sets serialize"),
2106        );
2107    }
2108    // Grounded-labelling declaration — served behaviour (the
2109    // `_labelling` read insert and the `labelling` health axis) an
2110    // agent must see at introspection time; echoed in its YAML shape,
2111    // in BOTH modes, only when declared.
2112    if let Some(lab) = &manifest.relationships.labelling {
2113        obj.insert(
2114            "labelling".into(),
2115            serde_json::to_value(lab).expect("labelling declaration serializes"),
2116        );
2117    }
2118
2119    // Schema-level prose — FULL mode only. An agent that asked for the
2120    // lite skeleton is orienting on structure; the human-readable
2121    // `description` / `when_to_use` is exactly the weight the lite cut
2122    // exists to drop. The schema `ref` still identifies the schema.
2123    if full {
2124        obj.insert(
2125            "description".into(),
2126            serde_json::Value::String(manifest.description.clone()),
2127        );
2128        obj.insert(
2129            "when_to_use".into(),
2130            serde_json::Value::String(manifest.when_to_use.clone()),
2131        );
2132        // Schema-level `system_message`, wire-named `system_context` to
2133        // match the per-type key. Without this the manifest's voice/
2134        // posture prose is unreachable from the agent surface entirely
2135        // (its only other consumer is the `memstead type` CLI markdown).
2136        // Omitted when undeclared so existing schemas render unchanged.
2137        if let Some(msg) = &manifest.system_message {
2138            obj.insert(
2139                "system_context".into(),
2140                serde_json::Value::String(msg.clone()),
2141            );
2142        }
2143    }
2144
2145    // One-line effect note for the per-type `no_self_loop_relationships`
2146    // arrays — present in BOTH modes, right where the field is read.
2147    // The retired `propagating_relationships` name misled outside
2148    // schema authors into declaring impact propagation; the renamed
2149    // key states the single functional effect. Top-level (not
2150    // per-type) so the note costs one key, not one per type.
2151    obj.insert(
2152        "no_self_loop_relationships_effect".into(),
2153        serde_json::Value::String(
2154            "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
2155             memstead_relate refuses a self-loop (from == to) on a rel-type the \
2156             source type lists here. It does not propagate impact, imply an \
2157             evidence obligation, or have any other effect (the name says it \
2158             all). To declare real impact propagation, use the \
2159             `status_propagation` constraint (`constraints:` on the type), which \
2160             taints dependents of a terminal status value via a named rel-type \
2161             and direction and surfaces them as health findings."
2162                .to_string(),
2163        ),
2164    );
2165
2166    // Schema-level `alias_target_rel_type` pointer — names the rel-type
2167    // that body wiki-links `[[target]]` auto-emit through the
2168    // alias-synthesis pass. Present in BOTH modes: it governs whether an
2169    // unbacked wiki-link bakes an edge or refuses with
2170    // `WIKILINK_WITHOUT_RELATION`, so dropping it from lite would leave a
2171    // caller one round-trip from a write-time refusal. Schemas omitting
2172    // the field render with the key absent so existing agents don't see
2173    // a noisy `null`.
2174    if let Some(target) = &manifest.alias_target_rel_type {
2175        obj.insert(
2176            "alias_target_rel_type".into(),
2177            serde_json::Value::String(target.clone()),
2178        );
2179    }
2180
2181    // Surface `default_writing_guidance` at the top level so plugin-side
2182    // resolvers can concatenate the schema-generic prose with per-mem
2183    // additions without parsing schema YAML themselves. FULL mode only —
2184    // it is guidance prose. Field-by-field omission — a schema with
2185    // neither `avoid` nor `goal` declared emits no key at all (both
2186    // `Option<String>` inside an `Option<DefaultWritingGuidance>`).
2187    if full && let Some(dwg) = &manifest.default_writing_guidance {
2188        let mut block = serde_json::Map::new();
2189        if let Some(avoid) = &dwg.avoid {
2190            block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
2191        }
2192        if let Some(goal) = &dwg.goal {
2193            block.insert("goal".into(), serde_json::Value::String(goal.clone()));
2194        }
2195        if !block.is_empty() {
2196            obj.insert(
2197                "default_writing_guidance".into(),
2198                serde_json::Value::Object(block),
2199            );
2200        }
2201    }
2202
2203    // The selection partitions the manifest-ordered type roster into
2204    // served and omitted halves. `types_omitted` is emitted whenever
2205    // any type was NOT served in the requested tier — the visible-scope
2206    // guarantee (a reader always sees what a reply does not carry).
2207    let selected = |name: &serde_json::Value| -> bool {
2208        match type_selection {
2209            None => true,
2210            Some(sel) => name.as_str().is_some_and(|n| sel.iter().any(|s| s == n)),
2211        }
2212    };
2213    let omitted_names: Vec<serde_json::Value> = types_full
2214        .iter()
2215        .filter(|t| !selected(&t["name"]))
2216        .map(|t| t["name"].clone())
2217        .collect();
2218
2219    if full {
2220        obj.insert(
2221            "relationships".into(),
2222            serde_json::Value::Array(relationships),
2223        );
2224        // Only surface the cross-mem block when the schema declares
2225        // outbound entries — keeps the response minimal for schemas
2226        // that don't speak cross-mem vocabulary.
2227        if !cross_mem_relationships.is_empty() {
2228            obj.insert(
2229                "cross_mem_relationships".into(),
2230                serde_json::Value::Array(cross_mem_relationships),
2231            );
2232        }
2233        match type_selection {
2234            Some(_) => {
2235                let served: Vec<serde_json::Value> = types_full
2236                    .iter()
2237                    .filter(|t| selected(&t["name"]))
2238                    .cloned()
2239                    .collect();
2240                obj.insert("types".into(), serde_json::Value::Array(served));
2241                if !omitted_names.is_empty() {
2242                    obj.insert(
2243                        "types_omitted".into(),
2244                        serde_json::Value::Array(omitted_names),
2245                    );
2246                }
2247            }
2248            None => {
2249                obj.insert("types".into(), serde_json::Value::Array(types_full.clone()));
2250                // Budget guard on the UNSCOPED full reply: when the
2251                // assembled payload exceeds the budget, degrade
2252                // visibly — the per-type prose drops to the lite
2253                // skeleton, the mode is stamped, and the hint steers
2254                // to per-type retrieval. Never silent truncation: the
2255                // caller sees `_schema_mode: "reduced"` plus the full
2256                // roster in `types_omitted`.
2257                if let Some(budget) = token_budget {
2258                    let estimated = estimate_payload_tokens(&payload);
2259                    if estimated > budget {
2260                        let obj = payload.as_object_mut().unwrap();
2261                        obj.remove("types");
2262                        let all_names: Vec<serde_json::Value> =
2263                            types_full.iter().map(|t| t["name"].clone()).collect();
2264                        obj.insert(
2265                            "types_summary".into(),
2266                            serde_json::Value::Array(lite_types_projection(&types_full)),
2267                        );
2268                        obj.insert("types_omitted".into(), serde_json::Value::Array(all_names));
2269                        obj.insert(
2270                            "_schema_mode".into(),
2271                            serde_json::Value::String("reduced".into()),
2272                        );
2273                        obj.insert("_estimated_tokens".into(), serde_json::json!(estimated));
2274                        obj.insert("_token_budget".into(), serde_json::json!(budget));
2275                        obj.insert(
2276                            "_hint".into(),
2277                            serde_json::Value::String(format!(
2278                                "the full prose for all {} types (~{estimated} tokens) exceeds \
2279                                 the response budget ({budget}); per-type prose is served as the \
2280                                 lite skeleton here — request the full prose for exactly the \
2281                                 types you will write via `types: [\"<name>\", …]` (valid names \
2282                                 in `types_omitted`)",
2283                                types_full.len(),
2284                            )),
2285                        );
2286                    }
2287                }
2288            }
2289        }
2290    } else {
2291        // Lite relationship form: name + endpoint constraints
2292        // (`allowed_sources`/`allowed_targets`) + manual-authoring
2293        // posture + `acyclic` + per-edge-description posture — every flag
2294        // that governs a relate-path refusal (`INVALID_REL_SHAPE`,
2295        // `RELATION_MANUAL_AUTHORING_FORBIDDEN`, cycle check,
2296        // `MISSING_REQUIRED_DESCRIPTION`) — with the description /
2297        // when_to_use / weight prose dropped. The ~42 rel-types carry the
2298        // bulk of the bytes, so this is the load-bearing half of the cut.
2299        // Projected from the rich array so each field value has one source.
2300        let relationships_summary: Vec<serde_json::Value> = relationships
2301            .iter()
2302            .map(|r| {
2303                let mut o = serde_json::json!({
2304                    "name": r["name"],
2305                    "allowed_sources": r["allowed_sources"],
2306                    "allowed_targets": r["allowed_targets"],
2307                    "manual_authoring": r["manual_authoring"],
2308                    "acyclic": r["acyclic"],
2309                    "per_edge_description": r["per_edge_description"],
2310                });
2311                if r.get("derivation") == Some(&serde_json::json!(true)) {
2312                    o["derivation"] = serde_json::json!(true);
2313                }
2314                o
2315            })
2316            .collect();
2317        obj.insert(
2318            "relationships_summary".into(),
2319            serde_json::Value::Array(relationships_summary),
2320        );
2321
2322        // Lite cross-mem form mirrors the intra-mem lite shape:
2323        // name + endpoint pinning, prose dropped. Same emit-when-non-empty
2324        // rule as full mode.
2325        if !cross_mem_relationships.is_empty() {
2326            let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
2327                .iter()
2328                .map(|e| {
2329                    let definitions: Vec<serde_json::Value> = e["definitions"]
2330                        .as_array()
2331                        .map(|defs| {
2332                            defs.iter()
2333                                .map(|d| {
2334                                    serde_json::json!({
2335                                        "name": d["name"],
2336                                        "source_types": d["source_types"],
2337                                        "target_types": d["target_types"],
2338                                    })
2339                                })
2340                                .collect()
2341                        })
2342                        .unwrap_or_default();
2343                    serde_json::json!({
2344                        "to_schema": e["to_schema"],
2345                        "definitions": definitions,
2346                    })
2347                })
2348                .collect();
2349            obj.insert(
2350                "cross_mem_relationships_summary".into(),
2351                serde_json::Value::Array(cross_summary),
2352            );
2353        }
2354
2355        // Lite entity-type form — see [`lite_types_projection`]. The
2356        // selection filters the skeleton the same way it filters the
2357        // full tier, with the same visible `types_omitted` roster.
2358        let served: Vec<serde_json::Value> = types_full
2359            .iter()
2360            .filter(|t| selected(&t["name"]))
2361            .cloned()
2362            .collect();
2363        obj.insert(
2364            "types_summary".into(),
2365            serde_json::Value::Array(lite_types_projection(&served)),
2366        );
2367        if !omitted_names.is_empty() {
2368            obj.insert(
2369                "types_omitted".into(),
2370                serde_json::Value::Array(omitted_names),
2371            );
2372        }
2373    }
2374
2375    Ok(payload)
2376}
2377
2378/// Lite entity-type form: name + section keys (each with its
2379/// `required` marker) + metadata-field shapes (name, required,
2380/// `enum`, `default`) + `no_self_loop_relationships` +
2381/// `required_outgoing` — the structural minimum to author a
2382/// legal write — with the type/section prose (descriptions,
2383/// write_rules, writing_guidance, system_context) dropped.
2384/// `no_self_loop_relationships` rides along because it governs
2385/// the self-loop relate refusal (relate R X→X when type T lists
2386/// R), one of the refusals the lite view must let an
2387/// agent avoid. `required_outgoing` rides along because it is
2388/// the only declared legality condition on outgoing edges —
2389/// dropping it would make "enough to plan a legal write" false.
2390/// Projected from the rich array so each field value has one
2391/// source; also the degrade target for an over-budget unscoped
2392/// full reply.
2393fn lite_types_projection(types_full: &[serde_json::Value]) -> Vec<serde_json::Value> {
2394    types_full
2395        .iter()
2396        .map(|t| {
2397            let sections: Vec<serde_json::Value> = t["sections"]
2398                .as_array()
2399                .map(|secs| {
2400                    secs.iter()
2401                        .map(|s| {
2402                            let mut o = serde_json::Map::new();
2403                            o.insert("key".into(), s["key"].clone());
2404                            o.insert("required".into(), s["required"].clone());
2405                            // The format declaration is a
2406                            // legality condition — the lite
2407                            // skeleton carries it in full.
2408                            for k in [
2409                                "content",
2410                                "item_pattern",
2411                                "table",
2412                                "example",
2413                                "format_severity",
2414                            ] {
2415                                if let Some(v) = s.get(k) {
2416                                    o.insert(k.into(), v.clone());
2417                                }
2418                            }
2419                            serde_json::Value::Object(o)
2420                        })
2421                        .collect()
2422                })
2423                .unwrap_or_default();
2424            let fields: Vec<serde_json::Value> = t["fields"]
2425                .as_array()
2426                .map(|fs| {
2427                    fs.iter()
2428                        .map(|f| {
2429                            let mut o = serde_json::Map::new();
2430                            o.insert("name".into(), f["name"].clone());
2431                            o.insert("required".into(), f["required"].clone());
2432                            if let Some(e) = f.get("enum") {
2433                                o.insert("enum".into(), e.clone());
2434                            }
2435                            if let Some(d) = f.get("default") {
2436                                o.insert("default".into(), d.clone());
2437                            }
2438                            serde_json::Value::Object(o)
2439                        })
2440                        .collect()
2441                })
2442                .unwrap_or_default();
2443            let mut o = serde_json::json!({
2444                "name": t["name"],
2445                "sections": sections,
2446                "fields": fields,
2447                "no_self_loop_relationships": t["no_self_loop_relationships"],
2448                "required_outgoing": t["required_outgoing"],
2449                "constraints": t["constraints"],
2450            });
2451            // Leaf declaration rides the lite skeleton too — it is
2452            // a legality-relevant per-type fact.
2453            if t.get("leaf") == Some(&serde_json::json!(true)) {
2454                o["leaf"] = serde_json::json!(true);
2455            }
2456            // Reachability obligations ride whole — a health condition
2457            // the skeleton must not hide; key present only when the
2458            // full payload carries it.
2459            if let Some(mr) = t.get("must_reach") {
2460                o["must_reach"] = mr.clone();
2461            }
2462            // Signal declarations ride whole for the same reason.
2463            if let Some(sig) = t.get("signals") {
2464                o["signals"] = sig.clone();
2465            }
2466            o
2467        })
2468        .collect()
2469}
2470
2471/// Format a metadata field definition as a single bullet line.
2472fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
2473    let type_str = match field.field_type {
2474        FieldType::String => "String",
2475        FieldType::Number => "Number",
2476        FieldType::Date => "Date",
2477        FieldType::Boolean => "Boolean",
2478    };
2479
2480    let mut flags: Vec<&str> = Vec::new();
2481    if !field.is_required() {
2482        flags.push("optional");
2483    } else {
2484        flags.push("required");
2485    }
2486    if field.init_timestamp {
2487        flags.push("auto-init");
2488    }
2489    if field.auto_timestamp {
2490        flags.push("auto-update");
2491    }
2492    match field.serialization {
2493        Serialization::CsvArray => flags.push("csv array"),
2494        Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
2495        Serialization::Default => {}
2496    }
2497
2498    let mut extras: Vec<String> = Vec::new();
2499    if let Some(values) = &field.enum_values {
2500        extras.push(format!("enum: {}", values.join(", ")));
2501    }
2502    if let Some(default) = &field.default_value {
2503        extras.push(format!("default: {default}"));
2504    }
2505    let filterable_str = match field.filterable {
2506        Filterable::None => None,
2507        Filterable::Equality => Some("filterable: equality"),
2508        Filterable::Range => Some("filterable: range"),
2509    };
2510    if let Some(f) = filterable_str {
2511        extras.push(f.to_string());
2512    }
2513
2514    let extras_str = if extras.is_empty() {
2515        String::new()
2516    } else {
2517        format!(" — {}", extras.join(" — "))
2518    };
2519
2520    format!(
2521        "**{key}**: {type_str} ({flags}){extras_str}",
2522        key = field.key,
2523        flags = flags.join(", "),
2524    )
2525}
2526
2527#[cfg(test)]
2528mod tests {
2529    use super::*;
2530    use crate::{Entity, EntityId, ListResult, SearchResult};
2531    use indexmap::IndexMap;
2532    use std::collections::HashMap;
2533
2534    fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
2535        SearchHit {
2536            id: EntityId(id.to_string()),
2537            last_modified: None,
2538            title: title.to_string(),
2539            mem: id.split("--").next().unwrap_or("").to_string(),
2540            entity_type: entity_type.to_string(),
2541            stub: false,
2542            score: 1.0,
2543            tokens: 10,
2544            snippet: None,
2545            sections: sections
2546                .iter()
2547                .map(|(k, v)| (k.to_string(), v.to_string()))
2548                .collect(),
2549            score_breakdown: None,
2550            matched_terms: None,
2551            expansion: None,
2552            // Test fixtures exercise the render-time fallback (default-schema
2553            // lookup); the engine-precomputed path is set in the search op.
2554            summary: None,
2555        }
2556    }
2557
2558    fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2559        let returned = hits.len();
2560        let total_tokens = hits.iter().map(|h| h.tokens).sum();
2561        SearchResult {
2562            total: returned,
2563            returned,
2564            offset: 0,
2565            total_tokens,
2566            hits,
2567            facets: None,
2568            warnings: vec![],
2569        }
2570    }
2571
2572    fn list_result(hits: Vec<SearchHit>) -> ListResult {
2573        let returned = hits.len();
2574        ListResult {
2575            total: returned,
2576            returned,
2577            offset: 0,
2578            total_tokens: hits.iter().map(|h| h.tokens).sum(),
2579            hits,
2580            warnings: vec![],
2581        }
2582    }
2583
2584    fn test_entity() -> Entity {
2585        Entity {
2586            id: EntityId("specs--test-entity".to_string()),
2587            title: "Test Entity".to_string(),
2588            entity_type: "spec".to_string(),
2589            mem: "specs".to_string(),
2590            file_path: "test-entity.md".to_string(),
2591            metadata: IndexMap::new(),
2592            sections: IndexMap::from([
2593                ("identity".to_string(), "A test entity for unit tests.".to_string()),
2594                ("purpose".to_string(), "Validates render logic.".to_string()),
2595                ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2596            ]),
2597            relationships: vec![],
2598            content_hash: "abc123".to_string(),
2599            stub: false,
2600            stub_kind: None,
2601            heading_spans: std::collections::HashMap::new(),
2602            raw_section_headings: Vec::new(),
2603        }
2604    }
2605
2606    #[test]
2607    fn markdown_frontmatter_filters_computed_and_reserved_metadata_keys() {
2608        // A stored `_hash` metadata key (frontmatter copied out of a read
2609        // response and written back) must not render as a second `_hash:`
2610        // line beside the computed one, and the reserved triple stays
2611        // structural — same predicate as the JSON envelope's metadata map.
2612        use crate::entity::MetadataValue;
2613        let mut entity = test_entity();
2614        entity.metadata.insert(
2615            "_hash".to_string(),
2616            MetadataValue::String("stale".to_string()),
2617        );
2618        entity.metadata.insert(
2619            "type".to_string(),
2620            MetadataValue::String("spec".to_string()),
2621        );
2622        entity
2623            .metadata
2624            .insert("level".to_string(), MetadataValue::String("M0".to_string()));
2625
2626        let md = render_entity_markdown(&entity, None);
2627        assert_eq!(
2628            md.matches("_hash:").count(),
2629            1,
2630            "one computed _hash line, no stored copy"
2631        );
2632        assert!(md.contains("_hash: abc123"), "the computed hash wins");
2633        assert!(
2634            !md.contains("stale"),
2635            "the stored _hash value never renders"
2636        );
2637        assert!(
2638            !md.contains("\ntype: "),
2639            "the reserved triple stays structural"
2640        );
2641        assert!(md.contains("level: M0"), "declared metadata still renders");
2642    }
2643
2644    #[test]
2645    fn section_key_to_heading_basic() {
2646        assert_eq!(section_key_to_heading("identity"), "Identity");
2647        assert_eq!(section_key_to_heading("current_state"), "Current state");
2648    }
2649
2650    #[test]
2651    fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2652        // The `ingest.inconsistency` schema declares `claim_a` with
2653        // heading "Claim A" — the simple key-derivation would produce
2654        // "Claim a", which would disagree with the on-disk markdown
2655        // emitted by the generator. The renderer must echo the
2656        // schema's declared heading verbatim.
2657        let mut sections: IndexMap<String, String> = IndexMap::new();
2658        sections.insert("claim_a".to_string(), "Body A.".to_string());
2659        sections.insert("claim_b".to_string(), "Body B.".to_string());
2660
2661        let entity = Entity {
2662            id: EntityId("ingest--example".to_string()),
2663            title: "Example".to_string(),
2664            entity_type: "inconsistency".to_string(),
2665            mem: "ingest".to_string(),
2666            file_path: "example.md".to_string(),
2667            metadata: IndexMap::new(),
2668            sections,
2669            relationships: vec![],
2670            content_hash: "h".to_string(),
2671            stub: false,
2672            stub_kind: None,
2673            heading_spans: std::collections::HashMap::new(),
2674            raw_section_headings: Vec::new(),
2675        };
2676
2677        let md = render_entity_markdown(&entity, None);
2678        assert!(
2679            md.contains("## Claim A"),
2680            "expected schema-declared `## Claim A` heading; got:\n{md}"
2681        );
2682        assert!(
2683            md.contains("## Claim B"),
2684            "expected schema-declared `## Claim B` heading; got:\n{md}"
2685        );
2686        // The naive derivation would have produced lower-case `a`/`b`.
2687        assert!(
2688            !md.contains("## Claim a"),
2689            "renderer must not fall back to key-derivation when the \
2690             schema declares a heading; got:\n{md}"
2691        );
2692    }
2693
2694    #[test]
2695    fn render_falls_back_to_key_derivation_for_unknown_types() {
2696        // When the entity_type is not in any built-in schema (custom
2697        // workspace schemas, legacy entities), the renderer falls back
2698        // to the simple key→heading derivation.
2699        let mut sections: IndexMap<String, String> = IndexMap::new();
2700        sections.insert("identity".to_string(), "body".to_string());
2701
2702        let entity = Entity {
2703            id: EntityId("custom--example".to_string()),
2704            title: "Example".to_string(),
2705            entity_type: "not-a-builtin-type".to_string(),
2706            mem: "custom".to_string(),
2707            file_path: "example.md".to_string(),
2708            metadata: IndexMap::new(),
2709            sections,
2710            relationships: vec![],
2711            content_hash: "h".to_string(),
2712            stub: false,
2713            stub_kind: None,
2714            heading_spans: std::collections::HashMap::new(),
2715            raw_section_headings: Vec::new(),
2716        };
2717
2718        let md = render_entity_markdown(&entity, None);
2719        assert!(
2720            md.contains("## Identity"),
2721            "fallback derivation must produce `## Identity`; got:\n{md}"
2722        );
2723    }
2724
2725    // Regression lock for deterministic section order. The invariant:
2726    // render_entity_body walks `entity.sections` in IndexMap insertion order,
2727    // so whatever order the parser/caller inserts is what ships. The parser
2728    // inserts in schema-declared order; this test deliberately inserts in
2729    // REVERSE schema order to prove the renderer honors insertion order
2730    // (not the schema's declared order directly).
2731    #[test]
2732    fn render_entity_sections_follow_indexmap_insertion_order() {
2733        let mut sections: IndexMap<String, String> = IndexMap::new();
2734        sections.insert("specifies".to_string(), "S content.".to_string());
2735        sections.insert("purpose".to_string(), "P content.".to_string());
2736        sections.insert("identity".to_string(), "I content.".to_string());
2737
2738        let entity = Entity {
2739            id: EntityId("specs--order-test".to_string()),
2740            title: "Order Test".to_string(),
2741            entity_type: "spec".to_string(),
2742            mem: "specs".to_string(),
2743            file_path: "order-test.md".to_string(),
2744            metadata: IndexMap::new(),
2745            sections,
2746            relationships: vec![],
2747            content_hash: "abc123".to_string(),
2748            stub: false,
2749            stub_kind: None,
2750            heading_spans: std::collections::HashMap::new(),
2751            raw_section_headings: Vec::new(),
2752        };
2753
2754        let md = render_entity_markdown(&entity, None);
2755        let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2756        let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2757        let identity_pos = md.find("## Identity").expect("## Identity must appear");
2758
2759        assert!(
2760            specifies_pos < purpose_pos,
2761            "Specifies (inserted first) must render before Purpose; got:\n{md}"
2762        );
2763        assert!(
2764            purpose_pos < identity_pos,
2765            "Purpose (inserted second) must render before Identity; got:\n{md}"
2766        );
2767    }
2768
2769    /// `_tokens_unfiltered_body` rides only when a section filter
2770    /// narrows the rendered output; it carries the unfiltered-base
2771    /// cost so agents can predict the cost of dropping the filter. The
2772    /// name avoids a monotonic-relationship implication
2773    /// that the opt-in path could invert.
2774    #[test]
2775    fn tokens_reflect_filtered_output() {
2776        let entity = test_entity();
2777
2778        // Full render — no filter
2779        let full = render_entity_markdown(&entity, None);
2780        assert!(full.contains("_tokens:"), "should have _tokens");
2781        assert!(
2782            !full.contains("_tokens_unfiltered_body:"),
2783            "should NOT have _tokens_unfiltered_body when unfiltered"
2784        );
2785        assert!(
2786            !full.contains("_tokens_full:"),
2787            "old _tokens_full name must not survive — rename is one-way"
2788        );
2789
2790        // Filtered render — request only "identity"
2791        let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2792        assert!(filtered.contains("_tokens:"), "should have _tokens");
2793        assert!(
2794            filtered.contains("_tokens_unfiltered_body:"),
2795            "should have _tokens_unfiltered_body when filtered"
2796        );
2797        assert!(
2798            !filtered.contains("_tokens_full:"),
2799            "old _tokens_full name must not survive — rename is one-way"
2800        );
2801
2802        // Extract token values
2803        let full_tokens: usize = full
2804            .lines()
2805            .find(|l| l.starts_with("_tokens:"))
2806            .unwrap()
2807            .trim_start_matches("_tokens: ")
2808            .parse()
2809            .unwrap();
2810        let filtered_tokens: usize = filtered
2811            .lines()
2812            .find(|l| l.starts_with("_tokens:"))
2813            .unwrap()
2814            .trim_start_matches("_tokens: ")
2815            .parse()
2816            .unwrap();
2817        let tokens_unfiltered_body: usize = filtered
2818            .lines()
2819            .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2820            .unwrap()
2821            .trim_start_matches("_tokens_unfiltered_body: ")
2822            .parse()
2823            .unwrap();
2824
2825        assert!(
2826            filtered_tokens < full_tokens,
2827            "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2828        );
2829        assert!(
2830            tokens_unfiltered_body >= full_tokens,
2831            "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2832        );
2833    }
2834
2835    // -----------------------------------------------------------------------
2836    // Summary line — search rendering
2837    // -----------------------------------------------------------------------
2838
2839    #[test]
2840    fn render_search_uses_first_required_section_for_spec() {
2841        let hit = make_hit(
2842            "specs--demo",
2843            "Demo Spec",
2844            "spec",
2845            &[
2846                ("identity", "A demo spec."),
2847                ("purpose", "Verifies rendering."),
2848            ],
2849        );
2850        let out = render_search_markdown(&search_result(vec![hit]), 0);
2851        assert!(
2852            out.contains("**Identity**: A demo spec."),
2853            "expected Identity line for spec hit, got:\n{out}"
2854        );
2855    }
2856
2857    #[test]
2858    fn render_search_uses_first_required_section_for_memo() {
2859        let hit = make_hit(
2860            "memos--d1",
2861            "Memo One",
2862            "memo",
2863            &[("claim", "Some claim."), ("context", "Some context.")],
2864        );
2865        let out = render_search_markdown(&search_result(vec![hit]), 0);
2866        assert!(
2867            out.contains("**Claim**: Some claim."),
2868            "expected Claim line for memo hit, got:\n{out}"
2869        );
2870        assert!(
2871            !out.contains("**Identity**"),
2872            "memo hit must not render Identity label"
2873        );
2874        assert!(
2875            !out.contains("**Purpose**"),
2876            "memo hit must not render Purpose label"
2877        );
2878    }
2879
2880    #[test]
2881    fn render_search_uses_first_required_section_for_concept() {
2882        let hit = make_hit(
2883            "concepts--thing",
2884            "Thing",
2885            "concept",
2886            &[("definition", "A thing."), ("explanation", "Details.")],
2887        );
2888        let out = render_search_markdown(&search_result(vec![hit]), 0);
2889        assert!(
2890            out.contains("**Definition**: A thing."),
2891            "expected Definition line for concept hit, got:\n{out}"
2892        );
2893    }
2894
2895    #[test]
2896    fn render_search_missing_summary_section_shows_dash() {
2897        // Memo hit with no "claim" section — renderer falls back to em-dash.
2898        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2899        let out = render_search_markdown(&search_result(vec![hit]), 0);
2900        assert!(
2901            out.contains("**Claim**: —"),
2902            "expected Claim dash fallback, got:\n{out}"
2903        );
2904    }
2905
2906    #[test]
2907    fn render_search_mixes_schemas_in_one_result() {
2908        let spec_hit = make_hit(
2909            "specs--s1",
2910            "Spec One",
2911            "spec",
2912            &[("identity", "Spec body.")],
2913        );
2914        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2915        let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2916        assert!(
2917            out.contains("**Identity**: Spec body."),
2918            "spec hit should still render Identity, got:\n{out}"
2919        );
2920        assert!(
2921            out.contains("**Claim**: Memo claim."),
2922            "memo hit should render Claim in the same output, got:\n{out}"
2923        );
2924    }
2925
2926    #[test]
2927    fn render_search_unknown_schema_shows_summary_dash() {
2928        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2929        let out = render_search_markdown(&search_result(vec![hit]), 0);
2930        assert!(
2931            out.contains("**Summary**: —"),
2932            "unknown schema should render Summary dash, got:\n{out}"
2933        );
2934    }
2935
2936    #[test]
2937    fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2938        use memstead_schema::{SectionDef, TypeDefinition};
2939
2940        let schema = TypeDefinition {
2941            name: "spec".to_string(),
2942            description: "test".to_string(),
2943            when_to_use: "test".to_string(),
2944            boundaries: vec![],
2945            exemplar: None,
2946            legacy_examples: None,
2947            system_message: None,
2948            sections: vec![SectionDef {
2949                key: "note".to_string(),
2950                heading: "Note".to_string(),
2951                required: false,
2952                load_bearing: None,
2953                search_weight: 1.0,
2954                catch_all: false,
2955                write_rules: vec![],
2956                description: None,
2957                content: None,
2958                item_pattern: None,
2959                table: None,
2960                example: None,
2961                format_severity: memstead_schema::ConstraintSeverity::Block,
2962                compiled_content: None,
2963                format_problems: Vec::new(),
2964            }],
2965            metadata_fields: vec![],
2966            title_weight: 1.0,
2967            text_fields: vec![],
2968            hierarchy_relationship: "PART_OF".to_string(),
2969            edge_weight_overrides: indexmap::IndexMap::new(),
2970            edge_weights: indexmap::IndexMap::new(),
2971            no_self_loop_relationships: vec![],
2972            legacy_propagating_relationships: None,
2973            due: None,
2974            leaf: false,
2975            updatable_fields: vec![],
2976            health_required_fields: vec![],
2977            staleness_threshold_days: 90,
2978            write_rules: vec![],
2979            required_outgoing: vec![],
2980            must_reach: vec![],
2981            signals: vec![],
2982            constraints: vec![],
2983            declared_metadata_keys: vec![],
2984        };
2985
2986        let mut sections = HashMap::new();
2987        sections.insert("note".to_string(), "a note".to_string());
2988        assert_eq!(
2989            summary_pair(Some(&schema), &sections),
2990            ("Note".to_string(), "a note".to_string()),
2991        );
2992
2993        assert_eq!(
2994            summary_pair(Some(&schema), &HashMap::new()),
2995            ("Note".to_string(), "—".to_string()),
2996        );
2997    }
2998
2999    // -----------------------------------------------------------------------
3000    // Summary line — list rendering (symmetric)
3001    // -----------------------------------------------------------------------
3002
3003    #[test]
3004    fn render_list_uses_first_required_section_for_spec() {
3005        let hit = make_hit(
3006            "specs--demo",
3007            "Demo Spec",
3008            "spec",
3009            &[
3010                ("identity", "A demo spec."),
3011                ("purpose", "Verifies rendering."),
3012            ],
3013        );
3014        let out = render_list_markdown(&list_result(vec![hit]));
3015        assert!(
3016            out.contains("**Identity**: A demo spec."),
3017            "expected Identity line for spec hit, got:\n{out}"
3018        );
3019    }
3020
3021    #[test]
3022    fn render_list_uses_first_required_section_for_memo() {
3023        let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
3024        let out = render_list_markdown(&list_result(vec![hit]));
3025        assert!(
3026            out.contains("**Claim**: Some claim."),
3027            "expected Claim line for memo hit, got:\n{out}"
3028        );
3029        assert!(
3030            !out.contains("**Identity**"),
3031            "memo hit must not render Identity label in list output"
3032        );
3033    }
3034
3035    #[test]
3036    fn render_list_uses_first_required_section_for_concept() {
3037        let hit = make_hit(
3038            "concepts--thing",
3039            "Thing",
3040            "concept",
3041            &[("definition", "A thing.")],
3042        );
3043        let out = render_list_markdown(&list_result(vec![hit]));
3044        assert!(
3045            out.contains("**Definition**: A thing."),
3046            "expected Definition line for concept hit, got:\n{out}"
3047        );
3048    }
3049
3050    #[test]
3051    fn render_list_missing_summary_section_shows_dash() {
3052        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
3053        let out = render_list_markdown(&list_result(vec![hit]));
3054        assert!(
3055            out.contains("**Claim**: —"),
3056            "expected Claim dash fallback in list output, got:\n{out}"
3057        );
3058    }
3059
3060    #[test]
3061    fn render_list_mixes_schemas_in_one_result() {
3062        let spec_hit = make_hit(
3063            "specs--s1",
3064            "Spec One",
3065            "spec",
3066            &[("identity", "Spec body.")],
3067        );
3068        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3069        let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
3070        assert!(
3071            out.contains("**Identity**: Spec body."),
3072            "spec hit should still render Identity in list output, got:\n{out}"
3073        );
3074        assert!(
3075            out.contains("**Claim**: Memo claim."),
3076            "memo hit should render Claim in list output, got:\n{out}"
3077        );
3078    }
3079
3080    #[test]
3081    fn render_list_unknown_schema_shows_summary_dash() {
3082        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
3083        let out = render_list_markdown(&list_result(vec![hit]));
3084        assert!(
3085            out.contains("**Summary**: —"),
3086            "unknown schema should render Summary dash in list output, got:\n{out}"
3087        );
3088    }
3089
3090    // -----------------------------------------------------------------------
3091    // summary_pair — structured-content source of truth
3092    // -----------------------------------------------------------------------
3093
3094    #[test]
3095    fn summary_pair_for_spec_returns_identity() {
3096        let schema = type_by_name("spec");
3097        let mut sections = HashMap::new();
3098        sections.insert("identity".to_string(), "A demo spec.".to_string());
3099        assert_eq!(
3100            summary_pair(schema.as_deref(), &sections),
3101            ("Identity".to_string(), "A demo spec.".to_string()),
3102        );
3103    }
3104
3105    #[test]
3106    fn summary_pair_for_memo_returns_claim() {
3107        let schema = type_by_name("memo");
3108        let mut sections = HashMap::new();
3109        sections.insert("claim".to_string(), "Memos matter.".to_string());
3110        assert_eq!(
3111            summary_pair(schema.as_deref(), &sections),
3112            ("Claim".to_string(), "Memos matter.".to_string()),
3113        );
3114    }
3115
3116    #[test]
3117    fn summary_pair_missing_section_returns_dash() {
3118        let schema = type_by_name("memo");
3119        assert_eq!(
3120            summary_pair(schema.as_deref(), &HashMap::new()),
3121            ("Claim".to_string(), "—".to_string()),
3122        );
3123    }
3124
3125    #[test]
3126    fn summary_pair_unknown_schema_returns_summary_dash() {
3127        assert_eq!(
3128            summary_pair(None, &HashMap::new()),
3129            ("Summary".to_string(), "—".to_string()),
3130        );
3131    }
3132
3133    // -----------------------------------------------------------------------
3134    // Envelope serialization — structured-content sidecar
3135    // -----------------------------------------------------------------------
3136
3137    #[test]
3138    fn envelope_serializes_summary_fields() {
3139        let hit = make_hit(
3140            "memos--d1",
3141            "Memo One",
3142            "memo",
3143            &[("claim", "Memos matter.")],
3144        );
3145        let result = search_result(vec![hit]);
3146        let envelope = build_search_envelope(&result, 0);
3147        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3148
3149        // The top-level counters use the `_-prefixed` engine-emitted
3150        // shape so the wire signals "engine-authored metadata, not
3151        // user data".
3152        assert_eq!(value["_total"], 1);
3153        assert_eq!(value["_returned"], 1);
3154        assert_eq!(value["_offset"], 0);
3155        // Warnings field is omitted when empty (skip_serializing_if).
3156        assert!(
3157            value.get("warnings").is_none(),
3158            "empty warnings must be elided, got: {value}"
3159        );
3160
3161        let hit0 = &value["hits"][0];
3162        assert_eq!(hit0["summary_heading"], "Claim");
3163        assert_eq!(hit0["summary_value"], "Memos matter.");
3164        // Flattened SearchHit fields present.
3165        assert_eq!(hit0["id"], "memos--d1");
3166        assert_eq!(hit0["title"], "Memo One");
3167        assert_eq!(hit0["entity_type"], "memo");
3168        assert_eq!(hit0["mem"], "memos");
3169        assert_eq!(hit0["stub"], false);
3170        assert_eq!(hit0["tokens"], 10);
3171        assert!(hit0["sections"].is_object());
3172    }
3173
3174    #[test]
3175    fn envelope_roundtrips_through_structured_content() {
3176        // Mixed-schema result: one spec hit, one memo hit. Both summary pairs
3177        // must match what summary_pair produces for each schema.
3178        let spec_hit = make_hit(
3179            "specs--s1",
3180            "Spec One",
3181            "spec",
3182            &[("identity", "Spec body.")],
3183        );
3184        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3185        let result = search_result(vec![spec_hit, memo_hit]);
3186        let envelope = build_search_envelope(&result, 0);
3187        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3188
3189        let hits = value["hits"].as_array().expect("hits must be array");
3190        assert_eq!(hits.len(), 2);
3191        assert_eq!(hits[0]["summary_heading"], "Identity");
3192        assert_eq!(hits[0]["summary_value"], "Spec body.");
3193        assert_eq!(hits[1]["summary_heading"], "Claim");
3194        assert_eq!(hits[1]["summary_value"], "Memo claim.");
3195    }
3196
3197    #[test]
3198    fn list_envelope_includes_total_tokens() {
3199        let hit = make_hit(
3200            "concepts--c1",
3201            "Thing",
3202            "concept",
3203            &[("definition", "A thing.")],
3204        );
3205        let result = list_result(vec![hit]);
3206        let envelope = build_list_envelope(&result);
3207        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3208
3209        // `_`-prefixed engine-meta keys, matching the search envelope.
3210        assert_eq!(value["_total"], 1);
3211        assert_eq!(value["_total_tokens"], 10);
3212        assert!(value.get("total").is_none(), "unprefixed keys retired");
3213        assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3214        assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3215    }
3216
3217    #[test]
3218    fn envelope_emits_warnings_when_present() {
3219        let mut result = search_result(vec![]);
3220        // Search warnings ship as typed `WarningHint` entries (same
3221        // `{code, details, message}` envelope every other tool uses).
3222        result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3223            field: "foo".to_string(),
3224        }];
3225        let envelope = build_search_envelope(&result, 0);
3226        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3227        assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3228        assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3229        assert!(
3230            value["warnings"][0]["message"]
3231                .as_str()
3232                .is_some_and(|m| m.contains("not filterable"))
3233        );
3234    }
3235
3236    // -----------------------------------------------------------------------
3237    // Per-hit and per-result fields that must appear in the Markdown body.
3238    // -----------------------------------------------------------------------
3239
3240    fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3241        TermMatch {
3242            field: field.to_string(),
3243            snippet: snippet.to_string(),
3244            heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3245        }
3246    }
3247
3248    fn sample_facets() -> Facets {
3249        use crate::ops::SubsectionFacet;
3250        Facets {
3251            by_type: HashMap::from([
3252                ("spec".to_string(), 7),
3253                ("memo".to_string(), 3),
3254                ("decision".to_string(), 2),
3255            ]),
3256            by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3257            by_level: HashMap::from([("high".to_string(), 4)]),
3258            by_status: HashMap::from([("active".to_string(), 6)]),
3259            by_confidence: HashMap::from([("medium".to_string(), 3)]),
3260            by_subsection: vec![
3261                SubsectionFacet {
3262                    path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3263                    count: 4,
3264                },
3265                SubsectionFacet {
3266                    path: vec!["purpose".to_string(), "Rationale".to_string()],
3267                    count: 2,
3268                },
3269            ],
3270            by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3271        }
3272    }
3273
3274    #[test]
3275    fn render_search_emits_matched_terms_line() {
3276        let mut hit = make_hit(
3277            "specs--e1",
3278            "Entity One",
3279            "spec",
3280            &[("identity", "Body text.")],
3281        );
3282        hit.matched_terms = Some(HashMap::from([
3283            (
3284                "entity".to_string(),
3285                vec![
3286                    tm("title", "...entity...", None),
3287                    tm("purpose", "...entity...", None),
3288                    tm("purpose", "...entity two...", None),
3289                ],
3290            ),
3291            ("one".to_string(), vec![tm("title", "...one...", None)]),
3292        ]));
3293        let out = render_search_markdown(&search_result(vec![hit]), 0);
3294        assert!(
3295            out.contains("**Matched terms:**"),
3296            "missing Matched terms line; got:\n{out}"
3297        );
3298        assert!(
3299            out.contains("`entity` (purpose×2, title×1)"),
3300            "entity term grouping wrong; got:\n{out}"
3301        );
3302        assert!(
3303            out.contains("`one` (title×1)"),
3304            "one term grouping wrong; got:\n{out}"
3305        );
3306    }
3307
3308    #[test]
3309    fn render_search_emits_score_breakdown_line() {
3310        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3311        hit.score_breakdown = Some(ScoreBreakdown {
3312            bm25: 2.5,
3313            title_boost: 2.0,
3314            field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3315            expansion_decay: Some(0.5),
3316        });
3317        let out = render_search_markdown(&search_result(vec![hit]), 0);
3318        assert!(
3319            out.contains(
3320                "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3321            ),
3322            "score breakdown line wrong; got:\n{out}"
3323        );
3324    }
3325
3326    #[test]
3327    fn render_search_omits_expansion_decay_when_none() {
3328        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3329        hit.score_breakdown = Some(ScoreBreakdown {
3330            bm25: 1.5,
3331            title_boost: 1.0,
3332            field_weights: HashMap::new(),
3333            expansion_decay: None,
3334        });
3335        let out = render_search_markdown(&search_result(vec![hit]), 0);
3336        assert!(
3337            out.contains("**Score:** bm25 1.5 + title 1.0"),
3338            "base score wrong; got:\n{out}"
3339        );
3340        assert!(
3341            !out.contains("expansion_decay"),
3342            "expansion_decay must be absent when None; got:\n{out}"
3343        );
3344    }
3345
3346    #[test]
3347    fn render_search_emits_heading_path_line() {
3348        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3349        hit.matched_terms = Some(HashMap::from([(
3350            "x".to_string(),
3351            vec![
3352                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3353                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), // duplicate, dedupe
3354                tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3355            ],
3356        )]));
3357        let out = render_search_markdown(&search_result(vec![hit]), 0);
3358        assert!(
3359            out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3360            "heading path line wrong; got:\n{out}"
3361        );
3362    }
3363
3364    #[test]
3365    fn render_search_emits_expansion_line() {
3366        let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3367        hit.expansion = Some(ExpansionInfo {
3368            of: EntityId("specs--seed".to_string()),
3369            via_edge: "refines".to_string(),
3370            via_direction: crate::graph::query::TraversalDirection::Out,
3371            depth: 1,
3372        });
3373        let out = render_search_markdown(&search_result(vec![hit]), 0);
3374        assert!(
3375            out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3376            "expansion line reports the traversal direction beside the label; got:\n{out}"
3377        );
3378    }
3379
3380    #[test]
3381    fn render_search_emits_facets_block() {
3382        let mut result = search_result(vec![]);
3383        result.facets = Some(sample_facets());
3384        let out = render_search_markdown(&result, 0);
3385        assert!(
3386            out.contains("## Facets"),
3387            "facets header missing; got:\n{out}"
3388        );
3389        assert!(
3390            out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3391            "by_type bucket wrong; got:\n{out}"
3392        );
3393        assert!(
3394            out.contains("- **by_mem:** specs=10, memos=2"),
3395            "by_mem bucket wrong; got:\n{out}"
3396        );
3397        assert!(
3398            out.contains("- **by_level:** high=4"),
3399            "by_level bucket wrong; got:\n{out}"
3400        );
3401        assert!(
3402            out.contains("- **by_status:** active=6"),
3403            "by_status bucket wrong; got:\n{out}"
3404        );
3405        assert!(
3406            out.contains("- **by_confidence:** medium=3"),
3407            "by_confidence bucket wrong; got:\n{out}"
3408        );
3409        assert!(
3410            out.contains("- **by_expansion:** primary=8, expanded=4"),
3411            "by_expansion bucket wrong; got:\n{out}"
3412        );
3413        assert!(
3414            out.contains("- **by_subsection:**"),
3415            "by_subsection header missing; got:\n{out}"
3416        );
3417        assert!(
3418            out.contains("`specifies › Response Shapes`: 4"),
3419            "subsection facet wrong; got:\n{out}"
3420        );
3421    }
3422
3423    #[test]
3424    fn render_search_omits_facets_block_when_all_empty() {
3425        let mut result = search_result(vec![]);
3426        result.facets = Some(Facets::default());
3427        let out = render_search_markdown(&result, 0);
3428        assert!(
3429            !out.contains("## Facets"),
3430            "empty facets must not emit header; got:\n{out}"
3431        );
3432    }
3433
3434    /// Every field the search-tool description promises must be rendered
3435    /// in Markdown. This test exercises all of them in one result and
3436    /// asserts they all appear.
3437    #[test]
3438    fn search_markdown_covers_every_sidecar_field() {
3439        let mut hit = make_hit(
3440            "specs--e1",
3441            "Entity One",
3442            "spec",
3443            &[("identity", "Body text.")],
3444        );
3445        hit.matched_terms = Some(HashMap::from([(
3446            "entity".to_string(),
3447            vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3448        )]));
3449        hit.score_breakdown = Some(ScoreBreakdown {
3450            bm25: 1.5,
3451            title_boost: 1.0,
3452            field_weights: HashMap::from([("body".to_string(), 0.4)]),
3453            expansion_decay: Some(0.5),
3454        });
3455        hit.expansion = Some(ExpansionInfo {
3456            of: EntityId("specs--seed".to_string()),
3457            via_edge: "refines".to_string(),
3458            via_direction: crate::graph::query::TraversalDirection::Out,
3459            depth: 2,
3460        });
3461
3462        let mut result = search_result(vec![hit]);
3463        result.facets = Some(sample_facets());
3464
3465        let out = render_search_markdown(&result, 0);
3466        for marker in [
3467            "## Facets",
3468            "- **by_type:**",
3469            "- **by_mem:**",
3470            "- **by_level:**",
3471            "- **by_status:**",
3472            "- **by_confidence:**",
3473            "- **by_expansion:**",
3474            "- **by_subsection:**",
3475            "**Matched terms:**",
3476            "**Score:**",
3477            "**Heading path:**",
3478            "**Expansion:**",
3479        ] {
3480            assert!(
3481                out.contains(marker),
3482                "lockstep marker `{marker}` missing from search markdown; \
3483                 update render_search_markdown when adding sidecar fields. got:\n{out}"
3484            );
3485        }
3486    }
3487
3488    /// The envelope's `relationships[].source` field reads the store's
3489    /// `EdgeSource` discriminator rather than a hardcoded `"explicit"`,
3490    /// which would disagree with the stub-adoption
3491    /// response for alias-synthesised edges (and would be
3492    /// misleading because REFERENCES carries `manual_authoring:
3493    /// forbidden`).
3494    #[test]
3495    fn build_entity_envelope_source_field_reads_edge_source() {
3496        let mut entity = test_entity();
3497        let body_link_target = EntityId("specs--body-link-target".to_string());
3498        let explicit_target = EntityId("specs--explicit-target".to_string());
3499        entity.relationships = vec![
3500            crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3501            crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3502        ];
3503
3504        let edges = vec![
3505            crate::store::Edge {
3506                rel_type: "REFERENCES".to_string(),
3507                target: body_link_target.clone(),
3508                source: crate::store::EdgeSource::BodyLink,
3509            },
3510            crate::store::Edge {
3511                rel_type: "USES".to_string(),
3512                target: explicit_target.clone(),
3513                source: crate::store::EdgeSource::Explicit,
3514            },
3515        ];
3516
3517        let env = build_entity_envelope(
3518            &entity,
3519            0,
3520            None,
3521            None,
3522            None,
3523            OriginClass::FirstParty,
3524            &edges,
3525            None,
3526            None,
3527            None,
3528        );
3529        let relationships = env["relationships"].as_array().expect("array");
3530        let refs = relationships
3531            .iter()
3532            .find(|r| r["rel_type"] == "REFERENCES")
3533            .expect("REFERENCES present");
3534        assert_eq!(
3535            refs["source"], "body_link",
3536            "alias-synthesised edge must label body_link"
3537        );
3538        let uses = relationships
3539            .iter()
3540            .find(|r| r["rel_type"] == "USES")
3541            .expect("USES present");
3542        assert_eq!(
3543            uses["source"], "explicit",
3544            "explicit-authored edge must label explicit"
3545        );
3546    }
3547
3548    /// The envelope's read contract is structural (cold-start 0-8-0,
3549    /// F9/F13/F15): `origin` is present on every envelope, every
3550    /// relationship entry declares its `direction`, and incoming edges
3551    /// — when the caller passes them — appear as `direction: "in"`
3552    /// entries carrying the other endpoint under `from`. A consumer
3553    /// can therefore always tell whether the block is one-directional.
3554    #[test]
3555    fn build_entity_envelope_carries_origin_direction_and_incoming() {
3556        let mut entity = test_entity();
3557        let out_target = EntityId("specs--downstream".to_string());
3558        entity.relationships = vec![crate::entity::Relationship::new(
3559            "USES".to_string(),
3560            out_target.clone(),
3561        )];
3562        let edges = vec![crate::store::Edge {
3563            rel_type: "USES".to_string(),
3564            target: out_target,
3565            source: crate::store::EdgeSource::Explicit,
3566        }];
3567        let incoming = vec![crate::store::InEdge {
3568            rel_type: "MANAGES".to_string(),
3569            from: EntityId("specs--upstream".to_string()),
3570            source: crate::store::EdgeSource::Explicit,
3571        }];
3572
3573        // Without incoming: outgoing entries are direction-labelled.
3574        let env = build_entity_envelope(
3575            &entity,
3576            0,
3577            None,
3578            None,
3579            None,
3580            OriginClass::ThirdParty,
3581            &edges,
3582            None,
3583            None,
3584            None,
3585        );
3586        assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3587        let rels = env["relationships"].as_array().expect("array");
3588        assert_eq!(rels.len(), 1);
3589        assert_eq!(rels[0]["direction"], "out");
3590
3591        // With incoming: the other half of the neighbourhood appears,
3592        // direction-labelled, endpoint under `from`.
3593        let env = build_entity_envelope(
3594            &entity,
3595            0,
3596            None,
3597            None,
3598            None,
3599            OriginClass::FirstParty,
3600            &edges,
3601            Some(&incoming),
3602            None,
3603            None,
3604        );
3605        assert_eq!(env["origin"], "first-party");
3606        let rels = env["relationships"].as_array().expect("array");
3607        assert_eq!(rels.len(), 2);
3608        let inc = rels
3609            .iter()
3610            .find(|r| r["direction"] == "in")
3611            .expect("incoming entry present");
3612        assert_eq!(inc["rel_type"], "MANAGES");
3613        assert_eq!(inc["from"], "specs--upstream");
3614        assert!(
3615            inc.get("target").is_none(),
3616            "incoming carries from, not target"
3617        );
3618    }
3619
3620    /// A relationship whose store edge is missing
3621    /// (transitional drift, store-rebuild lag) falls back to
3622    /// `"explicit"` so the envelope doesn't crash. The fallback is
3623    /// the conservative label — agents already branch on it.
3624    #[test]
3625    fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3626        let mut entity = test_entity();
3627        let target = EntityId("specs--unmapped".to_string());
3628        entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3629        let edges: Vec<crate::store::Edge> = Vec::new();
3630        let env = build_entity_envelope(
3631            &entity,
3632            0,
3633            None,
3634            None,
3635            None,
3636            OriginClass::FirstParty,
3637            &edges,
3638            None,
3639            None,
3640            None,
3641        );
3642        let relationships = env["relationships"].as_array().expect("array");
3643        assert_eq!(relationships[0]["source"], "explicit");
3644    }
3645
3646    /// Every schema-declared frontmatter key surfaces under the nested
3647    /// `metadata` map — its single home. The four
3648    /// formerly-hoisted scalars are not at the top level; the
3649    /// read-only identity triple (mem/id/type) and underscore-prefixed
3650    /// internal keys are excluded from the nested map.
3651    #[test]
3652    fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3653        use crate::entity::MetadataValue;
3654        let mut entity = test_entity();
3655        entity.entity_type = "contract".to_string();
3656        // Pre-fix the envelope dropped every non-promoted key.
3657        entity.metadata = IndexMap::from([
3658            ("level".to_string(), MetadataValue::String("M0".to_string())),
3659            (
3660                "stability".to_string(),
3661                MetadataValue::String("stable".to_string()),
3662            ),
3663            (
3664                "created_date".to_string(),
3665                MetadataValue::String("2026-01-01".to_string()),
3666            ),
3667            (
3668                "last_modified".to_string(),
3669                MetadataValue::String("2026-05-19".to_string()),
3670            ),
3671            (
3672                "protocol".to_string(),
3673                MetadataValue::String("https".to_string()),
3674            ),
3675            (
3676                "version".to_string(),
3677                MetadataValue::String("0.1.0".to_string()),
3678            ),
3679            (
3680                "deprecation_status".to_string(),
3681                MetadataValue::String("none".to_string()),
3682            ),
3683        ]);
3684
3685        let env = build_entity_envelope(
3686            &entity,
3687            0,
3688            None,
3689            None,
3690            None,
3691            OriginClass::FirstParty,
3692            &[],
3693            None,
3694            None,
3695            None,
3696        );
3697
3698        // Metadata scalars are NOT hoisted to the top level — the
3699        // nested map is their single home.
3700        assert!(
3701            env.get("level").is_none(),
3702            "level must not be hoisted top-level"
3703        );
3704        assert!(
3705            env.get("stability").is_none(),
3706            "stability must not be hoisted"
3707        );
3708        assert!(
3709            env.get("created_date").is_none(),
3710            "created_date must not be hoisted"
3711        );
3712        assert!(
3713            env.get("last_modified").is_none(),
3714            "last_modified must not be hoisted"
3715        );
3716        // The entity's type stays top-level as identity, spelled
3717        // `entity_type` on the wire (2026-08-28 batch); the retired `type`
3718        // key is gone, not aliased.
3719        assert_eq!(env["entity_type"], "contract");
3720        assert!(
3721            env.get("type").is_none(),
3722            "the retired wire key must not survive"
3723        );
3724
3725        // Nested map carries every non-internal, non-identity frontmatter key.
3726        let metadata = env["metadata"].as_object().expect("metadata map");
3727        assert_eq!(metadata["level"], "M0");
3728        assert_eq!(metadata["stability"], "stable");
3729        assert_eq!(metadata["created_date"], "2026-01-01");
3730        assert_eq!(metadata["last_modified"], "2026-05-19");
3731        assert_eq!(metadata["protocol"], "https");
3732        assert_eq!(metadata["version"], "0.1.0");
3733        assert_eq!(metadata["deprecation_status"], "none");
3734
3735        // Internal underscore-prefixed keys and the read-only identity
3736        // triple (mem/id/type) do NOT appear inside the nested map.
3737        for k in metadata.keys() {
3738            assert!(
3739                !k.starts_with('_'),
3740                "metadata map must not carry underscore-prefixed key `{k}`"
3741            );
3742            assert!(
3743                !["mem", "id", "type"].contains(&k.as_str()),
3744                "metadata map must not carry identity key `{k}` (it lives top-level)"
3745            );
3746        }
3747    }
3748
3749    /// Stub envelopes carry an
3750    /// empty `metadata: {}` map so consumers don't branch on the
3751    /// map's presence.
3752    #[test]
3753    fn build_entity_envelope_stub_carries_empty_metadata_map() {
3754        let mut entity = test_entity();
3755        entity.stub = true;
3756        entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3757        entity.metadata = IndexMap::new();
3758        let env = build_entity_envelope(
3759            &entity,
3760            0,
3761            None,
3762            None,
3763            None,
3764            OriginClass::FirstParty,
3765            &[],
3766            None,
3767            None,
3768            None,
3769        );
3770        let metadata = env["metadata"]
3771            .as_object()
3772            .expect("metadata key present even on stubs");
3773        assert!(metadata.is_empty(), "stub metadata map must be empty");
3774    }
3775
3776    /// A user-defined schema names a
3777    /// metadata field colliding with structured envelope slots
3778    /// (`sections`, `relationships`). The colliding name surfaces
3779    /// under `metadata.sections` / `metadata.relationships` without
3780    /// disturbing the top-level structured arrays — the nested map
3781    /// decouples user namespace from engine namespace.
3782    #[test]
3783    fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3784        use crate::entity::MetadataValue;
3785        let mut entity = test_entity();
3786        entity.metadata = IndexMap::from([
3787            (
3788                "sections".to_string(),
3789                MetadataValue::String("user-supplied-shadow".to_string()),
3790            ),
3791            (
3792                "relationships".to_string(),
3793                MetadataValue::String("also-shadowed".to_string()),
3794            ),
3795        ]);
3796        let env = build_entity_envelope(
3797            &entity,
3798            0,
3799            None,
3800            None,
3801            None,
3802            OriginClass::FirstParty,
3803            &[],
3804            None,
3805            None,
3806            None,
3807        );
3808        // Top-level structured slots stay structured.
3809        assert!(
3810            env["sections"].is_object(),
3811            "top-level sections stays a map"
3812        );
3813        assert!(
3814            env["relationships"].is_array(),
3815            "top-level relationships stays an array"
3816        );
3817        // User-supplied collisions land inside the nested map.
3818        let metadata = env["metadata"].as_object().expect("metadata map");
3819        assert_eq!(metadata["sections"], "user-supplied-shadow");
3820        assert_eq!(metadata["relationships"], "also-shadowed");
3821    }
3822
3823    /// `_tokens_unfiltered_body` on the structured envelope rides only
3824    /// when `full_tokens` is supplied (a section filter was active);
3825    /// the legacy `_tokens_full` name is not present as an alias.
3826    #[test]
3827    fn build_entity_envelope_unfiltered_body_token_field_name() {
3828        let entity = test_entity();
3829        // Filter-active path — field present under new name.
3830        let env_filtered = build_entity_envelope(
3831            &entity,
3832            10,
3833            Some(42),
3834            None,
3835            None,
3836            OriginClass::FirstParty,
3837            &[],
3838            None,
3839            None,
3840            None,
3841        );
3842        assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3843        assert!(
3844            env_filtered.get("_tokens_full").is_none(),
3845            "_tokens_full must not survive — rename is one-way"
3846        );
3847        // No-filter path — field absent under both names.
3848        let env_unfiltered = build_entity_envelope(
3849            &entity,
3850            10,
3851            None,
3852            None,
3853            None,
3854            OriginClass::FirstParty,
3855            &[],
3856            None,
3857            None,
3858            None,
3859        );
3860        assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3861        assert!(env_unfiltered.get("_tokens_full").is_none());
3862    }
3863
3864    // ------------------------------------------------------------------
3865    // Schema verbosity (lite vs. full) — Plan 01.
3866    // ------------------------------------------------------------------
3867
3868    /// Load the embedded `software` schema (~42 rel-types, 9 entity
3869    /// types, `alias_target_rel_type: REFERENCES`) — the heaviest builtin,
3870    /// so the lite cut has something to bite into.
3871    fn software_schema() -> Arc<Schema> {
3872        memstead_schema::builtins::load_builtin_schemas()
3873            .expect("builtins load")
3874            .into_iter()
3875            .find(|s| s.manifest.name == "software")
3876            .expect("software schema is a builtin")
3877    }
3878
3879    #[test]
3880    fn schema_verbosity_wire_round_trips() {
3881        assert_eq!(
3882            SchemaVerbosity::from_wire("full"),
3883            Some(SchemaVerbosity::Full)
3884        );
3885        assert_eq!(
3886            SchemaVerbosity::from_wire("lite"),
3887            Some(SchemaVerbosity::Lite)
3888        );
3889        assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3890        assert_eq!(SchemaVerbosity::from_wire(""), None);
3891        assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3892        assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3893        assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3894    }
3895
3896    /// Exemplar serving (agent-trust plan 09): `verbosity: full`
3897    /// carries each type's exemplar (title, metadata, sections,
3898    /// relations with placeholder targets); the lite skeleton is
3899    /// BYTE-unchanged between the same schema with and without an
3900    /// exemplar — the per-session lite fetch never grows.
3901    #[test]
3902    fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3903        let manifest = r#"name: servefix
3904version: 1.0.0
3905description: serving fixture
3906when_to_use: tests
3907types:
3908  - sample
3909relationships:
3910  mode: strict
3911  definitions:
3912    - name: PART_OF
3913      description: hier
3914      default_weight: 3.0
3915    - name: _default
3916      description: fallback
3917      default_weight: 1.0
3918community:
3919  resolution: 1.0
3920  seed: 42
3921"#;
3922        let base_type = r#"name: sample
3923description: t
3924when_to_use: tests
3925sections:
3926  - key: body
3927    heading: Body
3928    required: true
3929    search_weight: 10.0
3930    catch_all: true
3931    write_rules: []
3932metadata_fields:
3933  - key: status
3934    description: state
3935    field_type: string
3936    enum_values: [draft, final]
3937    optional: true
3938title_weight: 100.0
3939text_fields:
3940  - body
3941hierarchy_relationship: PART_OF
3942no_self_loop_relationships: []
3943updatable_fields:
3944  - title
3945  - body
3946health_required_fields:
3947  - body
3948staleness_threshold_days: 90
3949write_rules: []
3950"#;
3951        let with_exemplar = format!(
3952            "{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"
3953        );
3954
3955        let plain = Arc::new(
3956            memstead_schema::loader::load_schema_from_memory(
3957                manifest,
3958                &[("sample".to_string(), base_type.to_string())],
3959            )
3960            .expect("fixture loads"),
3961        );
3962        let exemplary = Arc::new(
3963            memstead_schema::loader::load_schema_from_memory(
3964                manifest,
3965                &[("sample".to_string(), with_exemplar)],
3966            )
3967            .expect("fixture loads"),
3968        );
3969
3970        // FULL serves the exemplar with the type.
3971        let full = build_schema_payload(
3972            &exemplary,
3973            vec![],
3974            SchemaVerbosity::Full,
3975            OriginClass::FirstParty,
3976        );
3977        let ex = &full["types"][0]["exemplar"];
3978        assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3979        assert_eq!(ex["metadata"]["status"], "draft");
3980        assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3981        assert_eq!(ex["relations"][0]["target"], "parent-placeholder");
3982        assert_eq!(ex["relations"][0]["rel_type"], "PART_OF");
3983
3984        // FULL without an exemplar: no key (absent, not null).
3985        let full_plain = build_schema_payload(
3986            &plain,
3987            vec![],
3988            SchemaVerbosity::Full,
3989            OriginClass::FirstParty,
3990        );
3991        assert!(full_plain["types"][0].get("exemplar").is_none());
3992
3993        // LITE is byte-identical with and without the exemplar — the
3994        // skeleton every session fetches does not grow.
3995        let lite_with = build_schema_payload(
3996            &exemplary,
3997            vec![],
3998            SchemaVerbosity::Lite,
3999            OriginClass::FirstParty,
4000        );
4001        let lite_without = build_schema_payload(
4002            &plain,
4003            vec![],
4004            SchemaVerbosity::Lite,
4005            OriginClass::FirstParty,
4006        );
4007        assert_eq!(
4008            serde_json::to_string(&lite_with).unwrap(),
4009            serde_json::to_string(&lite_without).unwrap(),
4010            "lite must not change when an exemplar exists"
4011        );
4012        assert!(
4013            !serde_json::to_string(&lite_with)
4014                .unwrap()
4015                .contains("exemplar"),
4016            "lite must not mention exemplars at all"
4017        );
4018    }
4019
4020    /// A first-party schema labels its origin and serves its full prose
4021    /// under `full`. The origin field is additive and present in both
4022    /// verbosities so a consuming host can always read it.
4023    #[test]
4024    fn first_party_origin_is_labelled_and_keeps_prose() {
4025        let schema = software_schema();
4026        let full = build_schema_payload(
4027            &schema,
4028            vec!["v".into()],
4029            SchemaVerbosity::Full,
4030            OriginClass::FirstParty,
4031        );
4032        assert_eq!(full["origin"], "first-party");
4033        // First-party full keeps the prose-instruction fields.
4034        assert!(full["description"].is_string());
4035        let t = &full["types"].as_array().unwrap()[0];
4036        assert!(t.get("system_context").is_some());
4037        assert!(t.get("writing_guidance").is_some());
4038
4039        // The origin label rides the lite skeleton too.
4040        let lite = build_schema_payload(
4041            &schema,
4042            vec!["v".into()],
4043            SchemaVerbosity::Lite,
4044            OriginClass::FirstParty,
4045        );
4046        assert_eq!(lite["origin"], "first-party");
4047    }
4048
4049    /// Declared constraints and `required_outgoing` severities are
4050    /// visible at BOTH verbosity levels — no legality condition may
4051    /// exist that the schema response omits. Complement: a type
4052    /// declaring none renders `constraints: []`, never an absent key.
4053    #[test]
4054    fn constraints_and_severity_render_at_both_verbosities() {
4055        let manifest = r#"name: constrained
4056version: 1.0.0
4057description: constraint render fixture
4058when_to_use: render tests
4059types:
4060  - sample
4061relationships:
4062  mode: strict
4063  definitions:
4064    - name: PART_OF
4065      description: hier
4066      default_weight: 3.0
4067    - name: _default
4068      description: fallback
4069      default_weight: 1.0
4070community:
4071  resolution: 1.0
4072  seed: 42
4073"#;
4074        let type_yaml = r#"name: sample
4075description: t
4076when_to_use: tests
4077sections:
4078  - key: body
4079    heading: Body
4080    required: true
4081    search_weight: 10.0
4082    catch_all: true
4083    write_rules: []
4084metadata_fields:
4085  - key: status
4086    description: state
4087    field_type: string
4088    enum_values: [open, checked]
4089    optional: true
4090  - key: checked_by
4091    description: who
4092    field_type: string
4093    optional: true
4094title_weight: 100.0
4095text_fields:
4096  - body
4097hierarchy_relationship: PART_OF
4098no_self_loop_relationships: []
4099updatable_fields:
4100  - title
4101  - body
4102health_required_fields:
4103  - body
4104staleness_threshold_days: 90
4105required_outgoing:
4106  - relationships: [PART_OF]
4107    cardinality: at_least_one
4108    severity: block
4109constraints:
4110  - kind: requires_when
4111    field: checked_by
4112    when_field: status
4113    when_value: checked
4114  - kind: unique
4115    fields: [status, checked_by]
4116  - kind: enum_from_neighbour
4117    field: status
4118    rel_type: PART_OF
4119    section: body
4120  - kind: status_propagation
4121    field: status
4122    value: checked
4123    rel_type: PART_OF
4124    direction: incoming
4125write_rules: []
4126"#;
4127        let schema = Arc::new(
4128            memstead_schema::loader::load_schema_from_memory(
4129                manifest,
4130                &[("sample".to_string(), type_yaml.to_string())],
4131            )
4132            .expect("fixture loads"),
4133        );
4134
4135        // All five constraint forms (requires_when, unique,
4136        // enum_from_neighbour, status_propagation here; form 4 is the
4137        // required_outgoing severity) must be visible with their
4138        // severity at both verbosity levels.
4139        let expected_constraints = serde_json::json!([
4140            {
4141                "kind": "requires_when",
4142                "field": "checked_by",
4143                "when_field": "status",
4144                "when_value": "checked",
4145                "severity": "warn",
4146            },
4147            {
4148                "kind": "unique",
4149                "fields": ["status", "checked_by"],
4150                "severity": "block",
4151            },
4152            {
4153                "kind": "enum_from_neighbour",
4154                "field": "status",
4155                "rel_type": "PART_OF",
4156                "section": "body",
4157                "severity": "warn",
4158            },
4159            {
4160                "kind": "status_propagation",
4161                "field": "status",
4162                "value": "checked",
4163                "rel_type": "PART_OF",
4164                "direction": "incoming",
4165                "severity": "warn",
4166            },
4167        ]);
4168
4169        let full = build_schema_payload(
4170            &schema,
4171            vec![],
4172            SchemaVerbosity::Full,
4173            OriginClass::FirstParty,
4174        );
4175        let t = &full["types"].as_array().unwrap()[0];
4176        assert_eq!(t["constraints"], expected_constraints);
4177        assert_eq!(t["required_outgoing"][0]["severity"], "block");
4178
4179        let lite = build_schema_payload(
4180            &schema,
4181            vec![],
4182            SchemaVerbosity::Lite,
4183            OriginClass::FirstParty,
4184        );
4185        let ts = &lite["types_summary"].as_array().unwrap()[0];
4186        assert_eq!(ts["constraints"], expected_constraints);
4187        assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4188
4189        // Section-format declarations render at BOTH verbosity
4190        // levels (plan 08 shares plan 07's no-hidden-legality rule).
4191        let fmt_manifest = r#"name: formatted
4192version: 1.0.0
4193description: format render fixture
4194when_to_use: render tests
4195types:
4196  - plan
4197relationships:
4198  mode: strict
4199  definitions:
4200    - name: PART_OF
4201      description: hier
4202      default_weight: 1.0
4203    - name: _default
4204      description: fallback
4205      default_weight: 1.0
4206community:
4207  resolution: 1.0
4208  seed: 42
4209"#;
4210        let fmt_type = r#"name: plan
4211description: t
4212when_to_use: tests
4213sections:
4214  - key: body
4215    heading: Body
4216    required: true
4217    search_weight: 10.0
4218    catch_all: true
4219    write_rules: []
4220  - key: meilensteine
4221    heading: Meilensteine
4222    required: false
4223    search_weight: 5.0
4224    catch_all: false
4225    write_rules: []
4226    content: "(heading(3) list(bullet))+"
4227    item_pattern: '\*\*(?<name>[^*]+)\*\*'
4228    example: |
4229      ### Phase 1
4230      - **Kickoff**
4231    format_severity: warn
4232  - key: tabelle
4233    heading: Tabelle
4234    required: false
4235    search_weight: 5.0
4236    catch_all: false
4237    write_rules: []
4238    content: "table"
4239    table:
4240      columns: [Name, Datum]
4241      column_patterns:
4242        Datum: '\d{4}-\d{2}-\d{2}'
4243  - key: belege
4244    heading: Belege
4245    required: false
4246    search_weight: 5.0
4247    catch_all: false
4248    write_rules: []
4249    content: "paragraph+"
4250    item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4251metadata_fields: []
4252title_weight: 100.0
4253text_fields:
4254  - body
4255hierarchy_relationship: PART_OF
4256no_self_loop_relationships: []
4257updatable_fields:
4258  - title
4259  - body
4260health_required_fields:
4261  - body
4262staleness_threshold_days: 90
4263write_rules: []
4264"#;
4265        let fmt_schema = Arc::new(
4266            memstead_schema::loader::load_schema_from_memory(
4267                fmt_manifest,
4268                &[("plan".to_string(), fmt_type.to_string())],
4269            )
4270            .expect("format fixture loads"),
4271        );
4272        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4273            let payload =
4274                build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4275            let sections_key = match verbosity {
4276                SchemaVerbosity::Full => &payload["types"][0]["sections"],
4277                SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4278            };
4279            let secs = sections_key.as_array().unwrap();
4280            let meilensteine = secs
4281                .iter()
4282                .find(|s| s["key"] == "meilensteine")
4283                .expect("declared section present");
4284            assert_eq!(
4285                meilensteine["content"], "(heading(3) list(bullet))+",
4286                "{verbosity:?} carries content"
4287            );
4288            assert!(
4289                meilensteine["item_pattern"]
4290                    .as_str()
4291                    .unwrap()
4292                    .contains("name")
4293            );
4294            assert!(
4295                meilensteine["example"]
4296                    .as_str()
4297                    .unwrap()
4298                    .contains("Kickoff")
4299            );
4300            assert_eq!(meilensteine["format_severity"], "warn");
4301            let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4302            assert_eq!(tabelle["format_severity"], "block", "default renders");
4303            assert_eq!(tabelle["table"]["columns"][0], "Name");
4304            assert!(
4305                tabelle["table"]["column_patterns"]["Datum"]
4306                    .as_str()
4307                    .is_some()
4308            );
4309            let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4310            assert_eq!(belege["content"], "paragraph+");
4311            assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4312            let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4313            assert!(
4314                body.get("content").is_none() && body.get("format_severity").is_none(),
4315                "undeclared section keeps its pre-plan shape"
4316            );
4317        }
4318
4319        // Complement: a constraint-free builtin renders the
4320        // always-present empty list at both levels.
4321        let plain_full = build_schema_payload(
4322            &software_schema(),
4323            vec![],
4324            SchemaVerbosity::Full,
4325            OriginClass::FirstParty,
4326        );
4327        let pt = &plain_full["types"].as_array().unwrap()[0];
4328        assert_eq!(pt["constraints"], serde_json::json!([]));
4329        let plain_lite = build_schema_payload(
4330            &software_schema(),
4331            vec![],
4332            SchemaVerbosity::Lite,
4333            OriginClass::FirstParty,
4334        );
4335        let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4336        assert_eq!(pts["constraints"], serde_json::json!([]));
4337    }
4338
4339    /// A third-party schema is de-framed: a `full`-verbosity request is
4340    /// overridden to the structural-only skeleton, so NONE of the
4341    /// prose-instruction fields (`system_context`, `writing_guidance`,
4342    /// section `write_rules`, schema `description` / `when_to_use`,
4343    /// `default_writing_guidance`, rel `description` / `when_to_use`)
4344    /// reach a consuming agent — even though `full` was asked for. The
4345    /// structural skeleton (type/section/field/rel shape) survives so the
4346    /// mem stays understandable and queryable. This is the refusal
4347    /// complement: a `full` request cannot re-admit the prose.
4348    #[test]
4349    fn third_party_origin_forces_structural_only_even_under_full() {
4350        let schema = software_schema();
4351        let full_requested = build_schema_payload(
4352            &schema,
4353            vec!["v".into()],
4354            SchemaVerbosity::Full,
4355            OriginClass::ThirdParty,
4356        );
4357
4358        // Origin label.
4359        assert_eq!(full_requested["origin"], "third-party");
4360
4361        // Prose-bearing rich arrays are GONE despite the full request;
4362        // the structural-only summaries are present instead.
4363        assert!(
4364            full_requested.get("types").is_none(),
4365            "third-party omits the rich `types` array even under full"
4366        );
4367        assert!(
4368            full_requested.get("relationships").is_none(),
4369            "third-party omits the rich `relationships` array even under full"
4370        );
4371        assert!(
4372            full_requested["types_summary"].is_array(),
4373            "third-party serves the structural `types_summary` skeleton"
4374        );
4375        assert!(
4376            full_requested["relationships_summary"].is_array(),
4377            "third-party serves the structural `relationships_summary` skeleton"
4378        );
4379
4380        // Schema-level prose-instruction fields dropped.
4381        assert!(
4382            full_requested.get("description").is_none(),
4383            "third-party drops schema description prose"
4384        );
4385        assert!(
4386            full_requested.get("when_to_use").is_none(),
4387            "third-party drops schema when_to_use prose"
4388        );
4389        assert!(
4390            full_requested.get("default_writing_guidance").is_none(),
4391            "third-party drops default_writing_guidance prose"
4392        );
4393
4394        // Per-type prose-instruction fields dropped.
4395        for t in full_requested["types_summary"].as_array().unwrap() {
4396            assert!(
4397                t.get("system_context").is_none(),
4398                "third-party drops system_context"
4399            );
4400            assert!(
4401                t.get("writing_guidance").is_none(),
4402                "third-party drops writing_guidance"
4403            );
4404            assert!(
4405                t.get("description").is_none(),
4406                "third-party drops type description"
4407            );
4408            for s in t["sections"].as_array().unwrap() {
4409                assert!(
4410                    s.get("write_rules").is_none(),
4411                    "third-party drops section write_rules"
4412                );
4413            }
4414        }
4415        // Per-rel prose dropped.
4416        for r in full_requested["relationships_summary"].as_array().unwrap() {
4417            assert!(
4418                r.get("description").is_none(),
4419                "third-party drops rel description"
4420            );
4421            assert!(
4422                r.get("when_to_use").is_none(),
4423                "third-party drops rel when_to_use"
4424            );
4425        }
4426
4427        // A third-party schema served under `full` is byte-identical to
4428        // the same schema served under `lite` (modulo the origin label,
4429        // which is identical here) — the override fully collapses to Lite.
4430        let lite_requested = build_schema_payload(
4431            &schema,
4432            vec!["v".into()],
4433            SchemaVerbosity::Lite,
4434            OriginClass::ThirdParty,
4435        );
4436        assert_eq!(
4437            full_requested, lite_requested,
4438            "third-party full must collapse to the lite skeleton"
4439        );
4440    }
4441
4442    #[test]
4443    fn full_payload_carries_the_rich_arrays_and_prose() {
4444        let schema = software_schema();
4445        let full = build_schema_payload(
4446            &schema,
4447            vec!["v".into()],
4448            SchemaVerbosity::Full,
4449            OriginClass::FirstParty,
4450        );
4451
4452        // Full keeps today's contract: rich arrays + schema-level prose.
4453        assert!(full["types"].is_array(), "full has `types`");
4454        assert!(full["relationships"].is_array(), "full has `relationships`");
4455        assert!(
4456            full.get("types_summary").is_none(),
4457            "full omits `types_summary`"
4458        );
4459        assert!(
4460            full.get("relationships_summary").is_none(),
4461            "full omits `relationships_summary`"
4462        );
4463        assert!(
4464            full["description"].is_string(),
4465            "full keeps schema description"
4466        );
4467        assert!(
4468            full["when_to_use"].is_string(),
4469            "full keeps schema when_to_use"
4470        );
4471        assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4472
4473        // A full type entry keeps the prose the lite cut drops.
4474        let t = &full["types"].as_array().unwrap()[0];
4475        assert!(t["description"].is_string());
4476        assert!(t.get("writing_guidance").is_some());
4477        assert!(t.get("system_context").is_some());
4478        // A full rel entry keeps its prose.
4479        let r = &full["relationships"].as_array().unwrap()[0];
4480        assert!(r["description"].is_string());
4481        assert!(r.get("when_to_use").is_some());
4482        assert!(r.get("default_weight").is_some());
4483    }
4484
4485    /// The declared `required_outgoing` blocks appear per type — with
4486    /// their relationship lists and cardinality, in declaration order —
4487    /// at BOTH verbosity levels, and a type declaring none reports an
4488    /// empty list (never a missing key). The `project` built-in is the
4489    /// live fixture: `evidence` declares one block, `decision` (among
4490    /// others) declares none. The `no_self_loop_relationships_effect`
4491    /// note ships at both levels and claims nothing beyond the
4492    /// self-loop refusal.
4493    #[test]
4494    fn required_outgoing_reported_with_cardinality_at_both_levels() {
4495        let reg = memstead_schema::SchemaRegistry::builtin();
4496        let project = reg
4497            .get("project", &semver::Version::new(0, 2, 0))
4498            .expect("project is a built-in");
4499
4500        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4501            let payload =
4502                build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4503            let types_key = if verbosity == SchemaVerbosity::Full {
4504                "types"
4505            } else {
4506                "types_summary"
4507            };
4508            let types = payload[types_key].as_array().expect("types array");
4509
4510            let mut saw_evidence = false;
4511            let mut saw_memo = false;
4512            for t in types {
4513                let ro = t
4514                    .get("required_outgoing")
4515                    .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4516                    .as_array()
4517                    .expect("required_outgoing is an array for every type");
4518                if t["name"] == "evidence" {
4519                    saw_evidence = true;
4520                    assert_eq!(ro.len(), 1, "evidence declares one block");
4521                    assert_eq!(
4522                        ro[0]["relationships"],
4523                        serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4524                        "relationship alternatives in declaration order"
4525                    );
4526                    assert_eq!(
4527                        ro[0]["cardinality"], "at_least_one",
4528                        "cardinality rendered as declared — the open upper bound \
4529                         stays open, never a finite number"
4530                    );
4531                } else if t["name"] == "memo" {
4532                    // A type declaring no blocks reports the empty
4533                    // list, not a missing key.
4534                    saw_memo = true;
4535                    assert!(ro.is_empty(), "memo declares no blocks → empty list");
4536                }
4537            }
4538            assert!(saw_evidence, "project schema carries the evidence type");
4539            assert!(saw_memo, "project schema carries the memo type");
4540
4541            // The effect note for no_self_loop_relationships ships at both
4542            // levels and states the single real effect.
4543            let note = payload["no_self_loop_relationships_effect"]
4544                .as_str()
4545                .expect("effect note present at both verbosity levels");
4546            assert!(note.contains("self-loop"), "names the actual effect");
4547            assert!(
4548                !note.contains("propagates impact") || note.contains("does not propagate"),
4549                "claims no propagation behaviour beyond the self-loop refusal"
4550            );
4551            assert!(
4552                note.contains("status_propagation"),
4553                "deprecation pointer names the real propagation declaration"
4554            );
4555        }
4556    }
4557
4558    /// A conditional `required_outgoing` block's trigger (`when_field`
4559    /// / `when_value`) is visible at BOTH verbosity levels — no
4560    /// legality condition the schema response omits — while an
4561    /// unconditional block keeps its byte-identical three-key shape
4562    /// (no `when_*` keys at all).
4563    #[test]
4564    fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4565        let manifest = r#"name: condro-render
4566version: 0.1.0
4567description: conditional required_outgoing render fixture
4568when_to_use: tests
4569types:
4570  - task
4571relationships:
4572  mode: strict
4573  definitions:
4574    - name: PART_OF
4575      description: hier
4576      default_weight: 3.0
4577    - name: _default
4578      description: fallback
4579      default_weight: 1.0
4580community:
4581  resolution: 1.0
4582  seed: 42
4583"#;
4584        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";
4585        let schema = Arc::new(
4586            memstead_schema::load_schema_from_memory(
4587                manifest,
4588                &[("task".to_string(), task_yaml.to_string())],
4589            )
4590            .expect("render fixture schema must parse"),
4591        );
4592
4593        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4594            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4595            let types_key = if verbosity == SchemaVerbosity::Full {
4596                "types"
4597            } else {
4598                "types_summary"
4599            };
4600            let task = &payload[types_key].as_array().expect("types array")[0];
4601            let ro = task["required_outgoing"].as_array().expect("blocks array");
4602            assert_eq!(ro.len(), 2);
4603            assert!(
4604                ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4605                "unconditional block carries no when_* keys: {:?}",
4606                ro[0]
4607            );
4608            assert_eq!(ro[1]["when_field"], "status");
4609            assert_eq!(ro[1]["when_value"], "checked");
4610        }
4611    }
4612
4613    /// Declared `acyclic_sets` and a `status_propagation` relation
4614    /// set are visible at BOTH verbosity levels; a single-name
4615    /// propagation declaration keeps its `rel_type` key with no
4616    /// `rel_types`, and a schema without sets carries no
4617    /// `acyclic_sets` key at all.
4618    #[test]
4619    fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4620        let manifest = r#"name: relsets-render
4621version: 0.1.0
4622description: relation-set render fixture
4623when_to_use: tests
4624types:
4625  - claim
4626relationships:
4627  mode: strict
4628  acyclic_sets:
4629    - [GROUNDS, CONCLUDES]
4630  definitions:
4631    - name: GROUNDS
4632      description: g
4633      default_weight: 3.0
4634    - name: CONCLUDES
4635      description: c
4636      default_weight: 3.0
4637    - name: PART_OF
4638      description: hier
4639      default_weight: 1.0
4640    - name: _default
4641      description: fallback
4642      default_weight: 1.0
4643community:
4644  resolution: 1.0
4645  seed: 42
4646"#;
4647        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";
4648        let schema = Arc::new(
4649            memstead_schema::load_schema_from_memory(
4650                manifest,
4651                &[("claim".to_string(), claim.to_string())],
4652            )
4653            .expect("render fixture schema must parse"),
4654        );
4655
4656        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4657            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4658            assert_eq!(
4659                payload["acyclic_sets"],
4660                serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4661                "acyclic_sets present at {verbosity:?}"
4662            );
4663            let types_key = if verbosity == SchemaVerbosity::Full {
4664                "types"
4665            } else {
4666                "types_summary"
4667            };
4668            let claim = &payload[types_key].as_array().expect("types array")[0];
4669            let constraints = claim["constraints"].as_array().expect("constraints array");
4670            assert_eq!(
4671                constraints[0]["rel_types"],
4672                serde_json::json!(["GROUNDS", "CONCLUDES"])
4673            );
4674            assert!(
4675                constraints[0].get("rel_type").is_none(),
4676                "set declaration carries no single-name key: {:?}",
4677                constraints[0]
4678            );
4679            assert_eq!(constraints[1]["rel_type"], "PART_OF");
4680            assert!(
4681                constraints[1].get("rel_types").is_none(),
4682                "single-name declaration stays byte-identical: {:?}",
4683                constraints[1]
4684            );
4685        }
4686
4687        // A schema without sets carries no `acyclic_sets` key.
4688        let plain = software_schema();
4689        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4690            let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4691            assert!(
4692                payload.get("acyclic_sets").is_none(),
4693                "undeclared schema carries no acyclic_sets key"
4694            );
4695        }
4696    }
4697
4698    /// The labelling declaration is visible at BOTH verbosity levels
4699    /// with attack set and support walk echoed whole; a schema
4700    /// declaring none carries no `labelling` key at all.
4701    #[test]
4702    fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4703        let manifest = r#"name: labelling-render
4704version: 0.1.0
4705description: labelling render fixture
4706when_to_use: tests
4707types:
4708  - claim
4709relationships:
4710  mode: strict
4711  labelling:
4712    attack: [REBUTS]
4713    support:
4714      relationships: [GROUNDS]
4715      direction: out
4716      terminal_types: [claim]
4717  definitions:
4718    - name: REBUTS
4719      description: attack
4720      default_weight: 3.0
4721    - name: GROUNDS
4722      description: support
4723      default_weight: 3.0
4724    - name: PART_OF
4725      description: hier
4726      default_weight: 1.0
4727    - name: _default
4728      description: fallback
4729      default_weight: 1.0
4730community:
4731  resolution: 1.0
4732  seed: 42
4733"#;
4734        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";
4735        let schema = Arc::new(
4736            memstead_schema::load_schema_from_memory(
4737                manifest,
4738                &[("claim".to_string(), claim.to_string())],
4739            )
4740            .expect("render fixture schema must parse"),
4741        );
4742
4743        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4744            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4745            assert_eq!(
4746                payload["labelling"]["attack"],
4747                serde_json::json!(["REBUTS"]),
4748                "attack set present at {verbosity:?}"
4749            );
4750            assert_eq!(
4751                payload["labelling"]["support"]["relationships"],
4752                serde_json::json!(["GROUNDS"])
4753            );
4754            assert_eq!(payload["labelling"]["support"]["direction"], "out");
4755        }
4756
4757        let plain = software_schema();
4758        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4759            let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4760            assert!(
4761                payload.get("labelling").is_none(),
4762                "undeclared schema carries no labelling key"
4763            );
4764        }
4765    }
4766
4767    /// Declared signals are visible at BOTH verbosity levels with the
4768    /// declaration echoed whole; a type declaring none carries no
4769    /// `signals` key at all.
4770    #[test]
4771    fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4772        let manifest = r#"name: signals-render
4773version: 0.1.0
4774description: signal render fixture
4775when_to_use: tests
4776types:
4777  - claim
4778  - objection
4779relationships:
4780  mode: strict
4781  definitions:
4782    - name: REBUTS
4783      description: r
4784      default_weight: 3.0
4785    - name: PART_OF
4786      description: hier
4787      default_weight: 1.0
4788    - name: _default
4789      description: fallback
4790      default_weight: 1.0
4791community:
4792  resolution: 1.0
4793  seed: 42
4794"#;
4795        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";
4796        let claim = format!(
4797            "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"
4798        );
4799        let objection = format!(
4800            "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}"
4801        );
4802        let schema = Arc::new(
4803            memstead_schema::load_schema_from_memory(
4804                manifest,
4805                &[
4806                    ("claim".to_string(), claim),
4807                    ("objection".to_string(), objection),
4808                ],
4809            )
4810            .expect("render fixture schema must parse"),
4811        );
4812
4813        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4814            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4815            let types_key = if verbosity == SchemaVerbosity::Full {
4816                "types"
4817            } else {
4818                "types_summary"
4819            };
4820            let types = payload[types_key].as_array().expect("types array");
4821            let claim = types
4822                .iter()
4823                .find(|t| t["name"] == "claim")
4824                .expect("claim type present");
4825            let sigs = claim["signals"].as_array().expect("signals array");
4826            assert_eq!(sigs[0]["name"], "attack_load");
4827            assert_eq!(sigs[0]["kind"], "edge_load");
4828            assert_eq!(sigs[0]["direction"], "in");
4829            assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4830            assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4831            let objection = types
4832                .iter()
4833                .find(|t| t["name"] == "objection")
4834                .expect("objection type present");
4835            assert!(
4836                objection.get("signals").is_none(),
4837                "undeclared type carries no signals key"
4838            );
4839        }
4840    }
4841
4842    /// A declared `must_reach` obligation is visible at BOTH verbosity
4843    /// levels with the declaration echoed (relation set, direction,
4844    /// terminal types, depth); a type declaring none carries no
4845    /// `must_reach` key at all (undeclared schemas keep their payload
4846    /// bytes unchanged).
4847    #[test]
4848    fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4849        let manifest = r#"name: mustreach-render
4850version: 0.1.0
4851description: must_reach render fixture
4852when_to_use: tests
4853types:
4854  - claim
4855  - evidence
4856relationships:
4857  mode: strict
4858  definitions:
4859    - name: GROUNDS
4860      description: g
4861      default_weight: 3.0
4862    - name: PART_OF
4863      description: hier
4864      default_weight: 1.0
4865    - name: _default
4866      description: fallback
4867      default_weight: 1.0
4868community:
4869  resolution: 1.0
4870  seed: 42
4871"#;
4872        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";
4873        let claim = format!(
4874            "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"
4875        );
4876        let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4877        let schema = Arc::new(
4878            memstead_schema::load_schema_from_memory(
4879                manifest,
4880                &[
4881                    ("claim".to_string(), claim),
4882                    ("evidence".to_string(), evidence),
4883                ],
4884            )
4885            .expect("render fixture schema must parse"),
4886        );
4887
4888        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4889            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4890            let types_key = if verbosity == SchemaVerbosity::Full {
4891                "types"
4892            } else {
4893                "types_summary"
4894            };
4895            let types = payload[types_key].as_array().expect("types array");
4896            let claim = types
4897                .iter()
4898                .find(|t| t["name"] == "claim")
4899                .expect("claim type present");
4900            let mr = claim["must_reach"].as_array().expect("obligations array");
4901            assert_eq!(mr.len(), 1);
4902            assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4903            assert_eq!(mr[0]["direction"], "out");
4904            assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4905            assert_eq!(mr[0]["max_depth"], 12);
4906            let evidence = types
4907                .iter()
4908                .find(|t| t["name"] == "evidence")
4909                .expect("evidence type present");
4910            assert!(
4911                evidence.get("must_reach").is_none(),
4912                "undeclared type carries no must_reach key: {evidence:?}"
4913            );
4914        }
4915    }
4916
4917    #[test]
4918    fn lite_payload_is_the_structural_skeleton_without_prose() {
4919        let schema = software_schema();
4920        let lite = build_schema_payload(
4921            &schema,
4922            vec!["v".into()],
4923            SchemaVerbosity::Lite,
4924            OriginClass::FirstParty,
4925        );
4926
4927        // Heavy arrays under the distinct lite keys; rich keys absent.
4928        let types = lite["types_summary"]
4929            .as_array()
4930            .expect("lite has `types_summary`");
4931        let rels = lite["relationships_summary"]
4932            .as_array()
4933            .expect("lite has `relationships_summary`");
4934        assert!(lite.get("types").is_none(), "lite omits rich `types`");
4935        assert!(
4936            lite.get("relationships").is_none(),
4937            "lite omits rich `relationships`"
4938        );
4939
4940        // Alias pointer + endpoint constraints survive the cut — every
4941        // flag an agent needs to author a legal write.
4942        assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4943
4944        // Schema-level prose dropped.
4945        assert!(
4946            lite.get("description").is_none(),
4947            "lite drops schema description"
4948        );
4949        assert!(
4950            lite.get("when_to_use").is_none(),
4951            "lite drops schema when_to_use"
4952        );
4953        assert!(
4954            lite.get("default_writing_guidance").is_none(),
4955            "lite drops default_writing_guidance"
4956        );
4957
4958        // Every entity-type name carries its section keys (with `required`)
4959        // and field shapes — and NO type/section prose.
4960        for t in types {
4961            assert!(t["name"].is_string());
4962            let sections = t["sections"].as_array().expect("lite type has sections");
4963            for s in sections {
4964                assert!(s["key"].is_string(), "section carries its key");
4965                assert!(s["required"].is_boolean(), "section carries required flag");
4966                assert!(
4967                    s.get("write_rules").is_none(),
4968                    "lite section drops write_rules prose"
4969                );
4970                assert!(s.get("heading").is_none(), "lite section drops heading");
4971            }
4972            assert!(
4973                t.get("description").is_none(),
4974                "lite type drops description"
4975            );
4976            assert!(
4977                t.get("writing_guidance").is_none(),
4978                "lite type drops writing_guidance"
4979            );
4980            assert!(
4981                t.get("system_context").is_none(),
4982                "lite type drops system_context"
4983            );
4984            // `no_self_loop_relationships` rides along — it governs the
4985            // self-loop relate refusal, a write-time refusal lite must let
4986            // an agent avoid.
4987            assert!(
4988                t.get("no_self_loop_relationships").is_some(),
4989                "lite type keeps no_self_loop_relationships"
4990            );
4991            // `required_outgoing` rides along — the only declared
4992            // legality condition on outgoing edges. Always an array,
4993            // never an absent key (absence would read as "unknown").
4994            assert!(
4995                t.get("required_outgoing").is_some_and(|v| v.is_array()),
4996                "lite type keeps required_outgoing as an array"
4997            );
4998            // Field shapes present (name + required), prose absent.
4999            if let Some(fields) = t["fields"].as_array() {
5000                for f in fields {
5001                    assert!(f["name"].is_string());
5002                    assert!(f["required"].is_boolean());
5003                    assert!(
5004                        f.get("description").is_none(),
5005                        "lite field drops description"
5006                    );
5007                }
5008            }
5009        }
5010
5011        // Every relationship name carries its allowed endpoints and the
5012        // refusal-governing flags — and NO description/when_to_use prose.
5013        for r in rels {
5014            assert!(r["name"].is_string());
5015            assert!(
5016                r.get("allowed_sources").is_some(),
5017                "lite rel has allowed_sources"
5018            );
5019            assert!(
5020                r.get("allowed_targets").is_some(),
5021                "lite rel has allowed_targets"
5022            );
5023            assert!(
5024                r.get("manual_authoring").is_some(),
5025                "lite rel keeps manual_authoring"
5026            );
5027            assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
5028            assert!(
5029                r.get("per_edge_description").is_some(),
5030                "lite rel keeps per_edge_description"
5031            );
5032            assert!(r.get("description").is_none(), "lite rel drops description");
5033            assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
5034            assert!(
5035                r.get("default_weight").is_none(),
5036                "lite rel drops default_weight"
5037            );
5038        }
5039    }
5040
5041    #[test]
5042    fn lite_is_measurably_smaller_than_full() {
5043        let schema = software_schema();
5044        let full = build_schema_payload(
5045            &schema,
5046            vec!["v".into()],
5047            SchemaVerbosity::Full,
5048            OriginClass::FirstParty,
5049        );
5050        let lite = build_schema_payload(
5051            &schema,
5052            vec!["v".into()],
5053            SchemaVerbosity::Lite,
5054            OriginClass::FirstParty,
5055        );
5056        let full_len = serde_json::to_string(&full).unwrap().len();
5057        let lite_len = serde_json::to_string(&lite).unwrap().len();
5058        assert!(
5059            lite_len * 2 < full_len,
5060            "lite ({lite_len} B) must be well under half of full ({full_len} B)"
5061        );
5062    }
5063
5064    #[test]
5065    fn lite_full_carry_the_same_type_and_rel_names() {
5066        // The cut drops prose, never an entity type or a rel-type — an
5067        // agent orienting on lite sees the full vocabulary.
5068        let schema = software_schema();
5069        let full = build_schema_payload(
5070            &schema,
5071            vec!["v".into()],
5072            SchemaVerbosity::Full,
5073            OriginClass::FirstParty,
5074        );
5075        let lite = build_schema_payload(
5076            &schema,
5077            vec!["v".into()],
5078            SchemaVerbosity::Lite,
5079            OriginClass::FirstParty,
5080        );
5081
5082        let names = |arr: &serde_json::Value| -> Vec<String> {
5083            arr.as_array()
5084                .unwrap()
5085                .iter()
5086                .map(|v| v["name"].as_str().unwrap().to_string())
5087                .collect()
5088        };
5089        assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
5090        assert_eq!(
5091            names(&full["relationships"]),
5092            names(&lite["relationships_summary"])
5093        );
5094    }
5095
5096    /// Criterion 4 of 04/02, on the surface an agent actually reads. The
5097    /// conformance axis knew, but it is opt-in; a plain `memstead_entity` /
5098    /// `memstead entity --json` returned the swallowed sections as `""` with
5099    /// nothing to distinguish them from sections the author left blank.
5100    #[test]
5101    fn swallowed_sections_carry_a_marker_on_the_plain_read() {
5102        let mut e = test_entity();
5103        e.sections.insert(
5104            "identity".to_string(),
5105            "intro\n\n```rust\nfn main() {}".to_string(),
5106        );
5107        e.sections.insert("purpose".to_string(), String::new());
5108        let env = build_entity_envelope(
5109            &e,
5110            10,
5111            None,
5112            None,
5113            None,
5114            OriginClass::FirstParty,
5115            &[],
5116            None,
5117            None,
5118            None,
5119        );
5120        let marker = &env["_unread_sections"];
5121        assert_eq!(marker["reason"], "UNTERMINATED_FENCE");
5122        assert_eq!(marker["absorbed_into"], "identity");
5123        assert_eq!(marker["sections"], serde_json::json!(["purpose"]));
5124    }
5125
5126    #[test]
5127    fn an_ordinary_entity_carries_no_unread_marker() {
5128        // Including one whose empty section is genuinely just empty, and one
5129        // carrying a CLOSED fence: a marker that fired on either would make
5130        // every blank section look like data loss.
5131        for body in ["plain prose", "```rust\nfn main() {}\n```"] {
5132            let mut e = test_entity();
5133            e.sections.insert("identity".to_string(), body.to_string());
5134            e.sections.insert("purpose".to_string(), String::new());
5135            let env = build_entity_envelope(
5136                &e,
5137                10,
5138                None,
5139                None,
5140                None,
5141                OriginClass::FirstParty,
5142                &[],
5143                None,
5144                None,
5145                None,
5146            );
5147            assert!(
5148                env.get("_unread_sections").is_none(),
5149                "body {body:?} produced a marker"
5150            );
5151        }
5152    }
5153}