Skip to main content

memstead_base/
runtime_validator.rs

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