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