Skip to main content

memstead_base/
runtime_validator.rs

1//! Runtime CRUD validators consumed by the unified [`crate::Engine`] —
2//! the single mutation engine, whatever storage backend (mem-repo git
3//! branch, plain folder, archive) sits behind a mount.
4//!
5//! Distinct concern from [`crate::validator`], which validates sealed
6//! archive bytes at the registry / read-mem ingress boundary. This
7//! module sits *inside* the mutation engine and gates per-mutation
8//! payloads (section keys, metadata keys, enum values) against the
9//! pinned schema. The wire-format error codes
10//! (`UNKNOWN_SECTION`, `UNKNOWN_METADATA`, `INVALID_ENUM_VALUE`,
11//! `MISSING_REQUIRED_SECTION`) are stable regardless of workspace
12//! storage, so MCP callers always see the same envelope shape.
13//!
14//! Returns a typed [`ValidationError`] (or a list of
15//! [`MissingRequiredSection`] for the warning surface) — the engine
16//! layer above wraps these into its error/Result type.
17
18use std::sync::OnceLock;
19
20use indexmap::IndexMap;
21use memstead_schema::{
22    CrossMemRelationshipEntry, FieldType, RelationshipDef, RelationshipMode, Schema, TypeDefinition,
23};
24use regex::Regex;
25
26use crate::entity::MetadataValue;
27
28/// Compact relationship-vocabulary entry — `name` plus optional
29/// `when_to_use` prose. Surfaces inside [`ValidationError::InvalidRelationshipType`]
30/// recovery payloads so an agent reads the canonical vocabulary in
31/// the same response that rejected the call. Mirrors the public
32/// `RelationshipHint` shape in `memstead-git-branch`; the engine adapter
33/// there converts between the two with a 1:1 field copy.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RelationshipHint {
36    pub name: String,
37    pub when_to_use: Option<String>,
38}
39
40/// Inline rendering on the text mirror is the relationship name —
41/// `when_to_use` stays on `details.allowed[].when_to_use` for callers
42/// that branch on the typed shape.
43impl std::fmt::Display for RelationshipHint {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str(&self.name)
46    }
47}
48
49/// A typed CRUD-time validation failure. Mirrors the wire-format error
50/// codes the MCP layer surfaces; the engine adapters convert each
51/// variant into their own error type.
52#[derive(Debug, Clone, thiserror::Error)]
53pub enum ValidationError {
54    /// `UNKNOWN_SECTION`: section key not declared on the type and
55    /// not absorbed by a `catch_all` section.
56    #[error("unknown section '{key}' for type '{entity_type}'")]
57    UnknownSection {
58        key: String,
59        entity_type: String,
60        declared: Vec<String>,
61        suggestion: Option<String>,
62    },
63    /// `UNKNOWN_METADATA`: metadata key not declared on the type.
64    #[error("unknown metadata field '{key}' for type '{entity_type}'")]
65    UnknownMetadata {
66        key: String,
67        entity_type: String,
68        declared: Vec<String>,
69        suggestion: Option<String>,
70    },
71    /// `INVALID_ENUM_VALUE`: enum-typed metadata field rejected the
72    /// supplied value.
73    #[error("invalid value '{value}' for field '{field}' on type '{entity_type}'")]
74    InvalidEnumValue {
75        field: String,
76        value: String,
77        allowed: Vec<String>,
78        field_description: Option<String>,
79        suggestion: Option<String>,
80        type_write_rules: Vec<String>,
81        entity_type: String,
82    },
83    /// `READ_ONLY_FIELD`: caller tried to set or unset a read-only
84    /// metadata key (`mem`, `id`, `type`) on update.
85    #[error("cannot change read-only field '{field}' via update")]
86    ReadOnlyField { field: String },
87    /// `SECTION_NOT_UPDATABLE`: the section is not in the type's
88    /// `updatable_fields` allowlist, or it is the virtual
89    /// `relationships` surface (which is managed by `memstead_relate`,
90    /// not `memstead_update`).
91    #[error("section '{section}' is not updatable for type '{entity_type}'")]
92    SectionNotUpdatable {
93        section: String,
94        entity_type: String,
95    },
96    /// `INVALID_REL_TYPE`: the relationship name is not declared in
97    /// the active schema and the schema runs in strict mode. Open-mode
98    /// schemas admit unknown names with a warning instead — see
99    /// [`check_relationship_strict_or_open`].
100    #[error("invalid relationship type '{input}'")]
101    InvalidRelationshipType {
102        input: String,
103        allowed: Vec<RelationshipHint>,
104        suggestion: Option<String>,
105    },
106    /// `INVALID_REL_SHAPE`: the edge's `(from_type, to_type)` pair
107    /// violates the schema's declared `source_types` / `target_types`
108    /// for this relationship. Only fires for shape-pinned edges; an
109    /// edge with empty constraint lists admits any pair.
110    #[error(
111        "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape"
112    )]
113    InvalidRelationshipShape {
114        rel_type: String,
115        from_type: String,
116        to_type: String,
117        allowed_source_types: Vec<String>,
118        allowed_target_types: Vec<String>,
119        suggestion: Option<RelationshipHint>,
120    },
121    /// `SECTION_CONTENT_INVALID`: a section body contains a `^## `
122    /// line (level-2 heading) which the entity's compose-then-reparse
123    /// pipeline would interpret as a section delimiter. Without this
124    /// guard a caller can inject content into a different section by
125    /// embedding a heading in another section's body. Deeper headings
126    /// (`### ` and below) are allowed — the parser anchors only on
127    /// level 2.
128    #[error(
129        "section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — \
130         the compose-then-reparse pipeline would split the value at that heading; use `### ` or \
131         deeper for sub-headings"
132    )]
133    SectionContentInvalid {
134        section: String,
135        embedded_heading: String,
136    },
137    /// `EMPTY_UNDECLARED_HEADING`: caller-supplied content carries a heading
138    /// the type does not declare with NO body under it (consistency-sweep
139    /// 04/01, criterion 7). The catch-all builder skips empty content, so such
140    /// a heading is dropped on the write that accepts it: the caller is told
141    /// now rather than discovering the loss afterwards.
142    ///
143    /// The boundary is exactly the complement of the catch-all exemption
144    /// [`validate_section_content`] grants: an undeclared heading WITH a body
145    /// survives verbatim and is accepted, one without a body does not survive
146    /// and is refused. The exemption and this refusal are the same rule read
147    /// from its two sides.
148    #[error(
149        "section '{section}' carries heading '{heading}', which type '{entity_type}' does not \
150         declare and which has no body under it. The catch-all keeps absorbed content but skips \
151         empty content, so this heading would be dropped by the write. Give it a body, or \
152         remove the heading."
153    )]
154    EmptyUndeclaredHeading {
155        section: String,
156        heading: String,
157        entity_type: String,
158    },
159    /// `UNTERMINATED_FENCE`: caller-supplied section content ends inside a
160    /// fenced code block that never closes (consistency-sweep 04/02,
161    /// criterion 1). Nothing about the parse is wrong: an open fence's range
162    /// runs to end of text in CommonMark, so every `## ` line the generator
163    /// writes after this section lands inside the fence, is masked, and is
164    /// absorbed into this section's body on the next read. The sections then
165    /// render as empty and the entity reads as healthy.
166    ///
167    /// The sibling guard [`ValidationError::SectionContentInvalid`] refuses
168    /// content that would BECOME a delimiter; this one refuses content that
169    /// would HIDE one. Both are about the same seam from opposite sides, and
170    /// neither can see the other's case: a fence opener is not a heading line,
171    /// and the heading guard's own mask is what renders the hidden heading
172    /// invisible to it.
173    ///
174    /// The oracle is `markdown::closing_fence_if_unterminated`, the referee's
175    /// existing sentinel probe. No second fence model is written here.
176    #[error(
177        "section '{section}' ends inside an unterminated `{fence}` code fence — every section \
178         heading written after it would be swallowed into this body and read back as empty. \
179         Close the fence with a line of `{fence}`."
180    )]
181    UnterminatedFence { section: String, fence: String },
182    /// `SECTION_CONTENT_INVALID` (control-byte sub-case): a section body
183    /// contains a control character other than tab (`\t`) or newline
184    /// (`\n`). A NUL especially makes git classify the `.md` blob as
185    /// binary, defeating the diffable-markdown invariant the storage
186    /// model rests on, and downstream text tooling truncates at it.
187    /// Shares the wire code with the heading-injection case (both are
188    /// `SECTION_CONTENT_INVALID`) — the `control_char`/`byte_offset`
189    /// recovery fields discriminate it from `embedded_heading`. Mirrors
190    /// the title control-char guard (refuse with an actionable hint, not
191    /// silent strip). `\t` and `\n` stay legal; the verbatim-escape
192    /// contract is untouched (this screens a byte class, it does not
193    /// de-escape).
194    #[error(
195        "section '{section}' content contains a disallowed control character U+{codepoint:04X} \
196         at byte offset {byte_offset} — only tab and newline are permitted in section bodies"
197    )]
198    SectionContentControlByte {
199        section: String,
200        /// The offending control character (a `char`, since the body is
201        /// already valid UTF-8 — a NUL is `U+0000`).
202        control_char: char,
203        /// Its Unicode scalar value, surfaced as a number for
204        /// unambiguous machine reading (the string form JSON-escapes).
205        codepoint: u32,
206        /// Byte offset into the section body — matches the `od -c` view a
207        /// caller uses to locate the byte.
208        byte_offset: usize,
209    },
210    /// `INVALID_FIELD_VALUE`: a non-enum typed metadata field received a
211    /// value that does not parse as its declared type — a `Date` field
212    /// given `"not-a-real-date"` or `""`, or a `Number` field given
213    /// non-numeric text. Distinct from `INVALID_ENUM_VALUE` (the key and
214    /// type are valid but the value is out of an enum's vocabulary) and
215    /// `UNKNOWN_METADATA_FIELD` (the key is not declared): here the key
216    /// and field-type are valid but the *value* is malformed for the
217    /// type. Without this check the value round-trips raw and corrupts
218    /// range-filter results (a non-date string sorts lexically against
219    /// real dates, so `*_after` matches it).
220    #[error(
221        "invalid value '{value}' for field '{field}' on type '{entity_type}' — expected {expected_type}"
222    )]
223    InvalidFieldValue {
224        field: String,
225        value: String,
226        expected_type: String,
227        expected_format: Option<String>,
228        field_description: Option<String>,
229        entity_type: String,
230    },
231}
232
233impl ValidationError {
234    /// Stable `UPPER_SNAKE_CASE` wire code for this validation sub-variant.
235    /// Single source of truth — both `EngineError::code()` (via the
236    /// `Validation(_)` arm) and the MCP `validation_envelope` mapper read
237    /// from here, so the wire code cannot drift between channels.
238    pub fn code(&self) -> &'static str {
239        match self {
240            ValidationError::UnknownSection { .. } => "UNKNOWN_SECTION",
241            ValidationError::UnknownMetadata { .. } => "UNKNOWN_METADATA_FIELD",
242            ValidationError::InvalidEnumValue { .. } => "INVALID_ENUM_VALUE",
243            ValidationError::ReadOnlyField { .. } => "READ_ONLY_FIELD",
244            ValidationError::SectionNotUpdatable { .. } => "SECTION_NOT_UPDATABLE",
245            ValidationError::InvalidRelationshipType { .. } => "INVALID_REL_TYPE",
246            ValidationError::InvalidRelationshipShape { .. } => "INVALID_REL_SHAPE",
247            ValidationError::EmptyUndeclaredHeading { .. } => "EMPTY_UNDECLARED_HEADING",
248            ValidationError::UnterminatedFence { .. } => "UNTERMINATED_FENCE",
249            ValidationError::SectionContentInvalid { .. } => "SECTION_CONTENT_INVALID",
250            ValidationError::SectionContentControlByte { .. } => "SECTION_CONTENT_INVALID",
251            ValidationError::InvalidFieldValue { .. } => "INVALID_FIELD_VALUE",
252        }
253    }
254
255    /// Structured recovery payload for this validation sub-variant. The
256    /// payload mirrors the variant's declared fields so callers branching
257    /// on `code` can read the same shape `validation_envelope` ships on
258    /// the MCP wire. Returned shape is documented in the MCP tool
259    /// descriptions — see `Errors:` blocks on `memstead_create` / `memstead_update`
260    /// / `memstead_relate`.
261    pub fn details(&self) -> serde_json::Value {
262        match self {
263            ValidationError::UnknownSection {
264                key,
265                entity_type,
266                declared,
267                suggestion,
268            } => serde_json::json!({
269                "key": key,
270                "entity_type": entity_type,
271                "declared": declared,
272                "suggestion": suggestion,
273            }),
274            ValidationError::UnknownMetadata {
275                key,
276                entity_type,
277                declared,
278                suggestion,
279            } => serde_json::json!({
280                "key": key,
281                "entity_type": entity_type,
282                "declared": declared,
283                "suggestion": suggestion,
284            }),
285            ValidationError::InvalidEnumValue {
286                field,
287                value,
288                allowed,
289                field_description,
290                suggestion,
291                type_write_rules,
292                entity_type,
293            } => serde_json::json!({
294                "field": field,
295                "value": value,
296                "allowed": allowed,
297                "field_description": field_description,
298                "suggestion": suggestion,
299                "type_write_rules": type_write_rules,
300                "entity_type": entity_type,
301            }),
302            ValidationError::ReadOnlyField { field } => serde_json::json!({
303                "field": field,
304            }),
305            ValidationError::SectionNotUpdatable {
306                section,
307                entity_type,
308            } => serde_json::json!({
309                "section": section,
310                "entity_type": entity_type,
311            }),
312            ValidationError::InvalidRelationshipType {
313                input,
314                allowed,
315                suggestion,
316            } => {
317                let allowed_json: Vec<serde_json::Value> = allowed
318                    .iter()
319                    .map(|h| {
320                        serde_json::json!({
321                            "name": h.name,
322                            "when_to_use": h.when_to_use,
323                        })
324                    })
325                    .collect();
326                serde_json::json!({
327                    "input": input,
328                    "allowed": allowed_json,
329                    "suggestion": suggestion,
330                })
331            }
332            ValidationError::InvalidRelationshipShape {
333                rel_type,
334                from_type,
335                to_type,
336                allowed_source_types,
337                allowed_target_types,
338                suggestion,
339            } => {
340                let suggestion_json = suggestion.as_ref().map(|h| {
341                    serde_json::json!({
342                        "name": h.name,
343                        "when_to_use": h.when_to_use,
344                    })
345                });
346                let mut details = serde_json::Map::new();
347                details.insert(
348                    "rel_type".into(),
349                    serde_json::Value::String(rel_type.clone()),
350                );
351                details.insert(
352                    "from_type".into(),
353                    serde_json::Value::String(from_type.clone()),
354                );
355                details.insert("to_type".into(), serde_json::Value::String(to_type.clone()));
356                // Empty source/target_types in the schema mean shape-free
357                // (any type admitted). Surface that as an omitted field on
358                // the structured payload — presence implies a constraint,
359                // absence implies "any". Disambiguates "no targets allowed"
360                // (which the engine cannot produce — empty is never a
361                // forbid-all signal) from "any target allowed".
362                if !allowed_source_types.is_empty() {
363                    details.insert(
364                        "allowed_source_types".into(),
365                        serde_json::json!(allowed_source_types),
366                    );
367                }
368                if !allowed_target_types.is_empty() {
369                    details.insert(
370                        "allowed_target_types".into(),
371                        serde_json::json!(allowed_target_types),
372                    );
373                }
374                details.insert("suggestion".into(), serde_json::json!(suggestion_json));
375                serde_json::Value::Object(details)
376            }
377            ValidationError::UnterminatedFence { section, fence } => serde_json::json!({
378                "section": section,
379                "fence": fence,
380                "expected": format!(
381                    "close the fence with a line of `{fence}`, or remove the opener"
382                ),
383            }),
384            ValidationError::EmptyUndeclaredHeading {
385                section,
386                heading,
387                entity_type,
388            } => serde_json::json!({
389                "section": section,
390                "heading": heading,
391                "entity_type": entity_type,
392                "expected": "give the heading a body, or remove it: the catch-all keeps \
393                             absorbed content but skips empty content",
394            }),
395            ValidationError::SectionContentInvalid {
396                section,
397                embedded_heading,
398            } => serde_json::json!({
399                "section": section,
400                "embedded_heading": embedded_heading,
401            }),
402            ValidationError::SectionContentControlByte {
403                section,
404                control_char,
405                codepoint,
406                byte_offset,
407            } => serde_json::json!({
408                "section": section,
409                "control_char": control_char.to_string(),
410                "codepoint": codepoint,
411                "byte_offset": byte_offset,
412            }),
413            ValidationError::InvalidFieldValue {
414                field,
415                value,
416                expected_type,
417                expected_format,
418                field_description,
419                entity_type,
420            } => serde_json::json!({
421                "field": field,
422                "value": value,
423                "expected_type": expected_type,
424                "expected_format": expected_format,
425                "field_description": field_description,
426                "entity_type": entity_type,
427            }),
428        }
429    }
430
431    /// Render rich, fully-inlined recovery prose for the agent-visible
432    /// text channel. Closes the asymmetry where warnings render their
433    /// structured `details` inline but errors collapse `details.X`
434    /// references to a "+N more — see details.X" pointer pointing at a
435    /// channel the agent's MCP client doesn't surface to the model. The
436    /// structured
437    /// `details()` channel is unchanged — this method only governs
438    /// `result.content[0].text`. `Display` stays terse for logs and
439    /// `tracing::warn!` consumers.
440    pub fn prose_render(&self) -> String {
441        match self {
442            ValidationError::UnknownSection {
443                key,
444                entity_type,
445                declared,
446                suggestion,
447            } => {
448                let declared_inline = if declared.is_empty() {
449                    "(none)".to_string()
450                } else {
451                    declared.join(", ")
452                };
453                let suggestion_clause = suggestion
454                    .as_deref()
455                    .map(|s| format!(" Did you mean '{s}'?"))
456                    .unwrap_or_default();
457                format!(
458                    "unknown section '{key}' for type '{entity_type}' — declared sections: {declared_inline}.{suggestion_clause}"
459                )
460            }
461            ValidationError::UnknownMetadata {
462                key,
463                entity_type,
464                declared,
465                suggestion,
466            } => {
467                let declared_inline = if declared.is_empty() {
468                    "(none)".to_string()
469                } else {
470                    declared.join(", ")
471                };
472                let suggestion_clause = suggestion
473                    .as_deref()
474                    .map(|s| format!(" Did you mean '{s}'?"))
475                    .unwrap_or_default();
476                format!(
477                    "unknown metadata field '{key}' for type '{entity_type}' — declared fields: {declared_inline}.{suggestion_clause}"
478                )
479            }
480            ValidationError::InvalidEnumValue {
481                field,
482                value,
483                allowed,
484                field_description,
485                suggestion,
486                type_write_rules,
487                entity_type,
488            } => {
489                let allowed_inline = if allowed.is_empty() {
490                    "(none)".to_string()
491                } else {
492                    allowed.join(", ")
493                };
494                let desc_clause = field_description
495                    .as_deref()
496                    .map(|d| format!(" Field purpose: {d}."))
497                    .unwrap_or_default();
498                let suggestion_clause = suggestion
499                    .as_deref()
500                    .map(|s| format!(" Did you mean '{s}'?"))
501                    .unwrap_or_default();
502                let rules_clause = if type_write_rules.is_empty() {
503                    String::new()
504                } else {
505                    format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
506                };
507                format!(
508                    "invalid value '{value}' for field '{field}' on type '{entity_type}' — allowed: {allowed_inline}.{desc_clause}{suggestion_clause}{rules_clause}"
509                )
510            }
511            ValidationError::ReadOnlyField { field } => {
512                format!("cannot change read-only field '{field}' via update")
513            }
514            ValidationError::SectionNotUpdatable {
515                section,
516                entity_type,
517            } => format!("section '{section}' is not updatable for type '{entity_type}'"),
518            ValidationError::InvalidRelationshipType {
519                input,
520                allowed,
521                suggestion,
522            } => {
523                let allowed_inline = if allowed.is_empty() {
524                    "(none)".to_string()
525                } else {
526                    allowed
527                        .iter()
528                        .map(|h| h.name.clone())
529                        .collect::<Vec<_>>()
530                        .join(", ")
531                };
532                let suggestion_clause = suggestion
533                    .as_deref()
534                    .map(|s| format!(" Did you mean '{s}'?"))
535                    .unwrap_or_default();
536                format!(
537                    "invalid relationship type '{input}' — must be one of the schema's declared types: {allowed_inline}.{suggestion_clause}"
538                )
539            }
540            ValidationError::InvalidRelationshipShape {
541                rel_type,
542                from_type,
543                to_type,
544                allowed_source_types,
545                allowed_target_types,
546                suggestion,
547            } => {
548                let sources_inline = if allowed_source_types.is_empty() {
549                    "any".to_string()
550                } else {
551                    allowed_source_types.join(", ")
552                };
553                let targets_inline = if allowed_target_types.is_empty() {
554                    "any".to_string()
555                } else {
556                    allowed_target_types.join(", ")
557                };
558                let suggestion_clause = suggestion
559                    .as_ref()
560                    .map(|h| format!(" Suggested rel-type: '{}'.", h.name))
561                    .unwrap_or_default();
562                format!(
563                    "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape — allowed sources: {sources_inline}; allowed targets: {targets_inline}.{suggestion_clause}"
564                )
565            }
566            ValidationError::EmptyUndeclaredHeading {
567                section,
568                heading,
569                entity_type,
570            } => format!(
571                "section '{section}' carries heading '{heading}', which type '{entity_type}' does \
572                 not declare and which has no body under it. An undeclared heading WITH content \
573                 is kept byte-verbatim in the catch-all; an empty one is skipped, so this write \
574                 would drop it. Give the heading a body, or remove it."
575            ),
576            ValidationError::UnterminatedFence { section, fence } => format!(
577                "section '{section}' ends inside an unterminated `{fence}` code fence. In \
578                 CommonMark an open fence runs to the end of the text, so every section heading \
579                 written after this one would land inside the fence, be masked, and be read back \
580                 as part of THIS section's body: the sections after it would render empty and \
581                 the entity would still read as healthy. Close the fence with a line of \
582                 `{fence}`, or remove the opener."
583            ),
584            ValidationError::SectionContentInvalid {
585                section,
586                embedded_heading,
587            } => format!(
588                "section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — use `### ` or deeper for sub-headings"
589            ),
590            ValidationError::SectionContentControlByte {
591                section,
592                codepoint,
593                byte_offset,
594                ..
595            } => format!(
596                "section '{section}' content contains a disallowed control character U+{codepoint:04X} at byte offset {byte_offset} — \
597                 only tab (U+0009) and newline (U+000A) are permitted in section bodies. Remove the control character: it would break \
598                 the diffable-markdown invariant (a NUL makes git treat the file as binary and text tooling truncates at it)."
599            ),
600            ValidationError::InvalidFieldValue {
601                field,
602                value,
603                expected_type,
604                expected_format,
605                field_description,
606                entity_type,
607            } => {
608                let format_clause = expected_format
609                    .as_deref()
610                    .map(|f| format!(" Expected format: {f}."))
611                    .unwrap_or_default();
612                let desc_clause = field_description
613                    .as_deref()
614                    .map(|d| format!(" Field purpose: {d}."))
615                    .unwrap_or_default();
616                format!(
617                    "invalid value '{value}' for field '{field}' on type '{entity_type}' — \
618                     not a valid {expected_type}.{format_clause}{desc_clause}"
619                )
620            }
621        }
622    }
623}
624
625/// Reserved metadata keys — the entity's identity/discriminator triple.
626/// No write path accepts them as caller-supplied metadata (create and
627/// update both refuse a *set*); letting them ride the schema's declared
628/// fields would silently drift the entity-id contract. Unset is the one
629/// sanctioned exception: `metadata_unset` may name a reserved key to
630/// repair an entity that acquired a smuggled one before the write gates
631/// closed — removing a reserved key can only move the entity toward the
632/// invariant (the `type` discriminator is re-seeded by the engine, never
633/// left absent).
634pub const READ_ONLY_METADATA_KEYS: &[&str] = &["mem", "id", "type"];
635
636/// Reject a caller-supplied reserved identity/discriminator key
637/// (`mem` / `id` / `type`) as metadata — the create-path half of the
638/// reservation, deliberate and typed
639/// ([`ValidationError::ReadOnlyField`]) rather than the incidental
640/// `UNKNOWN_METADATA_FIELD` a reserved key would otherwise trip
641/// (no installable schema can declare one). Timestamp fields are NOT
642/// checked here: create's posture for `init_timestamp` /
643/// `auto_timestamp` fields is stamp-and-proceed with an
644/// `IGNORED_READONLY_FIELD` warning, deliberately.
645///
646/// The `_` prefix is refused as a namespace, not as a key list: every
647/// underscore-prefixed frontmatter key is a computed read-channel slot
648/// (`_hash`, `_tokens`, `_signals`, ...), so a stored metadata key in
649/// that namespace would render as a second, stale copy of a computed
650/// field — frontmatter copied out of a read response and pasted into a
651/// write is the observed ingress. Unset stays permissive (see
652/// [`validate_unsettable_metadata_key`]): removing a smuggled `_` key
653/// is the same sanctioned repair as removing a smuggled reserved key.
654pub fn validate_reserved_metadata_key(key: &str) -> Result<(), ValidationError> {
655    if READ_ONLY_METADATA_KEYS.contains(&key) || key.starts_with('_') {
656        return Err(ValidationError::ReadOnlyField {
657            field: key.to_string(),
658        });
659    }
660    Ok(())
661}
662
663/// Reject any attempt to **set** a read-only metadata key. The single
664/// mutation engine (`memstead-base`) calls this from its `update_entity`
665/// path over the `metadata` map: the `mem` / `id` / `type` triple stays
666/// engine-authoritative, and the schema's `init_timestamp` /
667/// `auto_timestamp` annotations are honoured on write — the engine
668/// owns those values on create (`init_timestamp`, set once) and on
669/// every update (`auto_timestamp`, re-stamped). Returns
670/// [`ValidationError::ReadOnlyField`] on rejection. The unset path has
671/// its own gate ([`validate_unsettable_metadata_key`]) because the
672/// reserved triple is unset-allowed there as the sanctioned repair.
673pub fn validate_writable_metadata_key(
674    key: &str,
675    schema: &TypeDefinition,
676) -> Result<(), ValidationError> {
677    validate_reserved_metadata_key(key)?;
678    if let Some(field) = schema.metadata_field(key)
679        && (field.init_timestamp || field.auto_timestamp)
680    {
681        return Err(ValidationError::ReadOnlyField {
682            field: key.to_string(),
683        });
684    }
685    Ok(())
686}
687
688/// Gate for `metadata_unset` keys. Unlike the set path
689/// ([`validate_writable_metadata_key`]), the reserved
690/// identity/discriminator triple (`mem` / `id` / `type`) IS
691/// unsettable: removing one can only move an entity toward the
692/// invariant, and it is the sanctioned repair for entities that
693/// acquired a smuggled reserved key before the write gates closed
694/// (delete-and-recreate would destroy provenance and edges).
695/// Engine-stamped timestamp fields (`init_timestamp` /
696/// `auto_timestamp`) stay refused on unset — the engine owns their
697/// values and re-stamps them; unsetting one is caller confusion, not
698/// repair.
699pub fn validate_unsettable_metadata_key(
700    key: &str,
701    schema: &TypeDefinition,
702) -> Result<(), ValidationError> {
703    if let Some(field) = schema.metadata_field(key)
704        && (field.init_timestamp || field.auto_timestamp)
705    {
706        return Err(ValidationError::ReadOnlyField {
707            field: key.to_string(),
708        });
709    }
710    Ok(())
711}
712
713/// Reject an `memstead_update` attempt to write a section that is either
714/// the virtual `relationships` surface (managed by `memstead_relate`) or
715/// not part of the type's `updatable_fields` allowlist. When the
716/// allowlist is empty the section passes — types that opt out of the
717/// allowlist accept any declared section.
718pub fn validate_updatable_section(
719    section: &str,
720    schema: &TypeDefinition,
721) -> Result<(), ValidationError> {
722    if section == "relationships" {
723        return Err(ValidationError::SectionNotUpdatable {
724            section: section.to_string(),
725            entity_type: schema.name.clone(),
726        });
727    }
728    if !schema.updatable_fields.is_empty() && !schema.updatable_fields.iter().any(|f| f == section)
729    {
730        return Err(ValidationError::SectionNotUpdatable {
731            section: section.to_string(),
732            entity_type: schema.name.clone(),
733        });
734    }
735    Ok(())
736}
737
738/// Tier-2 warning shape — the create / update path emits one entry per
739/// required section that is missing or empty. Same payload the MCP
740/// layer surfaces as `MISSING_REQUIRED_SECTION` warnings. Type-level
741/// `write_rules` no longer ride per warning — they ship once at the
742/// mutation-response top level on `type_guidance` keyed by
743/// `entity_type` (F9).
744#[derive(Debug, Clone)]
745pub struct MissingRequiredSection {
746    pub entity_type: String,
747    pub key: String,
748    pub heading: String,
749    pub write_rules: Vec<String>,
750}
751
752/// What a type's catch-all absorbs, for [`validate_section_content`].
753#[derive(Debug, Clone, Copy)]
754pub struct CatchAllContext<'a> {
755    /// The catch-all section's key.
756    pub key: &'a str,
757    /// The entity type, so a refusal can name what does not declare the
758    /// heading rather than leaving the caller to work it out.
759    pub entity_type: &'a str,
760    /// Every heading the type declares. A `## ` line matching one of these
761    /// forks the entity even inside the catch-all, so it stays refused.
762    pub declared_headings: &'a [&'a str],
763}
764
765/// Build a [`CatchAllContext`] for a type, when it has a catch-all section.
766/// The borrowed heading list lives in `buf`, which the caller owns.
767pub fn catch_all_context<'a>(
768    type_def: &'a memstead_schema::TypeDefinition,
769    buf: &'a mut Vec<&'a str>,
770) -> Option<CatchAllContext<'a>> {
771    let key = type_def.catch_all_section()?.key.as_str();
772    buf.extend(type_def.sections.iter().map(|s| s.heading.as_str()));
773    Some(CatchAllContext {
774        key,
775        entity_type: type_def.name.as_str(),
776        declared_headings: buf,
777    })
778}
779
780/// Whether the lines after `heading` in `body`, up to the next `## ` line,
781/// are all blank. That is what the catch-all builder skips.
782fn heading_body_is_empty(body: &str, heading_line: &str) -> bool {
783    let mut lines = body.lines().skip_while(|l| *l != heading_line);
784    lines.next();
785    for line in lines {
786        if line.starts_with("## ") {
787            return true;
788        }
789        if !line.trim().is_empty() {
790            return false;
791        }
792    }
793    true
794}
795
796/// Refuse section content that would round-trip through the compose
797/// pipeline as a section delimiter. The compose-then-reparse loop's
798/// parser anchors on `(?m)^## (.+)$` over the *masked* body, so a
799/// section body that shows a `^## ` line to that scan gets split at
800/// that heading on the next read — content after the heading lands
801/// under a different section key (or a fabricated one). Deeper
802/// headings (`### ` and below) are safe — the parser only matches
803/// level 2.
804///
805/// The guard is applied to the content **as the reparse will see it**,
806/// which is what makes it exact rather than approximate:
807///
808/// - the content is trimmed first, because the splitter stores the
809///   trimmed body — an indented block opening a section loses its
810///   indent on write-back, so `    ## Not A Heading` becomes a real
811///   column-0 delimiter on the next parse. Checking the still-indented
812///   provided content missed that fork entirely;
813/// - code blocks are masked first, by the same CommonMark referee the
814///   splitter uses ([`crate::markdown`]) — a `## ` inside a fenced or
815///   indented code block never splits anything, so refusing it was the
816///   write path disagreeing with the read path about what a code block
817///   is.
818///
819/// `catch_all` names the type's catch-all section and its declared headings,
820/// when the caller knows them (consistency-sweep 04/01, criterion 6). Inside
821/// the CATCH-ALL body only, a `## ` line whose heading the type does not
822/// declare is accepted, because the reparse absorbs it straight back into the
823/// catch-all: the content does not land under a different key, which is the
824/// whole basis of this guard. That case is not hypothetical — it is what the
825/// engine itself emits, since the catch-all builder re-emits absorbed content
826/// under its original heading line, and an agent that read an entity and wrote
827/// that section back in replace mode was refused its own value.
828///
829/// This does NOT weaken the guard. A DECLARED heading inside the catch-all
830/// still refuses, because that one really does fork: the reparse would move
831/// the content to the declared key. Every other section is unchanged, and a
832/// caller who passes `None` gets exactly the old behaviour.
833pub fn validate_section_content<'a>(
834    sections: impl Iterator<Item = (&'a str, &'a str)>,
835    catch_all: Option<CatchAllContext<'_>>,
836) -> Result<(), ValidationError> {
837    for (key, value) in sections {
838        // Refuse control characters other than tab/newline before the
839        // heading check. A NUL (and other C0/C1/DEL controls) persists
840        // verbatim today and breaks the diffable-markdown invariant — a
841        // NUL makes git classify the blob as binary and downstream text
842        // tooling truncates at it. Mirrors the title control-char guard
843        // (`char::is_control`, refuse-with-actionable-hint) but keeps
844        // `\t`/`\n` legal, which titles disallow. We refuse rather than
845        // strip — silently mutating caller-sent content is the
846        // no-silent-data-loss anti-pattern the title fix already flagged.
847        // The verbatim-escape contract is untouched: this screens a byte
848        // class, it does not interpret or de-escape content.
849        if let Some((byte_offset, ch)) = value
850            .char_indices()
851            .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
852        {
853            return Err(ValidationError::SectionContentControlByte {
854                section: key.to_string(),
855                control_char: ch,
856                codepoint: ch as u32,
857                byte_offset,
858            });
859        }
860        // Before the heading walk, because the two guards diagnose the same
861        // seam and this one has the better answer when both apply: a body
862        // that leaves a fence open AND carries a heading is swallowed, not
863        // forked, so naming the fence tells the caller what to fix.
864        if let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) {
865            return Err(ValidationError::UnterminatedFence {
866                section: key.to_string(),
867                fence,
868            });
869        }
870        // What the splitter will store, and what the splitter will
871        // see in it. `stored` and `masked` share byte offsets and line
872        // count, so the two line sequences correspond one-to-one and
873        // the refusal can quote the real line.
874        let stored = value.trim();
875        let masked = crate::markdown::mask_code_blocks(stored);
876        for (line, masked_line) in stored.lines().zip(masked.lines()) {
877            // Match the parser's regex shape: `^## ` (two hashes, one
878            // space, at least one trailing char). The trailing space
879            // requirement excludes bare `##` (which the parser does
880            // not match either) and `###`+ headings. `^# ` joins the
881            // guard (plan 08): h1 and h2 are the entity's own levels
882            // — the title and the section delimiters — so neither may
883            // be embedded in a section body.
884            let is_h2 = masked_line.starts_with("## ") && masked_line.len() > 3;
885            let is_h1 = masked_line.starts_with("# ") && masked_line.len() > 2;
886            // The one exemption, and it is exact: the catch-all re-absorbs an
887            // undeclared h2 rather than forking on it — but only when there is
888            // something under it. An undeclared heading WITH a body survives
889            // the round trip verbatim; one with NO body is skipped by the
890            // catch-all builder and silently dropped by the write, so it
891            // refuses instead. The exemption and the refusal are the same rule
892            // read from its two sides (04/01, criteria 6 and 7).
893            if is_h2
894                && catch_all.is_some_and(|c| {
895                    c.key == key && !c.declared_headings.contains(&&masked_line[3..])
896                })
897            {
898                if heading_body_is_empty(stored, line) {
899                    return Err(ValidationError::EmptyUndeclaredHeading {
900                        section: key.to_string(),
901                        heading: masked_line[3..].to_string(),
902                        entity_type: catch_all
903                            .map(|c| c.entity_type.to_string())
904                            .unwrap_or_default(),
905                    });
906                }
907                continue;
908            }
909            if is_h2 || is_h1 {
910                return Err(ValidationError::SectionContentInvalid {
911                    section: key.to_string(),
912                    embedded_heading: line.to_string(),
913                });
914            }
915        }
916    }
917    Ok(())
918}
919
920/// Validate that every section key in `provided` is either schema-declared
921/// for `schema`, or — if the schema has a catch-all section — admitted by
922/// it. Unknown keys return [`ValidationError::UnknownSection`] carrying
923/// the declared list plus a Levenshtein suggestion (or the catch-all key
924/// when no close match exists).
925///
926/// Pure function: no I/O, no allocation outside the eventual error
927/// payload. The `"relationships"` section is allowed through here — the
928/// engine layer above gates it via its own SectionNotUpdatable check.
929pub fn validate_section_keys<'a>(
930    provided: impl Iterator<Item = &'a str>,
931    schema: &TypeDefinition,
932) -> Result<(), ValidationError> {
933    let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
934    declared.sort();
935    let declared_set: std::collections::HashSet<&str> =
936        schema.sections.iter().map(|s| s.key.as_str()).collect();
937    let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
938
939    for key in provided {
940        if key == "relationships" {
941            continue;
942        }
943        if declared_set.contains(key) {
944            continue;
945        }
946        let suggestion = schema
947            .suggest_section(key)
948            .or_else(|| catch_all_key.clone());
949        return Err(ValidationError::UnknownSection {
950            key: key.to_string(),
951            entity_type: schema.name.clone(),
952            declared: declared.clone(),
953            suggestion,
954        });
955    }
956    Ok(())
957}
958
959/// Parse a metadata value string into the appropriate
960/// [`MetadataValue`] type, consulting the schema for field-type
961/// information. Validates enum constraints when the field definition
962/// specifies `enum_values`.
963///
964/// Unknown keys are a hard error — engine code that builds metadata
965/// only emits schema-declared fields, so a lenient insert would
966/// silently drop the value at write time and the agent would read a
967/// success response while losing data.
968pub fn parse_metadata_value(
969    key: &str,
970    value: &str,
971    schema: &TypeDefinition,
972) -> Result<MetadataValue, ValidationError> {
973    let Some(field_def) = schema.metadata_field(key) else {
974        let mut declared: Vec<String> = schema
975            .metadata_fields
976            .iter()
977            .map(|f| f.key.clone())
978            .collect();
979        declared.sort();
980        return Err(ValidationError::UnknownMetadata {
981            key: key.to_string(),
982            entity_type: schema.name.clone(),
983            declared,
984            suggestion: schema.suggest_metadata_field(key),
985        });
986    };
987
988    if let Some(ref allowed) = field_def.enum_values
989        && !allowed.iter().any(|v| v == value)
990    {
991        let suggestion = nearest_str_match(value, allowed);
992        return Err(ValidationError::InvalidEnumValue {
993            field: key.to_string(),
994            value: value.to_string(),
995            allowed: allowed.clone(),
996            field_description: Some(field_def.description.clone()),
997            suggestion,
998            type_write_rules: schema.write_rules.clone(),
999            entity_type: schema.name.clone(),
1000        });
1001    }
1002
1003    Ok(match field_def.field_type {
1004        FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
1005        FieldType::Number => {
1006            if let Ok(n) = value.parse::<i64>() {
1007                MetadataValue::Integer(n)
1008            } else if let Ok(f) = value.parse::<f64>() {
1009                MetadataValue::Float(f)
1010            } else {
1011                // Pre-fix this fell back to `String`, silently storing
1012                // non-numeric text in a Number field. Reject so the
1013                // value never reaches the store (and never corrupts a
1014                // range filter on the field).
1015                return Err(ValidationError::InvalidFieldValue {
1016                    field: key.to_string(),
1017                    value: value.to_string(),
1018                    expected_type: "Number".to_string(),
1019                    expected_format: Some("an integer or decimal number".to_string()),
1020                    field_description: Some(field_def.description.clone()),
1021                    entity_type: schema.name.clone(),
1022                });
1023            }
1024        }
1025        FieldType::Date => {
1026            // The field's declared shape is `YYYY-MM-DD` (or the ISO
1027            // datetime form). Pre-fix any string — including `""` and
1028            // arbitrary text — fell through to the `String` arm and was
1029            // stored raw; a non-date value then sorts lexically against
1030            // real dates and produces false `*_after` / `*_before`
1031            // range-filter matches. Validate at the write boundary so
1032            // the corruption can never land.
1033            if !is_date_shaped(value) {
1034                return Err(ValidationError::InvalidFieldValue {
1035                    field: key.to_string(),
1036                    value: value.to_string(),
1037                    expected_type: "Date".to_string(),
1038                    expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1039                    field_description: Some(field_def.description.clone()),
1040                    entity_type: schema.name.clone(),
1041                });
1042            }
1043            MetadataValue::String(value.to_string())
1044        }
1045        _ => MetadataValue::String(value.to_string()),
1046    })
1047}
1048
1049/// Does `s` match the shape a `Date`-typed metadata value must have —
1050/// `YYYY-MM-DD` or the ISO-8601 datetime form `YYYY-MM-DDTHH:MM:SSZ`?
1051///
1052/// Single source of truth for the date-shape check, shared by the CRUD
1053/// write path ([`parse_metadata_value`]) and the archive-ingress strict
1054/// validator (`crate::validator::strict::value_matches_type`). Keeping
1055/// one regex means the value a `memstead_create` accepts and the value an
1056/// import re-accepts cannot drift apart.
1057pub fn is_date_shaped(s: &str) -> bool {
1058    static RE: OnceLock<Regex> = OnceLock::new();
1059    RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
1060        .is_match(s)
1061}
1062
1063/// Tier-2 warning shape — the create path emits one entry per required
1064/// metadata field that is not auto-filled by the schema (no
1065/// `default_value`, no `init_timestamp`, no `auto_timestamp`) and was
1066/// not supplied by the caller. Same payload the MCP layer surfaces as
1067/// `MISSING_REQUIRED_FIELD` warnings — mirrors the
1068/// `REQUIRED_FIELD_UNSET` error envelope so a single decoder handles
1069/// both surfaces.
1070#[derive(Debug, Clone)]
1071pub struct MissingRequiredField {
1072    pub entity_type: String,
1073    pub key: String,
1074    pub description: String,
1075    pub enum_values: Vec<String>,
1076}
1077
1078/// Return one [`MissingRequiredField`] per required metadata field that
1079/// the caller did not supply and the schema does not auto-fill. A field
1080/// is "auto-filled" when it carries `default_value`, `init_timestamp`,
1081/// or `auto_timestamp` — the engine writes a non-trivial value without
1082/// caller input. Optional fields and supplied fields are skipped.
1083///
1084/// Caller-side intent: the warning fires when the entity would land in
1085/// a stuck state (placeholder today's-date / empty string) that the
1086/// agent did not opt into. Surfaced from the create path so dry-run
1087/// and real-write preview the same set of warnings.
1088pub fn missing_required_fields(
1089    schema: &TypeDefinition,
1090    supplied: &IndexMap<String, String>,
1091) -> Vec<MissingRequiredField> {
1092    schema
1093        .metadata_fields
1094        .iter()
1095        .filter(|f| {
1096            // Engine-managed fields (`type`, `id`, `mem`) are seeded
1097            // independently of caller input; not the agent's
1098            // responsibility to supply.
1099            !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
1100                && f.is_required()
1101                && f.default_value.is_none()
1102                && !f.init_timestamp
1103                && !f.auto_timestamp
1104                && !supplied.contains_key(f.key.as_str())
1105        })
1106        .map(|f| MissingRequiredField {
1107            entity_type: schema.name.clone(),
1108            key: f.key.clone(),
1109            description: f.description.clone(),
1110            enum_values: f.enum_values.clone().unwrap_or_default(),
1111        })
1112        .collect()
1113}
1114
1115/// Return one [`MissingRequiredSection`] per required section that is
1116/// absent or empty in `sections`. Empty (whitespace-only) bodies count
1117/// as missing — same predicate as the health report uses.
1118pub fn missing_required_sections(
1119    schema: &TypeDefinition,
1120    sections: &IndexMap<String, String>,
1121) -> Vec<MissingRequiredSection> {
1122    schema
1123        .required_sections()
1124        .filter_map(|sec| {
1125            let is_empty = sections
1126                .get(sec.key.as_str())
1127                .is_none_or(|c| c.trim().is_empty());
1128            is_empty.then(|| MissingRequiredSection {
1129                entity_type: schema.name.clone(),
1130                key: sec.key.clone(),
1131                heading: sec.heading.clone(),
1132                write_rules: sec.write_rules.clone(),
1133            })
1134        })
1135        .collect()
1136}
1137
1138/// Outcome of running a relationship name against a schema. The
1139/// engine adapter above decides whether to ride the warning out on
1140/// the response (open mode) or convert the error into its own type
1141/// (strict mode).
1142#[derive(Debug, Clone)]
1143pub enum RelationshipCheck {
1144    /// Name is declared in the schema's relationship vocabulary.
1145    Ok,
1146    /// Schema runs in open mode and admits the name with a warning
1147    /// the engine layer can surface to the agent.
1148    OpenWarning(String),
1149}
1150
1151/// Validate a relationship name against a mem schema's vocabulary.
1152/// Strict-mode schemas reject undeclared names with
1153/// [`ValidationError::InvalidRelationshipType`]; open-mode schemas
1154/// admit unknown names and return a warning string for the engine to
1155/// surface.
1156///
1157/// The mutation engine calls this from its `memstead_relate` path; the
1158/// wire shape (`INVALID_REL_TYPE`, `allowed[]`, `suggestion`) is stable
1159/// regardless of workspace storage.
1160pub fn validate_rel_type(
1161    rel_type: &str,
1162    schema: &Schema,
1163) -> Result<RelationshipCheck, ValidationError> {
1164    if schema.relationship_known(rel_type) {
1165        return Ok(RelationshipCheck::Ok);
1166    }
1167    match schema.mode() {
1168        RelationshipMode::Strict => {
1169            let allowed = declared_relationship_hints(schema);
1170            let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1171            let suggestion = nearest_str_match(rel_type, &candidate_names);
1172            Err(ValidationError::InvalidRelationshipType {
1173                input: rel_type.to_string(),
1174                allowed,
1175                suggestion,
1176            })
1177        }
1178        RelationshipMode::Open => {
1179            let declared: Vec<String> = declared_relationship_hints(schema)
1180                .into_iter()
1181                .map(|h| h.name)
1182                .collect();
1183            let suggestion = schema
1184                .suggest_relationship(rel_type)
1185                .map(|s| format!(" Did you mean '{s}'?"))
1186                .unwrap_or_default();
1187            let (schema_name, schema_version) = schema.id();
1188            Ok(RelationshipCheck::OpenWarning(format!(
1189                "relationship '{rel_type}' is not declared in schema \
1190                 '{schema_name}@{schema_version}' (mode: open). \
1191                 Accepted with default weight. Declared: [{}].{suggestion}",
1192                declared.join(", "),
1193            )))
1194        }
1195    }
1196}
1197
1198/// Reject an edge whose `(from_type, to_type)` pair violates the
1199/// schema's declared `source_types` / `target_types` for this
1200/// relationship. No-op when both constraint lists are empty
1201/// (shape-free edges) or when the relationship name is unknown
1202/// (callers run this only after [`validate_rel_type`] succeeds, so
1203/// this branch is defensive). The target-type check is skipped when
1204/// `to_type` is `None` — happens for auto-stubbed targets that have
1205/// no type yet; once the stub is authored as a real entity, future
1206/// edges land under the strict check.
1207///
1208/// Suggestion: nearest-match edge in the schema whose declared shape
1209/// would admit `(from_type, to_type)`. Tiebreaker is declaration
1210/// order in the YAML (deterministic).
1211pub fn validate_rel_shape(
1212    rel_type: &str,
1213    from_type: &str,
1214    to_type: Option<&str>,
1215    schema: &Schema,
1216) -> Result<(), ValidationError> {
1217    let Some(def) = schema.relationship_def(rel_type) else {
1218        return Ok(());
1219    };
1220    let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1221    let target_ok = def.target_types.is_empty()
1222        || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1223    if source_ok && target_ok {
1224        return Ok(());
1225    }
1226    let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1227    let suggestion = suggest_shape_admitting(from_type, to_type, schema);
1228    Err(ValidationError::InvalidRelationshipShape {
1229        rel_type: rel_type.to_string(),
1230        from_type: from_type.to_string(),
1231        to_type: to_for_err,
1232        allowed_source_types: def.source_types.clone(),
1233        allowed_target_types: def.target_types.clone(),
1234        suggestion,
1235    })
1236}
1237
1238/// Outcome of looking up a rel-type against a cross-mem entry in
1239/// the source schema's `cross_mem_relationships:` vocabulary.
1240/// `EdgeNotDeclared` carries the recovery payload the engine layer
1241/// wraps into [`crate::EngineError::CrossMemEdgeNotDeclared`]; the
1242/// other variants reuse the existing `ValidationError` shapes so
1243/// agents reading the wire shape decode `INVALID_REL_TYPE` /
1244/// `INVALID_REL_SHAPE` identically in both intra- and cross-mem
1245/// flows.
1246#[derive(Debug, Clone)]
1247pub enum CrossMemRelCheck {
1248    /// `(rel_type, from_type, to_type)` are admitted by the matched
1249    /// cross-mem entry's declared vocabulary and shape. The engine
1250    /// proceeds with the relate write.
1251    Ok,
1252    /// The source schema declares no cross-mem entry whose
1253    /// `to_schema:` matches the target schema. Carries the recovery
1254    /// payload for `CROSS_MEM_EDGE_NOT_DECLARED`.
1255    EdgeNotDeclared,
1256    /// Validation tripped the matched cross-mem entry's own
1257    /// vocabulary / shape — reuses the existing `INVALID_REL_TYPE` /
1258    /// `INVALID_REL_SHAPE` envelopes (carried as the wrapped
1259    /// `ValidationError`) so wire-shape decoders stay flat.
1260    Invalid(ValidationError),
1261}
1262
1263/// Validate a cross-mem edge whose source and target mems pin
1264/// schemas with *different names* against the source schema's
1265/// outbound `cross_mem_relationships:` vocabulary.
1266///
1267/// Caller responsibility: only invoke when the source and target
1268/// schema *names* differ — same-name mems (any version pair) fall
1269/// through to the intra-mem path ([`validate_rel_type`] +
1270/// [`validate_rel_shape`]); same-name is same domain.
1271///
1272/// The lookup goes through [`Schema::cross_mem_entry`], which
1273/// matches by target schema name only — eligibility is name-based,
1274/// so the target mem's pinned version never participates and a
1275/// version bump on the target side cannot invalidate a declaration.
1276///
1277/// On a match, the cross-mem entry's `definitions` list is the sole
1278/// vocabulary for this edge: the source schema's intra-mem
1279/// `relationships.definitions` is NOT consulted in this regime (per
1280/// AC #6 / #9). A rel-type present intra-mem but absent cross-mem
1281/// surfaces here as `INVALID_REL_TYPE`; a shape violation surfaces
1282/// here as `INVALID_REL_SHAPE` with the cross-mem entry's shape
1283/// (not the intra-mem entry's, if both exist).
1284pub fn validate_cross_mem_edge(
1285    rel_type: &str,
1286    from_type: &str,
1287    to_type: Option<&str>,
1288    source_schema: &Schema,
1289    target_schema_ref: &memstead_schema::SchemaRef,
1290) -> CrossMemRelCheck {
1291    // Priority-ordered entries: exact-name declaration first, then the
1292    // `to_schema: "*"` wildcard (loader-bound to the schema's alias
1293    // target rel-type). First rel-type hit across the entries wins, so
1294    // structural declarations for a destination never shadow the
1295    // wildcarded alias links into it.
1296    let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
1297    if entries.is_empty() {
1298        return CrossMemRelCheck::EdgeNotDeclared;
1299    }
1300
1301    let Some(def) = entries
1302        .iter()
1303        .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1304    else {
1305        // Only the wildcard matched and it doesn't carry this
1306        // rel-type: for THIS destination schema the rel-type has
1307        // genuinely no declaration — the historical
1308        // `CROSS_MEM_EDGE_NOT_DECLARED` refusal, so a structural edge
1309        // into an undeclared schema reads the same with or without a
1310        // wildcard present (the wildcard only ever admits the alias
1311        // rel-type).
1312        if !entries.iter().any(|e| e.to_schema != "*") {
1313            return CrossMemRelCheck::EdgeNotDeclared;
1314        }
1315        let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
1316        let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1317        let suggestion = nearest_str_match(rel_type, &candidate_names);
1318        return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1319            input: rel_type.to_string(),
1320            allowed,
1321            suggestion,
1322        });
1323    };
1324
1325    let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1326    let target_ok = def.target_types.is_empty()
1327        || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1328    if source_ok && target_ok {
1329        return CrossMemRelCheck::Ok;
1330    }
1331    let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1332    let suggestion = entries
1333        .iter()
1334        .find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
1335    CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1336        rel_type: rel_type.to_string(),
1337        from_type: from_type.to_string(),
1338        to_type: to_for_err,
1339        allowed_source_types: def.source_types.clone(),
1340        allowed_target_types: def.target_types.clone(),
1341        suggestion,
1342    })
1343}
1344
1345/// Union of [`cross_mem_entry_hints`] across priority-ordered entries,
1346/// de-duplicated by rel-type name (first entry's hint wins) and
1347/// re-sorted.
1348fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
1349    let mut out: Vec<RelationshipHint> = Vec::new();
1350    for entry in entries {
1351        for hint in cross_mem_entry_hints(entry) {
1352            if !out.iter().any(|h| h.name == hint.name) {
1353                out.push(hint);
1354            }
1355        }
1356    }
1357    out.sort_by(|a, b| a.name.cmp(&b.name));
1358    out
1359}
1360
1361/// Sorted vocabulary hints for one cross-mem entry, excluding the
1362/// `_default` sentinel — same shape as
1363/// [`declared_relationship_hints`] but scoped to a single cross-mem
1364/// declaration.
1365fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1366    let mut out: Vec<RelationshipHint> = entry
1367        .definitions
1368        .iter()
1369        .filter(|d| d.name != "_default")
1370        .map(|d| RelationshipHint {
1371            name: d.name.clone(),
1372            when_to_use: d.when_to_use.clone(),
1373        })
1374        .collect();
1375    out.sort_by(|a, b| a.name.cmp(&b.name));
1376    out
1377}
1378
1379/// First cross-mem `definition` in declaration order whose declared
1380/// shape would admit `(from_type, to_type)`. Mirrors
1381/// [`suggest_shape_admitting`] but scoped to a single cross-mem
1382/// entry.
1383fn cross_mem_suggest_shape(
1384    entry: &CrossMemRelationshipEntry,
1385    from_type: &str,
1386    to_type: Option<&str>,
1387) -> Option<RelationshipHint> {
1388    entry
1389        .definitions
1390        .iter()
1391        .filter(|d| d.name != "_default")
1392        .find(|d| cross_mem_def_admits(d, from_type, to_type))
1393        .map(|d| RelationshipHint {
1394            name: d.name.clone(),
1395            when_to_use: d.when_to_use.clone(),
1396        })
1397}
1398
1399fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1400    let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1401    let tgt_ok =
1402        d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1403    src_ok && tgt_ok
1404}
1405
1406/// First edge in declaration order whose declared shape would admit
1407/// the `(from_type, to_type)` pair. Empty `source_types` /
1408/// `target_types` admit anything. Returns `None` when no such edge
1409/// exists. The `_default` sentinel is excluded — it carries no shape
1410/// and is never a real edge's rel_type.
1411fn suggest_shape_admitting(
1412    from_type: &str,
1413    to_type: Option<&str>,
1414    schema: &Schema,
1415) -> Option<RelationshipHint> {
1416    schema
1417        .manifest
1418        .relationships
1419        .definitions
1420        .iter()
1421        .filter(|d| d.name != "_default")
1422        .find(|d| {
1423            let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1424            let tgt_ok = d.target_types.is_empty()
1425                || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1426            src_ok && tgt_ok
1427        })
1428        .map(|d| RelationshipHint {
1429            name: d.name.clone(),
1430            when_to_use: d.when_to_use.clone(),
1431        })
1432}
1433
1434/// Sorted relationship vocabulary as `RelationshipHint`s, excluding
1435/// the internal `_default` catch-all. Used inside
1436/// [`validate_rel_type`] to populate the `INVALID_REL_TYPE` recovery
1437/// payload's `allowed[]` list.
1438fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1439    let mut out: Vec<RelationshipHint> = schema
1440        .manifest
1441        .relationships
1442        .definitions
1443        .iter()
1444        .filter(|d| d.name != "_default")
1445        .map(|d| RelationshipHint {
1446            name: d.name.clone(),
1447            when_to_use: d.when_to_use.clone(),
1448        })
1449        .collect();
1450    out.sort_by(|a, b| a.name.cmp(&b.name));
1451    out
1452}
1453
1454/// Levenshtein-nearest match against a candidate set, with a noise
1455/// floor of `chars/2` (beyond that the input shares almost nothing with
1456/// the schema vocabulary, so a "did you mean" suggestion does not
1457/// help). Returns `None` when no candidate is close enough.
1458fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1459    let noise_floor = (needle.chars().count() / 2).max(1);
1460    let mut best: Option<(usize, String)> = None;
1461    for cand in candidates {
1462        let d = strsim::levenshtein(needle, cand);
1463        if d == 0 || d > noise_floor {
1464            continue;
1465        }
1466        match &best {
1467            Some((bd, _)) if *bd <= d => {}
1468            _ => best = Some((d, cand.clone())),
1469        }
1470    }
1471    best.map(|(_, name)| name)
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    /// Tiny in-memory schema for the rel-shape shape-test fixture:
1479    /// `EXECUTES: step → decision`, plus a shape-free `USES` and
1480    /// `PART_OF`. Used by the rel-shape unit tests; `_default` is
1481    /// preserved for parity with the loader's invariants.
1482    fn shape_test_schema() -> std::sync::Arc<Schema> {
1483        let manifest_yaml = r#"name: tests-rel-shape
1484version: 0.1.0
1485description: rel-shape test schema
1486when_to_use: tests
1487types:
1488  - step
1489  - decision
1490  - note
1491relationships:
1492  mode: strict
1493  definitions:
1494    - name: PART_OF
1495      description: parent containment
1496      default_weight: 3.0
1497      acyclic: true
1498    - name: USES
1499      description: shape-free reference
1500      default_weight: 1.0
1501    - name: EXECUTES
1502      description: step carries out decision
1503      default_weight: 2.5
1504      source_types: [step]
1505      target_types: [decision]
1506    - name: _default
1507      description: fallback
1508      default_weight: 1.0
1509community:
1510  resolution: 1.0
1511  seed: 42
1512"#;
1513        let body_section = r#"sections:
1514  - key: body
1515    heading: Body
1516    required: true
1517    search_weight: 10.0
1518    catch_all: true
1519    write_rules: []
1520metadata_fields: []
1521title_weight: 100.0
1522text_fields:
1523  - body
1524hierarchy_relationship: PART_OF
1525no_self_loop_relationships: []
1526updatable_fields:
1527  - title
1528  - body
1529health_required_fields:
1530  - body
1531staleness_threshold_days: 90
1532write_rules: []
1533"#;
1534        let make_type =
1535            |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1536        std::sync::Arc::new(
1537            memstead_schema::load_schema_from_memory(
1538                manifest_yaml,
1539                &[
1540                    ("step".to_string(), make_type("step")),
1541                    ("decision".to_string(), make_type("decision")),
1542                    ("note".to_string(), make_type("note")),
1543                ],
1544            )
1545            .expect("test schema must load"),
1546        )
1547    }
1548
1549    #[test]
1550    fn reserved_metadata_gate_refuses_the_underscore_namespace_on_set() {
1551        // The `_` prefix is the computed read-channel namespace (`_hash`,
1552        // `_tokens`, ...); a stored key there would render as a second,
1553        // stale copy of a computed field. Refused as a namespace on every
1554        // set path; ordinary keys pass unchanged.
1555        for key in ["_hash", "_tokens", "_anything"] {
1556            assert!(matches!(
1557                validate_reserved_metadata_key(key),
1558                Err(ValidationError::ReadOnlyField { .. })
1559            ));
1560        }
1561        assert!(validate_reserved_metadata_key("level").is_ok());
1562        assert!(matches!(
1563            validate_reserved_metadata_key("type"),
1564            Err(ValidationError::ReadOnlyField { .. })
1565        ));
1566    }
1567
1568    #[test]
1569    fn rel_shape_admits_pair_in_declared_source_target() {
1570        let schema = shape_test_schema();
1571        // step → decision is the declared shape; admits cleanly.
1572        assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1573    }
1574
1575    #[test]
1576    fn rel_shape_rejects_violating_source() {
1577        let schema = shape_test_schema();
1578        // EXECUTES is shape-pinned to source=step; note → decision violates source.
1579        let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1580        match err {
1581            ValidationError::InvalidRelationshipShape {
1582                rel_type,
1583                from_type,
1584                to_type,
1585                allowed_source_types,
1586                allowed_target_types,
1587                ..
1588            } => {
1589                assert_eq!(rel_type, "EXECUTES");
1590                assert_eq!(from_type, "note");
1591                assert_eq!(to_type, "decision");
1592                assert_eq!(allowed_source_types, vec!["step".to_string()]);
1593                assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1594            }
1595            other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1596        }
1597    }
1598
1599    #[test]
1600    fn rel_shape_rejects_violating_target() {
1601        let schema = shape_test_schema();
1602        // step → note violates target: EXECUTES requires target=decision.
1603        let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1604        assert!(matches!(
1605            err,
1606            ValidationError::InvalidRelationshipShape { .. }
1607        ));
1608    }
1609
1610    #[test]
1611    fn rel_shape_admits_shape_free_relationship() {
1612        let schema = shape_test_schema();
1613        // USES has empty source_types/target_types — admits anything.
1614        assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1615    }
1616
1617    #[test]
1618    fn rel_shape_skips_target_check_when_target_type_unknown() {
1619        let schema = shape_test_schema();
1620        // Target stub has no resolved type — target-side check skipped.
1621        // Source still checked: step is the declared source, so this admits.
1622        assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1623    }
1624
1625    #[test]
1626    fn rel_shape_no_op_for_unknown_rel_name() {
1627        let schema = shape_test_schema();
1628        // Defensive branch: callers run validate_rel_type first, but
1629        // an unknown name here returns Ok rather than panicking.
1630        assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1631    }
1632
1633    // ---------------------------------------------------------------
1634    // validate_cross_mem_edge — covers the pure-function layer. The
1635    // engine relate path's routing wraps these outcomes into
1636    // `CROSS_MEM_EDGE_NOT_DECLARED` / `INVALID_REL_TYPE` /
1637    // `INVALID_REL_SHAPE` envelopes.
1638    // ---------------------------------------------------------------
1639
1640    /// Cross-mem-aware source schema: declares one outbound entry
1641    /// to the `other` domain with `ADDRESSES: step → requirement` and
1642    /// a shape-free `MENTIONS`. Intra-mem `relationships` carries a
1643    /// disjoint `IMPLEMENTS` rel-type so the "intra-mem-only is
1644    /// invisible cross-mem" AC is exercisable.
1645    fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1646        let manifest_yaml = r#"name: source-cv
1647version: 0.1.0
1648description: cross-mem source schema
1649when_to_use: tests
1650types:
1651  - step
1652  - decision
1653relationships:
1654  mode: strict
1655  definitions:
1656    - name: IMPLEMENTS
1657      description: intra-mem only
1658      default_weight: 1.0
1659    - name: _default
1660      description: fallback
1661      default_weight: 1.0
1662cross_mem_relationships:
1663  - to_schema: other
1664    definitions:
1665      - name: ADDRESSES
1666        description: outbound shape-pinned
1667        default_weight: 1.0
1668        source_types: [step]
1669        target_types: [requirement]
1670      - name: MENTIONS
1671        description: outbound shape-free
1672        default_weight: 0.5
1673community:
1674  resolution: 1.0
1675  seed: 42
1676"#;
1677        let body_section = r#"sections:
1678  - key: body
1679    heading: Body
1680    required: true
1681    search_weight: 10.0
1682    catch_all: true
1683    write_rules: []
1684metadata_fields: []
1685title_weight: 100.0
1686text_fields:
1687  - body
1688hierarchy_relationship: _default
1689no_self_loop_relationships: []
1690updatable_fields:
1691  - title
1692  - body
1693health_required_fields:
1694  - body
1695staleness_threshold_days: 90
1696write_rules: []
1697"#;
1698        let make_type =
1699            |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1700        std::sync::Arc::new(
1701            memstead_schema::load_schema_from_memory(
1702                manifest_yaml,
1703                &[
1704                    ("step".to_string(), make_type("step")),
1705                    ("decision".to_string(), make_type("decision")),
1706                ],
1707            )
1708            .expect("cross-mem source schema must load"),
1709        )
1710    }
1711
1712    fn other_target_ref() -> memstead_schema::SchemaRef {
1713        memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1714    }
1715
1716    #[test]
1717    fn cross_mem_admits_declared_shape() {
1718        let src = cross_mem_source_schema();
1719        let target = other_target_ref();
1720        match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1721            CrossMemRelCheck::Ok => {}
1722            other => panic!("expected Ok, got {other:?}"),
1723        }
1724    }
1725
1726    #[test]
1727    fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1728        let src = cross_mem_source_schema();
1729        // Target schema not present in source schema's
1730        // cross_mem_relationships — source only declares the
1731        // `other` domain.
1732        let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1733        match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1734            CrossMemRelCheck::EdgeNotDeclared => {}
1735            other => panic!("expected EdgeNotDeclared, got {other:?}"),
1736        }
1737    }
1738
1739    #[test]
1740    fn cross_mem_entry_matches_any_target_version() {
1741        // Eligibility is name-based: the `other` declaration is
1742        // satisfied by a target mem pinning *any* version of
1743        // `other` — a target-side version bump cannot invalidate it.
1744        let src = cross_mem_source_schema();
1745        for version in [
1746            semver::Version::new(1, 0, 0),
1747            semver::Version::new(1, 1, 0),
1748            semver::Version::new(2, 5, 0),
1749        ] {
1750            let target = memstead_schema::SchemaRef::new("other", version.clone());
1751            match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1752                CrossMemRelCheck::Ok => {}
1753                other => panic!("expected Ok against other@{version}, got {other:?}"),
1754            }
1755        }
1756    }
1757
1758    #[test]
1759    fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1760        let src = cross_mem_source_schema();
1761        let target = other_target_ref();
1762        // `IMPLEMENTS` is declared intra-mem only — invisible to
1763        // the cross-mem entry and refused with INVALID_REL_TYPE.
1764        match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1765            CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1766                input,
1767                allowed,
1768                ..
1769            }) => {
1770                assert_eq!(input, "IMPLEMENTS");
1771                // Cross-mem entry's vocabulary surfaces: ADDRESSES + MENTIONS.
1772                let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1773                assert!(names.iter().any(|n| n == "ADDRESSES"));
1774                assert!(names.iter().any(|n| n == "MENTIONS"));
1775                // Intra-mem-only rel-type must not leak into the cross-mem list.
1776                assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1777            }
1778            other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1779        }
1780    }
1781
1782    #[test]
1783    fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1784        let src = cross_mem_source_schema();
1785        let target = other_target_ref();
1786        // ADDRESSES is shape-pinned to step → requirement. `decision`
1787        // is a declared source type in source-cv but not admitted by
1788        // this cross-mem entry; the shape check refuses with the
1789        // cross-mem entry's shape (not intra-mem's).
1790        match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1791            CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1792                rel_type,
1793                from_type,
1794                allowed_source_types,
1795                allowed_target_types,
1796                ..
1797            }) => {
1798                assert_eq!(rel_type, "ADDRESSES");
1799                assert_eq!(from_type, "decision");
1800                assert_eq!(allowed_source_types, vec!["step".to_string()]);
1801                assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1802            }
1803            other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1804        }
1805    }
1806
1807    #[test]
1808    fn cross_mem_shape_free_rel_type_admits_any_pair() {
1809        let src = cross_mem_source_schema();
1810        let target = other_target_ref();
1811        // MENTIONS has empty source_types/target_types — admits any pair.
1812        assert!(matches!(
1813            validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1814            CrossMemRelCheck::Ok
1815        ));
1816    }
1817
1818    /// Plan 11: a schema with a `to_schema: "*"` entry (loader-bound to
1819    /// its alias rel-type) plus an exact per-schema entry for
1820    /// structural edges.
1821    fn wildcard_source_schema() -> std::sync::Arc<Schema> {
1822        let manifest_yaml = r#"name: source-wc
1823version: 0.1.0
1824description: wildcard cross-mem source schema
1825when_to_use: tests
1826types:
1827  - step
1828  - decision
1829relationships:
1830  mode: strict
1831  definitions:
1832    - name: SOFT_REF
1833      description: alias-emitted soft reference
1834      default_weight: 0.5
1835    - name: ADDRESSES
1836      description: structural
1837      default_weight: 1.0
1838    - name: _default
1839      description: fallback
1840      default_weight: 1.0
1841alias_target_rel_type: SOFT_REF
1842cross_mem_relationships:
1843  - to_schema: other
1844    definitions:
1845      - name: ADDRESSES
1846        description: structural, per-schema
1847        default_weight: 1.0
1848        source_types: [step]
1849        target_types: [requirement]
1850  - to_schema: "*"
1851    definitions:
1852      - name: SOFT_REF
1853        description: soft reference anywhere
1854        default_weight: 0.5
1855        source_types: [step]
1856community:
1857  resolution: 1.0
1858  seed: 42
1859"#;
1860        let body_section = r#"description: t
1861when_to_use: tests
1862sections:
1863  - key: body
1864    heading: Body
1865    required: true
1866    search_weight: 10.0
1867    catch_all: true
1868    write_rules: []
1869metadata_fields: []
1870title_weight: 100.0
1871text_fields:
1872  - body
1873hierarchy_relationship: _default
1874no_self_loop_relationships: []
1875updatable_fields:
1876  - title
1877  - body
1878health_required_fields:
1879  - body
1880staleness_threshold_days: 90
1881write_rules: []
1882"#;
1883        let types = vec![
1884            ("step".to_string(), format!("name: step\n{body_section}")),
1885            (
1886                "decision".to_string(),
1887                format!("name: decision\n{body_section}"),
1888            ),
1889        ];
1890        std::sync::Arc::new(
1891            memstead_schema::load_schema_from_memory(manifest_yaml, &types)
1892                .expect("wildcard schema loads"),
1893        )
1894    }
1895
1896    /// The wildcard admits the alias rel-type into ANY destination
1897    /// schema — including one carrying its own exact structural entry
1898    /// (coexistence: the exact entry must not shadow the wildcard).
1899    #[test]
1900    fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
1901        let src = wildcard_source_schema();
1902        // Arbitrary user-written destination schema, arbitrary type.
1903        let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1904        assert!(matches!(
1905            validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
1906            CrossMemRelCheck::Ok
1907        ));
1908        // Destination with an exact structural entry: BOTH work.
1909        let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
1910        assert!(matches!(
1911            validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
1912            CrossMemRelCheck::Ok
1913        ));
1914        assert!(matches!(
1915            validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
1916            CrossMemRelCheck::Ok
1917        ));
1918    }
1919
1920    /// Refusal complements around the wildcard: the source-type list on
1921    /// the wildcard declaration still gates; a structural rel-type into
1922    /// a destination with no per-schema declaration is still the
1923    /// historical `CROSS_MEM_EDGE_NOT_DECLARED` refusal.
1924    #[test]
1925    fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
1926        let src = wildcard_source_schema();
1927        let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1928        // `decision` is not in the wildcard declaration's source_types.
1929        match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
1930            CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1931                from_type,
1932                allowed_source_types,
1933                ..
1934            }) => {
1935                assert_eq!(from_type, "decision");
1936                assert_eq!(allowed_source_types, vec!["step".to_string()]);
1937            }
1938            other => panic!("expected shape refusal on source-type gate, got {other:?}"),
1939        }
1940        // Structural rel-type into an undeclared destination: the
1941        // wildcard (alias-only) does not admit it — same refusal as
1942        // before the wildcard existed.
1943        assert!(matches!(
1944            validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
1945            CrossMemRelCheck::EdgeNotDeclared
1946        ));
1947    }
1948
1949    // --- prose_render -----------------------------------------------
1950    // The text channel inlines every recovery field instead of pointing
1951    // at the structured channel. These tests pin that contract.
1952
1953    #[test]
1954    fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1955        let err = ValidationError::UnknownSection {
1956            key: "implimentation".to_string(),
1957            entity_type: "spec".to_string(),
1958            declared: (0..8).map(|i| format!("sec{i}")).collect(),
1959            suggestion: Some("sec0".to_string()),
1960        };
1961        let prose = err.prose_render();
1962        for d in (0..8).map(|i| format!("sec{i}")) {
1963            assert!(prose.contains(&d), "missing {d} in: {prose}");
1964        }
1965        assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1966        assert!(!prose.contains("see details"), "got: {prose}");
1967    }
1968
1969    #[test]
1970    fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1971        let err = ValidationError::InvalidEnumValue {
1972            field: "level".to_string(),
1973            value: "M7".to_string(),
1974            allowed: (0..7).map(|i| format!("M{i}")).collect(),
1975            field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1976            suggestion: Some("M6".to_string()),
1977            type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1978            entity_type: "spec".to_string(),
1979        };
1980        let prose = err.prose_render();
1981        assert!(prose.contains("M0"), "got: {prose}");
1982        assert!(prose.contains("M6"), "got: {prose}");
1983        assert!(
1984            prose.contains("maturity rung"),
1985            "field_description missing: {prose}"
1986        );
1987        assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1988        assert!(
1989            prose.contains("specs land at M0"),
1990            "type_write_rules missing: {prose}"
1991        );
1992        assert!(!prose.contains("see details"), "got: {prose}");
1993    }
1994
1995    #[test]
1996    fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1997        let err = ValidationError::InvalidRelationshipShape {
1998            rel_type: "OWNS".to_string(),
1999            from_type: "spec".to_string(),
2000            to_type: "spec".to_string(),
2001            allowed_source_types: vec!["actor".to_string()],
2002            allowed_target_types: vec![],
2003            suggestion: None,
2004        };
2005        let prose = err.prose_render();
2006        // The shape-free target axis renders as `any` (no brackets,
2007        // matching the existing convention pinned by
2008        // `relate_shape_violation_surfaces_typed_envelope`).
2009        assert!(prose.contains("allowed sources: actor"), "got: {prose}");
2010        assert!(prose.contains("allowed targets: any"), "got: {prose}");
2011        assert!(!prose.contains("see details"), "got: {prose}");
2012    }
2013
2014    // ---------------------------------------------------------------
2015    // parse_metadata_value typed-value validation. A Date / Number
2016    // field's value is validated against its declared type at the write
2017    // boundary, so a malformed value cannot land (and cannot corrupt
2018    // range filters).
2019    // ---------------------------------------------------------------
2020
2021    /// In-memory schema with one type carrying a `Date` field
2022    /// (`verified_on`), a `Number` field (`order`), and a free-form
2023    /// `String` field (`note`) — the three arms the value check
2024    /// distinguishes.
2025    fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
2026        let manifest_yaml = r#"name: tests-typed-fields
2027version: 0.1.0
2028description: typed-field test schema
2029when_to_use: tests
2030types:
2031  - widget
2032relationships:
2033  mode: strict
2034  definitions:
2035    - name: _default
2036      description: fallback
2037      default_weight: 1.0
2038community:
2039  resolution: 1.0
2040  seed: 42
2041"#;
2042        let type_yaml = r#"name: widget
2043description: t
2044when_to_use: Here
2045sections:
2046  - key: body
2047    heading: Body
2048    required: true
2049    search_weight: 10.0
2050    catch_all: true
2051    write_rules: []
2052metadata_fields:
2053  - key: verified_on
2054    description: ISO YYYY-MM-DD date the widget was verified
2055    field_type: date
2056    optional: true
2057  - key: order
2058    description: numeric ordering within a plan
2059    field_type: number
2060    optional: true
2061  - key: note
2062    description: free-form note
2063    field_type: string
2064    optional: true
2065title_weight: 100.0
2066text_fields:
2067  - body
2068hierarchy_relationship: _default
2069no_self_loop_relationships: []
2070updatable_fields:
2071  - title
2072  - body
2073health_required_fields:
2074  - body
2075staleness_threshold_days: 90
2076write_rules: []
2077"#;
2078        let schema = memstead_schema::load_schema_from_memory(
2079            manifest_yaml,
2080            &[("widget".to_string(), type_yaml.to_string())],
2081        )
2082        .expect("typed-field test schema must load");
2083        schema.get_type("widget").expect("widget type present")
2084    }
2085
2086    #[test]
2087    fn date_field_rejects_non_date_value() {
2088        let ty = typed_field_type();
2089        let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
2090        assert_eq!(err.code(), "INVALID_FIELD_VALUE");
2091        match err {
2092            ValidationError::InvalidFieldValue {
2093                field,
2094                value,
2095                expected_type,
2096                entity_type,
2097                ..
2098            } => {
2099                assert_eq!(field, "verified_on");
2100                assert_eq!(value, "not-a-real-date");
2101                assert_eq!(expected_type, "Date");
2102                assert_eq!(entity_type, "widget");
2103            }
2104            other => panic!("expected InvalidFieldValue, got {other:?}"),
2105        }
2106    }
2107
2108    #[test]
2109    fn date_field_rejects_empty_string() {
2110        let ty = typed_field_type();
2111        let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
2112        assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
2113    }
2114
2115    #[test]
2116    fn date_field_accepts_iso_date_and_datetime() {
2117        let ty = typed_field_type();
2118        match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
2119            MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
2120            other => panic!("expected String, got {other:?}"),
2121        }
2122        // ISO-8601 datetime form is also accepted.
2123        assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
2124    }
2125
2126    #[test]
2127    fn number_field_rejects_non_numeric_value() {
2128        let ty = typed_field_type();
2129        let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
2130        match err {
2131            ValidationError::InvalidFieldValue {
2132                field,
2133                expected_type,
2134                ..
2135            } => {
2136                assert_eq!(field, "order");
2137                assert_eq!(expected_type, "Number");
2138            }
2139            other => panic!("expected InvalidFieldValue, got {other:?}"),
2140        }
2141    }
2142
2143    #[test]
2144    fn number_field_accepts_integer_and_float() {
2145        let ty = typed_field_type();
2146        assert!(matches!(
2147            parse_metadata_value("order", "3", &ty).unwrap(),
2148            MetadataValue::Integer(3)
2149        ));
2150        assert!(matches!(
2151            parse_metadata_value("order", "2.5", &ty).unwrap(),
2152            MetadataValue::Float(_)
2153        ));
2154    }
2155
2156    #[test]
2157    fn string_field_accepts_any_value() {
2158        let ty = typed_field_type();
2159        // The free-form String arm is untouched — arbitrary text lands.
2160        assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
2161    }
2162
2163    #[test]
2164    fn invalid_field_value_prose_inlines_format_and_purpose() {
2165        let err = ValidationError::InvalidFieldValue {
2166            field: "verified_on".to_string(),
2167            value: "not-a-real-date".to_string(),
2168            expected_type: "Date".to_string(),
2169            expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
2170            field_description: Some("date the widget was verified".to_string()),
2171            entity_type: "widget".to_string(),
2172        };
2173        let prose = err.prose_render();
2174        assert!(prose.contains("not-a-real-date"), "got: {prose}");
2175        assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
2176        assert!(
2177            prose.contains("date the widget was verified"),
2178            "purpose missing: {prose}"
2179        );
2180        assert!(!prose.contains("see details"), "got: {prose}");
2181    }
2182
2183    #[test]
2184    fn is_date_shaped_matches_strict_validator_contract() {
2185        assert!(is_date_shaped("2024-06-01"));
2186        assert!(is_date_shaped("2024-06-01T12:30:00Z"));
2187        assert!(!is_date_shaped(""));
2188        assert!(!is_date_shaped("not-a-real-date"));
2189        assert!(!is_date_shaped("2024-6-1"));
2190        assert!(!is_date_shaped("2024-06-01 extra"));
2191    }
2192
2193    #[test]
2194    fn section_content_refuses_nul_byte() {
2195        let err = validate_section_content([("body", "line1\u{0}line2")].into_iter(), None)
2196            .expect_err("NUL in a section body must be refused");
2197        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2198        match &err {
2199            ValidationError::SectionContentControlByte {
2200                section,
2201                control_char,
2202                codepoint,
2203                byte_offset,
2204            } => {
2205                assert_eq!(section, "body");
2206                assert_eq!(*control_char, '\u{0}');
2207                assert_eq!(*codepoint, 0);
2208                // "line1" is 5 bytes — the NUL sits at offset 5.
2209                assert_eq!(*byte_offset, 5);
2210            }
2211            other => panic!("expected SectionContentControlByte, got {other:?}"),
2212        }
2213        // Recovery payload names the offending char + offset.
2214        let details = err.details();
2215        assert_eq!(details["codepoint"], 0);
2216        assert_eq!(details["byte_offset"], 5);
2217        assert_eq!(details["section"], "body");
2218    }
2219
2220    #[test]
2221    fn section_content_refuses_other_c0_controls_and_cr() {
2222        // Bell, vertical tab, form feed, carriage return — all C0
2223        // controls outside the tab/newline allow-list.
2224        for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
2225            let body = format!("ok{bad}more");
2226            let err = validate_section_content([("s", body.as_str())].into_iter(), None)
2227                .expect_err("control char must be refused");
2228            assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
2229        }
2230    }
2231
2232    #[test]
2233    fn section_content_allows_tab_and_newline() {
2234        // The two legitimate whitespace controls round-trip; multi-line
2235        // and tabbed bodies are unaffected.
2236        validate_section_content(
2237            [("body", "line1\nline2\n\tindented\tcols\n")].into_iter(),
2238            None,
2239        )
2240        .expect("tab and newline must stay legal in section bodies");
2241    }
2242
2243    /// Criterion 6 (consistency-sweep 04/01): the engine must accept back a
2244    /// value it emitted. The catch-all re-emits absorbed content under its
2245    /// original heading line, so an agent that read an entity and wrote that
2246    /// section back in replace mode was refused its own value.
2247    #[test]
2248    fn the_catch_all_accepts_back_the_value_the_engine_emits() {
2249        let declared = ["Body", "Notes"];
2250        let ctx = CatchAllContext {
2251            key: "notes",
2252            entity_type: "doc",
2253            declared_headings: &declared,
2254        };
2255        // What the engine hands out: the absorbed heading, verbatim.
2256        validate_section_content(
2257            [("notes", "## Field Notes\n\nsomething useful\n")].into_iter(),
2258            Some(ctx),
2259        )
2260        .expect("the catch-all re-absorbs an undeclared heading, so writing it back is safe");
2261    }
2262
2263    /// The refusal complement, and the reason the exemption is exact rather
2264    /// than a loosening: a DECLARED heading inside the catch-all really does
2265    /// fork the entity, because the reparse moves that content to the declared
2266    /// key. It stays refused, and so does every other section.
2267    #[test]
2268    fn the_catch_all_exemption_does_not_weaken_the_guard() {
2269        let declared = ["Body", "Notes"];
2270        let ctx = CatchAllContext {
2271            key: "notes",
2272            entity_type: "doc",
2273            declared_headings: &declared,
2274        };
2275        let err = validate_section_content(
2276            [("notes", "## Body\n\nthis would move to `body` on reparse\n")].into_iter(),
2277            Some(ctx),
2278        )
2279        .expect_err("a declared heading inside the catch-all forks the entity");
2280        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2281
2282        // A non-catch-all section is untouched by the exemption.
2283        let err = validate_section_content([("body", "## Anything\n")].into_iter(), Some(ctx))
2284            .expect_err("only the catch-all absorbs; every other section still forks");
2285        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2286
2287        // And an h1 is refused inside the catch-all too: it is the entity's
2288        // own title level, not something the catch-all absorbs.
2289        let err = validate_section_content([("notes", "# A Title\n")].into_iter(), Some(ctx))
2290            .expect_err("h1 is the entity's title level");
2291        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2292    }
2293
2294    /// Criterion 7 (consistency-sweep 04/01): caller-supplied content carrying
2295    /// an undeclared heading with NO body refuses, naming what was rejected.
2296    /// This is the exact complement of the criterion-6 exemption: the catch-all
2297    /// keeps absorbed content but SKIPS empty content, so accepting this write
2298    /// would drop the heading and tell the caller nothing.
2299    #[test]
2300    fn content_that_would_hide_a_delimiter_is_refused() {
2301        // Criterion 1. An open fence's range runs to end of text, so every
2302        // heading the generator writes after this section would be masked and
2303        // absorbed into it.
2304        let err = validate_section_content([("body", "```rust\nfn main() {}")].into_iter(), None)
2305            .expect_err("an unterminated fence must be refused");
2306        assert_eq!(err.code(), "UNTERMINATED_FENCE");
2307        assert_eq!(err.details()["section"], "body");
2308        assert_eq!(err.details()["fence"], "```");
2309        // Tilde fences and longer runs report the closer that actually works.
2310        let err = validate_section_content([("body", "~~~\nopen")].into_iter(), None)
2311            .expect_err("tilde fences too");
2312        assert_eq!(err.details()["fence"], "~~~");
2313        let err = validate_section_content([("body", "````\n```\nstill inside")].into_iter(), None)
2314            .expect_err("a longer opener needs a longer closer");
2315        assert_eq!(err.details()["fence"], "````");
2316    }
2317
2318    #[test]
2319    fn a_closed_fence_around_headings_is_admitted_unchanged() {
2320        // Criterion 2, both halves. A guard that refuses every fenced `##`
2321        // fails, and so does one that refuses content whose fence closes
2322        // later in the same body.
2323        validate_section_content(
2324            [(
2325                "body",
2326                "prose\n\n```md\n## Not A Section\n# Nor This\n```\n\nmore prose",
2327            )]
2328            .into_iter(),
2329            None,
2330        )
2331        .expect("a closed fence containing heading lines is ordinary content");
2332        // The closer arriving late in the body is still a closer.
2333        validate_section_content([("body", "```\n## Hidden\n```")].into_iter(), None)
2334            .expect("closed is closed, wherever the closer sits");
2335        // A fence inside a container the next column-0 line closes implicitly.
2336        validate_section_content([("body", "> ```\n> quoted")].into_iter(), None)
2337            .expect("a blockquote's fence cannot reach past the quote");
2338    }
2339
2340    #[test]
2341    fn an_ordinary_body_is_untouched_by_the_fence_guard() {
2342        // Criterion 7 at this tier: no fence characters, no new refusal.
2343        validate_section_content(
2344            [("body", "plain prose\nwith lines\n\nand a paragraph")].into_iter(),
2345            None,
2346        )
2347        .expect("content with no fence at all cannot trip a fence guard");
2348    }
2349
2350    #[test]
2351    fn an_empty_undeclared_heading_is_refused_at_the_write() {
2352        let declared = ["Body", "Notes"];
2353        let ctx = CatchAllContext {
2354            key: "notes",
2355            entity_type: "doc",
2356            declared_headings: &declared,
2357        };
2358        for (body, label) in [
2359            ("## Scratch\n", "bare heading, nothing after it"),
2360            (
2361                "## Scratch\n\n   \n",
2362                "heading followed only by blank lines",
2363            ),
2364            (
2365                "## Scratch\n\n## Other\n\nreal content\n",
2366                "heading with the next heading under it",
2367            ),
2368        ] {
2369            let err = validate_section_content([("notes", body)].into_iter(), Some(ctx))
2370                .expect_err(label);
2371            assert_eq!(err.code(), "EMPTY_UNDECLARED_HEADING", "{label}");
2372            let d = err.details();
2373            assert_eq!(d["heading"], "Scratch", "{label}");
2374            assert_eq!(d["entity_type"], "doc", "{label}");
2375        }
2376    }
2377
2378    /// The refusal complement of criterion 7, which is what keeps it from
2379    /// stranding every read-modify-write: a heading WITH a body is accepted,
2380    /// because it survives.
2381    #[test]
2382    fn an_undeclared_heading_with_a_body_is_not_refused() {
2383        let declared = ["Body", "Notes"];
2384        let ctx = CatchAllContext {
2385            key: "notes",
2386            entity_type: "doc",
2387            declared_headings: &declared,
2388        };
2389        validate_section_content(
2390            [("notes", "## Scratch\n\nsomething\n")].into_iter(),
2391            Some(ctx),
2392        )
2393        .expect("content under the heading survives, so the write is accepted");
2394    }
2395
2396    #[test]
2397    fn section_content_keeps_backslashes_verbatim() {
2398        // The fix screens a byte class — it must not interpret or
2399        // de-escape content. Literal backslashes (incl. ones that look
2400        // like escapes) pass through untouched.
2401        validate_section_content(
2402            [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
2403            None,
2404        )
2405        .expect("backslashes are literal content, not control bytes");
2406    }
2407
2408    #[test]
2409    fn section_content_still_refuses_heading_injection() {
2410        // The pre-existing heading-injection guard is unchanged and
2411        // shares the wire code.
2412        let err =
2413            validate_section_content([("body", "intro\n## Injected\ntail")].into_iter(), None)
2414                .expect_err("embedded `## ` heading must still be refused");
2415        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2416        assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
2417    }
2418
2419    /// The write guard classifies code blocks the way the splitter
2420    /// does: a `## ` line inside a code block splits nothing on
2421    /// reparse, so refusing it was the write path disagreeing with the
2422    /// read path about what a code block is.
2423    #[test]
2424    fn section_content_admits_a_heading_inside_a_code_block() {
2425        for body in [
2426            "intro\n\n```\n## Not A Heading\n```\n",
2427            "intro\n\n~~~\n## Not A Heading\n~~~\n",
2428            "intro\n\n> ```\n> ## Not A Heading\n> ```\n",
2429            "intro\n\n    ## Not A Heading\n",
2430        ] {
2431            validate_section_content([("body", body)].into_iter(), None)
2432                .unwrap_or_else(|e| panic!("code-block content must be admitted: {body:?} -> {e}"));
2433        }
2434    }
2435
2436    /// The trim-fork class: the splitter stores the *trimmed* body, so
2437    /// an indented code block that opens a section loses its indent on
2438    /// write-back and its `## ` line lands at column 0 on the next
2439    /// parse. The guard sees what the reparse will see.
2440    #[test]
2441    fn section_content_refuses_the_trim_fork() {
2442        let err = validate_section_content(
2443            [("body", "    ## Not A Heading\n    more\n")].into_iter(),
2444            None,
2445        )
2446        .expect_err("content whose trim exposes a column-0 heading must be refused");
2447        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2448        match err {
2449            ValidationError::SectionContentInvalid {
2450                embedded_heading, ..
2451            } => assert_eq!(
2452                embedded_heading, "## Not A Heading",
2453                "the refusal quotes the line the reparse will see"
2454            ),
2455            other => panic!("unexpected error: {other}"),
2456        }
2457    }
2458
2459    #[test]
2460    fn section_content_refuses_the_trim_fork_for_h1_too() {
2461        let err = validate_section_content([("body", "  # Not A Title\n")].into_iter(), None)
2462            .expect_err("h1 exposed by the trim must be refused");
2463        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2464    }
2465}