Skip to main content

memstead_base/
runtime_validator.rs

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