Skip to main content

memstead_base/
runtime_validator.rs

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