Skip to main content

memstead_base/entity/
generator.rs

1//! Entity → Markdown generator. Deterministic output for roundtrip stability.
2//!
3//! Output order:
4//! 1. YAML frontmatter (metadata keys in schema metadata_fields order)
5//! 2. `# {title}`
6//! 3. Required sections (schema order)
7//! 4. Relationships section (if any explicit relations)
8//! 5. Optional sections (schema order)
9
10use memstead_schema::{FieldType, Serialization, TypeDefinition};
11
12use super::Entity;
13
14/// Sentinel value emitted into the frontmatter for required date
15/// fields the caller did not supply and the schema does not
16/// auto-fill. Reads cleanly as "obviously a placeholder" to humans
17/// and agents (epoch zero in ISO 8601) and never gets mistaken for a
18/// real claim.
19pub const MISSING_DATE_SENTINEL: &str = "1970-01-01";
20
21/// Generate markdown from a structured entity.
22///
23/// Invariant: `parse(generate(parse(md))) == parse(md)` — field order, spacing,
24/// and metadata serialization are deterministic.
25pub fn generate_markdown(entity: &Entity, schema: &TypeDefinition) -> String {
26    let mut parts = Vec::new();
27
28    // YAML frontmatter
29    let metadata_str = build_metadata(entity, schema);
30    parts.push(format!("---\n{metadata_str}\n---"));
31
32    // Title
33    parts.push(format!("# {}", entity.title));
34
35    // Required sections (schema order)
36    for s in schema.sections.iter().filter(|s| s.required) {
37        let content = entity
38            .sections
39            .get(s.key.as_str())
40            .map(|v| v.as_str())
41            .unwrap_or("");
42        parts.push(close_open_fence(
43            format!("## {}\n{content}", s.heading),
44            content,
45        ));
46    }
47
48    // Relationships section (between required and optional).
49    // Cross-mem relations render as `[[<mem>:<slug>]]` so the
50    // wiki-link round-trips through the parser (which interprets
51    // bare-slug `[[<slug>]]` as same-mem). Pre-fix the renderer
52    // always emitted `[[<slug>]]`, breaking the round-trip for
53    // cross-mem relations — the parser would land them in the
54    // entity's own mem and the in-memory edge would drift from
55    // the on-disk intent.
56    if !entity.relationships.is_empty() {
57        let rel_lines: Vec<String> = entity
58            .relationships
59            .iter()
60            .map(|r| {
61                let link = if r.target.mem() == entity.mem {
62                    r.target.path().to_string()
63                } else {
64                    format!("{}:{}", r.target.mem(), r.target.path())
65                };
66                // Em-dash form when the relation carries a per-edge
67                // description; canonical delimiter is the three-byte
68                // ` — ` (space + U+2014 + space). Empty / whitespace-only
69                // descriptions are normalised to `None` at every
70                // mutation entry, so `Some("")` should never reach the
71                // renderer — if it does we still avoid emitting a bare
72                // em-dash by treating empty-after-trim as the simple form.
73                match r
74                    .description
75                    .as_deref()
76                    .map(str::trim)
77                    .filter(|s| !s.is_empty())
78                {
79                    Some(text) => format!("- **{}**: [[{link}]] \u{2014} {text}", r.rel_type),
80                    None => format!("- **{}**: [[{link}]]", r.rel_type),
81                }
82            })
83            .collect();
84        parts.push(format!("## Relationships\n{}", rel_lines.join("\n")));
85    }
86
87    // Optional sections (schema order)
88    for s in schema.sections.iter().filter(|s| !s.required) {
89        let content = entity
90            .sections
91            .get(s.key.as_str())
92            .map(|v| v.as_str())
93            .unwrap_or("");
94        // All sections get the same spacing for roundtrip stability
95        parts.push(close_open_fence(
96            format!("## {}\n\n{content}", s.heading),
97            content,
98        ));
99    }
100
101    parts.join("\n\n") + "\n"
102}
103
104/// Terminate a section block whose content ends inside an open code
105/// fence. Without this, everything the generator writes after the block
106/// — the following section headings included — is inside that fence on
107/// the next parse: sections are absorbed, and the document grows on
108/// every parse→generate round. The fix is at generation, not by
109/// mutating the stored content: one round normalises (the reparsed
110/// section content then carries the closing fence), after which
111/// parse→generate is a fixpoint. Balanced content is untouched, so
112/// canonical bytes of well-formed entities do not change.
113fn close_open_fence(mut part: String, content: &str) -> String {
114    if let Some(closer) = crate::markdown::closing_fence_if_unterminated(content) {
115        part.push('\n');
116        part.push_str(&closer);
117    }
118    part
119}
120
121/// Build YAML frontmatter metadata string.
122/// Keys are emitted in the order defined by schema.metadata_fields.
123fn build_metadata(entity: &Entity, schema: &TypeDefinition) -> String {
124    let mut lines = Vec::new();
125
126    for field_def in &schema.metadata_fields {
127        let value = entity.metadata.get(field_def.key.as_str());
128
129        // Optional fields: skip if absent
130        if !field_def.is_required() && value.is_none() {
131            continue;
132        }
133
134        // omit-when-falsy: only emit when truthy
135        if field_def.serialization == Serialization::OmitWhenFalsy {
136            match value {
137                Some(v) if !v.is_falsy() => {}
138                _ => continue,
139            }
140        }
141
142        let formatted = match field_def.field_type {
143            FieldType::Date => {
144                // Schema-managed timestamps (`init_timestamp` /
145                // `auto_timestamp`) are written into metadata by the
146                // create / update flow before the generator runs, so
147                // `value.is_some()` for those. When the value is absent
148                // here, the field is required, the caller did not
149                // supply it, and the schema does not auto-fill it —
150                // `MISSING_REQUIRED_FIELD` already warned. A fallback of
151                // today's date would be indistinguishable from a
152                // real "set today" claim, so use a clearly-unreal
153                // sentinel (`1970-01-01`) so an agent or reviewer
154                // reading the frontmatter sees the placeholder
155                // immediately. Schema-declared `default_value` (when
156                // set) wins over the sentinel — the schema's choice
157                // is authoritative.
158                let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
159                    field_def
160                        .default_value
161                        .as_deref()
162                        .unwrap_or(MISSING_DATE_SENTINEL)
163                        .to_string()
164                });
165                format!("{}: {val}", field_def.key)
166            }
167            FieldType::Boolean => {
168                let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
169                    field_def
170                        .default_value
171                        .as_deref()
172                        .unwrap_or("false")
173                        .to_string()
174                });
175                format!("{}: {val}", field_def.key)
176            }
177            FieldType::Number => {
178                let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
179                    field_def
180                        .default_value
181                        .as_deref()
182                        .unwrap_or("0")
183                        .to_string()
184                });
185                format!("{}: {val}", field_def.key)
186            }
187            FieldType::String => {
188                if field_def.serialization == Serialization::CsvArray {
189                    // csv-array: emit as comma-separated
190                    let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_default();
191                    format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
192                } else {
193                    let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
194                        field_def.default_value.as_deref().unwrap_or("").to_string()
195                    });
196                    format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
197                }
198            }
199        };
200
201        lines.push(formatted);
202    }
203
204    lines.join("\n")
205}
206
207/// YAML-quote a string value when emitting it unquoted would make the
208/// tolerant parser re-type it (as bool/int/float) on the next read.
209///
210/// Round-trip protection for `FieldType::String` metadata whose stored
211/// value happens to look like another type — e.g. a `temporal_range:
212/// "1968"` in source would otherwise be re-emitted as `temporal_range:
213/// 1968`, re-parsed as `MetadataValue::Integer(1968)`, and rejected by
214/// strict ingress as a String-vs-Integer type mismatch. Quoting forces
215/// the tolerant parser's `strip_quotes` branch, which keeps the value
216/// in `MetadataValue::String`.
217///
218/// Prefer double quotes; fall back to single quotes if the value
219/// contains `"`. If the value contains both quote styles the tolerant
220/// parser's `strip_quotes` cannot escape either, so we leave it
221/// unquoted — accepted as a rare lossy corner case, not defended here.
222fn quote_if_ambiguous(s: &str) -> String {
223    if !crate::entity::parser::would_coerce_from_string(s) {
224        return s.to_string();
225    }
226    if !s.contains('"') {
227        format!("\"{s}\"")
228    } else if !s.contains('\'') {
229        format!("'{s}'")
230    } else {
231        s.to_string()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::entity::{EntityId, MetadataValue, Relationship};
239    use indexmap::IndexMap;
240    use memstead_schema::{builtin_names, type_by_name};
241
242    fn make_entity(title: &str, mem: &str) -> Entity {
243        let schema = type_by_name(builtin_names::SPEC).unwrap();
244        let mut metadata = IndexMap::new();
245        metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
246        metadata.insert(
247            "created_date".to_string(),
248            MetadataValue::String("2026-01-15".to_string()),
249        );
250        metadata.insert(
251            "last_modified".to_string(),
252            MetadataValue::String("2026-04-12".to_string()),
253        );
254        metadata.insert(
255            "tags".to_string(),
256            MetadataValue::String("backend, api".to_string()),
257        );
258        metadata.insert(
259            "type".to_string(),
260            MetadataValue::String("spec".to_string()),
261        );
262
263        let mut sections = IndexMap::new();
264        for s in &schema.sections {
265            sections.insert(s.key.clone(), String::new());
266        }
267        sections.insert("identity".to_string(), "Test identity.".to_string());
268        sections.insert("purpose".to_string(), "Test purpose.".to_string());
269
270        let slug = crate::entity::id::title_to_slug(title).unwrap();
271        Entity {
272            id: EntityId::new(mem, &slug),
273            title: title.to_string(),
274            entity_type: "spec".to_string(),
275            mem: mem.to_string(),
276            file_path: format!("{slug}.md"),
277            metadata,
278            sections,
279            relationships: Vec::new(),
280            content_hash: String::new(),
281            stub: false,
282            stub_kind: None,
283            heading_spans: std::collections::HashMap::new(),
284            raw_section_headings: Vec::new(),
285        }
286    }
287
288    fn make_assertion_scaffold(title: &str) -> Entity {
289        let schema = type_by_name(builtin_names::ASSERTION).unwrap();
290        let mut metadata = IndexMap::new();
291        metadata.insert(
292            "created_date".to_string(),
293            MetadataValue::String("2026-01-15".to_string()),
294        );
295        metadata.insert(
296            "last_modified".to_string(),
297            MetadataValue::String("2026-04-12".to_string()),
298        );
299        metadata.insert(
300            "type".to_string(),
301            MetadataValue::String("assertion".to_string()),
302        );
303
304        let mut sections = IndexMap::new();
305        for s in &schema.sections {
306            sections.insert(s.key.clone(), String::new());
307        }
308
309        let slug = crate::entity::id::title_to_slug(title).unwrap();
310        Entity {
311            id: EntityId::new("assertions", &slug),
312            title: title.to_string(),
313            entity_type: "assertion".to_string(),
314            mem: "assertions".to_string(),
315            file_path: format!("{slug}.md"),
316            metadata,
317            sections,
318            relationships: Vec::new(),
319            content_hash: String::new(),
320            stub: false,
321            stub_kind: None,
322            heading_spans: std::collections::HashMap::new(),
323            raw_section_headings: Vec::new(),
324        }
325    }
326
327    #[test]
328    fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
329        let schema = type_by_name(builtin_names::ASSERTION).unwrap();
330        let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
331        let md = generate_markdown(&entity, &schema);
332
333        // Schema-declared headings, not spec ones
334        assert!(md.contains("## Claim"));
335        assert!(md.contains("## Evidence"));
336        assert!(md.contains("## Conditions"));
337        assert!(md.contains("## Counterevidence"));
338        assert!(!md.contains("## Identity"));
339        assert!(!md.contains("## Purpose"));
340
341        // Required sections before optional ones (schema order)
342        let claim_pos = md.find("## Claim").unwrap();
343        let evid_pos = md.find("## Evidence").unwrap();
344        let cond_pos = md.find("## Conditions").unwrap();
345        let counter_pos = md.find("## Counterevidence").unwrap();
346        assert!(claim_pos < evid_pos);
347        assert!(evid_pos < cond_pos);
348        assert!(cond_pos < counter_pos);
349
350        // Metadata defaults from schemas.rs
351        assert!(md.contains("type: assertion"));
352        assert!(md.contains("confidence: medium"));
353        assert!(md.contains("verification_status: unverified"));
354
355        // Optional fields with no value are omitted
356        assert!(!md.contains("source_quality:"));
357        assert!(!md.contains("last_verified:"));
358    }
359
360    #[test]
361    fn generate_basic_entity() {
362        let schema = type_by_name(builtin_names::SPEC).unwrap();
363        let entity = make_entity("Test Entity", "specs");
364        let md = generate_markdown(&entity, &schema);
365
366        assert!(md.starts_with("---\n"));
367        assert!(md.contains("# Test Entity"));
368        assert!(md.contains("## Identity\nTest identity."));
369        assert!(md.contains("## Purpose\nTest purpose."));
370        assert!(md.contains("type: spec"));
371        assert!(md.contains("level: M0"));
372    }
373
374    #[test]
375    fn generate_with_relationships() {
376        let schema = type_by_name(builtin_names::SPEC).unwrap();
377        let mut entity = make_entity("Parent", "specs");
378        entity.relationships.push(Relationship {
379            rel_type: "USES".to_string(),
380            target: EntityId::new("specs", "child-entity"),
381            description: None,
382        });
383        let md = generate_markdown(&entity, &schema);
384
385        assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
386    }
387
388    #[test]
389    fn generate_relationship_with_description_uses_em_dash_delimiter() {
390        let schema = type_by_name(builtin_names::SPEC).unwrap();
391        let mut entity = make_entity("Parent", "specs");
392        entity.relationships.push(Relationship {
393            rel_type: "OTHER".to_string(),
394            target: EntityId::new("specs", "child-entity"),
395            description: Some("replaced by checkout-flow".to_string()),
396        });
397        let md = generate_markdown(&entity, &schema);
398
399        assert!(
400            md.contains(
401                "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
402            ),
403            "expected canonical em-dash delimiter; got:\n{md}"
404        );
405    }
406
407    #[test]
408    fn generate_relationship_without_description_omits_em_dash() {
409        let schema = type_by_name(builtin_names::SPEC).unwrap();
410        let mut entity = make_entity("Parent", "specs");
411        entity.relationships.push(Relationship {
412            rel_type: "USES".to_string(),
413            target: EntityId::new("specs", "child-entity"),
414            description: None,
415        });
416        let md = generate_markdown(&entity, &schema);
417        // Trailing whitespace, em-dash, or stray content must not
418        // appear after the closing `]]` when description is None.
419        assert!(md.contains("- **USES**: [[child-entity]]\n"));
420        assert!(
421            !md.contains("\u{2014}"),
422            "no em-dash should be emitted when description is None"
423        );
424    }
425
426    #[test]
427    fn generate_metadata_order_follows_schema() {
428        let schema = type_by_name(builtin_names::SPEC).unwrap();
429        let entity = make_entity("Order Test", "specs");
430        let md = generate_markdown(&entity, &schema);
431
432        // Canonical base-metadata order: type, created_date, last_modified,
433        // <type-specific>, tags. Type-specific level sits between timestamps
434        // and tags.
435        let type_pos = md.find("type:").unwrap();
436        let created_pos = md.find("created_date:").unwrap();
437        let modified_pos = md.find("last_modified:").unwrap();
438        let level_pos = md.find("level:").unwrap();
439        let tags_pos = md.find("tags:").unwrap();
440
441        assert!(type_pos < created_pos);
442        assert!(created_pos < modified_pos);
443        assert!(modified_pos < level_pos);
444        assert!(level_pos < tags_pos);
445    }
446
447    #[test]
448    fn generate_omits_optional_absent() {
449        let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
450        let mut metadata = IndexMap::new();
451        metadata.insert(
452            "reliability".to_string(),
453            MetadataValue::String("firsthand".to_string()),
454        );
455        metadata.insert(
456            "created_date".to_string(),
457            MetadataValue::String("2026-01-15".to_string()),
458        );
459        metadata.insert(
460            "last_modified".to_string(),
461            MetadataValue::String("2026-04-12".to_string()),
462        );
463        metadata.insert(
464            "type".to_string(),
465            MetadataValue::String("narrative".to_string()),
466        );
467
468        let mut sections = IndexMap::new();
469        for s in &schema.sections {
470            sections.insert(s.key.clone(), "placeholder.".to_string());
471        }
472
473        let entity = Entity {
474            id: EntityId::new("narratives", "no-optional"),
475            title: "No Optional".to_string(),
476            entity_type: "narrative".to_string(),
477            mem: "narratives".to_string(),
478            file_path: "no-optional.md".to_string(),
479            metadata,
480            sections,
481            relationships: Vec::new(),
482            content_hash: String::new(),
483            stub: false,
484            stub_kind: None,
485            heading_spans: std::collections::HashMap::new(),
486            raw_section_headings: Vec::new(),
487        };
488
489        let md = generate_markdown(&entity, &schema);
490        // temporal_range is optional on narrative with no value — must be omitted.
491        assert!(!md.contains("temporal_range:"));
492    }
493
494    #[test]
495    fn generate_ends_with_newline() {
496        let schema = type_by_name(builtin_names::SPEC).unwrap();
497        let entity = make_entity("Newline Test", "specs");
498        let md = generate_markdown(&entity, &schema);
499        assert!(md.ends_with('\n'));
500    }
501
502    /// The planning `goal` type's scope sections round-trip after the
503    /// scope_in→in_scope / scope_out→out_of_scope key fix: content
504    /// written under `## In Scope` / `## Out of Scope` parses back
505    /// under the declared keys instead of falling through to the
506    /// catch-all (which pre-fix silently absorbed and re-headed it).
507    #[test]
508    fn planning_goal_scope_sections_roundtrip() {
509        // Pinned: the builtin ships two planning versions since the
510        // 0.2.0 section-format bump, so a bare-name lookup is
511        // ambiguous by design.
512        let reg = memstead_schema::SchemaRegistry::builtin();
513        let planning = reg
514            .get("planning", &semver::Version::new(0, 1, 0))
515            .expect("planning@0.1.0 is a built-in");
516        let goal = planning.get_type("goal").expect("goal type exists");
517
518        let mut metadata = IndexMap::new();
519        metadata.insert(
520            "type".to_string(),
521            MetadataValue::String("goal".to_string()),
522        );
523        let mut sections = IndexMap::new();
524        for s in &goal.sections {
525            sections.insert(s.key.clone(), String::new());
526        }
527        sections.insert("statement".to_string(), "Ship the gate.".to_string());
528        sections.insert("in_scope".to_string(), "- the engine".to_string());
529        sections.insert("out_of_scope".to_string(), "- the moon".to_string());
530
531        let entity = Entity {
532            id: EntityId::new("plan", "ship-the-gate"),
533            title: "Ship The Gate".to_string(),
534            entity_type: "goal".to_string(),
535            mem: "plan".to_string(),
536            file_path: "ship-the-gate.md".to_string(),
537            metadata,
538            sections,
539            relationships: Vec::new(),
540            content_hash: String::new(),
541            stub: false,
542            stub_kind: None,
543            heading_spans: std::collections::HashMap::new(),
544            raw_section_headings: Vec::new(),
545        };
546
547        let md = generate_markdown(&entity, &goal);
548        assert!(
549            md.contains("## In Scope"),
550            "declared heading emitted:\n{md}"
551        );
552        assert!(
553            md.contains("## Out of Scope"),
554            "declared heading emitted:\n{md}"
555        );
556
557        let parsed = crate::entity::parser::parse_markdown(&md, "ship-the-gate.md", &goal, "plan")
558            .expect("round-trip parse");
559        assert_eq!(
560            parsed.entity.sections.get("in_scope").map(|s| s.trim()),
561            Some("- the engine"),
562            "In Scope content lands under its declared key"
563        );
564        assert_eq!(
565            parsed.entity.sections.get("out_of_scope").map(|s| s.trim()),
566            Some("- the moon"),
567            "Out of Scope content lands under its declared key"
568        );
569        let notes = parsed
570            .entity
571            .sections
572            .get("notes")
573            .map(|s| s.as_str())
574            .unwrap_or("");
575        assert!(
576            !notes.contains("the engine") && !notes.contains("the moon"),
577            "scope content must not be absorbed into the catch-all: {notes:?}"
578        );
579    }
580
581    #[test]
582    fn roundtrip_parse_generate_parse() {
583        let schema = type_by_name(builtin_names::SPEC).unwrap();
584        let md = "\
585---
586type: spec
587created_date: 2026-01-15
588last_modified: 2026-04-12
589level: M0
590tags: backend, api
591---
592# Roundtrip Test
593
594## Identity
595
596This is a roundtrip test entity.
597
598## Purpose
599
600Testing parse→generate→parse stability.
601
602## Relationships
603
604- **USES**: [[other-entity]]
605
606## Specifies
607
608Some content with [[inline-link]].
609
610## Constraints
611
612
613
614## Rationale
615
616";
617
618        // First parse
619        let result1 =
620            crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
621                .unwrap();
622
623        // Generate
624        let generated = generate_markdown(&result1.entity, &schema);
625
626        // Second parse
627        let result2 = crate::entity::parser::parse_markdown(
628            &generated,
629            "roundtrip-test.md",
630            &schema,
631            "specs",
632        )
633        .unwrap();
634
635        // Compare: parse(generate(parse(md))) == parse(md)
636        assert_eq!(result1.entity.title, result2.entity.title);
637        assert_eq!(result1.entity.id, result2.entity.id);
638        assert_eq!(
639            result1.entity.relationships.len(),
640            result2.entity.relationships.len()
641        );
642        for (r1, r2) in result1
643            .entity
644            .relationships
645            .iter()
646            .zip(&result2.entity.relationships)
647        {
648            assert_eq!(r1.rel_type, r2.rel_type);
649            assert_eq!(r1.target, r2.target);
650        }
651        // Compare sections
652        for key in result1.entity.sections.keys() {
653            assert_eq!(
654                result1.entity.sections.get(key).map(|s| s.trim()),
655                result2.entity.sections.get(key).map(|s| s.trim()),
656                "Section '{key}' differs after roundtrip"
657            );
658        }
659        // Compare metadata
660        for (key, val) in &result1.entity.metadata {
661            assert_eq!(
662                Some(val),
663                result2.entity.metadata.get(key),
664                "Metadata '{key}' differs after roundtrip"
665            );
666        }
667
668        // Verify second roundtrip is byte-stable
669        let generated2 = generate_markdown(&result2.entity, &schema);
670        assert_eq!(
671            generated, generated2,
672            "Second roundtrip changed the markdown"
673        );
674    }
675
676    /// A `FieldType::String` value whose text happens to look like a
677    /// number/bool must round-trip as String — the generator has to
678    /// YAML-quote it, otherwise the tolerant parser re-types it on the
679    /// next read and strict ingress rejects the canonical bytes.
680    ///
681    /// Regression lock for the narrative-schema `temporal_range` bug
682    /// surfaced by V3's project-mems round-trip test.
683    #[test]
684    fn generate_quotes_number_shaped_string_value() {
685        let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
686        let mut metadata = IndexMap::new();
687        metadata.insert(
688            "temporal_range".to_string(),
689            MetadataValue::String("1968".to_string()),
690        );
691        metadata.insert(
692            "reliability".to_string(),
693            MetadataValue::String("firsthand".to_string()),
694        );
695        metadata.insert(
696            "created_date".to_string(),
697            MetadataValue::String("2026-04-12".to_string()),
698        );
699        metadata.insert(
700            "last_modified".to_string(),
701            MetadataValue::String("2026-04-12".to_string()),
702        );
703        metadata.insert(
704            "tags".to_string(),
705            MetadataValue::String("history".to_string()),
706        );
707        metadata.insert(
708            "type".to_string(),
709            MetadataValue::String("narrative".to_string()),
710        );
711
712        let mut sections = IndexMap::new();
713        for s in &schema.sections {
714            sections.insert(s.key.clone(), "placeholder.".to_string());
715        }
716
717        let entity = Entity {
718            id: EntityId::new("specs", "ambiguous"),
719            title: "Ambiguous".to_string(),
720            entity_type: "narrative".to_string(),
721            mem: "specs".to_string(),
722            file_path: "ambiguous.md".to_string(),
723            metadata,
724            sections,
725            relationships: Vec::new(),
726            content_hash: String::new(),
727            stub: false,
728            stub_kind: None,
729            heading_spans: std::collections::HashMap::new(),
730            raw_section_headings: Vec::new(),
731        };
732
733        let md = generate_markdown(&entity, &schema);
734        assert!(
735            md.contains("temporal_range: \"1968\""),
736            "number-shaped string value must be emitted quoted, got:\n{md}"
737        );
738
739        // Round-trip must preserve the String typing — unquoted would
740        // coerce back to Integer and break strict ingress.
741        let parsed =
742            crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
743        assert_eq!(
744            parsed.entity.metadata.get("temporal_range"),
745            Some(&MetadataValue::String("1968".to_string())),
746            "quoted value must round-trip as String"
747        );
748    }
749}