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)^## (.+)$` over the *masked* body, so a
663/// section body that shows a `^## ` line to that scan gets split at
664/// that heading on the next read — content after the heading lands
665/// under a different section key (or a fabricated one). Deeper
666/// headings (`### ` and below) are safe — the parser only matches
667/// level 2.
668///
669/// The guard is applied to the content **as the reparse will see it**,
670/// which is what makes it exact rather than approximate:
671///
672/// - the content is trimmed first, because the splitter stores the
673///   trimmed body — an indented block opening a section loses its
674///   indent on write-back, so `    ## Not A Heading` becomes a real
675///   column-0 delimiter on the next parse. Checking the still-indented
676///   provided content missed that fork entirely;
677/// - code blocks are masked first, by the same CommonMark referee the
678///   splitter uses ([`crate::markdown`]) — a `## ` inside a fenced or
679///   indented code block never splits anything, so refusing it was the
680///   write path disagreeing with the read path about what a code block
681///   is.
682pub fn validate_section_content<'a>(
683    sections: impl Iterator<Item = (&'a str, &'a str)>,
684) -> Result<(), ValidationError> {
685    for (key, value) in sections {
686        // Refuse control characters other than tab/newline before the
687        // heading check. A NUL (and other C0/C1/DEL controls) persists
688        // verbatim today and breaks the diffable-markdown invariant — a
689        // NUL makes git classify the blob as binary and downstream text
690        // tooling truncates at it. Mirrors the title control-char guard
691        // (`char::is_control`, refuse-with-actionable-hint) but keeps
692        // `\t`/`\n` legal, which titles disallow. We refuse rather than
693        // strip — silently mutating caller-sent content is the
694        // no-silent-data-loss anti-pattern the title fix already flagged.
695        // The verbatim-escape contract is untouched: this screens a byte
696        // class, it does not interpret or de-escape content.
697        if let Some((byte_offset, ch)) = value
698            .char_indices()
699            .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
700        {
701            return Err(ValidationError::SectionContentControlByte {
702                section: key.to_string(),
703                control_char: ch,
704                codepoint: ch as u32,
705                byte_offset,
706            });
707        }
708        // What the splitter will store, and what the splitter will
709        // see in it. `stored` and `masked` share byte offsets and line
710        // count, so the two line sequences correspond one-to-one and
711        // the refusal can quote the real line.
712        let stored = value.trim();
713        let masked = crate::markdown::mask_code_blocks(stored);
714        for (line, masked_line) in stored.lines().zip(masked.lines()) {
715            // Match the parser's regex shape: `^## ` (two hashes, one
716            // space, at least one trailing char). The trailing space
717            // requirement excludes bare `##` (which the parser does
718            // not match either) and `###`+ headings. `^# ` joins the
719            // guard (plan 08): h1 and h2 are the entity's own levels
720            // — the title and the section delimiters — so neither may
721            // be embedded in a section body.
722            if (masked_line.starts_with("## ") && masked_line.len() > 3)
723                || (masked_line.starts_with("# ") && masked_line.len() > 2)
724            {
725                return Err(ValidationError::SectionContentInvalid {
726                    section: key.to_string(),
727                    embedded_heading: line.to_string(),
728                });
729            }
730        }
731    }
732    Ok(())
733}
734
735/// Validate that every section key in `provided` is either schema-declared
736/// for `schema`, or — if the schema has a catch-all section — admitted by
737/// it. Unknown keys return [`ValidationError::UnknownSection`] carrying
738/// the declared list plus a Levenshtein suggestion (or the catch-all key
739/// when no close match exists).
740///
741/// Pure function: no I/O, no allocation outside the eventual error
742/// payload. The `"relationships"` section is allowed through here — the
743/// engine layer above gates it via its own SectionNotUpdatable check.
744pub fn validate_section_keys<'a>(
745    provided: impl Iterator<Item = &'a str>,
746    schema: &TypeDefinition,
747) -> Result<(), ValidationError> {
748    let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
749    declared.sort();
750    let declared_set: std::collections::HashSet<&str> =
751        schema.sections.iter().map(|s| s.key.as_str()).collect();
752    let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
753
754    for key in provided {
755        if key == "relationships" {
756            continue;
757        }
758        if declared_set.contains(key) {
759            continue;
760        }
761        let suggestion = schema
762            .suggest_section(key)
763            .or_else(|| catch_all_key.clone());
764        return Err(ValidationError::UnknownSection {
765            key: key.to_string(),
766            entity_type: schema.name.clone(),
767            declared: declared.clone(),
768            suggestion,
769        });
770    }
771    Ok(())
772}
773
774/// Parse a metadata value string into the appropriate
775/// [`MetadataValue`] type, consulting the schema for field-type
776/// information. Validates enum constraints when the field definition
777/// specifies `enum_values`.
778///
779/// Unknown keys are a hard error — engine code that builds metadata
780/// only emits schema-declared fields, so a lenient insert would
781/// silently drop the value at write time and the agent would read a
782/// success response while losing data.
783pub fn parse_metadata_value(
784    key: &str,
785    value: &str,
786    schema: &TypeDefinition,
787) -> Result<MetadataValue, ValidationError> {
788    let Some(field_def) = schema.metadata_field(key) else {
789        let mut declared: Vec<String> = schema
790            .metadata_fields
791            .iter()
792            .map(|f| f.key.clone())
793            .collect();
794        declared.sort();
795        return Err(ValidationError::UnknownMetadata {
796            key: key.to_string(),
797            entity_type: schema.name.clone(),
798            declared,
799            suggestion: schema.suggest_metadata_field(key),
800        });
801    };
802
803    if let Some(ref allowed) = field_def.enum_values
804        && !allowed.iter().any(|v| v == value)
805    {
806        let suggestion = nearest_str_match(value, allowed);
807        return Err(ValidationError::InvalidEnumValue {
808            field: key.to_string(),
809            value: value.to_string(),
810            allowed: allowed.clone(),
811            field_description: Some(field_def.description.clone()),
812            suggestion,
813            type_write_rules: schema.write_rules.clone(),
814            entity_type: schema.name.clone(),
815        });
816    }
817
818    Ok(match field_def.field_type {
819        FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
820        FieldType::Number => {
821            if let Ok(n) = value.parse::<i64>() {
822                MetadataValue::Integer(n)
823            } else if let Ok(f) = value.parse::<f64>() {
824                MetadataValue::Float(f)
825            } else {
826                // Pre-fix this fell back to `String`, silently storing
827                // non-numeric text in a Number field. Reject so the
828                // value never reaches the store (and never corrupts a
829                // range filter on the field).
830                return Err(ValidationError::InvalidFieldValue {
831                    field: key.to_string(),
832                    value: value.to_string(),
833                    expected_type: "Number".to_string(),
834                    expected_format: Some("an integer or decimal number".to_string()),
835                    field_description: Some(field_def.description.clone()),
836                    entity_type: schema.name.clone(),
837                });
838            }
839        }
840        FieldType::Date => {
841            // The field's declared shape is `YYYY-MM-DD` (or the ISO
842            // datetime form). Pre-fix any string — including `""` and
843            // arbitrary text — fell through to the `String` arm and was
844            // stored raw; a non-date value then sorts lexically against
845            // real dates and produces false `*_after` / `*_before`
846            // range-filter matches. Validate at the write boundary so
847            // the corruption can never land.
848            if !is_date_shaped(value) {
849                return Err(ValidationError::InvalidFieldValue {
850                    field: key.to_string(),
851                    value: value.to_string(),
852                    expected_type: "Date".to_string(),
853                    expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
854                    field_description: Some(field_def.description.clone()),
855                    entity_type: schema.name.clone(),
856                });
857            }
858            MetadataValue::String(value.to_string())
859        }
860        _ => MetadataValue::String(value.to_string()),
861    })
862}
863
864/// Does `s` match the shape a `Date`-typed metadata value must have —
865/// `YYYY-MM-DD` or the ISO-8601 datetime form `YYYY-MM-DDTHH:MM:SSZ`?
866///
867/// Single source of truth for the date-shape check, shared by the CRUD
868/// write path ([`parse_metadata_value`]) and the archive-ingress strict
869/// validator (`crate::validator::strict::value_matches_type`). Keeping
870/// one regex means the value a `memstead_create` accepts and the value an
871/// import re-accepts cannot drift apart.
872pub fn is_date_shaped(s: &str) -> bool {
873    static RE: OnceLock<Regex> = OnceLock::new();
874    RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
875        .is_match(s)
876}
877
878/// Tier-2 warning shape — the create path emits one entry per required
879/// metadata field that is not auto-filled by the schema (no
880/// `default_value`, no `init_timestamp`, no `auto_timestamp`) and was
881/// not supplied by the caller. Same payload the MCP layer surfaces as
882/// `MISSING_REQUIRED_FIELD` warnings — mirrors the
883/// `REQUIRED_FIELD_UNSET` error envelope so a single decoder handles
884/// both surfaces.
885#[derive(Debug, Clone)]
886pub struct MissingRequiredField {
887    pub entity_type: String,
888    pub key: String,
889    pub description: String,
890    pub enum_values: Vec<String>,
891}
892
893/// Return one [`MissingRequiredField`] per required metadata field that
894/// the caller did not supply and the schema does not auto-fill. A field
895/// is "auto-filled" when it carries `default_value`, `init_timestamp`,
896/// or `auto_timestamp` — the engine writes a non-trivial value without
897/// caller input. Optional fields and supplied fields are skipped.
898///
899/// Caller-side intent: the warning fires when the entity would land in
900/// a stuck state (placeholder today's-date / empty string) that the
901/// agent did not opt into. Surfaced from the create path so dry-run
902/// and real-write preview the same set of warnings.
903pub fn missing_required_fields(
904    schema: &TypeDefinition,
905    supplied: &IndexMap<String, String>,
906) -> Vec<MissingRequiredField> {
907    schema
908        .metadata_fields
909        .iter()
910        .filter(|f| {
911            // Engine-managed fields (`type`, `id`, `mem`) are seeded
912            // independently of caller input; not the agent's
913            // responsibility to supply.
914            !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
915                && f.is_required()
916                && f.default_value.is_none()
917                && !f.init_timestamp
918                && !f.auto_timestamp
919                && !supplied.contains_key(f.key.as_str())
920        })
921        .map(|f| MissingRequiredField {
922            entity_type: schema.name.clone(),
923            key: f.key.clone(),
924            description: f.description.clone(),
925            enum_values: f.enum_values.clone().unwrap_or_default(),
926        })
927        .collect()
928}
929
930/// Return one [`MissingRequiredSection`] per required section that is
931/// absent or empty in `sections`. Empty (whitespace-only) bodies count
932/// as missing — same predicate as the health report uses.
933pub fn missing_required_sections(
934    schema: &TypeDefinition,
935    sections: &IndexMap<String, String>,
936) -> Vec<MissingRequiredSection> {
937    schema
938        .required_sections()
939        .filter_map(|sec| {
940            let is_empty = sections
941                .get(sec.key.as_str())
942                .is_none_or(|c| c.trim().is_empty());
943            is_empty.then(|| MissingRequiredSection {
944                entity_type: schema.name.clone(),
945                key: sec.key.clone(),
946                heading: sec.heading.clone(),
947                write_rules: sec.write_rules.clone(),
948            })
949        })
950        .collect()
951}
952
953/// Outcome of running a relationship name against a schema. The
954/// engine adapter above decides whether to ride the warning out on
955/// the response (open mode) or convert the error into its own type
956/// (strict mode).
957#[derive(Debug, Clone)]
958pub enum RelationshipCheck {
959    /// Name is declared in the schema's relationship vocabulary.
960    Ok,
961    /// Schema runs in open mode and admits the name with a warning
962    /// the engine layer can surface to the agent.
963    OpenWarning(String),
964}
965
966/// Validate a relationship name against a mem schema's vocabulary.
967/// Strict-mode schemas reject undeclared names with
968/// [`ValidationError::InvalidRelationshipType`]; open-mode schemas
969/// admit unknown names and return a warning string for the engine to
970/// surface.
971///
972/// The mutation engine calls this from its `memstead_relate` path; the
973/// wire shape (`INVALID_REL_TYPE`, `allowed[]`, `suggestion`) is stable
974/// regardless of workspace storage.
975pub fn validate_rel_type(
976    rel_type: &str,
977    schema: &Schema,
978) -> Result<RelationshipCheck, ValidationError> {
979    if schema.relationship_known(rel_type) {
980        return Ok(RelationshipCheck::Ok);
981    }
982    match schema.mode() {
983        RelationshipMode::Strict => {
984            let allowed = declared_relationship_hints(schema);
985            let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
986            let suggestion = nearest_str_match(rel_type, &candidate_names);
987            Err(ValidationError::InvalidRelationshipType {
988                input: rel_type.to_string(),
989                allowed,
990                suggestion,
991            })
992        }
993        RelationshipMode::Open => {
994            let declared: Vec<String> = declared_relationship_hints(schema)
995                .into_iter()
996                .map(|h| h.name)
997                .collect();
998            let suggestion = schema
999                .suggest_relationship(rel_type)
1000                .map(|s| format!(" Did you mean '{s}'?"))
1001                .unwrap_or_default();
1002            let (schema_name, schema_version) = schema.id();
1003            Ok(RelationshipCheck::OpenWarning(format!(
1004                "relationship '{rel_type}' is not declared in schema \
1005                 '{schema_name}@{schema_version}' (mode: open). \
1006                 Accepted with default weight. Declared: [{}].{suggestion}",
1007                declared.join(", "),
1008            )))
1009        }
1010    }
1011}
1012
1013/// Reject an edge whose `(from_type, to_type)` pair violates the
1014/// schema's declared `source_types` / `target_types` for this
1015/// relationship. No-op when both constraint lists are empty
1016/// (shape-free edges) or when the relationship name is unknown
1017/// (callers run this only after [`validate_rel_type`] succeeds, so
1018/// this branch is defensive). The target-type check is skipped when
1019/// `to_type` is `None` — happens for auto-stubbed targets that have
1020/// no type yet; once the stub is authored as a real entity, future
1021/// edges land under the strict check.
1022///
1023/// Suggestion: nearest-match edge in the schema whose declared shape
1024/// would admit `(from_type, to_type)`. Tiebreaker is declaration
1025/// order in the YAML (deterministic).
1026pub fn validate_rel_shape(
1027    rel_type: &str,
1028    from_type: &str,
1029    to_type: Option<&str>,
1030    schema: &Schema,
1031) -> Result<(), ValidationError> {
1032    let Some(def) = schema.relationship_def(rel_type) else {
1033        return Ok(());
1034    };
1035    let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1036    let target_ok = def.target_types.is_empty()
1037        || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1038    if source_ok && target_ok {
1039        return Ok(());
1040    }
1041    let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1042    let suggestion = suggest_shape_admitting(from_type, to_type, schema);
1043    Err(ValidationError::InvalidRelationshipShape {
1044        rel_type: rel_type.to_string(),
1045        from_type: from_type.to_string(),
1046        to_type: to_for_err,
1047        allowed_source_types: def.source_types.clone(),
1048        allowed_target_types: def.target_types.clone(),
1049        suggestion,
1050    })
1051}
1052
1053/// Outcome of looking up a rel-type against a cross-mem entry in
1054/// the source schema's `cross_mem_relationships:` vocabulary.
1055/// `EdgeNotDeclared` carries the recovery payload the engine layer
1056/// wraps into [`crate::EngineError::CrossMemEdgeNotDeclared`]; the
1057/// other variants reuse the existing `ValidationError` shapes so
1058/// agents reading the wire shape decode `INVALID_REL_TYPE` /
1059/// `INVALID_REL_SHAPE` identically in both intra- and cross-mem
1060/// flows.
1061#[derive(Debug, Clone)]
1062pub enum CrossMemRelCheck {
1063    /// `(rel_type, from_type, to_type)` are admitted by the matched
1064    /// cross-mem entry's declared vocabulary and shape. The engine
1065    /// proceeds with the relate write.
1066    Ok,
1067    /// The source schema declares no cross-mem entry whose
1068    /// `to_schema:` matches the target schema. Carries the recovery
1069    /// payload for `CROSS_MEM_EDGE_NOT_DECLARED`.
1070    EdgeNotDeclared,
1071    /// Validation tripped the matched cross-mem entry's own
1072    /// vocabulary / shape — reuses the existing `INVALID_REL_TYPE` /
1073    /// `INVALID_REL_SHAPE` envelopes (carried as the wrapped
1074    /// `ValidationError`) so wire-shape decoders stay flat.
1075    Invalid(ValidationError),
1076}
1077
1078/// Validate a cross-mem edge whose source and target mems pin
1079/// schemas with *different names* against the source schema's
1080/// outbound `cross_mem_relationships:` vocabulary.
1081///
1082/// Caller responsibility: only invoke when the source and target
1083/// schema *names* differ — same-name mems (any version pair) fall
1084/// through to the intra-mem path ([`validate_rel_type`] +
1085/// [`validate_rel_shape`]); same-name is same domain.
1086///
1087/// The lookup goes through [`Schema::cross_mem_entry`], which
1088/// matches by target schema name only — eligibility is name-based,
1089/// so the target mem's pinned version never participates and a
1090/// version bump on the target side cannot invalidate a declaration.
1091///
1092/// On a match, the cross-mem entry's `definitions` list is the sole
1093/// vocabulary for this edge: the source schema's intra-mem
1094/// `relationships.definitions` is NOT consulted in this regime (per
1095/// AC #6 / #9). A rel-type present intra-mem but absent cross-mem
1096/// surfaces here as `INVALID_REL_TYPE`; a shape violation surfaces
1097/// here as `INVALID_REL_SHAPE` with the cross-mem entry's shape
1098/// (not the intra-mem entry's, if both exist).
1099pub fn validate_cross_mem_edge(
1100    rel_type: &str,
1101    from_type: &str,
1102    to_type: Option<&str>,
1103    source_schema: &Schema,
1104    target_schema_ref: &memstead_schema::SchemaRef,
1105) -> CrossMemRelCheck {
1106    // Priority-ordered entries: exact-name declaration first, then the
1107    // `to_schema: "*"` wildcard (loader-bound to the schema's alias
1108    // target rel-type). First rel-type hit across the entries wins, so
1109    // structural declarations for a destination never shadow the
1110    // wildcarded alias links into it.
1111    let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
1112    if entries.is_empty() {
1113        return CrossMemRelCheck::EdgeNotDeclared;
1114    }
1115
1116    let Some(def) = entries
1117        .iter()
1118        .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1119    else {
1120        // Only the wildcard matched and it doesn't carry this
1121        // rel-type: for THIS destination schema the rel-type has
1122        // genuinely no declaration — the historical
1123        // `CROSS_MEM_EDGE_NOT_DECLARED` refusal, so a structural edge
1124        // into an undeclared schema reads the same with or without a
1125        // wildcard present (the wildcard only ever admits the alias
1126        // rel-type).
1127        if !entries.iter().any(|e| e.to_schema != "*") {
1128            return CrossMemRelCheck::EdgeNotDeclared;
1129        }
1130        let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
1131        let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1132        let suggestion = nearest_str_match(rel_type, &candidate_names);
1133        return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1134            input: rel_type.to_string(),
1135            allowed,
1136            suggestion,
1137        });
1138    };
1139
1140    let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1141    let target_ok = def.target_types.is_empty()
1142        || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1143    if source_ok && target_ok {
1144        return CrossMemRelCheck::Ok;
1145    }
1146    let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1147    let suggestion = entries
1148        .iter()
1149        .find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
1150    CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1151        rel_type: rel_type.to_string(),
1152        from_type: from_type.to_string(),
1153        to_type: to_for_err,
1154        allowed_source_types: def.source_types.clone(),
1155        allowed_target_types: def.target_types.clone(),
1156        suggestion,
1157    })
1158}
1159
1160/// Union of [`cross_mem_entry_hints`] across priority-ordered entries,
1161/// de-duplicated by rel-type name (first entry's hint wins) and
1162/// re-sorted.
1163fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
1164    let mut out: Vec<RelationshipHint> = Vec::new();
1165    for entry in entries {
1166        for hint in cross_mem_entry_hints(entry) {
1167            if !out.iter().any(|h| h.name == hint.name) {
1168                out.push(hint);
1169            }
1170        }
1171    }
1172    out.sort_by(|a, b| a.name.cmp(&b.name));
1173    out
1174}
1175
1176/// Sorted vocabulary hints for one cross-mem entry, excluding the
1177/// `_default` sentinel — same shape as
1178/// [`declared_relationship_hints`] but scoped to a single cross-mem
1179/// declaration.
1180fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1181    let mut out: Vec<RelationshipHint> = entry
1182        .definitions
1183        .iter()
1184        .filter(|d| d.name != "_default")
1185        .map(|d| RelationshipHint {
1186            name: d.name.clone(),
1187            when_to_use: d.when_to_use.clone(),
1188        })
1189        .collect();
1190    out.sort_by(|a, b| a.name.cmp(&b.name));
1191    out
1192}
1193
1194/// First cross-mem `definition` in declaration order whose declared
1195/// shape would admit `(from_type, to_type)`. Mirrors
1196/// [`suggest_shape_admitting`] but scoped to a single cross-mem
1197/// entry.
1198fn cross_mem_suggest_shape(
1199    entry: &CrossMemRelationshipEntry,
1200    from_type: &str,
1201    to_type: Option<&str>,
1202) -> Option<RelationshipHint> {
1203    entry
1204        .definitions
1205        .iter()
1206        .filter(|d| d.name != "_default")
1207        .find(|d| cross_mem_def_admits(d, from_type, to_type))
1208        .map(|d| RelationshipHint {
1209            name: d.name.clone(),
1210            when_to_use: d.when_to_use.clone(),
1211        })
1212}
1213
1214fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1215    let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1216    let tgt_ok =
1217        d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1218    src_ok && tgt_ok
1219}
1220
1221/// First edge in declaration order whose declared shape would admit
1222/// the `(from_type, to_type)` pair. Empty `source_types` /
1223/// `target_types` admit anything. Returns `None` when no such edge
1224/// exists. The `_default` sentinel is excluded — it carries no shape
1225/// and is never a real edge's rel_type.
1226fn suggest_shape_admitting(
1227    from_type: &str,
1228    to_type: Option<&str>,
1229    schema: &Schema,
1230) -> Option<RelationshipHint> {
1231    schema
1232        .manifest
1233        .relationships
1234        .definitions
1235        .iter()
1236        .filter(|d| d.name != "_default")
1237        .find(|d| {
1238            let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1239            let tgt_ok = d.target_types.is_empty()
1240                || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1241            src_ok && tgt_ok
1242        })
1243        .map(|d| RelationshipHint {
1244            name: d.name.clone(),
1245            when_to_use: d.when_to_use.clone(),
1246        })
1247}
1248
1249/// Sorted relationship vocabulary as `RelationshipHint`s, excluding
1250/// the internal `_default` catch-all. Used inside
1251/// [`validate_rel_type`] to populate the `INVALID_REL_TYPE` recovery
1252/// payload's `allowed[]` list.
1253fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1254    let mut out: Vec<RelationshipHint> = schema
1255        .manifest
1256        .relationships
1257        .definitions
1258        .iter()
1259        .filter(|d| d.name != "_default")
1260        .map(|d| RelationshipHint {
1261            name: d.name.clone(),
1262            when_to_use: d.when_to_use.clone(),
1263        })
1264        .collect();
1265    out.sort_by(|a, b| a.name.cmp(&b.name));
1266    out
1267}
1268
1269/// Levenshtein-nearest match against a candidate set, with a noise
1270/// floor of `chars/2` (beyond that the input shares almost nothing with
1271/// the schema vocabulary, so a "did you mean" suggestion does not
1272/// help). Returns `None` when no candidate is close enough.
1273fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1274    let noise_floor = (needle.chars().count() / 2).max(1);
1275    let mut best: Option<(usize, String)> = None;
1276    for cand in candidates {
1277        let d = strsim::levenshtein(needle, cand);
1278        if d == 0 || d > noise_floor {
1279            continue;
1280        }
1281        match &best {
1282            Some((bd, _)) if *bd <= d => {}
1283            _ => best = Some((d, cand.clone())),
1284        }
1285    }
1286    best.map(|(_, name)| name)
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292
1293    /// Tiny in-memory schema for the rel-shape shape-test fixture:
1294    /// `EXECUTES: step → decision`, plus a shape-free `USES` and
1295    /// `PART_OF`. Used by the rel-shape unit tests; `_default` is
1296    /// preserved for parity with the loader's invariants.
1297    fn shape_test_schema() -> std::sync::Arc<Schema> {
1298        let manifest_yaml = r#"name: tests-rel-shape
1299version: 0.1.0
1300description: rel-shape test schema
1301when_to_use: tests
1302types:
1303  - step
1304  - decision
1305  - note
1306relationships:
1307  mode: strict
1308  definitions:
1309    - name: PART_OF
1310      description: parent containment
1311      default_weight: 3.0
1312      acyclic: true
1313    - name: USES
1314      description: shape-free reference
1315      default_weight: 1.0
1316    - name: EXECUTES
1317      description: step carries out decision
1318      default_weight: 2.5
1319      source_types: [step]
1320      target_types: [decision]
1321    - name: _default
1322      description: fallback
1323      default_weight: 1.0
1324community:
1325  resolution: 1.0
1326  seed: 42
1327"#;
1328        let body_section = r#"sections:
1329  - key: body
1330    heading: Body
1331    required: true
1332    search_weight: 10.0
1333    catch_all: true
1334    write_rules: []
1335metadata_fields: []
1336title_weight: 100.0
1337text_fields:
1338  - body
1339hierarchy_relationship: PART_OF
1340no_self_loop_relationships: []
1341updatable_fields:
1342  - title
1343  - body
1344health_required_fields:
1345  - body
1346staleness_threshold_days: 90
1347write_rules: []
1348"#;
1349        let make_type =
1350            |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1351        std::sync::Arc::new(
1352            memstead_schema::load_schema_from_memory(
1353                manifest_yaml,
1354                &[
1355                    ("step".to_string(), make_type("step")),
1356                    ("decision".to_string(), make_type("decision")),
1357                    ("note".to_string(), make_type("note")),
1358                ],
1359            )
1360            .expect("test schema must load"),
1361        )
1362    }
1363
1364    #[test]
1365    fn rel_shape_admits_pair_in_declared_source_target() {
1366        let schema = shape_test_schema();
1367        // step → decision is the declared shape; admits cleanly.
1368        assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1369    }
1370
1371    #[test]
1372    fn rel_shape_rejects_violating_source() {
1373        let schema = shape_test_schema();
1374        // EXECUTES is shape-pinned to source=step; note → decision violates source.
1375        let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1376        match err {
1377            ValidationError::InvalidRelationshipShape {
1378                rel_type,
1379                from_type,
1380                to_type,
1381                allowed_source_types,
1382                allowed_target_types,
1383                ..
1384            } => {
1385                assert_eq!(rel_type, "EXECUTES");
1386                assert_eq!(from_type, "note");
1387                assert_eq!(to_type, "decision");
1388                assert_eq!(allowed_source_types, vec!["step".to_string()]);
1389                assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1390            }
1391            other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1392        }
1393    }
1394
1395    #[test]
1396    fn rel_shape_rejects_violating_target() {
1397        let schema = shape_test_schema();
1398        // step → note violates target: EXECUTES requires target=decision.
1399        let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1400        assert!(matches!(
1401            err,
1402            ValidationError::InvalidRelationshipShape { .. }
1403        ));
1404    }
1405
1406    #[test]
1407    fn rel_shape_admits_shape_free_relationship() {
1408        let schema = shape_test_schema();
1409        // USES has empty source_types/target_types — admits anything.
1410        assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1411    }
1412
1413    #[test]
1414    fn rel_shape_skips_target_check_when_target_type_unknown() {
1415        let schema = shape_test_schema();
1416        // Target stub has no resolved type — target-side check skipped.
1417        // Source still checked: step is the declared source, so this admits.
1418        assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1419    }
1420
1421    #[test]
1422    fn rel_shape_no_op_for_unknown_rel_name() {
1423        let schema = shape_test_schema();
1424        // Defensive branch: callers run validate_rel_type first, but
1425        // an unknown name here returns Ok rather than panicking.
1426        assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1427    }
1428
1429    // ---------------------------------------------------------------
1430    // validate_cross_mem_edge — covers the pure-function layer. The
1431    // engine relate path's routing wraps these outcomes into
1432    // `CROSS_MEM_EDGE_NOT_DECLARED` / `INVALID_REL_TYPE` /
1433    // `INVALID_REL_SHAPE` envelopes.
1434    // ---------------------------------------------------------------
1435
1436    /// Cross-mem-aware source schema: declares one outbound entry
1437    /// to the `other` domain with `ADDRESSES: step → requirement` and
1438    /// a shape-free `MENTIONS`. Intra-mem `relationships` carries a
1439    /// disjoint `IMPLEMENTS` rel-type so the "intra-mem-only is
1440    /// invisible cross-mem" AC is exercisable.
1441    fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1442        let manifest_yaml = r#"name: source-cv
1443version: 0.1.0
1444description: cross-mem source schema
1445when_to_use: tests
1446types:
1447  - step
1448  - decision
1449relationships:
1450  mode: strict
1451  definitions:
1452    - name: IMPLEMENTS
1453      description: intra-mem only
1454      default_weight: 1.0
1455    - name: _default
1456      description: fallback
1457      default_weight: 1.0
1458cross_mem_relationships:
1459  - to_schema: other
1460    definitions:
1461      - name: ADDRESSES
1462        description: outbound shape-pinned
1463        default_weight: 1.0
1464        source_types: [step]
1465        target_types: [requirement]
1466      - name: MENTIONS
1467        description: outbound shape-free
1468        default_weight: 0.5
1469community:
1470  resolution: 1.0
1471  seed: 42
1472"#;
1473        let body_section = r#"sections:
1474  - key: body
1475    heading: Body
1476    required: true
1477    search_weight: 10.0
1478    catch_all: true
1479    write_rules: []
1480metadata_fields: []
1481title_weight: 100.0
1482text_fields:
1483  - body
1484hierarchy_relationship: _default
1485no_self_loop_relationships: []
1486updatable_fields:
1487  - title
1488  - body
1489health_required_fields:
1490  - body
1491staleness_threshold_days: 90
1492write_rules: []
1493"#;
1494        let make_type =
1495            |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1496        std::sync::Arc::new(
1497            memstead_schema::load_schema_from_memory(
1498                manifest_yaml,
1499                &[
1500                    ("step".to_string(), make_type("step")),
1501                    ("decision".to_string(), make_type("decision")),
1502                ],
1503            )
1504            .expect("cross-mem source schema must load"),
1505        )
1506    }
1507
1508    fn other_target_ref() -> memstead_schema::SchemaRef {
1509        memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1510    }
1511
1512    #[test]
1513    fn cross_mem_admits_declared_shape() {
1514        let src = cross_mem_source_schema();
1515        let target = other_target_ref();
1516        match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1517            CrossMemRelCheck::Ok => {}
1518            other => panic!("expected Ok, got {other:?}"),
1519        }
1520    }
1521
1522    #[test]
1523    fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1524        let src = cross_mem_source_schema();
1525        // Target schema not present in source schema's
1526        // cross_mem_relationships — source only declares the
1527        // `other` domain.
1528        let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1529        match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1530            CrossMemRelCheck::EdgeNotDeclared => {}
1531            other => panic!("expected EdgeNotDeclared, got {other:?}"),
1532        }
1533    }
1534
1535    #[test]
1536    fn cross_mem_entry_matches_any_target_version() {
1537        // Eligibility is name-based: the `other` declaration is
1538        // satisfied by a target mem pinning *any* version of
1539        // `other` — a target-side version bump cannot invalidate it.
1540        let src = cross_mem_source_schema();
1541        for version in [
1542            semver::Version::new(1, 0, 0),
1543            semver::Version::new(1, 1, 0),
1544            semver::Version::new(2, 5, 0),
1545        ] {
1546            let target = memstead_schema::SchemaRef::new("other", version.clone());
1547            match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1548                CrossMemRelCheck::Ok => {}
1549                other => panic!("expected Ok against other@{version}, got {other:?}"),
1550            }
1551        }
1552    }
1553
1554    #[test]
1555    fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1556        let src = cross_mem_source_schema();
1557        let target = other_target_ref();
1558        // `IMPLEMENTS` is declared intra-mem only — invisible to
1559        // the cross-mem entry and refused with INVALID_REL_TYPE.
1560        match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1561            CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1562                input,
1563                allowed,
1564                ..
1565            }) => {
1566                assert_eq!(input, "IMPLEMENTS");
1567                // Cross-mem entry's vocabulary surfaces: ADDRESSES + MENTIONS.
1568                let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1569                assert!(names.iter().any(|n| n == "ADDRESSES"));
1570                assert!(names.iter().any(|n| n == "MENTIONS"));
1571                // Intra-mem-only rel-type must not leak into the cross-mem list.
1572                assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1573            }
1574            other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1575        }
1576    }
1577
1578    #[test]
1579    fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1580        let src = cross_mem_source_schema();
1581        let target = other_target_ref();
1582        // ADDRESSES is shape-pinned to step → requirement. `decision`
1583        // is a declared source type in source-cv but not admitted by
1584        // this cross-mem entry; the shape check refuses with the
1585        // cross-mem entry's shape (not intra-mem's).
1586        match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1587            CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1588                rel_type,
1589                from_type,
1590                allowed_source_types,
1591                allowed_target_types,
1592                ..
1593            }) => {
1594                assert_eq!(rel_type, "ADDRESSES");
1595                assert_eq!(from_type, "decision");
1596                assert_eq!(allowed_source_types, vec!["step".to_string()]);
1597                assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1598            }
1599            other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1600        }
1601    }
1602
1603    #[test]
1604    fn cross_mem_shape_free_rel_type_admits_any_pair() {
1605        let src = cross_mem_source_schema();
1606        let target = other_target_ref();
1607        // MENTIONS has empty source_types/target_types — admits any pair.
1608        assert!(matches!(
1609            validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1610            CrossMemRelCheck::Ok
1611        ));
1612    }
1613
1614    /// Plan 11: a schema with a `to_schema: "*"` entry (loader-bound to
1615    /// its alias rel-type) plus an exact per-schema entry for
1616    /// structural edges.
1617    fn wildcard_source_schema() -> std::sync::Arc<Schema> {
1618        let manifest_yaml = r#"name: source-wc
1619version: 0.1.0
1620description: wildcard cross-mem source schema
1621when_to_use: tests
1622types:
1623  - step
1624  - decision
1625relationships:
1626  mode: strict
1627  definitions:
1628    - name: SOFT_REF
1629      description: alias-emitted soft reference
1630      default_weight: 0.5
1631    - name: ADDRESSES
1632      description: structural
1633      default_weight: 1.0
1634    - name: _default
1635      description: fallback
1636      default_weight: 1.0
1637alias_target_rel_type: SOFT_REF
1638cross_mem_relationships:
1639  - to_schema: other
1640    definitions:
1641      - name: ADDRESSES
1642        description: structural, per-schema
1643        default_weight: 1.0
1644        source_types: [step]
1645        target_types: [requirement]
1646  - to_schema: "*"
1647    definitions:
1648      - name: SOFT_REF
1649        description: soft reference anywhere
1650        default_weight: 0.5
1651        source_types: [step]
1652community:
1653  resolution: 1.0
1654  seed: 42
1655"#;
1656        let body_section = r#"description: t
1657when_to_use: tests
1658sections:
1659  - key: body
1660    heading: Body
1661    required: true
1662    search_weight: 10.0
1663    catch_all: true
1664    write_rules: []
1665metadata_fields: []
1666title_weight: 100.0
1667text_fields:
1668  - body
1669hierarchy_relationship: _default
1670no_self_loop_relationships: []
1671updatable_fields:
1672  - title
1673  - body
1674health_required_fields:
1675  - body
1676staleness_threshold_days: 90
1677write_rules: []
1678"#;
1679        let types = vec![
1680            ("step".to_string(), format!("name: step\n{body_section}")),
1681            (
1682                "decision".to_string(),
1683                format!("name: decision\n{body_section}"),
1684            ),
1685        ];
1686        std::sync::Arc::new(
1687            memstead_schema::load_schema_from_memory(manifest_yaml, &types)
1688                .expect("wildcard schema loads"),
1689        )
1690    }
1691
1692    /// The wildcard admits the alias rel-type into ANY destination
1693    /// schema — including one carrying its own exact structural entry
1694    /// (coexistence: the exact entry must not shadow the wildcard).
1695    #[test]
1696    fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
1697        let src = wildcard_source_schema();
1698        // Arbitrary user-written destination schema, arbitrary type.
1699        let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1700        assert!(matches!(
1701            validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
1702            CrossMemRelCheck::Ok
1703        ));
1704        // Destination with an exact structural entry: BOTH work.
1705        let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
1706        assert!(matches!(
1707            validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
1708            CrossMemRelCheck::Ok
1709        ));
1710        assert!(matches!(
1711            validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
1712            CrossMemRelCheck::Ok
1713        ));
1714    }
1715
1716    /// Refusal complements around the wildcard: the source-type list on
1717    /// the wildcard declaration still gates; a structural rel-type into
1718    /// a destination with no per-schema declaration is still the
1719    /// historical `CROSS_MEM_EDGE_NOT_DECLARED` refusal.
1720    #[test]
1721    fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
1722        let src = wildcard_source_schema();
1723        let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1724        // `decision` is not in the wildcard declaration's source_types.
1725        match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
1726            CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1727                from_type,
1728                allowed_source_types,
1729                ..
1730            }) => {
1731                assert_eq!(from_type, "decision");
1732                assert_eq!(allowed_source_types, vec!["step".to_string()]);
1733            }
1734            other => panic!("expected shape refusal on source-type gate, got {other:?}"),
1735        }
1736        // Structural rel-type into an undeclared destination: the
1737        // wildcard (alias-only) does not admit it — same refusal as
1738        // before the wildcard existed.
1739        assert!(matches!(
1740            validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
1741            CrossMemRelCheck::EdgeNotDeclared
1742        ));
1743    }
1744
1745    // --- prose_render -----------------------------------------------
1746    // The text channel inlines every recovery field instead of pointing
1747    // at the structured channel. These tests pin that contract.
1748
1749    #[test]
1750    fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1751        let err = ValidationError::UnknownSection {
1752            key: "implimentation".to_string(),
1753            entity_type: "spec".to_string(),
1754            declared: (0..8).map(|i| format!("sec{i}")).collect(),
1755            suggestion: Some("sec0".to_string()),
1756        };
1757        let prose = err.prose_render();
1758        for d in (0..8).map(|i| format!("sec{i}")) {
1759            assert!(prose.contains(&d), "missing {d} in: {prose}");
1760        }
1761        assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1762        assert!(!prose.contains("see details"), "got: {prose}");
1763    }
1764
1765    #[test]
1766    fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1767        let err = ValidationError::InvalidEnumValue {
1768            field: "level".to_string(),
1769            value: "M7".to_string(),
1770            allowed: (0..7).map(|i| format!("M{i}")).collect(),
1771            field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1772            suggestion: Some("M6".to_string()),
1773            type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1774            entity_type: "spec".to_string(),
1775        };
1776        let prose = err.prose_render();
1777        assert!(prose.contains("M0"), "got: {prose}");
1778        assert!(prose.contains("M6"), "got: {prose}");
1779        assert!(
1780            prose.contains("maturity rung"),
1781            "field_description missing: {prose}"
1782        );
1783        assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1784        assert!(
1785            prose.contains("specs land at M0"),
1786            "type_write_rules missing: {prose}"
1787        );
1788        assert!(!prose.contains("see details"), "got: {prose}");
1789    }
1790
1791    #[test]
1792    fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1793        let err = ValidationError::InvalidRelationshipShape {
1794            rel_type: "OWNS".to_string(),
1795            from_type: "spec".to_string(),
1796            to_type: "spec".to_string(),
1797            allowed_source_types: vec!["actor".to_string()],
1798            allowed_target_types: vec![],
1799            suggestion: None,
1800        };
1801        let prose = err.prose_render();
1802        // The shape-free target axis renders as `any` (no brackets,
1803        // matching the existing convention pinned by
1804        // `relate_shape_violation_surfaces_typed_envelope`).
1805        assert!(prose.contains("allowed sources: actor"), "got: {prose}");
1806        assert!(prose.contains("allowed targets: any"), "got: {prose}");
1807        assert!(!prose.contains("see details"), "got: {prose}");
1808    }
1809
1810    // ---------------------------------------------------------------
1811    // parse_metadata_value typed-value validation. A Date / Number
1812    // field's value is validated against its declared type at the write
1813    // boundary, so a malformed value cannot land (and cannot corrupt
1814    // range filters).
1815    // ---------------------------------------------------------------
1816
1817    /// In-memory schema with one type carrying a `Date` field
1818    /// (`verified_on`), a `Number` field (`order`), and a free-form
1819    /// `String` field (`note`) — the three arms the value check
1820    /// distinguishes.
1821    fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
1822        let manifest_yaml = r#"name: tests-typed-fields
1823version: 0.1.0
1824description: typed-field test schema
1825when_to_use: tests
1826types:
1827  - widget
1828relationships:
1829  mode: strict
1830  definitions:
1831    - name: _default
1832      description: fallback
1833      default_weight: 1.0
1834community:
1835  resolution: 1.0
1836  seed: 42
1837"#;
1838        let type_yaml = r#"name: widget
1839description: t
1840when_to_use: Here
1841sections:
1842  - key: body
1843    heading: Body
1844    required: true
1845    search_weight: 10.0
1846    catch_all: true
1847    write_rules: []
1848metadata_fields:
1849  - key: verified_on
1850    description: ISO YYYY-MM-DD date the widget was verified
1851    field_type: date
1852    optional: true
1853  - key: order
1854    description: numeric ordering within a plan
1855    field_type: number
1856    optional: true
1857  - key: note
1858    description: free-form note
1859    field_type: string
1860    optional: true
1861title_weight: 100.0
1862text_fields:
1863  - body
1864hierarchy_relationship: _default
1865no_self_loop_relationships: []
1866updatable_fields:
1867  - title
1868  - body
1869health_required_fields:
1870  - body
1871staleness_threshold_days: 90
1872write_rules: []
1873"#;
1874        let schema = memstead_schema::load_schema_from_memory(
1875            manifest_yaml,
1876            &[("widget".to_string(), type_yaml.to_string())],
1877        )
1878        .expect("typed-field test schema must load");
1879        schema.get_type("widget").expect("widget type present")
1880    }
1881
1882    #[test]
1883    fn date_field_rejects_non_date_value() {
1884        let ty = typed_field_type();
1885        let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
1886        assert_eq!(err.code(), "INVALID_FIELD_VALUE");
1887        match err {
1888            ValidationError::InvalidFieldValue {
1889                field,
1890                value,
1891                expected_type,
1892                entity_type,
1893                ..
1894            } => {
1895                assert_eq!(field, "verified_on");
1896                assert_eq!(value, "not-a-real-date");
1897                assert_eq!(expected_type, "Date");
1898                assert_eq!(entity_type, "widget");
1899            }
1900            other => panic!("expected InvalidFieldValue, got {other:?}"),
1901        }
1902    }
1903
1904    #[test]
1905    fn date_field_rejects_empty_string() {
1906        let ty = typed_field_type();
1907        let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
1908        assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
1909    }
1910
1911    #[test]
1912    fn date_field_accepts_iso_date_and_datetime() {
1913        let ty = typed_field_type();
1914        match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
1915            MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
1916            other => panic!("expected String, got {other:?}"),
1917        }
1918        // ISO-8601 datetime form is also accepted.
1919        assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
1920    }
1921
1922    #[test]
1923    fn number_field_rejects_non_numeric_value() {
1924        let ty = typed_field_type();
1925        let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
1926        match err {
1927            ValidationError::InvalidFieldValue {
1928                field,
1929                expected_type,
1930                ..
1931            } => {
1932                assert_eq!(field, "order");
1933                assert_eq!(expected_type, "Number");
1934            }
1935            other => panic!("expected InvalidFieldValue, got {other:?}"),
1936        }
1937    }
1938
1939    #[test]
1940    fn number_field_accepts_integer_and_float() {
1941        let ty = typed_field_type();
1942        assert!(matches!(
1943            parse_metadata_value("order", "3", &ty).unwrap(),
1944            MetadataValue::Integer(3)
1945        ));
1946        assert!(matches!(
1947            parse_metadata_value("order", "2.5", &ty).unwrap(),
1948            MetadataValue::Float(_)
1949        ));
1950    }
1951
1952    #[test]
1953    fn string_field_accepts_any_value() {
1954        let ty = typed_field_type();
1955        // The free-form String arm is untouched — arbitrary text lands.
1956        assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
1957    }
1958
1959    #[test]
1960    fn invalid_field_value_prose_inlines_format_and_purpose() {
1961        let err = ValidationError::InvalidFieldValue {
1962            field: "verified_on".to_string(),
1963            value: "not-a-real-date".to_string(),
1964            expected_type: "Date".to_string(),
1965            expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1966            field_description: Some("date the widget was verified".to_string()),
1967            entity_type: "widget".to_string(),
1968        };
1969        let prose = err.prose_render();
1970        assert!(prose.contains("not-a-real-date"), "got: {prose}");
1971        assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
1972        assert!(
1973            prose.contains("date the widget was verified"),
1974            "purpose missing: {prose}"
1975        );
1976        assert!(!prose.contains("see details"), "got: {prose}");
1977    }
1978
1979    #[test]
1980    fn is_date_shaped_matches_strict_validator_contract() {
1981        assert!(is_date_shaped("2024-06-01"));
1982        assert!(is_date_shaped("2024-06-01T12:30:00Z"));
1983        assert!(!is_date_shaped(""));
1984        assert!(!is_date_shaped("not-a-real-date"));
1985        assert!(!is_date_shaped("2024-6-1"));
1986        assert!(!is_date_shaped("2024-06-01 extra"));
1987    }
1988
1989    #[test]
1990    fn section_content_refuses_nul_byte() {
1991        let err = validate_section_content([("body", "line1\u{0}line2")].into_iter())
1992            .expect_err("NUL in a section body must be refused");
1993        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
1994        match &err {
1995            ValidationError::SectionContentControlByte {
1996                section,
1997                control_char,
1998                codepoint,
1999                byte_offset,
2000            } => {
2001                assert_eq!(section, "body");
2002                assert_eq!(*control_char, '\u{0}');
2003                assert_eq!(*codepoint, 0);
2004                // "line1" is 5 bytes — the NUL sits at offset 5.
2005                assert_eq!(*byte_offset, 5);
2006            }
2007            other => panic!("expected SectionContentControlByte, got {other:?}"),
2008        }
2009        // Recovery payload names the offending char + offset.
2010        let details = err.details();
2011        assert_eq!(details["codepoint"], 0);
2012        assert_eq!(details["byte_offset"], 5);
2013        assert_eq!(details["section"], "body");
2014    }
2015
2016    #[test]
2017    fn section_content_refuses_other_c0_controls_and_cr() {
2018        // Bell, vertical tab, form feed, carriage return — all C0
2019        // controls outside the tab/newline allow-list.
2020        for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
2021            let body = format!("ok{bad}more");
2022            let err = validate_section_content([("s", body.as_str())].into_iter())
2023                .expect_err("control char must be refused");
2024            assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
2025        }
2026    }
2027
2028    #[test]
2029    fn section_content_allows_tab_and_newline() {
2030        // The two legitimate whitespace controls round-trip; multi-line
2031        // and tabbed bodies are unaffected.
2032        validate_section_content([("body", "line1\nline2\n\tindented\tcols\n")].into_iter())
2033            .expect("tab and newline must stay legal in section bodies");
2034    }
2035
2036    #[test]
2037    fn section_content_keeps_backslashes_verbatim() {
2038        // The fix screens a byte class — it must not interpret or
2039        // de-escape content. Literal backslashes (incl. ones that look
2040        // like escapes) pass through untouched.
2041        validate_section_content(
2042            [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
2043        )
2044        .expect("backslashes are literal content, not control bytes");
2045    }
2046
2047    #[test]
2048    fn section_content_still_refuses_heading_injection() {
2049        // The pre-existing heading-injection guard is unchanged and
2050        // shares the wire code.
2051        let err = validate_section_content([("body", "intro\n## Injected\ntail")].into_iter())
2052            .expect_err("embedded `## ` heading must still be refused");
2053        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2054        assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
2055    }
2056
2057    /// The write guard classifies code blocks the way the splitter
2058    /// does: a `## ` line inside a code block splits nothing on
2059    /// reparse, so refusing it was the write path disagreeing with the
2060    /// read path about what a code block is.
2061    #[test]
2062    fn section_content_admits_a_heading_inside_a_code_block() {
2063        for body in [
2064            "intro\n\n```\n## Not A Heading\n```\n",
2065            "intro\n\n~~~\n## Not A Heading\n~~~\n",
2066            "intro\n\n> ```\n> ## Not A Heading\n> ```\n",
2067            "intro\n\n    ## Not A Heading\n",
2068        ] {
2069            validate_section_content([("body", body)].into_iter())
2070                .unwrap_or_else(|e| panic!("code-block content must be admitted: {body:?} -> {e}"));
2071        }
2072    }
2073
2074    /// The trim-fork class: the splitter stores the *trimmed* body, so
2075    /// an indented code block that opens a section loses its indent on
2076    /// write-back and its `## ` line lands at column 0 on the next
2077    /// parse. The guard sees what the reparse will see.
2078    #[test]
2079    fn section_content_refuses_the_trim_fork() {
2080        let err =
2081            validate_section_content([("body", "    ## Not A Heading\n    more\n")].into_iter())
2082                .expect_err("content whose trim exposes a column-0 heading must be refused");
2083        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2084        match err {
2085            ValidationError::SectionContentInvalid {
2086                embedded_heading, ..
2087            } => assert_eq!(
2088                embedded_heading, "## Not A Heading",
2089                "the refusal quotes the line the reparse will see"
2090            ),
2091            other => panic!("unexpected error: {other}"),
2092        }
2093    }
2094
2095    #[test]
2096    fn section_content_refuses_the_trim_fork_for_h1_too() {
2097        let err = validate_section_content([("body", "  # Not A Title\n")].into_iter())
2098            .expect_err("h1 exposed by the trim must be refused");
2099        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2100    }
2101}