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            last_resort: false,
2970            edge_weight_overrides: indexmap::IndexMap::new(),
2971            edge_weights: indexmap::IndexMap::new(),
2972            no_self_loop_relationships: vec![],
2973            legacy_propagating_relationships: None,
2974            due: None,
2975            leaf: false,
2976            updatable_fields: vec![],
2977            health_required_fields: vec![],
2978            staleness_threshold_days: 90,
2979            write_rules: vec![],
2980            required_outgoing: vec![],
2981            must_reach: vec![],
2982            signals: vec![],
2983            constraints: vec![],
2984            declared_metadata_keys: vec![],
2985        };
2986
2987        let mut sections = HashMap::new();
2988        sections.insert("note".to_string(), "a note".to_string());
2989        assert_eq!(
2990            summary_pair(Some(&schema), &sections),
2991            ("Note".to_string(), "a note".to_string()),
2992        );
2993
2994        assert_eq!(
2995            summary_pair(Some(&schema), &HashMap::new()),
2996            ("Note".to_string(), "—".to_string()),
2997        );
2998    }
2999
3000    // -----------------------------------------------------------------------
3001    // Summary line — list rendering (symmetric)
3002    // -----------------------------------------------------------------------
3003
3004    #[test]
3005    fn render_list_uses_first_required_section_for_spec() {
3006        let hit = make_hit(
3007            "specs--demo",
3008            "Demo Spec",
3009            "spec",
3010            &[
3011                ("identity", "A demo spec."),
3012                ("purpose", "Verifies rendering."),
3013            ],
3014        );
3015        let out = render_list_markdown(&list_result(vec![hit]));
3016        assert!(
3017            out.contains("**Identity**: A demo spec."),
3018            "expected Identity line for spec hit, got:\n{out}"
3019        );
3020    }
3021
3022    #[test]
3023    fn render_list_uses_first_required_section_for_memo() {
3024        let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
3025        let out = render_list_markdown(&list_result(vec![hit]));
3026        assert!(
3027            out.contains("**Claim**: Some claim."),
3028            "expected Claim line for memo hit, got:\n{out}"
3029        );
3030        assert!(
3031            !out.contains("**Identity**"),
3032            "memo hit must not render Identity label in list output"
3033        );
3034    }
3035
3036    #[test]
3037    fn render_list_uses_first_required_section_for_concept() {
3038        let hit = make_hit(
3039            "concepts--thing",
3040            "Thing",
3041            "concept",
3042            &[("definition", "A thing.")],
3043        );
3044        let out = render_list_markdown(&list_result(vec![hit]));
3045        assert!(
3046            out.contains("**Definition**: A thing."),
3047            "expected Definition line for concept hit, got:\n{out}"
3048        );
3049    }
3050
3051    #[test]
3052    fn render_list_missing_summary_section_shows_dash() {
3053        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
3054        let out = render_list_markdown(&list_result(vec![hit]));
3055        assert!(
3056            out.contains("**Claim**: —"),
3057            "expected Claim dash fallback in list output, got:\n{out}"
3058        );
3059    }
3060
3061    #[test]
3062    fn render_list_mixes_schemas_in_one_result() {
3063        let spec_hit = make_hit(
3064            "specs--s1",
3065            "Spec One",
3066            "spec",
3067            &[("identity", "Spec body.")],
3068        );
3069        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3070        let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
3071        assert!(
3072            out.contains("**Identity**: Spec body."),
3073            "spec hit should still render Identity in list output, got:\n{out}"
3074        );
3075        assert!(
3076            out.contains("**Claim**: Memo claim."),
3077            "memo hit should render Claim in list output, got:\n{out}"
3078        );
3079    }
3080
3081    #[test]
3082    fn render_list_unknown_schema_shows_summary_dash() {
3083        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
3084        let out = render_list_markdown(&list_result(vec![hit]));
3085        assert!(
3086            out.contains("**Summary**: —"),
3087            "unknown schema should render Summary dash in list output, got:\n{out}"
3088        );
3089    }
3090
3091    // -----------------------------------------------------------------------
3092    // summary_pair — structured-content source of truth
3093    // -----------------------------------------------------------------------
3094
3095    #[test]
3096    fn summary_pair_for_spec_returns_identity() {
3097        let schema = type_by_name("spec");
3098        let mut sections = HashMap::new();
3099        sections.insert("identity".to_string(), "A demo spec.".to_string());
3100        assert_eq!(
3101            summary_pair(schema.as_deref(), &sections),
3102            ("Identity".to_string(), "A demo spec.".to_string()),
3103        );
3104    }
3105
3106    #[test]
3107    fn summary_pair_for_memo_returns_claim() {
3108        let schema = type_by_name("memo");
3109        let mut sections = HashMap::new();
3110        sections.insert("claim".to_string(), "Memos matter.".to_string());
3111        assert_eq!(
3112            summary_pair(schema.as_deref(), &sections),
3113            ("Claim".to_string(), "Memos matter.".to_string()),
3114        );
3115    }
3116
3117    #[test]
3118    fn summary_pair_missing_section_returns_dash() {
3119        let schema = type_by_name("memo");
3120        assert_eq!(
3121            summary_pair(schema.as_deref(), &HashMap::new()),
3122            ("Claim".to_string(), "—".to_string()),
3123        );
3124    }
3125
3126    #[test]
3127    fn summary_pair_unknown_schema_returns_summary_dash() {
3128        assert_eq!(
3129            summary_pair(None, &HashMap::new()),
3130            ("Summary".to_string(), "—".to_string()),
3131        );
3132    }
3133
3134    // -----------------------------------------------------------------------
3135    // Envelope serialization — structured-content sidecar
3136    // -----------------------------------------------------------------------
3137
3138    #[test]
3139    fn envelope_serializes_summary_fields() {
3140        let hit = make_hit(
3141            "memos--d1",
3142            "Memo One",
3143            "memo",
3144            &[("claim", "Memos matter.")],
3145        );
3146        let result = search_result(vec![hit]);
3147        let envelope = build_search_envelope(&result, 0);
3148        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3149
3150        // The top-level counters use the `_-prefixed` engine-emitted
3151        // shape so the wire signals "engine-authored metadata, not
3152        // user data".
3153        assert_eq!(value["_total"], 1);
3154        assert_eq!(value["_returned"], 1);
3155        assert_eq!(value["_offset"], 0);
3156        // Warnings field is omitted when empty (skip_serializing_if).
3157        assert!(
3158            value.get("warnings").is_none(),
3159            "empty warnings must be elided, got: {value}"
3160        );
3161
3162        let hit0 = &value["hits"][0];
3163        assert_eq!(hit0["summary_heading"], "Claim");
3164        assert_eq!(hit0["summary_value"], "Memos matter.");
3165        // Flattened SearchHit fields present.
3166        assert_eq!(hit0["id"], "memos--d1");
3167        assert_eq!(hit0["title"], "Memo One");
3168        assert_eq!(hit0["entity_type"], "memo");
3169        assert_eq!(hit0["mem"], "memos");
3170        assert_eq!(hit0["stub"], false);
3171        assert_eq!(hit0["tokens"], 10);
3172        assert!(hit0["sections"].is_object());
3173    }
3174
3175    #[test]
3176    fn envelope_roundtrips_through_structured_content() {
3177        // Mixed-schema result: one spec hit, one memo hit. Both summary pairs
3178        // must match what summary_pair produces for each schema.
3179        let spec_hit = make_hit(
3180            "specs--s1",
3181            "Spec One",
3182            "spec",
3183            &[("identity", "Spec body.")],
3184        );
3185        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3186        let result = search_result(vec![spec_hit, memo_hit]);
3187        let envelope = build_search_envelope(&result, 0);
3188        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3189
3190        let hits = value["hits"].as_array().expect("hits must be array");
3191        assert_eq!(hits.len(), 2);
3192        assert_eq!(hits[0]["summary_heading"], "Identity");
3193        assert_eq!(hits[0]["summary_value"], "Spec body.");
3194        assert_eq!(hits[1]["summary_heading"], "Claim");
3195        assert_eq!(hits[1]["summary_value"], "Memo claim.");
3196    }
3197
3198    #[test]
3199    fn list_envelope_includes_total_tokens() {
3200        let hit = make_hit(
3201            "concepts--c1",
3202            "Thing",
3203            "concept",
3204            &[("definition", "A thing.")],
3205        );
3206        let result = list_result(vec![hit]);
3207        let envelope = build_list_envelope(&result);
3208        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3209
3210        // `_`-prefixed engine-meta keys, matching the search envelope.
3211        assert_eq!(value["_total"], 1);
3212        assert_eq!(value["_total_tokens"], 10);
3213        assert!(value.get("total").is_none(), "unprefixed keys retired");
3214        assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3215        assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3216    }
3217
3218    #[test]
3219    fn envelope_emits_warnings_when_present() {
3220        let mut result = search_result(vec![]);
3221        // Search warnings ship as typed `WarningHint` entries (same
3222        // `{code, details, message}` envelope every other tool uses).
3223        result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3224            field: "foo".to_string(),
3225        }];
3226        let envelope = build_search_envelope(&result, 0);
3227        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3228        assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3229        assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3230        assert!(
3231            value["warnings"][0]["message"]
3232                .as_str()
3233                .is_some_and(|m| m.contains("not filterable"))
3234        );
3235    }
3236
3237    // -----------------------------------------------------------------------
3238    // Per-hit and per-result fields that must appear in the Markdown body.
3239    // -----------------------------------------------------------------------
3240
3241    fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3242        TermMatch {
3243            field: field.to_string(),
3244            snippet: snippet.to_string(),
3245            heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3246        }
3247    }
3248
3249    fn sample_facets() -> Facets {
3250        use crate::ops::SubsectionFacet;
3251        Facets {
3252            by_type: HashMap::from([
3253                ("spec".to_string(), 7),
3254                ("memo".to_string(), 3),
3255                ("decision".to_string(), 2),
3256            ]),
3257            by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3258            by_level: HashMap::from([("high".to_string(), 4)]),
3259            by_status: HashMap::from([("active".to_string(), 6)]),
3260            by_confidence: HashMap::from([("medium".to_string(), 3)]),
3261            by_subsection: vec![
3262                SubsectionFacet {
3263                    path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3264                    count: 4,
3265                },
3266                SubsectionFacet {
3267                    path: vec!["purpose".to_string(), "Rationale".to_string()],
3268                    count: 2,
3269                },
3270            ],
3271            by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3272        }
3273    }
3274
3275    #[test]
3276    fn render_search_emits_matched_terms_line() {
3277        let mut hit = make_hit(
3278            "specs--e1",
3279            "Entity One",
3280            "spec",
3281            &[("identity", "Body text.")],
3282        );
3283        hit.matched_terms = Some(HashMap::from([
3284            (
3285                "entity".to_string(),
3286                vec![
3287                    tm("title", "...entity...", None),
3288                    tm("purpose", "...entity...", None),
3289                    tm("purpose", "...entity two...", None),
3290                ],
3291            ),
3292            ("one".to_string(), vec![tm("title", "...one...", None)]),
3293        ]));
3294        let out = render_search_markdown(&search_result(vec![hit]), 0);
3295        assert!(
3296            out.contains("**Matched terms:**"),
3297            "missing Matched terms line; got:\n{out}"
3298        );
3299        assert!(
3300            out.contains("`entity` (purpose×2, title×1)"),
3301            "entity term grouping wrong; got:\n{out}"
3302        );
3303        assert!(
3304            out.contains("`one` (title×1)"),
3305            "one term grouping wrong; got:\n{out}"
3306        );
3307    }
3308
3309    #[test]
3310    fn render_search_emits_score_breakdown_line() {
3311        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3312        hit.score_breakdown = Some(ScoreBreakdown {
3313            bm25: 2.5,
3314            title_boost: 2.0,
3315            field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3316            expansion_decay: Some(0.5),
3317        });
3318        let out = render_search_markdown(&search_result(vec![hit]), 0);
3319        assert!(
3320            out.contains(
3321                "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3322            ),
3323            "score breakdown line wrong; got:\n{out}"
3324        );
3325    }
3326
3327    #[test]
3328    fn render_search_omits_expansion_decay_when_none() {
3329        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3330        hit.score_breakdown = Some(ScoreBreakdown {
3331            bm25: 1.5,
3332            title_boost: 1.0,
3333            field_weights: HashMap::new(),
3334            expansion_decay: None,
3335        });
3336        let out = render_search_markdown(&search_result(vec![hit]), 0);
3337        assert!(
3338            out.contains("**Score:** bm25 1.5 + title 1.0"),
3339            "base score wrong; got:\n{out}"
3340        );
3341        assert!(
3342            !out.contains("expansion_decay"),
3343            "expansion_decay must be absent when None; got:\n{out}"
3344        );
3345    }
3346
3347    #[test]
3348    fn render_search_emits_heading_path_line() {
3349        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3350        hit.matched_terms = Some(HashMap::from([(
3351            "x".to_string(),
3352            vec![
3353                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3354                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), // duplicate, dedupe
3355                tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3356            ],
3357        )]));
3358        let out = render_search_markdown(&search_result(vec![hit]), 0);
3359        assert!(
3360            out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3361            "heading path line wrong; got:\n{out}"
3362        );
3363    }
3364
3365    #[test]
3366    fn render_search_emits_expansion_line() {
3367        let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3368        hit.expansion = Some(ExpansionInfo {
3369            of: EntityId("specs--seed".to_string()),
3370            via_edge: "refines".to_string(),
3371            via_direction: crate::graph::query::TraversalDirection::Out,
3372            depth: 1,
3373        });
3374        let out = render_search_markdown(&search_result(vec![hit]), 0);
3375        assert!(
3376            out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3377            "expansion line reports the traversal direction beside the label; got:\n{out}"
3378        );
3379    }
3380
3381    #[test]
3382    fn render_search_emits_facets_block() {
3383        let mut result = search_result(vec![]);
3384        result.facets = Some(sample_facets());
3385        let out = render_search_markdown(&result, 0);
3386        assert!(
3387            out.contains("## Facets"),
3388            "facets header missing; got:\n{out}"
3389        );
3390        assert!(
3391            out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3392            "by_type bucket wrong; got:\n{out}"
3393        );
3394        assert!(
3395            out.contains("- **by_mem:** specs=10, memos=2"),
3396            "by_mem bucket wrong; got:\n{out}"
3397        );
3398        assert!(
3399            out.contains("- **by_level:** high=4"),
3400            "by_level bucket wrong; got:\n{out}"
3401        );
3402        assert!(
3403            out.contains("- **by_status:** active=6"),
3404            "by_status bucket wrong; got:\n{out}"
3405        );
3406        assert!(
3407            out.contains("- **by_confidence:** medium=3"),
3408            "by_confidence bucket wrong; got:\n{out}"
3409        );
3410        assert!(
3411            out.contains("- **by_expansion:** primary=8, expanded=4"),
3412            "by_expansion bucket wrong; got:\n{out}"
3413        );
3414        assert!(
3415            out.contains("- **by_subsection:**"),
3416            "by_subsection header missing; got:\n{out}"
3417        );
3418        assert!(
3419            out.contains("`specifies › Response Shapes`: 4"),
3420            "subsection facet wrong; got:\n{out}"
3421        );
3422    }
3423
3424    #[test]
3425    fn render_search_omits_facets_block_when_all_empty() {
3426        let mut result = search_result(vec![]);
3427        result.facets = Some(Facets::default());
3428        let out = render_search_markdown(&result, 0);
3429        assert!(
3430            !out.contains("## Facets"),
3431            "empty facets must not emit header; got:\n{out}"
3432        );
3433    }
3434
3435    /// Every field the search-tool description promises must be rendered
3436    /// in Markdown. This test exercises all of them in one result and
3437    /// asserts they all appear.
3438    #[test]
3439    fn search_markdown_covers_every_sidecar_field() {
3440        let mut hit = make_hit(
3441            "specs--e1",
3442            "Entity One",
3443            "spec",
3444            &[("identity", "Body text.")],
3445        );
3446        hit.matched_terms = Some(HashMap::from([(
3447            "entity".to_string(),
3448            vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3449        )]));
3450        hit.score_breakdown = Some(ScoreBreakdown {
3451            bm25: 1.5,
3452            title_boost: 1.0,
3453            field_weights: HashMap::from([("body".to_string(), 0.4)]),
3454            expansion_decay: Some(0.5),
3455        });
3456        hit.expansion = Some(ExpansionInfo {
3457            of: EntityId("specs--seed".to_string()),
3458            via_edge: "refines".to_string(),
3459            via_direction: crate::graph::query::TraversalDirection::Out,
3460            depth: 2,
3461        });
3462
3463        let mut result = search_result(vec![hit]);
3464        result.facets = Some(sample_facets());
3465
3466        let out = render_search_markdown(&result, 0);
3467        for marker in [
3468            "## Facets",
3469            "- **by_type:**",
3470            "- **by_mem:**",
3471            "- **by_level:**",
3472            "- **by_status:**",
3473            "- **by_confidence:**",
3474            "- **by_expansion:**",
3475            "- **by_subsection:**",
3476            "**Matched terms:**",
3477            "**Score:**",
3478            "**Heading path:**",
3479            "**Expansion:**",
3480        ] {
3481            assert!(
3482                out.contains(marker),
3483                "lockstep marker `{marker}` missing from search markdown; \
3484                 update render_search_markdown when adding sidecar fields. got:\n{out}"
3485            );
3486        }
3487    }
3488
3489    /// The envelope's `relationships[].source` field reads the store's
3490    /// `EdgeSource` discriminator rather than a hardcoded `"explicit"`,
3491    /// which would disagree with the stub-adoption
3492    /// response for alias-synthesised edges (and would be
3493    /// misleading because REFERENCES carries `manual_authoring:
3494    /// forbidden`).
3495    #[test]
3496    fn build_entity_envelope_source_field_reads_edge_source() {
3497        let mut entity = test_entity();
3498        let body_link_target = EntityId("specs--body-link-target".to_string());
3499        let explicit_target = EntityId("specs--explicit-target".to_string());
3500        entity.relationships = vec![
3501            crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3502            crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3503        ];
3504
3505        let edges = vec![
3506            crate::store::Edge {
3507                rel_type: "REFERENCES".to_string(),
3508                target: body_link_target.clone(),
3509                source: crate::store::EdgeSource::BodyLink,
3510            },
3511            crate::store::Edge {
3512                rel_type: "USES".to_string(),
3513                target: explicit_target.clone(),
3514                source: crate::store::EdgeSource::Explicit,
3515            },
3516        ];
3517
3518        let env = build_entity_envelope(
3519            &entity,
3520            0,
3521            None,
3522            None,
3523            None,
3524            OriginClass::FirstParty,
3525            &edges,
3526            None,
3527            None,
3528            None,
3529        );
3530        let relationships = env["relationships"].as_array().expect("array");
3531        let refs = relationships
3532            .iter()
3533            .find(|r| r["rel_type"] == "REFERENCES")
3534            .expect("REFERENCES present");
3535        assert_eq!(
3536            refs["source"], "body_link",
3537            "alias-synthesised edge must label body_link"
3538        );
3539        let uses = relationships
3540            .iter()
3541            .find(|r| r["rel_type"] == "USES")
3542            .expect("USES present");
3543        assert_eq!(
3544            uses["source"], "explicit",
3545            "explicit-authored edge must label explicit"
3546        );
3547    }
3548
3549    /// The envelope's read contract is structural (cold-start 0-8-0,
3550    /// F9/F13/F15): `origin` is present on every envelope, every
3551    /// relationship entry declares its `direction`, and incoming edges
3552    /// — when the caller passes them — appear as `direction: "in"`
3553    /// entries carrying the other endpoint under `from`. A consumer
3554    /// can therefore always tell whether the block is one-directional.
3555    #[test]
3556    fn build_entity_envelope_carries_origin_direction_and_incoming() {
3557        let mut entity = test_entity();
3558        let out_target = EntityId("specs--downstream".to_string());
3559        entity.relationships = vec![crate::entity::Relationship::new(
3560            "USES".to_string(),
3561            out_target.clone(),
3562        )];
3563        let edges = vec![crate::store::Edge {
3564            rel_type: "USES".to_string(),
3565            target: out_target,
3566            source: crate::store::EdgeSource::Explicit,
3567        }];
3568        let incoming = vec![crate::store::InEdge {
3569            rel_type: "MANAGES".to_string(),
3570            from: EntityId("specs--upstream".to_string()),
3571            source: crate::store::EdgeSource::Explicit,
3572        }];
3573
3574        // Without incoming: outgoing entries are direction-labelled.
3575        let env = build_entity_envelope(
3576            &entity,
3577            0,
3578            None,
3579            None,
3580            None,
3581            OriginClass::ThirdParty,
3582            &edges,
3583            None,
3584            None,
3585            None,
3586        );
3587        assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3588        let rels = env["relationships"].as_array().expect("array");
3589        assert_eq!(rels.len(), 1);
3590        assert_eq!(rels[0]["direction"], "out");
3591
3592        // With incoming: the other half of the neighbourhood appears,
3593        // direction-labelled, endpoint under `from`.
3594        let env = build_entity_envelope(
3595            &entity,
3596            0,
3597            None,
3598            None,
3599            None,
3600            OriginClass::FirstParty,
3601            &edges,
3602            Some(&incoming),
3603            None,
3604            None,
3605        );
3606        assert_eq!(env["origin"], "first-party");
3607        let rels = env["relationships"].as_array().expect("array");
3608        assert_eq!(rels.len(), 2);
3609        let inc = rels
3610            .iter()
3611            .find(|r| r["direction"] == "in")
3612            .expect("incoming entry present");
3613        assert_eq!(inc["rel_type"], "MANAGES");
3614        assert_eq!(inc["from"], "specs--upstream");
3615        assert!(
3616            inc.get("target").is_none(),
3617            "incoming carries from, not target"
3618        );
3619    }
3620
3621    /// A relationship whose store edge is missing
3622    /// (transitional drift, store-rebuild lag) falls back to
3623    /// `"explicit"` so the envelope doesn't crash. The fallback is
3624    /// the conservative label — agents already branch on it.
3625    #[test]
3626    fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3627        let mut entity = test_entity();
3628        let target = EntityId("specs--unmapped".to_string());
3629        entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3630        let edges: Vec<crate::store::Edge> = Vec::new();
3631        let env = build_entity_envelope(
3632            &entity,
3633            0,
3634            None,
3635            None,
3636            None,
3637            OriginClass::FirstParty,
3638            &edges,
3639            None,
3640            None,
3641            None,
3642        );
3643        let relationships = env["relationships"].as_array().expect("array");
3644        assert_eq!(relationships[0]["source"], "explicit");
3645    }
3646
3647    /// Every schema-declared frontmatter key surfaces under the nested
3648    /// `metadata` map — its single home. The four
3649    /// formerly-hoisted scalars are not at the top level; the
3650    /// read-only identity triple (mem/id/type) and underscore-prefixed
3651    /// internal keys are excluded from the nested map.
3652    #[test]
3653    fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3654        use crate::entity::MetadataValue;
3655        let mut entity = test_entity();
3656        entity.entity_type = "contract".to_string();
3657        // Pre-fix the envelope dropped every non-promoted key.
3658        entity.metadata = IndexMap::from([
3659            ("level".to_string(), MetadataValue::String("M0".to_string())),
3660            (
3661                "stability".to_string(),
3662                MetadataValue::String("stable".to_string()),
3663            ),
3664            (
3665                "created_date".to_string(),
3666                MetadataValue::String("2026-01-01".to_string()),
3667            ),
3668            (
3669                "last_modified".to_string(),
3670                MetadataValue::String("2026-05-19".to_string()),
3671            ),
3672            (
3673                "protocol".to_string(),
3674                MetadataValue::String("https".to_string()),
3675            ),
3676            (
3677                "version".to_string(),
3678                MetadataValue::String("0.1.0".to_string()),
3679            ),
3680            (
3681                "deprecation_status".to_string(),
3682                MetadataValue::String("none".to_string()),
3683            ),
3684        ]);
3685
3686        let env = build_entity_envelope(
3687            &entity,
3688            0,
3689            None,
3690            None,
3691            None,
3692            OriginClass::FirstParty,
3693            &[],
3694            None,
3695            None,
3696            None,
3697        );
3698
3699        // Metadata scalars are NOT hoisted to the top level — the
3700        // nested map is their single home.
3701        assert!(
3702            env.get("level").is_none(),
3703            "level must not be hoisted top-level"
3704        );
3705        assert!(
3706            env.get("stability").is_none(),
3707            "stability must not be hoisted"
3708        );
3709        assert!(
3710            env.get("created_date").is_none(),
3711            "created_date must not be hoisted"
3712        );
3713        assert!(
3714            env.get("last_modified").is_none(),
3715            "last_modified must not be hoisted"
3716        );
3717        // The entity's type stays top-level as identity, spelled
3718        // `entity_type` on the wire (2026-08-28 batch); the retired `type`
3719        // key is gone, not aliased.
3720        assert_eq!(env["entity_type"], "contract");
3721        assert!(
3722            env.get("type").is_none(),
3723            "the retired wire key must not survive"
3724        );
3725
3726        // Nested map carries every non-internal, non-identity frontmatter key.
3727        let metadata = env["metadata"].as_object().expect("metadata map");
3728        assert_eq!(metadata["level"], "M0");
3729        assert_eq!(metadata["stability"], "stable");
3730        assert_eq!(metadata["created_date"], "2026-01-01");
3731        assert_eq!(metadata["last_modified"], "2026-05-19");
3732        assert_eq!(metadata["protocol"], "https");
3733        assert_eq!(metadata["version"], "0.1.0");
3734        assert_eq!(metadata["deprecation_status"], "none");
3735
3736        // Internal underscore-prefixed keys and the read-only identity
3737        // triple (mem/id/type) do NOT appear inside the nested map.
3738        for k in metadata.keys() {
3739            assert!(
3740                !k.starts_with('_'),
3741                "metadata map must not carry underscore-prefixed key `{k}`"
3742            );
3743            assert!(
3744                !["mem", "id", "type"].contains(&k.as_str()),
3745                "metadata map must not carry identity key `{k}` (it lives top-level)"
3746            );
3747        }
3748    }
3749
3750    /// Stub envelopes carry an
3751    /// empty `metadata: {}` map so consumers don't branch on the
3752    /// map's presence.
3753    #[test]
3754    fn build_entity_envelope_stub_carries_empty_metadata_map() {
3755        let mut entity = test_entity();
3756        entity.stub = true;
3757        entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3758        entity.metadata = IndexMap::new();
3759        let env = build_entity_envelope(
3760            &entity,
3761            0,
3762            None,
3763            None,
3764            None,
3765            OriginClass::FirstParty,
3766            &[],
3767            None,
3768            None,
3769            None,
3770        );
3771        let metadata = env["metadata"]
3772            .as_object()
3773            .expect("metadata key present even on stubs");
3774        assert!(metadata.is_empty(), "stub metadata map must be empty");
3775    }
3776
3777    /// A user-defined schema names a
3778    /// metadata field colliding with structured envelope slots
3779    /// (`sections`, `relationships`). The colliding name surfaces
3780    /// under `metadata.sections` / `metadata.relationships` without
3781    /// disturbing the top-level structured arrays — the nested map
3782    /// decouples user namespace from engine namespace.
3783    #[test]
3784    fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3785        use crate::entity::MetadataValue;
3786        let mut entity = test_entity();
3787        entity.metadata = IndexMap::from([
3788            (
3789                "sections".to_string(),
3790                MetadataValue::String("user-supplied-shadow".to_string()),
3791            ),
3792            (
3793                "relationships".to_string(),
3794                MetadataValue::String("also-shadowed".to_string()),
3795            ),
3796        ]);
3797        let env = build_entity_envelope(
3798            &entity,
3799            0,
3800            None,
3801            None,
3802            None,
3803            OriginClass::FirstParty,
3804            &[],
3805            None,
3806            None,
3807            None,
3808        );
3809        // Top-level structured slots stay structured.
3810        assert!(
3811            env["sections"].is_object(),
3812            "top-level sections stays a map"
3813        );
3814        assert!(
3815            env["relationships"].is_array(),
3816            "top-level relationships stays an array"
3817        );
3818        // User-supplied collisions land inside the nested map.
3819        let metadata = env["metadata"].as_object().expect("metadata map");
3820        assert_eq!(metadata["sections"], "user-supplied-shadow");
3821        assert_eq!(metadata["relationships"], "also-shadowed");
3822    }
3823
3824    /// `_tokens_unfiltered_body` on the structured envelope rides only
3825    /// when `full_tokens` is supplied (a section filter was active);
3826    /// the legacy `_tokens_full` name is not present as an alias.
3827    #[test]
3828    fn build_entity_envelope_unfiltered_body_token_field_name() {
3829        let entity = test_entity();
3830        // Filter-active path — field present under new name.
3831        let env_filtered = build_entity_envelope(
3832            &entity,
3833            10,
3834            Some(42),
3835            None,
3836            None,
3837            OriginClass::FirstParty,
3838            &[],
3839            None,
3840            None,
3841            None,
3842        );
3843        assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3844        assert!(
3845            env_filtered.get("_tokens_full").is_none(),
3846            "_tokens_full must not survive — rename is one-way"
3847        );
3848        // No-filter path — field absent under both names.
3849        let env_unfiltered = build_entity_envelope(
3850            &entity,
3851            10,
3852            None,
3853            None,
3854            None,
3855            OriginClass::FirstParty,
3856            &[],
3857            None,
3858            None,
3859            None,
3860        );
3861        assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3862        assert!(env_unfiltered.get("_tokens_full").is_none());
3863    }
3864
3865    // ------------------------------------------------------------------
3866    // Schema verbosity (lite vs. full) — Plan 01.
3867    // ------------------------------------------------------------------
3868
3869    /// Load the embedded `software` schema (~42 rel-types, 9 entity
3870    /// types, `alias_target_rel_type: REFERENCES`) — the heaviest builtin,
3871    /// so the lite cut has something to bite into.
3872    fn software_schema() -> Arc<Schema> {
3873        memstead_schema::builtins::load_builtin_schemas()
3874            .expect("builtins load")
3875            .into_iter()
3876            .find(|s| s.manifest.name == "software")
3877            .expect("software schema is a builtin")
3878    }
3879
3880    #[test]
3881    fn schema_verbosity_wire_round_trips() {
3882        assert_eq!(
3883            SchemaVerbosity::from_wire("full"),
3884            Some(SchemaVerbosity::Full)
3885        );
3886        assert_eq!(
3887            SchemaVerbosity::from_wire("lite"),
3888            Some(SchemaVerbosity::Lite)
3889        );
3890        assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3891        assert_eq!(SchemaVerbosity::from_wire(""), None);
3892        assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3893        assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3894        assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3895    }
3896
3897    /// Exemplar serving (agent-trust plan 09): `verbosity: full`
3898    /// carries each type's exemplar (title, metadata, sections,
3899    /// relations with placeholder targets); the lite skeleton is
3900    /// BYTE-unchanged between the same schema with and without an
3901    /// exemplar — the per-session lite fetch never grows.
3902    #[test]
3903    fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3904        let manifest = r#"name: servefix
3905version: 1.0.0
3906description: serving fixture
3907when_to_use: tests
3908types:
3909  - sample
3910relationships:
3911  mode: strict
3912  definitions:
3913    - name: PART_OF
3914      description: hier
3915      default_weight: 3.0
3916    - name: _default
3917      description: fallback
3918      default_weight: 1.0
3919community:
3920  resolution: 1.0
3921  seed: 42
3922"#;
3923        let base_type = r#"name: sample
3924description: t
3925when_to_use: tests
3926sections:
3927  - key: body
3928    heading: Body
3929    required: true
3930    search_weight: 10.0
3931    catch_all: true
3932    write_rules: []
3933metadata_fields:
3934  - key: status
3935    description: state
3936    field_type: string
3937    enum_values: [draft, final]
3938    optional: true
3939title_weight: 100.0
3940text_fields:
3941  - body
3942hierarchy_relationship: PART_OF
3943no_self_loop_relationships: []
3944updatable_fields:
3945  - title
3946  - body
3947health_required_fields:
3948  - body
3949staleness_threshold_days: 90
3950write_rules: []
3951"#;
3952        let with_exemplar = format!(
3953            "{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"
3954        );
3955
3956        let plain = Arc::new(
3957            memstead_schema::loader::load_schema_from_memory(
3958                manifest,
3959                &[("sample".to_string(), base_type.to_string())],
3960            )
3961            .expect("fixture loads"),
3962        );
3963        let exemplary = Arc::new(
3964            memstead_schema::loader::load_schema_from_memory(
3965                manifest,
3966                &[("sample".to_string(), with_exemplar)],
3967            )
3968            .expect("fixture loads"),
3969        );
3970
3971        // FULL serves the exemplar with the type.
3972        let full = build_schema_payload(
3973            &exemplary,
3974            vec![],
3975            SchemaVerbosity::Full,
3976            OriginClass::FirstParty,
3977        );
3978        let ex = &full["types"][0]["exemplar"];
3979        assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3980        assert_eq!(ex["metadata"]["status"], "draft");
3981        assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3982        assert_eq!(ex["relations"][0]["target"], "parent-placeholder");
3983        assert_eq!(ex["relations"][0]["rel_type"], "PART_OF");
3984
3985        // FULL without an exemplar: no key (absent, not null).
3986        let full_plain = build_schema_payload(
3987            &plain,
3988            vec![],
3989            SchemaVerbosity::Full,
3990            OriginClass::FirstParty,
3991        );
3992        assert!(full_plain["types"][0].get("exemplar").is_none());
3993
3994        // LITE is byte-identical with and without the exemplar — the
3995        // skeleton every session fetches does not grow.
3996        let lite_with = build_schema_payload(
3997            &exemplary,
3998            vec![],
3999            SchemaVerbosity::Lite,
4000            OriginClass::FirstParty,
4001        );
4002        let lite_without = build_schema_payload(
4003            &plain,
4004            vec![],
4005            SchemaVerbosity::Lite,
4006            OriginClass::FirstParty,
4007        );
4008        assert_eq!(
4009            serde_json::to_string(&lite_with).unwrap(),
4010            serde_json::to_string(&lite_without).unwrap(),
4011            "lite must not change when an exemplar exists"
4012        );
4013        assert!(
4014            !serde_json::to_string(&lite_with)
4015                .unwrap()
4016                .contains("exemplar"),
4017            "lite must not mention exemplars at all"
4018        );
4019    }
4020
4021    /// A first-party schema labels its origin and serves its full prose
4022    /// under `full`. The origin field is additive and present in both
4023    /// verbosities so a consuming host can always read it.
4024    #[test]
4025    fn first_party_origin_is_labelled_and_keeps_prose() {
4026        let schema = software_schema();
4027        let full = build_schema_payload(
4028            &schema,
4029            vec!["v".into()],
4030            SchemaVerbosity::Full,
4031            OriginClass::FirstParty,
4032        );
4033        assert_eq!(full["origin"], "first-party");
4034        // First-party full keeps the prose-instruction fields.
4035        assert!(full["description"].is_string());
4036        let t = &full["types"].as_array().unwrap()[0];
4037        assert!(t.get("system_context").is_some());
4038        assert!(t.get("writing_guidance").is_some());
4039
4040        // The origin label rides the lite skeleton too.
4041        let lite = build_schema_payload(
4042            &schema,
4043            vec!["v".into()],
4044            SchemaVerbosity::Lite,
4045            OriginClass::FirstParty,
4046        );
4047        assert_eq!(lite["origin"], "first-party");
4048    }
4049
4050    /// Declared constraints and `required_outgoing` severities are
4051    /// visible at BOTH verbosity levels — no legality condition may
4052    /// exist that the schema response omits. Complement: a type
4053    /// declaring none renders `constraints: []`, never an absent key.
4054    #[test]
4055    fn constraints_and_severity_render_at_both_verbosities() {
4056        let manifest = r#"name: constrained
4057version: 1.0.0
4058description: constraint render fixture
4059when_to_use: render tests
4060types:
4061  - sample
4062relationships:
4063  mode: strict
4064  definitions:
4065    - name: PART_OF
4066      description: hier
4067      default_weight: 3.0
4068    - name: _default
4069      description: fallback
4070      default_weight: 1.0
4071community:
4072  resolution: 1.0
4073  seed: 42
4074"#;
4075        let type_yaml = r#"name: sample
4076description: t
4077when_to_use: tests
4078sections:
4079  - key: body
4080    heading: Body
4081    required: true
4082    search_weight: 10.0
4083    catch_all: true
4084    write_rules: []
4085metadata_fields:
4086  - key: status
4087    description: state
4088    field_type: string
4089    enum_values: [open, checked]
4090    optional: true
4091  - key: checked_by
4092    description: who
4093    field_type: string
4094    optional: true
4095title_weight: 100.0
4096text_fields:
4097  - body
4098hierarchy_relationship: PART_OF
4099no_self_loop_relationships: []
4100updatable_fields:
4101  - title
4102  - body
4103health_required_fields:
4104  - body
4105staleness_threshold_days: 90
4106required_outgoing:
4107  - relationships: [PART_OF]
4108    cardinality: at_least_one
4109    severity: block
4110constraints:
4111  - kind: requires_when
4112    field: checked_by
4113    when_field: status
4114    when_value: checked
4115  - kind: unique
4116    fields: [status, checked_by]
4117  - kind: enum_from_neighbour
4118    field: status
4119    rel_type: PART_OF
4120    section: body
4121  - kind: status_propagation
4122    field: status
4123    value: checked
4124    rel_type: PART_OF
4125    direction: incoming
4126write_rules: []
4127"#;
4128        let schema = Arc::new(
4129            memstead_schema::loader::load_schema_from_memory(
4130                manifest,
4131                &[("sample".to_string(), type_yaml.to_string())],
4132            )
4133            .expect("fixture loads"),
4134        );
4135
4136        // All five constraint forms (requires_when, unique,
4137        // enum_from_neighbour, status_propagation here; form 4 is the
4138        // required_outgoing severity) must be visible with their
4139        // severity at both verbosity levels.
4140        let expected_constraints = serde_json::json!([
4141            {
4142                "kind": "requires_when",
4143                "field": "checked_by",
4144                "when_field": "status",
4145                "when_value": "checked",
4146                "severity": "warn",
4147            },
4148            {
4149                "kind": "unique",
4150                "fields": ["status", "checked_by"],
4151                "severity": "block",
4152            },
4153            {
4154                "kind": "enum_from_neighbour",
4155                "field": "status",
4156                "rel_type": "PART_OF",
4157                "section": "body",
4158                "severity": "warn",
4159            },
4160            {
4161                "kind": "status_propagation",
4162                "field": "status",
4163                "value": "checked",
4164                "rel_type": "PART_OF",
4165                "direction": "incoming",
4166                "severity": "warn",
4167            },
4168        ]);
4169
4170        let full = build_schema_payload(
4171            &schema,
4172            vec![],
4173            SchemaVerbosity::Full,
4174            OriginClass::FirstParty,
4175        );
4176        let t = &full["types"].as_array().unwrap()[0];
4177        assert_eq!(t["constraints"], expected_constraints);
4178        assert_eq!(t["required_outgoing"][0]["severity"], "block");
4179
4180        let lite = build_schema_payload(
4181            &schema,
4182            vec![],
4183            SchemaVerbosity::Lite,
4184            OriginClass::FirstParty,
4185        );
4186        let ts = &lite["types_summary"].as_array().unwrap()[0];
4187        assert_eq!(ts["constraints"], expected_constraints);
4188        assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4189
4190        // Section-format declarations render at BOTH verbosity
4191        // levels (plan 08 shares plan 07's no-hidden-legality rule).
4192        let fmt_manifest = r#"name: formatted
4193version: 1.0.0
4194description: format render fixture
4195when_to_use: render tests
4196types:
4197  - plan
4198relationships:
4199  mode: strict
4200  definitions:
4201    - name: PART_OF
4202      description: hier
4203      default_weight: 1.0
4204    - name: _default
4205      description: fallback
4206      default_weight: 1.0
4207community:
4208  resolution: 1.0
4209  seed: 42
4210"#;
4211        let fmt_type = r#"name: plan
4212description: t
4213when_to_use: tests
4214sections:
4215  - key: body
4216    heading: Body
4217    required: true
4218    search_weight: 10.0
4219    catch_all: true
4220    write_rules: []
4221  - key: meilensteine
4222    heading: Meilensteine
4223    required: false
4224    search_weight: 5.0
4225    catch_all: false
4226    write_rules: []
4227    content: "(heading(3) list(bullet))+"
4228    item_pattern: '\*\*(?<name>[^*]+)\*\*'
4229    example: |
4230      ### Phase 1
4231      - **Kickoff**
4232    format_severity: warn
4233  - key: tabelle
4234    heading: Tabelle
4235    required: false
4236    search_weight: 5.0
4237    catch_all: false
4238    write_rules: []
4239    content: "table"
4240    table:
4241      columns: [Name, Datum]
4242      column_patterns:
4243        Datum: '\d{4}-\d{2}-\d{2}'
4244  - key: belege
4245    heading: Belege
4246    required: false
4247    search_weight: 5.0
4248    catch_all: false
4249    write_rules: []
4250    content: "paragraph+"
4251    item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4252metadata_fields: []
4253title_weight: 100.0
4254text_fields:
4255  - body
4256hierarchy_relationship: PART_OF
4257no_self_loop_relationships: []
4258updatable_fields:
4259  - title
4260  - body
4261health_required_fields:
4262  - body
4263staleness_threshold_days: 90
4264write_rules: []
4265"#;
4266        let fmt_schema = Arc::new(
4267            memstead_schema::loader::load_schema_from_memory(
4268                fmt_manifest,
4269                &[("plan".to_string(), fmt_type.to_string())],
4270            )
4271            .expect("format fixture loads"),
4272        );
4273        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4274            let payload =
4275                build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4276            let sections_key = match verbosity {
4277                SchemaVerbosity::Full => &payload["types"][0]["sections"],
4278                SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4279            };
4280            let secs = sections_key.as_array().unwrap();
4281            let meilensteine = secs
4282                .iter()
4283                .find(|s| s["key"] == "meilensteine")
4284                .expect("declared section present");
4285            assert_eq!(
4286                meilensteine["content"], "(heading(3) list(bullet))+",
4287                "{verbosity:?} carries content"
4288            );
4289            assert!(
4290                meilensteine["item_pattern"]
4291                    .as_str()
4292                    .unwrap()
4293                    .contains("name")
4294            );
4295            assert!(
4296                meilensteine["example"]
4297                    .as_str()
4298                    .unwrap()
4299                    .contains("Kickoff")
4300            );
4301            assert_eq!(meilensteine["format_severity"], "warn");
4302            let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4303            assert_eq!(tabelle["format_severity"], "block", "default renders");
4304            assert_eq!(tabelle["table"]["columns"][0], "Name");
4305            assert!(
4306                tabelle["table"]["column_patterns"]["Datum"]
4307                    .as_str()
4308                    .is_some()
4309            );
4310            let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4311            assert_eq!(belege["content"], "paragraph+");
4312            assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4313            let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4314            assert!(
4315                body.get("content").is_none() && body.get("format_severity").is_none(),
4316                "undeclared section keeps its pre-plan shape"
4317            );
4318        }
4319
4320        // Complement: a constraint-free builtin renders the
4321        // always-present empty list at both levels.
4322        let plain_full = build_schema_payload(
4323            &software_schema(),
4324            vec![],
4325            SchemaVerbosity::Full,
4326            OriginClass::FirstParty,
4327        );
4328        let pt = &plain_full["types"].as_array().unwrap()[0];
4329        assert_eq!(pt["constraints"], serde_json::json!([]));
4330        let plain_lite = build_schema_payload(
4331            &software_schema(),
4332            vec![],
4333            SchemaVerbosity::Lite,
4334            OriginClass::FirstParty,
4335        );
4336        let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4337        assert_eq!(pts["constraints"], serde_json::json!([]));
4338    }
4339
4340    /// A third-party schema is de-framed: a `full`-verbosity request is
4341    /// overridden to the structural-only skeleton, so NONE of the
4342    /// prose-instruction fields (`system_context`, `writing_guidance`,
4343    /// section `write_rules`, schema `description` / `when_to_use`,
4344    /// `default_writing_guidance`, rel `description` / `when_to_use`)
4345    /// reach a consuming agent — even though `full` was asked for. The
4346    /// structural skeleton (type/section/field/rel shape) survives so the
4347    /// mem stays understandable and queryable. This is the refusal
4348    /// complement: a `full` request cannot re-admit the prose.
4349    #[test]
4350    fn third_party_origin_forces_structural_only_even_under_full() {
4351        let schema = software_schema();
4352        let full_requested = build_schema_payload(
4353            &schema,
4354            vec!["v".into()],
4355            SchemaVerbosity::Full,
4356            OriginClass::ThirdParty,
4357        );
4358
4359        // Origin label.
4360        assert_eq!(full_requested["origin"], "third-party");
4361
4362        // Prose-bearing rich arrays are GONE despite the full request;
4363        // the structural-only summaries are present instead.
4364        assert!(
4365            full_requested.get("types").is_none(),
4366            "third-party omits the rich `types` array even under full"
4367        );
4368        assert!(
4369            full_requested.get("relationships").is_none(),
4370            "third-party omits the rich `relationships` array even under full"
4371        );
4372        assert!(
4373            full_requested["types_summary"].is_array(),
4374            "third-party serves the structural `types_summary` skeleton"
4375        );
4376        assert!(
4377            full_requested["relationships_summary"].is_array(),
4378            "third-party serves the structural `relationships_summary` skeleton"
4379        );
4380
4381        // Schema-level prose-instruction fields dropped.
4382        assert!(
4383            full_requested.get("description").is_none(),
4384            "third-party drops schema description prose"
4385        );
4386        assert!(
4387            full_requested.get("when_to_use").is_none(),
4388            "third-party drops schema when_to_use prose"
4389        );
4390        assert!(
4391            full_requested.get("default_writing_guidance").is_none(),
4392            "third-party drops default_writing_guidance prose"
4393        );
4394
4395        // Per-type prose-instruction fields dropped.
4396        for t in full_requested["types_summary"].as_array().unwrap() {
4397            assert!(
4398                t.get("system_context").is_none(),
4399                "third-party drops system_context"
4400            );
4401            assert!(
4402                t.get("writing_guidance").is_none(),
4403                "third-party drops writing_guidance"
4404            );
4405            assert!(
4406                t.get("description").is_none(),
4407                "third-party drops type description"
4408            );
4409            for s in t["sections"].as_array().unwrap() {
4410                assert!(
4411                    s.get("write_rules").is_none(),
4412                    "third-party drops section write_rules"
4413                );
4414            }
4415        }
4416        // Per-rel prose dropped.
4417        for r in full_requested["relationships_summary"].as_array().unwrap() {
4418            assert!(
4419                r.get("description").is_none(),
4420                "third-party drops rel description"
4421            );
4422            assert!(
4423                r.get("when_to_use").is_none(),
4424                "third-party drops rel when_to_use"
4425            );
4426        }
4427
4428        // A third-party schema served under `full` is byte-identical to
4429        // the same schema served under `lite` (modulo the origin label,
4430        // which is identical here) — the override fully collapses to Lite.
4431        let lite_requested = build_schema_payload(
4432            &schema,
4433            vec!["v".into()],
4434            SchemaVerbosity::Lite,
4435            OriginClass::ThirdParty,
4436        );
4437        assert_eq!(
4438            full_requested, lite_requested,
4439            "third-party full must collapse to the lite skeleton"
4440        );
4441    }
4442
4443    #[test]
4444    fn full_payload_carries_the_rich_arrays_and_prose() {
4445        let schema = software_schema();
4446        let full = build_schema_payload(
4447            &schema,
4448            vec!["v".into()],
4449            SchemaVerbosity::Full,
4450            OriginClass::FirstParty,
4451        );
4452
4453        // Full keeps today's contract: rich arrays + schema-level prose.
4454        assert!(full["types"].is_array(), "full has `types`");
4455        assert!(full["relationships"].is_array(), "full has `relationships`");
4456        assert!(
4457            full.get("types_summary").is_none(),
4458            "full omits `types_summary`"
4459        );
4460        assert!(
4461            full.get("relationships_summary").is_none(),
4462            "full omits `relationships_summary`"
4463        );
4464        assert!(
4465            full["description"].is_string(),
4466            "full keeps schema description"
4467        );
4468        assert!(
4469            full["when_to_use"].is_string(),
4470            "full keeps schema when_to_use"
4471        );
4472        assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4473
4474        // A full type entry keeps the prose the lite cut drops.
4475        let t = &full["types"].as_array().unwrap()[0];
4476        assert!(t["description"].is_string());
4477        assert!(t.get("writing_guidance").is_some());
4478        assert!(t.get("system_context").is_some());
4479        // A full rel entry keeps its prose.
4480        let r = &full["relationships"].as_array().unwrap()[0];
4481        assert!(r["description"].is_string());
4482        assert!(r.get("when_to_use").is_some());
4483        assert!(r.get("default_weight").is_some());
4484    }
4485
4486    /// The declared `required_outgoing` blocks appear per type — with
4487    /// their relationship lists and cardinality, in declaration order —
4488    /// at BOTH verbosity levels, and a type declaring none reports an
4489    /// empty list (never a missing key). The `project` built-in is the
4490    /// live fixture: `evidence` declares one block, `decision` (among
4491    /// others) declares none. The `no_self_loop_relationships_effect`
4492    /// note ships at both levels and claims nothing beyond the
4493    /// self-loop refusal.
4494    #[test]
4495    fn required_outgoing_reported_with_cardinality_at_both_levels() {
4496        let reg = memstead_schema::SchemaRegistry::builtin();
4497        let project = reg
4498            .get("project", &semver::Version::new(0, 2, 0))
4499            .expect("project is a built-in");
4500
4501        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4502            let payload =
4503                build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4504            let types_key = if verbosity == SchemaVerbosity::Full {
4505                "types"
4506            } else {
4507                "types_summary"
4508            };
4509            let types = payload[types_key].as_array().expect("types array");
4510
4511            let mut saw_evidence = false;
4512            let mut saw_memo = false;
4513            for t in types {
4514                let ro = t
4515                    .get("required_outgoing")
4516                    .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4517                    .as_array()
4518                    .expect("required_outgoing is an array for every type");
4519                if t["name"] == "evidence" {
4520                    saw_evidence = true;
4521                    assert_eq!(ro.len(), 1, "evidence declares one block");
4522                    assert_eq!(
4523                        ro[0]["relationships"],
4524                        serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4525                        "relationship alternatives in declaration order"
4526                    );
4527                    assert_eq!(
4528                        ro[0]["cardinality"], "at_least_one",
4529                        "cardinality rendered as declared — the open upper bound \
4530                         stays open, never a finite number"
4531                    );
4532                } else if t["name"] == "memo" {
4533                    // A type declaring no blocks reports the empty
4534                    // list, not a missing key.
4535                    saw_memo = true;
4536                    assert!(ro.is_empty(), "memo declares no blocks → empty list");
4537                }
4538            }
4539            assert!(saw_evidence, "project schema carries the evidence type");
4540            assert!(saw_memo, "project schema carries the memo type");
4541
4542            // The effect note for no_self_loop_relationships ships at both
4543            // levels and states the single real effect.
4544            let note = payload["no_self_loop_relationships_effect"]
4545                .as_str()
4546                .expect("effect note present at both verbosity levels");
4547            assert!(note.contains("self-loop"), "names the actual effect");
4548            assert!(
4549                !note.contains("propagates impact") || note.contains("does not propagate"),
4550                "claims no propagation behaviour beyond the self-loop refusal"
4551            );
4552            assert!(
4553                note.contains("status_propagation"),
4554                "deprecation pointer names the real propagation declaration"
4555            );
4556        }
4557    }
4558
4559    /// A conditional `required_outgoing` block's trigger (`when_field`
4560    /// / `when_value`) is visible at BOTH verbosity levels — no
4561    /// legality condition the schema response omits — while an
4562    /// unconditional block keeps its byte-identical three-key shape
4563    /// (no `when_*` keys at all).
4564    #[test]
4565    fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4566        let manifest = r#"name: condro-render
4567version: 0.1.0
4568description: conditional required_outgoing render fixture
4569when_to_use: tests
4570types:
4571  - task
4572relationships:
4573  mode: strict
4574  definitions:
4575    - name: PART_OF
4576      description: hier
4577      default_weight: 3.0
4578    - name: _default
4579      description: fallback
4580      default_weight: 1.0
4581community:
4582  resolution: 1.0
4583  seed: 42
4584"#;
4585        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";
4586        let schema = Arc::new(
4587            memstead_schema::load_schema_from_memory(
4588                manifest,
4589                &[("task".to_string(), task_yaml.to_string())],
4590            )
4591            .expect("render fixture schema must parse"),
4592        );
4593
4594        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4595            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4596            let types_key = if verbosity == SchemaVerbosity::Full {
4597                "types"
4598            } else {
4599                "types_summary"
4600            };
4601            let task = &payload[types_key].as_array().expect("types array")[0];
4602            let ro = task["required_outgoing"].as_array().expect("blocks array");
4603            assert_eq!(ro.len(), 2);
4604            assert!(
4605                ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4606                "unconditional block carries no when_* keys: {:?}",
4607                ro[0]
4608            );
4609            assert_eq!(ro[1]["when_field"], "status");
4610            assert_eq!(ro[1]["when_value"], "checked");
4611        }
4612    }
4613
4614    /// Declared `acyclic_sets` and a `status_propagation` relation
4615    /// set are visible at BOTH verbosity levels; a single-name
4616    /// propagation declaration keeps its `rel_type` key with no
4617    /// `rel_types`, and a schema without sets carries no
4618    /// `acyclic_sets` key at all.
4619    #[test]
4620    fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4621        let manifest = r#"name: relsets-render
4622version: 0.1.0
4623description: relation-set render fixture
4624when_to_use: tests
4625types:
4626  - claim
4627relationships:
4628  mode: strict
4629  acyclic_sets:
4630    - [GROUNDS, CONCLUDES]
4631  definitions:
4632    - name: GROUNDS
4633      description: g
4634      default_weight: 3.0
4635    - name: CONCLUDES
4636      description: c
4637      default_weight: 3.0
4638    - name: PART_OF
4639      description: hier
4640      default_weight: 1.0
4641    - name: _default
4642      description: fallback
4643      default_weight: 1.0
4644community:
4645  resolution: 1.0
4646  seed: 42
4647"#;
4648        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";
4649        let schema = Arc::new(
4650            memstead_schema::load_schema_from_memory(
4651                manifest,
4652                &[("claim".to_string(), claim.to_string())],
4653            )
4654            .expect("render fixture schema must parse"),
4655        );
4656
4657        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4658            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4659            assert_eq!(
4660                payload["acyclic_sets"],
4661                serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4662                "acyclic_sets present at {verbosity:?}"
4663            );
4664            let types_key = if verbosity == SchemaVerbosity::Full {
4665                "types"
4666            } else {
4667                "types_summary"
4668            };
4669            let claim = &payload[types_key].as_array().expect("types array")[0];
4670            let constraints = claim["constraints"].as_array().expect("constraints array");
4671            assert_eq!(
4672                constraints[0]["rel_types"],
4673                serde_json::json!(["GROUNDS", "CONCLUDES"])
4674            );
4675            assert!(
4676                constraints[0].get("rel_type").is_none(),
4677                "set declaration carries no single-name key: {:?}",
4678                constraints[0]
4679            );
4680            assert_eq!(constraints[1]["rel_type"], "PART_OF");
4681            assert!(
4682                constraints[1].get("rel_types").is_none(),
4683                "single-name declaration stays byte-identical: {:?}",
4684                constraints[1]
4685            );
4686        }
4687
4688        // A schema without sets carries no `acyclic_sets` key.
4689        let plain = software_schema();
4690        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4691            let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4692            assert!(
4693                payload.get("acyclic_sets").is_none(),
4694                "undeclared schema carries no acyclic_sets key"
4695            );
4696        }
4697    }
4698
4699    /// The labelling declaration is visible at BOTH verbosity levels
4700    /// with attack set and support walk echoed whole; a schema
4701    /// declaring none carries no `labelling` key at all.
4702    #[test]
4703    fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4704        let manifest = r#"name: labelling-render
4705version: 0.1.0
4706description: labelling render fixture
4707when_to_use: tests
4708types:
4709  - claim
4710relationships:
4711  mode: strict
4712  labelling:
4713    attack: [REBUTS]
4714    support:
4715      relationships: [GROUNDS]
4716      direction: out
4717      terminal_types: [claim]
4718  definitions:
4719    - name: REBUTS
4720      description: attack
4721      default_weight: 3.0
4722    - name: GROUNDS
4723      description: support
4724      default_weight: 3.0
4725    - name: PART_OF
4726      description: hier
4727      default_weight: 1.0
4728    - name: _default
4729      description: fallback
4730      default_weight: 1.0
4731community:
4732  resolution: 1.0
4733  seed: 42
4734"#;
4735        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";
4736        let schema = Arc::new(
4737            memstead_schema::load_schema_from_memory(
4738                manifest,
4739                &[("claim".to_string(), claim.to_string())],
4740            )
4741            .expect("render fixture schema must parse"),
4742        );
4743
4744        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4745            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4746            assert_eq!(
4747                payload["labelling"]["attack"],
4748                serde_json::json!(["REBUTS"]),
4749                "attack set present at {verbosity:?}"
4750            );
4751            assert_eq!(
4752                payload["labelling"]["support"]["relationships"],
4753                serde_json::json!(["GROUNDS"])
4754            );
4755            assert_eq!(payload["labelling"]["support"]["direction"], "out");
4756        }
4757
4758        let plain = software_schema();
4759        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4760            let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4761            assert!(
4762                payload.get("labelling").is_none(),
4763                "undeclared schema carries no labelling key"
4764            );
4765        }
4766    }
4767
4768    /// Declared signals are visible at BOTH verbosity levels with the
4769    /// declaration echoed whole; a type declaring none carries no
4770    /// `signals` key at all.
4771    #[test]
4772    fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4773        let manifest = r#"name: signals-render
4774version: 0.1.0
4775description: signal render fixture
4776when_to_use: tests
4777types:
4778  - claim
4779  - objection
4780relationships:
4781  mode: strict
4782  definitions:
4783    - name: REBUTS
4784      description: r
4785      default_weight: 3.0
4786    - name: PART_OF
4787      description: hier
4788      default_weight: 1.0
4789    - name: _default
4790      description: fallback
4791      default_weight: 1.0
4792community:
4793  resolution: 1.0
4794  seed: 42
4795"#;
4796        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";
4797        let claim = format!(
4798            "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"
4799        );
4800        let objection = format!(
4801            "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}"
4802        );
4803        let schema = Arc::new(
4804            memstead_schema::load_schema_from_memory(
4805                manifest,
4806                &[
4807                    ("claim".to_string(), claim),
4808                    ("objection".to_string(), objection),
4809                ],
4810            )
4811            .expect("render fixture schema must parse"),
4812        );
4813
4814        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4815            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4816            let types_key = if verbosity == SchemaVerbosity::Full {
4817                "types"
4818            } else {
4819                "types_summary"
4820            };
4821            let types = payload[types_key].as_array().expect("types array");
4822            let claim = types
4823                .iter()
4824                .find(|t| t["name"] == "claim")
4825                .expect("claim type present");
4826            let sigs = claim["signals"].as_array().expect("signals array");
4827            assert_eq!(sigs[0]["name"], "attack_load");
4828            assert_eq!(sigs[0]["kind"], "edge_load");
4829            assert_eq!(sigs[0]["direction"], "in");
4830            assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4831            assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4832            let objection = types
4833                .iter()
4834                .find(|t| t["name"] == "objection")
4835                .expect("objection type present");
4836            assert!(
4837                objection.get("signals").is_none(),
4838                "undeclared type carries no signals key"
4839            );
4840        }
4841    }
4842
4843    /// A declared `must_reach` obligation is visible at BOTH verbosity
4844    /// levels with the declaration echoed (relation set, direction,
4845    /// terminal types, depth); a type declaring none carries no
4846    /// `must_reach` key at all (undeclared schemas keep their payload
4847    /// bytes unchanged).
4848    #[test]
4849    fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4850        let manifest = r#"name: mustreach-render
4851version: 0.1.0
4852description: must_reach render fixture
4853when_to_use: tests
4854types:
4855  - claim
4856  - evidence
4857relationships:
4858  mode: strict
4859  definitions:
4860    - name: GROUNDS
4861      description: g
4862      default_weight: 3.0
4863    - name: PART_OF
4864      description: hier
4865      default_weight: 1.0
4866    - name: _default
4867      description: fallback
4868      default_weight: 1.0
4869community:
4870  resolution: 1.0
4871  seed: 42
4872"#;
4873        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";
4874        let claim = format!(
4875            "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"
4876        );
4877        let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4878        let schema = Arc::new(
4879            memstead_schema::load_schema_from_memory(
4880                manifest,
4881                &[
4882                    ("claim".to_string(), claim),
4883                    ("evidence".to_string(), evidence),
4884                ],
4885            )
4886            .expect("render fixture schema must parse"),
4887        );
4888
4889        for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4890            let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4891            let types_key = if verbosity == SchemaVerbosity::Full {
4892                "types"
4893            } else {
4894                "types_summary"
4895            };
4896            let types = payload[types_key].as_array().expect("types array");
4897            let claim = types
4898                .iter()
4899                .find(|t| t["name"] == "claim")
4900                .expect("claim type present");
4901            let mr = claim["must_reach"].as_array().expect("obligations array");
4902            assert_eq!(mr.len(), 1);
4903            assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4904            assert_eq!(mr[0]["direction"], "out");
4905            assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4906            assert_eq!(mr[0]["max_depth"], 12);
4907            let evidence = types
4908                .iter()
4909                .find(|t| t["name"] == "evidence")
4910                .expect("evidence type present");
4911            assert!(
4912                evidence.get("must_reach").is_none(),
4913                "undeclared type carries no must_reach key: {evidence:?}"
4914            );
4915        }
4916    }
4917
4918    #[test]
4919    fn lite_payload_is_the_structural_skeleton_without_prose() {
4920        let schema = software_schema();
4921        let lite = build_schema_payload(
4922            &schema,
4923            vec!["v".into()],
4924            SchemaVerbosity::Lite,
4925            OriginClass::FirstParty,
4926        );
4927
4928        // Heavy arrays under the distinct lite keys; rich keys absent.
4929        let types = lite["types_summary"]
4930            .as_array()
4931            .expect("lite has `types_summary`");
4932        let rels = lite["relationships_summary"]
4933            .as_array()
4934            .expect("lite has `relationships_summary`");
4935        assert!(lite.get("types").is_none(), "lite omits rich `types`");
4936        assert!(
4937            lite.get("relationships").is_none(),
4938            "lite omits rich `relationships`"
4939        );
4940
4941        // Alias pointer + endpoint constraints survive the cut — every
4942        // flag an agent needs to author a legal write.
4943        assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4944
4945        // Schema-level prose dropped.
4946        assert!(
4947            lite.get("description").is_none(),
4948            "lite drops schema description"
4949        );
4950        assert!(
4951            lite.get("when_to_use").is_none(),
4952            "lite drops schema when_to_use"
4953        );
4954        assert!(
4955            lite.get("default_writing_guidance").is_none(),
4956            "lite drops default_writing_guidance"
4957        );
4958
4959        // Every entity-type name carries its section keys (with `required`)
4960        // and field shapes — and NO type/section prose.
4961        for t in types {
4962            assert!(t["name"].is_string());
4963            let sections = t["sections"].as_array().expect("lite type has sections");
4964            for s in sections {
4965                assert!(s["key"].is_string(), "section carries its key");
4966                assert!(s["required"].is_boolean(), "section carries required flag");
4967                assert!(
4968                    s.get("write_rules").is_none(),
4969                    "lite section drops write_rules prose"
4970                );
4971                assert!(s.get("heading").is_none(), "lite section drops heading");
4972            }
4973            assert!(
4974                t.get("description").is_none(),
4975                "lite type drops description"
4976            );
4977            assert!(
4978                t.get("writing_guidance").is_none(),
4979                "lite type drops writing_guidance"
4980            );
4981            assert!(
4982                t.get("system_context").is_none(),
4983                "lite type drops system_context"
4984            );
4985            // `no_self_loop_relationships` rides along — it governs the
4986            // self-loop relate refusal, a write-time refusal lite must let
4987            // an agent avoid.
4988            assert!(
4989                t.get("no_self_loop_relationships").is_some(),
4990                "lite type keeps no_self_loop_relationships"
4991            );
4992            // `required_outgoing` rides along — the only declared
4993            // legality condition on outgoing edges. Always an array,
4994            // never an absent key (absence would read as "unknown").
4995            assert!(
4996                t.get("required_outgoing").is_some_and(|v| v.is_array()),
4997                "lite type keeps required_outgoing as an array"
4998            );
4999            // Field shapes present (name + required), prose absent.
5000            if let Some(fields) = t["fields"].as_array() {
5001                for f in fields {
5002                    assert!(f["name"].is_string());
5003                    assert!(f["required"].is_boolean());
5004                    assert!(
5005                        f.get("description").is_none(),
5006                        "lite field drops description"
5007                    );
5008                }
5009            }
5010        }
5011
5012        // Every relationship name carries its allowed endpoints and the
5013        // refusal-governing flags — and NO description/when_to_use prose.
5014        for r in rels {
5015            assert!(r["name"].is_string());
5016            assert!(
5017                r.get("allowed_sources").is_some(),
5018                "lite rel has allowed_sources"
5019            );
5020            assert!(
5021                r.get("allowed_targets").is_some(),
5022                "lite rel has allowed_targets"
5023            );
5024            assert!(
5025                r.get("manual_authoring").is_some(),
5026                "lite rel keeps manual_authoring"
5027            );
5028            assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
5029            assert!(
5030                r.get("per_edge_description").is_some(),
5031                "lite rel keeps per_edge_description"
5032            );
5033            assert!(r.get("description").is_none(), "lite rel drops description");
5034            assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
5035            assert!(
5036                r.get("default_weight").is_none(),
5037                "lite rel drops default_weight"
5038            );
5039        }
5040    }
5041
5042    #[test]
5043    fn lite_is_measurably_smaller_than_full() {
5044        let schema = software_schema();
5045        let full = build_schema_payload(
5046            &schema,
5047            vec!["v".into()],
5048            SchemaVerbosity::Full,
5049            OriginClass::FirstParty,
5050        );
5051        let lite = build_schema_payload(
5052            &schema,
5053            vec!["v".into()],
5054            SchemaVerbosity::Lite,
5055            OriginClass::FirstParty,
5056        );
5057        let full_len = serde_json::to_string(&full).unwrap().len();
5058        let lite_len = serde_json::to_string(&lite).unwrap().len();
5059        assert!(
5060            lite_len * 2 < full_len,
5061            "lite ({lite_len} B) must be well under half of full ({full_len} B)"
5062        );
5063    }
5064
5065    #[test]
5066    fn lite_full_carry_the_same_type_and_rel_names() {
5067        // The cut drops prose, never an entity type or a rel-type — an
5068        // agent orienting on lite sees the full vocabulary.
5069        let schema = software_schema();
5070        let full = build_schema_payload(
5071            &schema,
5072            vec!["v".into()],
5073            SchemaVerbosity::Full,
5074            OriginClass::FirstParty,
5075        );
5076        let lite = build_schema_payload(
5077            &schema,
5078            vec!["v".into()],
5079            SchemaVerbosity::Lite,
5080            OriginClass::FirstParty,
5081        );
5082
5083        let names = |arr: &serde_json::Value| -> Vec<String> {
5084            arr.as_array()
5085                .unwrap()
5086                .iter()
5087                .map(|v| v["name"].as_str().unwrap().to_string())
5088                .collect()
5089        };
5090        assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
5091        assert_eq!(
5092            names(&full["relationships"]),
5093            names(&lite["relationships_summary"])
5094        );
5095    }
5096
5097    /// Criterion 4 of 04/02, on the surface an agent actually reads. The
5098    /// conformance axis knew, but it is opt-in; a plain `memstead_entity` /
5099    /// `memstead entity --json` returned the swallowed sections as `""` with
5100    /// nothing to distinguish them from sections the author left blank.
5101    #[test]
5102    fn swallowed_sections_carry_a_marker_on_the_plain_read() {
5103        let mut e = test_entity();
5104        e.sections.insert(
5105            "identity".to_string(),
5106            "intro\n\n```rust\nfn main() {}".to_string(),
5107        );
5108        e.sections.insert("purpose".to_string(), String::new());
5109        let env = build_entity_envelope(
5110            &e,
5111            10,
5112            None,
5113            None,
5114            None,
5115            OriginClass::FirstParty,
5116            &[],
5117            None,
5118            None,
5119            None,
5120        );
5121        let marker = &env["_unread_sections"];
5122        assert_eq!(marker["reason"], "UNTERMINATED_FENCE");
5123        assert_eq!(marker["absorbed_into"], "identity");
5124        assert_eq!(marker["sections"], serde_json::json!(["purpose"]));
5125    }
5126
5127    #[test]
5128    fn an_ordinary_entity_carries_no_unread_marker() {
5129        // Including one whose empty section is genuinely just empty, and one
5130        // carrying a CLOSED fence: a marker that fired on either would make
5131        // every blank section look like data loss.
5132        for body in ["plain prose", "```rust\nfn main() {}\n```"] {
5133            let mut e = test_entity();
5134            e.sections.insert("identity".to_string(), body.to_string());
5135            e.sections.insert("purpose".to_string(), String::new());
5136            let env = build_entity_envelope(
5137                &e,
5138                10,
5139                None,
5140                None,
5141                None,
5142                OriginClass::FirstParty,
5143                &[],
5144                None,
5145                None,
5146                None,
5147            );
5148            assert!(
5149                env.get("_unread_sections").is_none(),
5150                "body {body:?} produced a marker"
5151            );
5152        }
5153    }
5154}