Skip to main content

memstead_schema/
loader.rs

1//! Schema loader — reads on-disk schema directories (or in-memory YAML) into
2//! validated `Schema` values with `edge_weights` resolved.
3//!
4//! Three validation layers coordinate here:
5//! 1. Structural (serde + `deny_unknown_fields`) — handled by the deserialize.
6//! 2. Semantic (this module) — cross-field rules listed in `SchemaLoadError`.
7//! 3. Editor (JSON Schemas) — generated by `emit_json_schemas`, consumed by
8//!    schema authors via `# yaml-language-server: $schema=...`.
9
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use indexmap::IndexMap;
15use thiserror::Error;
16
17use crate::base_metadata;
18use crate::manifest::SchemaManifest;
19use crate::schema::Schema;
20use crate::types::TypeDefinition;
21
22#[derive(Debug, Error)]
23pub enum SchemaLoadError {
24    #[error("i/o error reading {}: {source}", .path.display())]
25    Io {
26        path: PathBuf,
27        #[source]
28        source: std::io::Error,
29    },
30
31    #[error("failed to parse manifest {}: {source}", .path.display())]
32    ParseManifest {
33        path: PathBuf,
34        #[source]
35        source: serde_yaml_ng::Error,
36    },
37
38    #[error("failed to parse type file {}: {source}", .path.display())]
39    ParseType {
40        path: PathBuf,
41        #[source]
42        source: serde_yaml_ng::Error,
43    },
44
45    #[error("invalid version '{value}': must be semver (e.g. 1.0.0)")]
46    InvalidVersion { value: String },
47
48    #[error("invalid schema name '{value}': {reason}")]
49    InvalidName { value: String, reason: &'static str },
50
51    #[error(
52        "schema type file mismatch — declared in manifest: [{}], found in types/: [{}]",
53        declared.join(", "),
54        found.join(", ")
55    )]
56    TypeFileMismatch {
57        declared: Vec<String>,
58        found: Vec<String>,
59    },
60
61    #[error(
62        "type file '{file}.yaml' has `name: {declared}` — filename and `name` field must match"
63    )]
64    TypeNameMismatch { file: String, declared: String },
65    /// The retired `propagating_relationships:` key was used in an
66    /// authoring context (a schema package loaded from a directory —
67    /// workspace `.memstead/schemas/`, `schema install`, `schema
68    /// validate`). The key's name promised propagation the engine
69    /// never performed; its only effect — refusing self-loops on the
70    /// listed rel-types — now lives under `no_self_loop_relationships`.
71    /// Sealed content (built-ins, installed refs) keeps loading with
72    /// the old key translated; only authoring refuses, so the fix is
73    /// one mechanical key rename per schema.
74    #[error(
75        "type '{type_name}': `propagating_relationships` was renamed — its only effect is \
76         refusing self-loops on the listed rel-types, so the key is now \
77         `no_self_loop_relationships` (optional; empty lists can simply be deleted). \
78         Rename the key and retry."
79    )]
80    PropagatingRelationshipsRenamed { type_name: String },
81
82    #[error(
83        "type '{type_name}' declares the retired `examples:` list — it was never \
84         validated nor served and is replaced by the engine-validated `exemplar:` \
85         (one canonical entity: title, metadata, sections, relations with \
86         placeholder targets). Move the material into `exemplar:` and retry."
87    )]
88    ExamplesRetired { type_name: String },
89
90    /// An exemplar relation entry used the retired authoring spelling
91    /// in an authoring context. Exemplars are authored in the mutation
92    /// vocabulary so what an agent copies from the served schema is
93    /// exactly what the write gate accepts. Sealed content keeps
94    /// loading with the old keys translated; only authoring refuses,
95    /// so the fix is one mechanical key rename per relation entry.
96    #[error(
97        "type '{type_name}': exemplar relation entries speak the mutation vocabulary — \
98         rename `to:` to `target:` and `type:` to `rel_type:`, then retry. (Sealed \
99         packages with the old spelling keep loading; only authoring refuses.)"
100    )]
101    ExemplarRelationSpellingRetired { type_name: String },
102
103    /// An exemplar relation entry is missing `target:` or `rel_type:`
104    /// after legacy translation — the entry cannot name an edge.
105    #[error(
106        "type '{type_name}': an exemplar relation entry must carry both `target:` \
107         (bare placeholder slug) and `rel_type:` (declared relationship name)."
108    )]
109    ExemplarRelationIncomplete { type_name: String },
110
111    /// The retired `optional:` metadata-field key was used in an
112    /// authoring context. The polarity flipped (first-author-path
113    /// plan 07): a field is optional unless it declares
114    /// `required: true` — the same rule sections follow. Sealed
115    /// content keeps loading with the old key inverted; only
116    /// authoring refuses, so the fix is one mechanical edit.
117    #[error(
118        "type '{type_name}' metadata field '{field}' declares the retired `optional:` key — \
119         fields are optional unless they declare `required: true`. Fix: delete `optional: true`; \
120         replace `optional: false` with `required: true`. Then retry."
121    )]
122    OptionalRetired { type_name: String, field: String },
123
124    /// A `due:` declaration referencing fields the type does not have
125    /// in the required shapes. `offender` names the bad reference,
126    /// `reason` states the shape rule it violates — the due axis is
127    /// spec (an external declaration), so its references validate at
128    /// load with the loader's usual recovery quality.
129    #[error("type '{type_name}' due axis is invalid: {reason} — offending name: '{offender}'")]
130    InvalidDueAxis {
131        type_name: String,
132        offender: String,
133        reason: String,
134    },
135
136    /// A `resolution:` declaration referencing a section, field or check
137    /// kind the type does not have in the required shape.
138    #[error(
139        "type '{type_name}' resolution declaration is invalid: {reason} — offending name: '{offender}'"
140    )]
141    InvalidResolutionAxis {
142        type_name: String,
143        offender: String,
144        reason: String,
145    },
146
147    /// A metadata field's `value_pattern` is not a valid regular
148    /// expression.
149    #[error(
150        "type '{type_name}' metadata field '{field}' value_pattern '{pattern}' does not compile: {reason}"
151    )]
152    InvalidFieldPattern {
153        type_name: String,
154        field: String,
155        pattern: String,
156        reason: String,
157    },
158
159    #[error("schema relationship vocabulary must include a '_default' definition")]
160    MissingDefaultWeight,
161
162    #[error("duplicate relationship definition: '{name}'")]
163    DuplicateRelationship { name: String },
164
165    #[error(
166        "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
167        available.join(", "),
168        format_suggestion(relationship, available)
169    )]
170    UndeclaredRelationship {
171        type_name: String,
172        field: &'static str,
173        relationship: String,
174        available: Vec<String>,
175    },
176
177    #[error(
178        "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
179    )]
180    CatchAllViolation { type_name: String, count: usize },
181
182    #[error(
183        "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
184    )]
185    UnknownFieldReference {
186        type_name: String,
187        field: &'static str,
188        reference: String,
189    },
190
191    #[error(
192        "type '{type_name}' constraint ({kind}) is invalid: {reason} — offending name: '{offender}'"
193    )]
194    InvalidConstraint {
195        type_name: String,
196        kind: &'static str,
197        offender: String,
198        reason: String,
199    },
200
201    #[error("relationships.acyclic_sets is invalid: {reason} — offending entry: '{offender}'")]
202    InvalidAcyclicSet { offender: String, reason: String },
203
204    #[error("relationships.labelling is invalid: {reason} — offending name: '{offender}'")]
205    InvalidLabelling { offender: String, reason: String },
206
207    #[error(
208        "type '{type_name}' section '{section}' format declaration is invalid: {}",
209        problems.join("; ")
210    )]
211    InvalidSectionFormat {
212        type_name: String,
213        section: String,
214        /// EVERY problem of the section's declaration — the loader
215        /// names all offenders, never the first only, so one repair
216        /// pass fixes the schema.
217        problems: Vec<String>,
218    },
219
220    #[error(
221        "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
222        allowed.join(", ")
223    )]
224    DefaultValueNotInEnum {
225        type_name: String,
226        field: String,
227        default: String,
228        allowed: Vec<String>,
229    },
230
231    #[error(
232        "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
233    )]
234    RedeclaredBaseField { type_name: String, field: String },
235
236    #[error(
237        "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
238        declared.join(", "),
239        format_suggestion(reference, declared)
240    )]
241    UndeclaredRelationshipType {
242        relationship: String,
243        field: &'static str,
244        reference: String,
245        declared: Vec<String>,
246    },
247
248    /// Schema declares a regular section or metadata field whose key
249    /// collides with an engine-invariant key. The reserved set covers
250    /// section key `relationships` (the parser's auto-managed
251    /// `## Relationships` section) and the metadata identity/
252    /// discriminator triple `type` / `mem` / `id`
253    /// ([`reserved_metadata_field_keys`]). Sections refuse at load;
254    /// metadata keys refuse on the install/strict-validation path
255    /// ([`check_reserved_metadata_keys`]) so sealed schemas keep
256    /// booting.
257    #[error(
258        "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
259        reserved_keys.join(", ")
260    )]
261    ReservedSchemaKey {
262        type_name: String,
263        kind: &'static str,
264        offending_key: String,
265        reserved_keys: Vec<String>,
266    },
267
268    /// A `cross_mem_relationships:` entry's `to_schema:` field is
269    /// not a bare schema name. Cross-mem eligibility is name-based —
270    /// versioned (`software@1.0.0`) and range (`software@^1.0`) forms
271    /// are refused so a version component can never silently re-enter
272    /// the eligibility path.
273    #[error(
274        "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
275    )]
276    InvalidCrossMemToSchema { value: String, reason: String },
277
278    /// Two `cross_mem_relationships:` entries declare the same
279    /// `to_schema:`. A schema declares each target-schema at most once
280    /// — the second entry would otherwise silently shadow or split
281    /// the vocabulary.
282    #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
283    DuplicateCrossMemToSchema { to_schema: String },
284
285    /// A sealed package's file list carries no `schema.yaml`. Only
286    /// reachable through [`load_sealed_package`] — the directory
287    /// loader surfaces a missing manifest as [`Self::Io`] instead.
288    #[error("sealed schema package carries no schema.yaml")]
289    SealedPackageMissingManifest,
290
291    /// A `to_schema: "*"` wildcard entry in a schema that declares no
292    /// `alias_target_rel_type`. The wildcard is BOUND to the
293    /// alias-synthesised rel-type — a schema that has not opted into
294    /// alias synthesis has made no decision the wildcard could extend.
295    #[error(
296        "cross_mem_relationships declares to_schema '*' but the schema declares no \
297         alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
298         declare alias_target_rel_type, or name each destination schema explicitly"
299    )]
300    CrossMemWildcardWithoutAliasTarget,
301
302    /// A `to_schema: "*"` wildcard entry declaring a rel-type other
303    /// than the schema's `alias_target_rel_type`. Hand-authored
304    /// structural edges keep requiring a per-destination-schema
305    /// declaration — the wildcard only extends the soft, auto-emitted
306    /// alias references the author already permitted.
307    #[error(
308        "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
309         wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
310         hand-authored structural edges need a per-destination-schema declaration"
311    )]
312    CrossMemWildcardNonAliasRelType {
313        rel_type: String,
314        alias_target: String,
315    },
316
317    /// A `cross_mem_relationships[].definitions[*].source_types` entry
318    /// references a type name not declared in the source schema's
319    /// `types` list. Source types belong to the source schema's
320    /// namespace; unknown names raise this error at load time.
321    /// (Target types are accepted as opaque strings — they belong to
322    /// the target schema's namespace, which is not in scope here.)
323    #[error(
324        "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
325        declared.join(", "),
326        format_suggestion(reference, declared)
327    )]
328    UndeclaredCrossMemSourceType {
329        to_schema: String,
330        relationship: String,
331        reference: String,
332        declared: Vec<String>,
333    },
334
335    /// The schema's `alias_target_rel_type:` pointer names a rel-type
336    /// not declared in `relationships.definitions`. Surfaces at
337    /// schema-load time so the alias-synthesis pass can trust the
338    /// pointer is resolvable at every later mutation call.
339    #[error(
340        "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
341        declared.join(", "),
342        format_suggestion(target, declared)
343    )]
344    AliasTargetRelTypeNotDeclared {
345        schema: String,
346        target: String,
347        declared: Vec<String>,
348    },
349
350    /// One or more declared section headings do not derive back to their
351    /// declared keys (`derive_section_key(heading) != key`), so content
352    /// written under the heading could never be parsed back into the
353    /// section — it would silently fork into a second heading or fall
354    /// through to the catch-all. Raised by
355    /// [`check_section_heading_roundtrip`] on the authoring/installation
356    /// path only; a schema already sealed into a mem-repo keeps loading
357    /// and surfaces the condition through health instead.
358    #[error(
359        "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
360         Fix: make each heading derive to its key — lowercasing the heading and replacing \
361         spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
362         `Current State`)",
363        format_heading_violations(violations)
364    )]
365    SectionHeadingMismatch {
366        violations: Vec<HeadingKeyViolation>,
367    },
368    /// Two or more types declare `last_resort: true`. A last resort is
369    /// the one type an author falls back to; two of them is no fallback
370    /// at all, and the `vital_signs` share signal would have nothing to
371    /// count.
372    #[error(
373        "schema declares more than one last-resort type: {}. Fix: keep `last_resort: true` on \
374         exactly one type (the catch-all) and remove it from the others",
375        .types.join(", ")
376    )]
377    MultipleLastResortTypes { types: Vec<String> },
378
379    /// Two or more independent semantic violations found in one load
380    /// pass. The loader accumulates every violation it can prove on
381    /// successfully parsed structure and refuses once, so the author
382    /// fixes the whole set in one edit instead of one violation per
383    /// validate round. Structural failures (a manifest that does not
384    /// parse, a declared-vs-found type-file mismatch) still
385    /// short-circuit — everything downstream of them would be noise
386    /// derived from a value that does not exist. A single violation is
387    /// returned bare, never as a one-element list.
388    #[error(
389        "schema has {} violations:\n{}",
390        errors.len(),
391        format_multiple(errors)
392    )]
393    Multiple { errors: Vec<SchemaLoadError> },
394}
395
396fn format_multiple(errors: &[SchemaLoadError]) -> String {
397    errors
398        .iter()
399        .enumerate()
400        .map(|(i, e)| format!("  {}. {e}", i + 1))
401        .collect::<Vec<_>>()
402        .join("\n")
403}
404
405/// Fold an accumulated violation list into one error: a single
406/// violation stays bare (the common case keeps today's message shape),
407/// several wrap in [`SchemaLoadError::Multiple`]. Callers guarantee
408/// the list is non-empty.
409fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
410    debug_assert!(!errors.is_empty());
411    if errors.len() == 1 {
412        errors.remove(0)
413    } else {
414        SchemaLoadError::Multiple { errors }
415    }
416}
417
418/// One `(type, key, heading, derived_key)` tuple in a
419/// [`SchemaLoadError::SectionHeadingMismatch`] refusal.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct HeadingKeyViolation {
422    pub type_name: String,
423    pub key: String,
424    pub heading: String,
425    pub derived_key: String,
426}
427
428fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
429    violations
430        .iter()
431        .map(|v| {
432            format!(
433                "type '{}' section key '{}' has heading '{}' (derives to '{}')",
434                v.type_name, v.key, v.heading, v.derived_key
435            )
436        })
437        .collect::<Vec<_>>()
438        .join("; ")
439}
440
441/// Refuse a schema in which any declared section's heading does not
442/// derive back to that section's declared key. Collects **every**
443/// offending `(type, key, heading, derived_key)` tuple — a schema with
444/// one good and one bad section is refused whole, and the author sees
445/// the complete list in one round.
446///
447/// Installation-path gate only: callers are the schema-authoring
448/// surfaces (CLI `schema validate` / `schema install`, the engine's
449/// `install_schema` primitive). Boot and sealed-schema loads must NOT
450/// call this — a schema already sealed on `__MEMSTEAD` that violates
451/// the rule keeps loading, and the violation surfaces as a health
452/// finding, never as a boot failure.
453pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
454    let mut violations = Vec::new();
455    // Deterministic report order: sort type names (Schema.types is a
456    // HashMap); sections keep declaration order within a type.
457    let mut type_names: Vec<&String> = schema.types.keys().collect();
458    type_names.sort();
459    for type_name in type_names {
460        let t = &schema.types[type_name];
461        for s in &t.sections {
462            let derived_key = crate::types::derive_section_key(&s.heading);
463            if derived_key != s.key {
464                violations.push(HeadingKeyViolation {
465                    type_name: type_name.clone(),
466                    key: s.key.clone(),
467                    heading: s.heading.clone(),
468                    derived_key,
469                });
470            }
471        }
472    }
473    if violations.is_empty() {
474        Ok(())
475    } else {
476        Err(SchemaLoadError::SectionHeadingMismatch { violations })
477    }
478}
479
480/// Engine-invariant section keys reserved against schema use. The
481/// parser's auto-managed `## Relationships` section is the only entry
482/// today.
483pub fn reserved_section_keys() -> &'static [&'static str] {
484    &["relationships"]
485}
486
487/// Engine-invariant metadata-field keys reserved against schema use —
488/// the entity's identity/discriminator triple. `type` is the engine-set
489/// frontmatter discriminator; `mem` and `id` are the entity's
490/// structural identity, owned by the engine's id grammar and mount
491/// routing. One reservation, one behaviour: no installable schema may
492/// declare any of them (see [`check_reserved_metadata_keys`]), and the
493/// engine write paths refuse them as caller-supplied metadata.
494pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
495    &["type", "mem", "id"]
496}
497
498/// Author/install-path check: refuse a schema whose types **declare**
499/// an engine-reserved metadata-field key (`type` / `mem` / `id`).
500/// Reads the raw pre-merge declaration list the loader records
501/// (`TypeDefinition::declared_metadata_keys`) so the engine-injected
502/// base fields never false-positive.
503///
504/// Same posture as [`check_section_heading_roundtrip`]: install and
505/// strict validation call this and refuse; boot and sealed-schema
506/// loads must NOT — a schema already sealed that violates the rule
507/// keeps loading (refusing at boot would brick the workspace), and the
508/// engine's write-path refusals keep the reserved keys unwritable
509/// regardless.
510pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
511    for td in schema.types.values() {
512        for key in &td.declared_metadata_keys {
513            if reserved_metadata_field_keys().contains(&key.as_str()) {
514                return Err(SchemaLoadError::ReservedSchemaKey {
515                    type_name: td.name.clone(),
516                    kind: "metadata_field",
517                    offending_key: key.clone(),
518                    reserved_keys: reserved_metadata_field_keys()
519                        .iter()
520                        .map(|s| s.to_string())
521                        .collect(),
522                });
523            }
524        }
525    }
526    Ok(())
527}
528
529fn format_suggestion(needle: &str, candidates: &[String]) -> String {
530    let mut best: Option<(usize, &String)> = None;
531    for cand in candidates {
532        let d = strsim::levenshtein(needle, cand);
533        match best {
534            Some((bd, _)) if bd <= d => {}
535            _ => best = Some((d, cand)),
536        }
537    }
538    match best {
539        Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
540            format!("Did you mean '{cand}'?")
541        }
542        _ => String::new(),
543    }
544}
545
546/// Load a schema from a directory containing `schema.yaml` and `types/*.yaml`.
547pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
548    let manifest_path = path.join("schema.yaml");
549    let manifest_text =
550        std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
551            path: manifest_path.clone(),
552            source: e,
553        })?;
554
555    let types_dir = path.join("types");
556    let mut type_files: Vec<(String, String)> = Vec::new();
557    if types_dir.is_dir() {
558        let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
559            path: types_dir.clone(),
560            source: e,
561        })?;
562        for entry in entries {
563            let entry = entry.map_err(|e| SchemaLoadError::Io {
564                path: types_dir.clone(),
565                source: e,
566            })?;
567            let p = entry.path();
568            if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
569                continue;
570            }
571            let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
572                continue;
573            };
574            let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
575                path: p.clone(),
576                source: e,
577            })?;
578            type_files.push((stem, contents));
579        }
580    }
581    // read_dir order is filesystem-dependent; accumulated violations
582    // must report in a stable order across runs on the same input.
583    type_files.sort_by(|a, b| a.0.cmp(&b.0));
584
585    load_with_context(
586        &manifest_text,
587        &type_files,
588        Some(&manifest_path),
589        Some(&types_dir),
590        // Authoring context — the author writes the current language.
591        MetadataPolarityFormat::RequiredOptIn,
592    )
593}
594
595/// The metadata-field polarity generation of a sealed package —
596/// decided by the presence of the package's format marker
597/// (`schema-format.json`), never by heuristics over the document body.
598///
599/// Under the pre-flip language an absent `required`/`optional` key
600/// meant **required**; under the current language absence means
601/// **optional**. The two are syntactically indistinguishable, so an
602/// unmarked sealed package reads with [`Self::Legacy`] semantics —
603/// its effective behaviour conserved — while packages sealed from
604/// this change on carry the marker and read as
605/// [`Self::RequiredOptIn`]. Directory (authoring) loads are always
606/// `RequiredOptIn`: the author writes against the current language.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum MetadataPolarityFormat {
609    /// Pre-flip sealed content: an absent key means required.
610    Legacy,
611    /// Current language: an absent key means optional.
612    RequiredOptIn,
613}
614
615/// The format-marker file name sealed alongside a schema package on
616/// the genuinely sealed surfaces — the `__MEMSTEAD` ref, published
617/// `.mem` archives (the export carries it, the archive validator
618/// admits it, the archive loader honors it), and new builtin version
619/// directories. Presence ⇒ [`MetadataPolarityFormat::RequiredOptIn`];
620/// absence ⇒ legacy. Content is informative JSON; presence is the
621/// contract.
622///
623/// Directory loads (`load_schema_from_dir`: workspace
624/// `.memstead/schemas/`, cache extractions, `schema validate` /
625/// `install`) are the AUTHORING tier and never consult the marker —
626/// they always read the current language, with the retired
627/// `optional:` key refusing loudly. A pre-flip directory package
628/// relying on absent-key-means-required flips soft (fields become
629/// optional — admits more, refuses nothing); that is the fail-soft
630/// direction by design, with `health_required_fields` and
631/// constraints as the data-quality backstop.
632pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
633
634/// The marker file's canonical content.
635pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
636
637/// Append the format marker to a package's file list if absent.
638///
639/// **For content verified current-language only.** The marker is a
640/// generation claim, and this helper cannot verify one — a legacy
641/// package stamped here would flip every bare field from required to
642/// optional the moment the sealed copy is read. The one place that
643/// KNOWS a package is current is the authoring/install resolver (a
644/// directory source just validated under the current language, where
645/// the retired `optional:` key refuses loudly); sealed and builtin
646/// sources carry their generation as-found and are never stamped —
647/// absence IS the legacy claim, and inventing a marker over it is the
648/// silent-flip defect this contract exists to prevent.
649pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
650    if !files
651        .iter()
652        .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
653    {
654        files.push((
655            SCHEMA_FORMAT_MARKER_FILE.to_string(),
656            SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
657        ));
658    }
659    files
660}
661
662/// Load a schema from in-memory YAML strings — **legacy sealed
663/// semantics** (an absent required/optional key means required).
664/// Use [`load_schema_from_memory_with_format`] when the caller knows
665/// the package's format generation from its marker.
666///
667/// `types_yamls` is a slice of `(filename_stem, contents)` tuples — the stems
668/// must match `manifest.types` exactly.
669pub fn load_schema_from_memory(
670    manifest_yaml: &str,
671    types_yamls: &[(String, String)],
672) -> Result<Schema, SchemaLoadError> {
673    load_with_context(
674        manifest_yaml,
675        types_yamls,
676        None,
677        None,
678        MetadataPolarityFormat::Legacy,
679    )
680}
681
682/// Load a **sealed** schema package from its `(relative-path, bytes)`
683/// file list — the shape every sealed surface carries: a published
684/// `.mem` archive's `.memstead/schema/` tree, the git-branch
685/// `__MEMSTEAD:schemas/<name>@<version>/` tree, a built-in version
686/// directory. Paths are package-relative (`schema.yaml`,
687/// `types/<stem>.yaml`, `schema-format.json`); anything else is
688/// ignored.
689///
690/// One function so the surfaces that *admit* a sealed package (the
691/// archive validator) and the surfaces that later *read it back* (the
692/// local schema source the install stages into) can never disagree —
693/// a package valid to publish stays valid to install. The metadata
694/// polarity comes from the package's own format marker, and keys
695/// retired after the package was sealed read with their written
696/// meaning instead of refusing.
697///
698/// Authoring content takes [`load_schema_from_dir`] instead, which
699/// refuses retired keys loudly so the author can act.
700pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
701    let mut manifest: Option<String> = None;
702    let mut types: Vec<(String, String)> = Vec::new();
703    let mut marked = false;
704    for (rel, bytes) in files {
705        if rel == "schema.yaml" {
706            manifest = Some(String::from_utf8_lossy(bytes).into_owned());
707        } else if rel == SCHEMA_FORMAT_MARKER_FILE {
708            marked = true;
709        } else if let Some(stem) = rel
710            .strip_prefix("types/")
711            .and_then(|f| f.strip_suffix(".yaml"))
712        {
713            types.push((
714                stem.to_string(),
715                String::from_utf8_lossy(bytes).into_owned(),
716            ));
717        }
718    }
719    let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
720    // Deterministic order regardless of how the caller enumerated the
721    // package — accumulated violations must report the same way twice.
722    types.sort_by(|a, b| a.0.cmp(&b.0));
723    let format = if marked {
724        MetadataPolarityFormat::RequiredOptIn
725    } else {
726        MetadataPolarityFormat::Legacy
727    };
728    load_with_context(&manifest, &types, None, None, format)
729}
730
731/// Would this package content pass the AUTHORING tier under the
732/// current language? Same strictness as [`load_schema_from_dir`] —
733/// retired keys (`propagating_relationships`, `examples:`, `optional:`)
734/// refuse loudly — but over in-memory content, so a caller holding a
735/// sealed package's files (git-branch `__MEMSTEAD:schemas/` tree,
736/// folder seal, archive) can ask whether the content is still
737/// re-authorable/installable without materialising a directory. The
738/// tolerant sealed read ([`load_sealed_package`]) is unaffected; this
739/// is a pure diagnosis — the health rot axis is the consumer.
740pub fn check_package_reauthorable(
741    manifest_yaml: &str,
742    types_yamls: &[(String, String)],
743) -> Result<(), SchemaLoadError> {
744    load_authoring_package_from_memory(manifest_yaml, types_yamls).map(|_| ())
745}
746
747/// Load in-memory package content under the AUTHORING tier — the same
748/// strictness as [`load_schema_from_dir`] (retired keys refuse, the
749/// current metadata polarity), without a directory. The schema comes
750/// back for callers that need to compare what the strict read resolves
751/// (the migrate verb's faithfulness check); [`check_package_reauthorable`]
752/// is the yes/no form.
753pub fn load_authoring_package_from_memory(
754    manifest_yaml: &str,
755    types_yamls: &[(String, String)],
756) -> Result<Schema, SchemaLoadError> {
757    // The `types_dir: Some(..)` context is what selects the authoring-
758    // strict legacy-key gates; the path itself only labels error
759    // messages.
760    let strict_context = Path::new("<authoring package>");
761    load_with_context(
762        manifest_yaml,
763        types_yamls,
764        Some(strict_context),
765        Some(strict_context),
766        MetadataPolarityFormat::RequiredOptIn,
767    )
768}
769
770/// Load a schema from in-memory YAML strings with an explicit
771/// metadata-polarity format generation (from the sealed package's
772/// format marker).
773pub fn load_schema_from_memory_with_format(
774    manifest_yaml: &str,
775    types_yamls: &[(String, String)],
776    format: MetadataPolarityFormat,
777) -> Result<Schema, SchemaLoadError> {
778    load_with_context(manifest_yaml, types_yamls, None, None, format)
779}
780
781fn load_with_context(
782    manifest_yaml: &str,
783    types_yamls: &[(String, String)],
784    manifest_path: Option<&Path>,
785    types_dir: Option<&Path>,
786    format: MetadataPolarityFormat,
787) -> Result<Schema, SchemaLoadError> {
788    // Semantic-violation accumulator. Every check operating on
789    // successfully parsed structure pushes here instead of returning,
790    // so the author sees the complete violation set in one refusal;
791    // only structural failures short-circuit (see
792    // [`SchemaLoadError::Multiple`]). Order is deterministic:
793    // manifest checks in declaration order, then type files in
794    // `types_yamls` order (sorted by stem when loaded from a
795    // directory).
796    let mut errors: Vec<SchemaLoadError> = Vec::new();
797
798    let mut manifest: SchemaManifest =
799        serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
800            path: manifest_path
801                .map(Path::to_path_buf)
802                .unwrap_or_else(|| PathBuf::from("<memory>")),
803            source: e,
804        })?;
805
806    if let Err(e) = validate_name(&manifest.name) {
807        errors.push(e);
808    }
809
810    // `None` only ever coexists with a non-empty accumulator, so the
811    // `Schema` construction at the bottom (reached only when the
812    // accumulator is empty) can unwrap.
813    let version = match semver::Version::parse(&manifest.version) {
814        Ok(v) => Some(v),
815        Err(_) => {
816            errors.push(SchemaLoadError::InvalidVersion {
817                value: manifest.version.clone(),
818            });
819            None
820        }
821    };
822
823    // Relationship vocabulary: unique names + _default present
824    let mut rel_names: HashSet<String> = HashSet::new();
825    for def in &manifest.relationships.definitions {
826        if !rel_names.insert(def.name.clone()) {
827            errors.push(SchemaLoadError::DuplicateRelationship {
828                name: def.name.clone(),
829            });
830        }
831    }
832    if !rel_names.contains("_default") {
833        errors.push(SchemaLoadError::MissingDefaultWeight);
834    }
835    let available_rels: Vec<String> = manifest
836        .relationships
837        .definitions
838        .iter()
839        .map(|d| d.name.clone())
840        .collect();
841
842    // Validate that the schema-level alias_target_rel_type pointer (if
843    // set) names a declared rel-type. The synthesis pass later relies
844    // on this invariant — running it as a load-time check keeps the
845    // mutation path's hot loop free of resolution failures.
846    if let Some(target) = &manifest.alias_target_rel_type
847        && !rel_names.contains(target)
848    {
849        let mut declared = available_rels.clone();
850        declared.sort();
851        errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
852            schema: manifest.name.clone(),
853            target: target.clone(),
854            declared,
855        });
856    }
857
858    // Acyclicity sets: each set names two or more DECLARED rel-types,
859    // and a rel-type appears in at most one set across all sets
860    // (overlapping sets have no coherent refusal message; a duplicate
861    // inside one set is the same defect). A single-member set is the
862    // per-definition `acyclic` flag's job and refuses here.
863    let mut acyclic_set_member_seen: HashSet<&str> = HashSet::new();
864    for set in &manifest.relationships.acyclic_sets {
865        if set.len() < 2 {
866            errors.push(SchemaLoadError::InvalidAcyclicSet {
867                offender: set.join(", "),
868                reason: "a set needs at least two rel-types (a single member is the \
869                         per-definition `acyclic` flag)"
870                    .to_string(),
871            });
872        }
873        for name in set {
874            if !rel_names.contains(name.as_str()) {
875                errors.push(SchemaLoadError::InvalidAcyclicSet {
876                    offender: name.clone(),
877                    reason: "names no declared relationship".to_string(),
878                });
879            }
880            if !acyclic_set_member_seen.insert(name.as_str()) {
881                errors.push(SchemaLoadError::InvalidAcyclicSet {
882                    offender: name.clone(),
883                    reason: "a rel-type may appear in at most one acyclicity set".to_string(),
884                });
885            }
886        }
887    }
888
889    // Labelling declaration: `attack` names at least one declared
890    // rel-type; a `support` block names declared rel-types too (its
891    // `terminal_types` need every type loaded and are checked in the
892    // schema-level pass; its `direction` is a closed enum).
893    if let Some(lab) = &manifest.relationships.labelling {
894        if lab.attack.is_empty() {
895            errors.push(SchemaLoadError::InvalidLabelling {
896                offender: "(empty)".to_string(),
897                reason: "`labelling.attack` must name at least one rel-type".to_string(),
898            });
899        }
900        for name in &lab.attack {
901            if !rel_names.contains(name.as_str()) {
902                errors.push(SchemaLoadError::InvalidLabelling {
903                    offender: name.clone(),
904                    reason: "`labelling.attack` entry names no declared relationship".to_string(),
905                });
906            }
907        }
908        if let Some(sup) = &lab.support {
909            if sup.relationships.is_empty() {
910                errors.push(SchemaLoadError::InvalidLabelling {
911                    offender: "(empty)".to_string(),
912                    reason: "`labelling.support.relationships` must name at least one rel-type"
913                        .to_string(),
914                });
915            }
916            for name in &sup.relationships {
917                if !rel_names.contains(name.as_str()) {
918                    errors.push(SchemaLoadError::InvalidLabelling {
919                        offender: name.clone(),
920                        reason: "`labelling.support.relationships` entry names no declared \
921                                 relationship"
922                            .to_string(),
923                    });
924                }
925            }
926        }
927    }
928
929    // Option C coupling — auto-force `manual_authoring: forbidden` on
930    // the rel-type named by `alias_target_rel_type`. Schemas setting
931    // the pointer opt the named rel-type out of explicit authoring;
932    // the only path to a relation of that rel-type is via the
933    // alias-synthesis pass that emits one per body wiki-link. This
934    // closes the explicit/synthesised coexistence question: with the
935    // coupling in place, edges of the pointer rel-type are always
936    // engine-emitted, so `EdgeSource::BodyLink` is unambiguous and
937    // GC can drop pointer-rel-type relations without risking
938    // explicit-author data.
939    //
940    // The coupling is silent — a schema that writes
941    // `manual_authoring: allow` (or `warn`) on the named rel-type
942    // gets overridden to `forbidden` at load. The override is the
943    // schema-strictness contract; explicit `allow`/`warn` on the
944    // pointer rel-type is meaningless under the design and would
945    // surprise the validator at runtime, so the loader corrects it
946    // here.
947    if let Some(pointer) = manifest.alias_target_rel_type.clone() {
948        for def in &mut manifest.relationships.definitions {
949            if def.name == pointer {
950                def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
951            }
952        }
953    }
954
955    // Cross-check `source_types` / `target_types` on each relationship
956    // definition against the manifest's declared type list. Unknown
957    // names raise `UndeclaredRelationshipType` with a "did you mean"
958    // suggestion — the schema-author equivalent of `INVALID_REL_SHAPE`.
959    for def in &manifest.relationships.definitions {
960        for t in &def.source_types {
961            if !manifest.types.iter().any(|d| d == t) {
962                errors.push(SchemaLoadError::UndeclaredRelationshipType {
963                    relationship: def.name.clone(),
964                    field: "source_types",
965                    reference: t.clone(),
966                    declared: manifest.types.clone(),
967                });
968            }
969        }
970        for t in &def.target_types {
971            if !manifest.types.iter().any(|d| d == t) {
972                errors.push(SchemaLoadError::UndeclaredRelationshipType {
973                    relationship: def.name.clone(),
974                    field: "target_types",
975                    reference: t.clone(),
976                    declared: manifest.types.clone(),
977                });
978            }
979        }
980    }
981
982    // Cross-mem relationships: validate `to_schema` is a bare schema
983    // name (cross-mem eligibility is name-based — a version suffix or
984    // range refuses), refuse duplicate target schemas, and cross-check
985    // `source_types` against the source schema's types. `target_types`
986    // are accepted as opaque strings — they belong to the target
987    // schema's namespace, which is out of scope at source-schema load
988    // time. The target schema may not even be present in the workspace
989    // when the source schema loads (and cross-mem declarations
990    // targeting absent schemas are legitimate for portable library
991    // schemas).
992    let mut seen_to_schemas: HashSet<String> = HashSet::new();
993    for entry in &manifest.cross_mem_relationships {
994        if entry.to_schema == "*" {
995            // Wildcard destination — bound to the alias-synthesised
996            // rel-type. The binding IS the safety argument: the author
997            // already permitted soft auto-emitted references of that
998            // type; the wildcard extends that decision across the mem
999            // boundary and introduces no new permission. Structural
1000            // rel-types stay per-destination-schema.
1001            match manifest.alias_target_rel_type.as_deref() {
1002                None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
1003                Some(alias) => {
1004                    for def in &entry.definitions {
1005                        if def.name != alias {
1006                            errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
1007                                rel_type: def.name.clone(),
1008                                alias_target: alias.to_string(),
1009                            });
1010                        }
1011                    }
1012                }
1013            }
1014        } else if entry.to_schema.contains('@') {
1015            errors.push(SchemaLoadError::InvalidCrossMemToSchema {
1016                value: entry.to_schema.clone(),
1017                reason: "must not carry a version or range".into(),
1018            });
1019        } else if let Err(reason) = name_shape(&entry.to_schema) {
1020            errors.push(SchemaLoadError::InvalidCrossMemToSchema {
1021                value: entry.to_schema.clone(),
1022                reason: reason.into(),
1023            });
1024        }
1025        if !seen_to_schemas.insert(entry.to_schema.clone()) {
1026            errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
1027                to_schema: entry.to_schema.clone(),
1028            });
1029        }
1030        for def in &entry.definitions {
1031            for t in &def.source_types {
1032                if !manifest.types.iter().any(|d| d == t) {
1033                    errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
1034                        to_schema: entry.to_schema.clone(),
1035                        relationship: def.name.clone(),
1036                        reference: t.clone(),
1037                        declared: manifest.types.clone(),
1038                    });
1039                }
1040            }
1041        }
1042    }
1043
1044    // Type file cross-check: declared vs found, set equality (order-insensitive)
1045    let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
1046    found_stems.sort();
1047    let mut declared = manifest.types.clone();
1048    declared.sort();
1049    if found_stems != declared {
1050        // Structural: the per-type pass below would run against a file
1051        // set the manifest never described. Refuse now, together with
1052        // every manifest-level violation already proven.
1053        errors.push(SchemaLoadError::TypeFileMismatch {
1054            declared,
1055            found: found_stems,
1056        });
1057        return Err(collapse(errors));
1058    }
1059
1060    // Per-type defaults map for edge_weights resolution
1061    let defaults: IndexMap<String, f32> = manifest
1062        .relationships
1063        .definitions
1064        .iter()
1065        .map(|d| (d.name.clone(), d.default_weight))
1066        .collect();
1067
1068    let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
1069    let mut had_type_parse_failure = false;
1070
1071    for (stem, text) in types_yamls {
1072        let type_path = types_dir
1073            .map(|d| d.join(format!("{stem}.yaml")))
1074            .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
1075
1076        let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
1077            Ok(td) => td,
1078            Err(e) => {
1079                // A type file that does not parse cannot be checked
1080                // semantically — record the parse failure, keep
1081                // checking the other files, and skip the cross-type
1082                // pass below (the missing type would make it noise).
1083                errors.push(SchemaLoadError::ParseType {
1084                    path: type_path.clone(),
1085                    source: e,
1086                });
1087                had_type_parse_failure = true;
1088                continue;
1089            }
1090        };
1091
1092        if td.name != *stem {
1093            errors.push(SchemaLoadError::TypeNameMismatch {
1094                file: stem.clone(),
1095                declared: td.name.clone(),
1096            });
1097        }
1098
1099        // Legacy-key gate: authoring contexts (loaded from a
1100        // directory — `types_dir` is `Some`) refuse the retired
1101        // `propagating_relationships` key with the rename error;
1102        // sealed contexts (in-memory: built-ins, installed refs)
1103        // translate it so shipped content keeps loading (install-time
1104        // strict, sealed-tolerant — the section-heading round-trip
1105        // doctrine).
1106        if let Some(legacy) = td.legacy_propagating_relationships.take() {
1107            if types_dir.is_some() {
1108                errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
1109                    type_name: td.name.clone(),
1110                });
1111            } else if td.no_self_loop_relationships.is_empty() {
1112                td.no_self_loop_relationships = legacy;
1113            }
1114        }
1115
1116        // Retired `examples:` list (agent-trust plan 09): dead
1117        // vocabulary — never validated, never served. Authoring
1118        // contexts refuse with the pointer at `exemplar:`; sealed
1119        // contexts tolerate and drop (nothing consumed it, so
1120        // dropping is lossless).
1121        if td.legacy_examples.take().is_some() && types_dir.is_some() {
1122            errors.push(SchemaLoadError::ExamplesRetired {
1123                type_name: td.name.clone(),
1124            });
1125        }
1126
1127        // Exemplar relation spelling (consistency-sweep 05-front-door/08
1128        // rider): entries are authored in the mutation vocabulary
1129        // (`target:` / `rel_type:`) so the served exemplar round-trips
1130        // into `memstead_create` unchanged. Authoring contexts refuse
1131        // the retired `to:` / `type:` spelling with the rename pointer;
1132        // sealed contexts translate it so every shipped version keeps
1133        // loading. After the gate, both resolved keys are guaranteed
1134        // present on every loaded schema.
1135        if let Some(ex) = td.exemplar.as_mut() {
1136            let mut retired_spelling = false;
1137            let mut incomplete = false;
1138            for rel in &mut ex.relations {
1139                let legacy_to = rel.legacy_to.take();
1140                let legacy_type = rel.legacy_type.take();
1141                if legacy_to.is_some() || legacy_type.is_some() {
1142                    if types_dir.is_some() {
1143                        retired_spelling = true;
1144                        continue;
1145                    }
1146                    if rel.target.is_none() {
1147                        rel.target = legacy_to;
1148                    }
1149                    if rel.rel_type.is_none() {
1150                        rel.rel_type = legacy_type;
1151                    }
1152                }
1153                if rel.target.is_none() || rel.rel_type.is_none() {
1154                    incomplete = true;
1155                }
1156            }
1157            if retired_spelling {
1158                errors.push(SchemaLoadError::ExemplarRelationSpellingRetired {
1159                    type_name: td.name.clone(),
1160                });
1161            }
1162            if incomplete {
1163                errors.push(SchemaLoadError::ExemplarRelationIncomplete {
1164                    type_name: td.name.clone(),
1165                });
1166            }
1167        }
1168
1169        // Metadata-required polarity (first-author-path plan 07):
1170        // authoring refuses the retired `optional:` key naming the
1171        // inversion; sealed content inverts it. An absent key resolves
1172        // by the package's format generation — legacy sealed content
1173        // reads absence as required (its written meaning), everything
1174        // else as optional.
1175        for field in &mut td.metadata_fields {
1176            // Current-language contexts (directory/authoring loads and
1177            // install validation, both RequiredOptIn) refuse the
1178            // retired key; only Legacy sealed loads invert silently.
1179            if matches!(format, MetadataPolarityFormat::RequiredOptIn)
1180                && field.legacy_optional.is_some()
1181            {
1182                errors.push(SchemaLoadError::OptionalRetired {
1183                    type_name: td.name.clone(),
1184                    field: field.key.clone(),
1185                });
1186            }
1187            field.required_resolved = match (field.required, field.legacy_optional.take()) {
1188                (Some(required), _) => required,
1189                (None, Some(optional)) => !optional,
1190                (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
1191            };
1192        }
1193
1194        // Record the raw author-declared metadata keys BEFORE the
1195        // base-metadata merge, so the install-path reserved-key check
1196        // ([`check_reserved_metadata_keys`]) can tell a declared
1197        // `type`/`mem`/`id` from the engine-injected base fields. The
1198        // check itself deliberately does NOT run here: this loader
1199        // serves boot and sealed-schema reads too, and a schema sealed
1200        // before the reservation widened must keep loading (heading-
1201        // round-trip posture — refusal fires on the authoring/install
1202        // path, never at boot).
1203        td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
1204
1205        // Reject redeclarations of remaining engine-implicit base metadata
1206        // (`created_date`, `last_modified`, `tags`). The reserved `type`
1207        // case is excluded here — it refuses with the typed reserved-key
1208        // error on the install path instead.
1209        for field in &td.metadata_fields {
1210            if base_metadata::is_base_key(&field.key)
1211                && !reserved_metadata_field_keys().contains(&field.key.as_str())
1212            {
1213                errors.push(SchemaLoadError::RedeclaredBaseField {
1214                    type_name: td.name.clone(),
1215                    field: field.key.clone(),
1216                });
1217            }
1218        }
1219
1220        // Merge base metadata around the type-declared fields. Canonical
1221        // order: type, created_date, last_modified, <declared>, tags.
1222        let mut merged = base_metadata::prefix_fields();
1223        merged.append(&mut td.metadata_fields);
1224        merged.extend(base_metadata::suffix_fields());
1225        td.metadata_fields = merged;
1226
1227        compile_section_formats(&mut td);
1228        validate_type(&td, &rel_names, &available_rels, &mut errors);
1229
1230        // Resolve edge_weights: start with schema defaults, apply overrides.
1231        let mut weights = defaults.clone();
1232        for (k, v) in &td.edge_weight_overrides {
1233            weights.insert(k.clone(), *v);
1234        }
1235        td.edge_weights = weights;
1236
1237        types_map.insert(stem.clone(), Arc::new(td));
1238    }
1239
1240    // Schema-level constraint pass — checks that need every type
1241    // loaded. `enum_from_neighbour.section` names a section on the
1242    // *reached* entity, whose type this schema cannot pin statically;
1243    // requiring the key to exist on at least one declared type catches
1244    // the typo class without over-constraining the endpoint. Skipped
1245    // when a type file failed to parse — the missing type's sections
1246    // would make the existence check report noise.
1247    // At most one last-resort type: a fallback that is two types is
1248    // none (the vital-signs share signal counts exactly one).
1249    if !had_type_parse_failure {
1250        let mut last_resort: Vec<String> = types_map
1251            .values()
1252            .filter(|t| t.last_resort)
1253            .map(|t| t.name.clone())
1254            .collect();
1255        if last_resort.len() > 1 {
1256            last_resort.sort();
1257            errors.push(SchemaLoadError::MultipleLastResortTypes { types: last_resort });
1258        }
1259    }
1260    if !had_type_parse_failure {
1261        let all_section_keys: HashSet<&str> = types_map
1262            .values()
1263            .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1264            .collect();
1265        // Deterministic report order: sort type names (types_map is a
1266        // HashMap); constraints keep declaration order within a type.
1267        let mut type_names: Vec<&String> = types_map.keys().collect();
1268        type_names.sort();
1269        for type_name in type_names {
1270            let td = &types_map[type_name];
1271            for c in &td.constraints {
1272                if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1273                    && !all_section_keys.contains(section.as_str())
1274                {
1275                    errors.push(SchemaLoadError::InvalidConstraint {
1276                        type_name: td.name.clone(),
1277                        kind: "enum_from_neighbour",
1278                        offender: section.clone(),
1279                        reason: "`section` names a section key no type of this schema declares"
1280                            .to_string(),
1281                    });
1282                }
1283            }
1284            // `must_reach.terminal_types` name types of this schema —
1285            // checkable only once every type is loaded.
1286            for ob in &td.must_reach {
1287                for t in &ob.terminal_types {
1288                    if !types_map.contains_key(t.as_str()) {
1289                        errors.push(SchemaLoadError::InvalidConstraint {
1290                            type_name: td.name.clone(),
1291                            kind: "must_reach",
1292                            offender: t.clone(),
1293                            reason: "`terminal_types` entry names no type of this schema"
1294                                .to_string(),
1295                        });
1296                    }
1297                }
1298            }
1299            // A signal's neighbour pair reads the COUNTERPART entity,
1300            // whose type this schema cannot pin statically: the field
1301            // must be declared with `enum_values` on at least one type
1302            // of the schema, and the value must be a member on at
1303            // least one such declaring type.
1304            for sig in &td.signals {
1305                if let (Some(field), Some(value)) = (&sig.neighbour_field, &sig.neighbour_value) {
1306                    let declaring: Vec<&crate::types::MetadataFieldDef> = types_map
1307                        .values()
1308                        .flat_map(|t| t.metadata_fields.iter())
1309                        .filter(|f| f.key == *field && f.enum_values.is_some())
1310                        .collect();
1311                    if declaring.is_empty() {
1312                        errors.push(SchemaLoadError::InvalidConstraint {
1313                            type_name: td.name.clone(),
1314                            kind: "signal",
1315                            offender: field.clone(),
1316                            reason: "`neighbour_field` is declared with `enum_values` on no \
1317                                     type of this schema"
1318                                .to_string(),
1319                        });
1320                    } else if !declaring.iter().any(|f| {
1321                        f.enum_values
1322                            .as_ref()
1323                            .is_some_and(|allowed| allowed.contains(value))
1324                    }) {
1325                        errors.push(SchemaLoadError::InvalidConstraint {
1326                            type_name: td.name.clone(),
1327                            kind: "signal",
1328                            offender: value.clone(),
1329                            reason: format!(
1330                                "`neighbour_value` is outside `{field}`'s enum_values on every \
1331                                 declaring type"
1332                            ),
1333                        });
1334                    }
1335                }
1336            }
1337        }
1338    }
1339
1340    // `labelling.support.terminal_types` name types of this schema —
1341    // checkable only once every type is loaded (mirrors the
1342    // `must_reach` pass; skipped on type-parse failure like the rest
1343    // of the schema-level pass).
1344    if !had_type_parse_failure
1345        && let Some(lab) = &manifest.relationships.labelling
1346        && let Some(sup) = &lab.support
1347    {
1348        for t in &sup.terminal_types {
1349            if !types_map.contains_key(t.as_str()) {
1350                errors.push(SchemaLoadError::InvalidLabelling {
1351                    offender: t.clone(),
1352                    reason: "`labelling.support.terminal_types` entry names no type of this \
1353                             schema"
1354                        .to_string(),
1355                });
1356            }
1357        }
1358    }
1359
1360    if !errors.is_empty() {
1361        return Err(collapse(errors));
1362    }
1363
1364    Ok(Schema {
1365        manifest,
1366        version: version.expect("version parse failure would have accumulated an error"),
1367        types: types_map,
1368    })
1369}
1370
1371fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1372    name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1373        value: name.into(),
1374        reason,
1375    })
1376}
1377
1378/// Author-time access to the schema-name shape rule — the same check
1379/// the loader runs on a manifest's `name:`. Exposed so scaffolding
1380/// tooling (`memstead schema new`) can refuse a bad name up front with
1381/// the loader's own reason string instead of a drifting copy of the
1382/// grammar.
1383pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1384    name_shape(name)
1385}
1386
1387/// Shared shape rule for schema names — the manifest's own `name:` and
1388/// every `cross_mem_relationships[].to_schema` follow the same
1389/// grammar; the two callers wrap violations in their field-specific
1390/// error variants.
1391fn name_shape(name: &str) -> Result<(), &'static str> {
1392    if name.is_empty() {
1393        return Err("must not be empty");
1394    }
1395    let mut chars = name.chars();
1396    let first = chars.next().unwrap();
1397    if !first.is_ascii_lowercase() {
1398        return Err("must start with a lowercase letter");
1399    }
1400    for c in chars {
1401        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1402            return Err("must contain only lowercase letters, digits, and hyphens");
1403        }
1404    }
1405    Ok(())
1406}
1407
1408/// Validate and compile the section-format declarations of a type
1409/// (plan 08). LENIENT at load: problems are recorded on the section
1410/// (`format_problems`) instead of refusing, so a sealed schema
1411/// carrying a bad declaration keeps loading — install and strict
1412/// validation refuse via [`check_section_formats`], and a defective
1413/// declaration is never enforced (`compiled_content` stays `None`). A
1414/// valid `content` expression is compiled once and cached.
1415fn compile_section_formats(td: &mut TypeDefinition) {
1416    use crate::content_expr::ContentExpr;
1417    for section in &mut td.sections {
1418        // A non-default severity only deserializes from an explicit
1419        // declaration, so a lone `format_severity: warn` without
1420        // `content` is detectable — and would otherwise load and be
1421        // silently ignored (`format_severity: block` alone equals the
1422        // default and is inherently a no-op).
1423        let declares_any = section.content.is_some()
1424            || section.item_pattern.is_some()
1425            || section.table.is_some()
1426            || section.example.is_some()
1427            || section.format_severity != crate::types::ConstraintSeverity::Block;
1428        if !declares_any {
1429            continue;
1430        }
1431        let mut problems: Vec<String> = Vec::new();
1432
1433        let compiled = match &section.content {
1434            None => {
1435                problems.push(
1436                    "`item_pattern` / `table` / `example` require a `content` declaration"
1437                        .to_string(),
1438                );
1439                None
1440            }
1441            Some(expr_src) => match ContentExpr::parse(expr_src) {
1442                Ok(expr) => Some(expr),
1443                Err(e) => {
1444                    problems.push(format!("`content` is invalid: {e}"));
1445                    None
1446                }
1447            },
1448        };
1449
1450        if let Some(pattern) = &section.item_pattern {
1451            if let Err(e) = regex::Regex::new(pattern) {
1452                problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1453            }
1454            if let Some(expr) = &compiled {
1455                let names = expr.mentioned_names();
1456                let has_list = names.contains(&"list");
1457                let has_paragraph = names.contains(&"paragraph");
1458                if has_list == has_paragraph {
1459                    problems.push(
1460                        "`item_pattern` requires a `content` expression containing exactly                          one of `list` / `paragraph` (tables use `column_patterns`)"
1461                            .to_string(),
1462                    );
1463                }
1464            }
1465        }
1466
1467        if let Some(table) = &section.table {
1468            if let Some(expr) = &compiled
1469                && !expr.mentioned_names().contains(&"table")
1470            {
1471                problems.push(
1472                    "`table` block is only legal when `content` contains `table`".to_string(),
1473                );
1474            }
1475            if table.columns.is_empty() {
1476                problems.push("`table.columns` must name at least one column".to_string());
1477            }
1478            for (column, pattern) in &table.column_patterns {
1479                if !table.columns.contains(column) {
1480                    problems.push(format!(
1481                        "`column_patterns` names '{column}', which is not in `columns`"
1482                    ));
1483                }
1484                if let Err(e) = regex::Regex::new(pattern) {
1485                    problems.push(format!(
1486                        "`column_patterns.{column}` is not a valid regex: {e}"
1487                    ));
1488                }
1489            }
1490        }
1491
1492        if problems.is_empty() {
1493            section.compiled_content = compiled;
1494        } else {
1495            section.format_problems = problems;
1496        }
1497    }
1498}
1499
1500/// Refuse a schema whose section-format declarations are defective —
1501/// the install / strict-validation half of the loader-honesty rule.
1502/// Same posture as [`check_reserved_metadata_keys`]: install and
1503/// strict validation call this and refuse (naming EVERY problem of
1504/// the first defective section); boot and sealed-schema loads must
1505/// NOT — the recorded `format_problems` surface as health findings
1506/// instead, and the defective declaration is never enforced.
1507pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1508    // Aggregate EVERY defective section across every type — the
1509    // refusal names all offenders, never the first only. `type_name`
1510    // / `section` carry the first offender; entries beyond it are
1511    // prefixed with their own type/section inside `problems`.
1512    let mut first: Option<(String, String)> = None;
1513    let mut problems: Vec<String> = Vec::new();
1514    for td in schema.types.values() {
1515        for section in &td.sections {
1516            if section.format_problems.is_empty() {
1517                continue;
1518            }
1519            if first.is_none() {
1520                first = Some((td.name.clone(), section.key.clone()));
1521                problems.extend(section.format_problems.iter().cloned());
1522            } else {
1523                problems.extend(
1524                    section
1525                        .format_problems
1526                        .iter()
1527                        .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1528                );
1529            }
1530        }
1531    }
1532    match first {
1533        Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1534            type_name,
1535            section,
1536            problems,
1537        }),
1538        None => Ok(()),
1539    }
1540}
1541
1542fn validate_type(
1543    td: &TypeDefinition,
1544    rel_names: &HashSet<String>,
1545    available_rels: &[String],
1546    errors: &mut Vec<SchemaLoadError>,
1547) {
1548    // Reserved-section check. Section key `relationships` collides
1549    // with the parser's auto-managed `## Relationships` section.
1550    // Domain conventions (`identity`, `purpose`, ...) are NOT reserved.
1551    // The reserved-metadata-key check runs earlier in
1552    // `load_with_context` against the *raw* author-declared field list
1553    // so the base-metadata merge doesn't false-positive against
1554    // engine-injected keys.
1555    for section in &td.sections {
1556        if reserved_section_keys().contains(&section.key.as_str()) {
1557            errors.push(SchemaLoadError::ReservedSchemaKey {
1558                type_name: td.name.clone(),
1559                kind: "section",
1560                offending_key: section.key.clone(),
1561                reserved_keys: reserved_section_keys()
1562                    .iter()
1563                    .map(|s| s.to_string())
1564                    .collect(),
1565            });
1566        }
1567    }
1568
1569    if let Err(e) = check_rel(
1570        &td.name,
1571        "hierarchy_relationship",
1572        &td.hierarchy_relationship,
1573        rel_names,
1574        available_rels,
1575    ) {
1576        errors.push(e);
1577    }
1578    for r in &td.no_self_loop_relationships {
1579        if let Err(e) = check_rel(
1580            &td.name,
1581            "no_self_loop_relationships",
1582            r,
1583            rel_names,
1584            available_rels,
1585        ) {
1586            errors.push(e);
1587        }
1588    }
1589    for r in td.edge_weight_overrides.keys() {
1590        if let Err(e) = check_rel(
1591            &td.name,
1592            "edge_weight_overrides",
1593            r,
1594            rel_names,
1595            available_rels,
1596        ) {
1597            errors.push(e);
1598        }
1599    }
1600    for block in &td.required_outgoing {
1601        for r in &block.relationships {
1602            if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1603                errors.push(e);
1604            }
1605        }
1606        // Conditional blocks: `when_field` / `when_value` travel as a
1607        // pair, the field must be a declared metadata field carrying
1608        // `enum_values`, and the value must be a member. Stricter than
1609        // `requires_when` (which tolerates non-enum trigger fields):
1610        // an edge obligation armed by a free-text value would never
1611        // fire predictably, so the loader refuses instead of loading a
1612        // dead condition.
1613        match (&block.when_field, &block.when_value) {
1614            (None, None) => {}
1615            (Some(f), None) => {
1616                errors.push(SchemaLoadError::InvalidConstraint {
1617                    type_name: td.name.clone(),
1618                    kind: "required_outgoing",
1619                    offender: f.clone(),
1620                    reason: "`when_field` requires `when_value` alongside it".to_string(),
1621                });
1622            }
1623            (None, Some(v)) => {
1624                errors.push(SchemaLoadError::InvalidConstraint {
1625                    type_name: td.name.clone(),
1626                    kind: "required_outgoing",
1627                    offender: v.clone(),
1628                    reason: "`when_value` requires `when_field` alongside it".to_string(),
1629                });
1630            }
1631            (Some(f), Some(v)) => match td.metadata_fields.iter().find(|mf| mf.key == *f) {
1632                None => {
1633                    errors.push(SchemaLoadError::InvalidConstraint {
1634                        type_name: td.name.clone(),
1635                        kind: "required_outgoing",
1636                        offender: f.clone(),
1637                        reason: "`when_field` names no metadata field of this type".to_string(),
1638                    });
1639                }
1640                Some(when_def) => match &when_def.enum_values {
1641                    None => {
1642                        errors.push(SchemaLoadError::InvalidConstraint {
1643                            type_name: td.name.clone(),
1644                            kind: "required_outgoing",
1645                            offender: f.clone(),
1646                            reason: format!(
1647                                "`when_field` must name a metadata field with `enum_values`; `{f}` declares none"
1648                            ),
1649                        });
1650                    }
1651                    Some(allowed) if !allowed.contains(v) => {
1652                        errors.push(SchemaLoadError::InvalidConstraint {
1653                            type_name: td.name.clone(),
1654                            kind: "required_outgoing",
1655                            offender: v.clone(),
1656                            reason: format!(
1657                                "`when_value` is not in `{f}`'s enum_values [{}]",
1658                                allowed.join(", ")
1659                            ),
1660                        });
1661                    }
1662                    Some(_) => {}
1663                },
1664            },
1665        }
1666    }
1667
1668    // Reachability obligations. Per-type checks here; `terminal_types`
1669    // needs every type loaded and is checked in the schema-level pass.
1670    for ob in &td.must_reach {
1671        if ob.relationships.is_empty() {
1672            errors.push(SchemaLoadError::InvalidConstraint {
1673                type_name: td.name.clone(),
1674                kind: "must_reach",
1675                offender: "(empty)".to_string(),
1676                reason: "`relationships` must name at least one relationship".to_string(),
1677            });
1678        }
1679        for r in &ob.relationships {
1680            if let Err(e) = check_rel(&td.name, "must_reach", r, rel_names, available_rels) {
1681                errors.push(e);
1682            }
1683        }
1684        if ob.terminal_types.is_empty() {
1685            errors.push(SchemaLoadError::InvalidConstraint {
1686                type_name: td.name.clone(),
1687                kind: "must_reach",
1688                offender: "(empty)".to_string(),
1689                reason: "`terminal_types` must name at least one type".to_string(),
1690            });
1691        }
1692        if ob.max_depth == Some(0) {
1693            errors.push(SchemaLoadError::InvalidConstraint {
1694                type_name: td.name.clone(),
1695                kind: "must_reach",
1696                offender: "0".to_string(),
1697                reason: "`max_depth` must be at least 1 — nothing is reachable in zero hops"
1698                    .to_string(),
1699            });
1700        }
1701        if ob.severity == crate::types::ConstraintSeverity::Block {
1702            // A transitive property is established by writes on OTHER
1703            // entities, so a write-time refusal would punish the wrong
1704            // mutation — refuse the promise rather than load-and-
1705            // downgrade (same posture as status_propagation).
1706            errors.push(SchemaLoadError::InvalidConstraint {
1707                type_name: td.name.clone(),
1708                kind: "must_reach",
1709                offender: "block".to_string(),
1710                reason: "must_reach is always warn-tier — a reachability gap is created by \
1711                         writes on other entities, so no single write can be refused for it"
1712                    .to_string(),
1713            });
1714        }
1715    }
1716
1717    // Aggregate signals. Per-type checks here; the neighbour pair's
1718    // cross-type validation needs every type loaded and runs in the
1719    // schema-level pass.
1720    let mut signal_names_seen: HashSet<&str> = HashSet::new();
1721    for sig in &td.signals {
1722        let name_ok = sig
1723            .name
1724            .chars()
1725            .next()
1726            .is_some_and(|c| c.is_ascii_lowercase())
1727            && sig
1728                .name
1729                .chars()
1730                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1731        if !name_ok {
1732            errors.push(SchemaLoadError::InvalidConstraint {
1733                type_name: td.name.clone(),
1734                kind: "signal",
1735                offender: sig.name.clone(),
1736                reason: "`name` must match [a-z][a-z0-9_]*".to_string(),
1737            });
1738        }
1739        if !signal_names_seen.insert(sig.name.as_str()) {
1740            errors.push(SchemaLoadError::InvalidConstraint {
1741                type_name: td.name.clone(),
1742                kind: "signal",
1743                offender: sig.name.clone(),
1744                reason: "duplicate signal name on this type".to_string(),
1745            });
1746        }
1747        if sig.relationships.is_empty() {
1748            errors.push(SchemaLoadError::InvalidConstraint {
1749                type_name: td.name.clone(),
1750                kind: "signal",
1751                offender: sig.name.clone(),
1752                reason: "`relationships` must name at least one relationship".to_string(),
1753            });
1754        }
1755        for r in &sig.relationships {
1756            if let Err(e) = check_rel(&td.name, "signals", r, rel_names, available_rels) {
1757                errors.push(e);
1758            }
1759        }
1760        if sig.thresholds.is_empty() {
1761            errors.push(SchemaLoadError::InvalidConstraint {
1762                type_name: td.name.clone(),
1763                kind: "signal",
1764                offender: sig.name.clone(),
1765                reason: "`thresholds` must declare at least one step".to_string(),
1766            });
1767        }
1768        for pair in sig.thresholds.windows(2) {
1769            if pair[1].at_least <= pair[0].at_least {
1770                errors.push(SchemaLoadError::InvalidConstraint {
1771                    type_name: td.name.clone(),
1772                    kind: "signal",
1773                    offender: pair[1].at_least.to_string(),
1774                    reason: "`thresholds` must have strictly increasing `at_least` values"
1775                        .to_string(),
1776                });
1777            }
1778        }
1779        match (&sig.neighbour_field, &sig.neighbour_value) {
1780            (None, None) | (Some(_), Some(_)) => {}
1781            (Some(f), None) => {
1782                errors.push(SchemaLoadError::InvalidConstraint {
1783                    type_name: td.name.clone(),
1784                    kind: "signal",
1785                    offender: f.clone(),
1786                    reason: "`neighbour_field` requires `neighbour_value` alongside it".to_string(),
1787                });
1788            }
1789            (None, Some(v)) => {
1790                errors.push(SchemaLoadError::InvalidConstraint {
1791                    type_name: td.name.clone(),
1792                    kind: "signal",
1793                    offender: v.clone(),
1794                    reason: "`neighbour_value` requires `neighbour_field` alongside it".to_string(),
1795                });
1796            }
1797        }
1798    }
1799
1800    // Constraint vocabulary (loader honesty: a malformed declaration
1801    // refuses with a typed error naming the offender — never
1802    // load-and-ignore).
1803    let field_keys: std::collections::HashSet<&str> =
1804        td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1805    let section_keys: std::collections::HashSet<&str> =
1806        td.sections.iter().map(|sec| sec.key.as_str()).collect();
1807    for c in &td.constraints {
1808        match c {
1809            crate::types::ConstraintDef::RequiresWhen {
1810                field,
1811                when_field,
1812                when_value,
1813                ..
1814            } => {
1815                if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1816                    errors.push(SchemaLoadError::InvalidConstraint {
1817                        type_name: td.name.clone(),
1818                        kind: "requires_when",
1819                        offender: field.clone(),
1820                        reason: "`field` names neither a metadata field nor a section of this type"
1821                            .to_string(),
1822                    });
1823                }
1824                let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1825                else {
1826                    errors.push(SchemaLoadError::InvalidConstraint {
1827                        type_name: td.name.clone(),
1828                        kind: "requires_when",
1829                        offender: when_field.clone(),
1830                        reason: "`when_field` names no metadata field of this type".to_string(),
1831                    });
1832                    continue;
1833                };
1834                if let Some(allowed) = &when_def.enum_values
1835                    && !allowed.contains(when_value)
1836                {
1837                    errors.push(SchemaLoadError::InvalidConstraint {
1838                        type_name: td.name.clone(),
1839                        kind: "requires_when",
1840                        offender: when_value.clone(),
1841                        reason: format!(
1842                            "`when_value` is not in `{when_field}`'s enum_values [{}]",
1843                            allowed.join(", ")
1844                        ),
1845                    });
1846                }
1847            }
1848            crate::types::ConstraintDef::Unique { fields, .. } => {
1849                if fields.is_empty() {
1850                    errors.push(SchemaLoadError::InvalidConstraint {
1851                        type_name: td.name.clone(),
1852                        kind: "unique",
1853                        offender: "(empty)".to_string(),
1854                        reason: "`fields` must name at least one metadata field".to_string(),
1855                    });
1856                }
1857                for f in fields {
1858                    if !field_keys.contains(f.as_str()) {
1859                        errors.push(SchemaLoadError::InvalidConstraint {
1860                            type_name: td.name.clone(),
1861                            kind: "unique",
1862                            offender: f.clone(),
1863                            reason: "`fields` entry names no metadata field of this type"
1864                                .to_string(),
1865                        });
1866                    }
1867                }
1868            }
1869            crate::types::ConstraintDef::EnumFromNeighbour {
1870                field, rel_type, ..
1871            } => {
1872                if !field_keys.contains(field.as_str()) {
1873                    errors.push(SchemaLoadError::InvalidConstraint {
1874                        type_name: td.name.clone(),
1875                        kind: "enum_from_neighbour",
1876                        offender: field.clone(),
1877                        reason: "`field` names no metadata field of this type".to_string(),
1878                    });
1879                }
1880                if !rel_names.contains(rel_type) {
1881                    errors.push(SchemaLoadError::InvalidConstraint {
1882                        type_name: td.name.clone(),
1883                        kind: "enum_from_neighbour",
1884                        offender: rel_type.clone(),
1885                        reason: "`rel_type` is not in the schema's relationship vocabulary"
1886                            .to_string(),
1887                    });
1888                }
1889                // `section` names a key on the *reached* type, which
1890                // this per-type pass cannot see — the schema-level
1891                // pass after all types load checks it.
1892            }
1893            crate::types::ConstraintDef::TransitionRequiresChecks {
1894                field,
1895                to_value,
1896                relationships,
1897                ..
1898            } => {
1899                match td.metadata_fields.iter().find(|f| f.key == *field) {
1900                    None => {
1901                        errors.push(SchemaLoadError::InvalidConstraint {
1902                            type_name: td.name.clone(),
1903                            kind: "transition_requires_checks",
1904                            offender: field.clone(),
1905                            reason: "`field` names no metadata field of this type".to_string(),
1906                        });
1907                    }
1908                    Some(field_def) => {
1909                        if let Some(allowed) = &field_def.enum_values
1910                            && !allowed.contains(to_value)
1911                        {
1912                            errors.push(SchemaLoadError::InvalidConstraint {
1913                                type_name: td.name.clone(),
1914                                kind: "transition_requires_checks",
1915                                offender: to_value.clone(),
1916                                reason: format!(
1917                                    "`to_value` is not in `{field}`'s enum_values [{}]",
1918                                    allowed.join(", ")
1919                                ),
1920                            });
1921                        }
1922                    }
1923                }
1924                if relationships.is_empty() {
1925                    errors.push(SchemaLoadError::InvalidConstraint {
1926                        type_name: td.name.clone(),
1927                        kind: "transition_requires_checks",
1928                        offender: "(empty)".to_string(),
1929                        reason: "`relationships` must name at least one declared relationship"
1930                            .to_string(),
1931                    });
1932                }
1933                for rel in relationships {
1934                    if !rel_names.contains(rel) {
1935                        errors.push(SchemaLoadError::InvalidConstraint {
1936                            type_name: td.name.clone(),
1937                            kind: "transition_requires_checks",
1938                            offender: rel.clone(),
1939                            reason: "`relationships` entry is not in the schema's relationship \
1940                                     vocabulary"
1941                                .to_string(),
1942                        });
1943                    }
1944                }
1945            }
1946            crate::types::ConstraintDef::StatusPropagation {
1947                field,
1948                value,
1949                rel_type,
1950                rel_types,
1951                severity,
1952                ..
1953            } => {
1954                match td.metadata_fields.iter().find(|f| f.key == *field) {
1955                    None => {
1956                        errors.push(SchemaLoadError::InvalidConstraint {
1957                            type_name: td.name.clone(),
1958                            kind: "status_propagation",
1959                            offender: field.clone(),
1960                            reason: "`field` names no metadata field of this type".to_string(),
1961                        });
1962                    }
1963                    Some(field_def) => {
1964                        if let Some(allowed) = &field_def.enum_values
1965                            && !allowed.contains(value)
1966                        {
1967                            errors.push(SchemaLoadError::InvalidConstraint {
1968                                type_name: td.name.clone(),
1969                                kind: "status_propagation",
1970                                offender: value.clone(),
1971                                reason: format!(
1972                                    "`value` is not in `{field}`'s enum_values [{}]",
1973                                    allowed.join(", ")
1974                                ),
1975                            });
1976                        }
1977                    }
1978                }
1979                // Exactly one of `rel_type` / `rel_types`; every named
1980                // member must be declared; an empty set is a defect.
1981                match (rel_type, rel_types) {
1982                    (Some(_), Some(_)) => {
1983                        errors.push(SchemaLoadError::InvalidConstraint {
1984                            type_name: td.name.clone(),
1985                            kind: "status_propagation",
1986                            offender: "rel_type".to_string(),
1987                            reason: "declare `rel_type` or `rel_types`, not both".to_string(),
1988                        });
1989                    }
1990                    (None, None) => {
1991                        errors.push(SchemaLoadError::InvalidConstraint {
1992                            type_name: td.name.clone(),
1993                            kind: "status_propagation",
1994                            offender: "(missing)".to_string(),
1995                            reason: "one of `rel_type` / `rel_types` is required".to_string(),
1996                        });
1997                    }
1998                    (Some(single), None) => {
1999                        if !rel_names.contains(single) {
2000                            errors.push(SchemaLoadError::InvalidConstraint {
2001                                type_name: td.name.clone(),
2002                                kind: "status_propagation",
2003                                offender: single.clone(),
2004                                reason: "`rel_type` is not in the schema's relationship vocabulary"
2005                                    .to_string(),
2006                            });
2007                        }
2008                    }
2009                    (None, Some(set)) => {
2010                        if set.is_empty() {
2011                            errors.push(SchemaLoadError::InvalidConstraint {
2012                                type_name: td.name.clone(),
2013                                kind: "status_propagation",
2014                                offender: "(empty)".to_string(),
2015                                reason: "`rel_types` must name at least one relationship"
2016                                    .to_string(),
2017                            });
2018                        }
2019                        for name in set {
2020                            if !rel_names.contains(name) {
2021                                errors.push(SchemaLoadError::InvalidConstraint {
2022                                    type_name: td.name.clone(),
2023                                    kind: "status_propagation",
2024                                    offender: name.clone(),
2025                                    reason: "`rel_types` entry is not in the schema's \
2026                                             relationship vocabulary"
2027                                        .to_string(),
2028                                });
2029                            }
2030                        }
2031                    }
2032                }
2033                if *severity == crate::types::ConstraintSeverity::Block {
2034                    // Propagation can never refuse a write (the taint
2035                    // arises from the ancestor's later change), so a
2036                    // `block` declaration would be a promise the
2037                    // engine will not keep — refuse it rather than
2038                    // load-and-downgrade.
2039                    errors.push(SchemaLoadError::InvalidConstraint {
2040                        type_name: td.name.clone(),
2041                        kind: "status_propagation",
2042                        offender: "block".to_string(),
2043                        reason: "status_propagation is always warn-tier — a parent falling after \
2044                                 the child was written cannot retroactively make the child's \
2045                                 write illegal"
2046                            .to_string(),
2047                    });
2048                }
2049            }
2050        }
2051    }
2052
2053    // Due axis (first-author-path plan 08): the declaration's
2054    // references must exist on this type in the declared shapes.
2055    if let Some(due) = &td.due {
2056        match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
2057            None => errors.push(SchemaLoadError::InvalidDueAxis {
2058                type_name: td.name.clone(),
2059                offender: due.date_field.clone(),
2060                reason: "`date_field` names no metadata field of this type".to_string(),
2061            }),
2062            Some(f) if f.field_type != crate::types::FieldType::Date => {
2063                errors.push(SchemaLoadError::InvalidDueAxis {
2064                    type_name: td.name.clone(),
2065                    offender: due.date_field.clone(),
2066                    reason: "`date_field` must name a date-typed metadata field".to_string(),
2067                })
2068            }
2069            Some(_) => {}
2070        }
2071        match td
2072            .metadata_fields
2073            .iter()
2074            .find(|f| f.key == due.status_field)
2075        {
2076            None => errors.push(SchemaLoadError::InvalidDueAxis {
2077                type_name: td.name.clone(),
2078                offender: due.status_field.clone(),
2079                reason: "`status_field` names no metadata field of this type".to_string(),
2080            }),
2081            Some(f) => match &f.enum_values {
2082                None => errors.push(SchemaLoadError::InvalidDueAxis {
2083                    type_name: td.name.clone(),
2084                    offender: due.status_field.clone(),
2085                    reason: "`status_field` must name an enum-typed metadata field \
2086                             (declare enum_values)"
2087                        .to_string(),
2088                }),
2089                Some(allowed) => {
2090                    for v in &due.open_values {
2091                        if !allowed.contains(v) {
2092                            errors.push(SchemaLoadError::InvalidDueAxis {
2093                                type_name: td.name.clone(),
2094                                offender: v.clone(),
2095                                reason: format!(
2096                                    "`open_values` entry is not in `{}`'s enum_values [{}]",
2097                                    due.status_field,
2098                                    allowed.join(", ")
2099                                ),
2100                            });
2101                        }
2102                    }
2103                }
2104            },
2105        }
2106        if due.open_values.is_empty() {
2107            errors.push(SchemaLoadError::InvalidDueAxis {
2108                type_name: td.name.clone(),
2109                offender: "(empty)".to_string(),
2110                reason: "`open_values` must name at least one open status value".to_string(),
2111            });
2112        }
2113        if let Some(lead) = &due.lead_section
2114            && !td.sections.iter().any(|s| s.key == *lead)
2115        {
2116            errors.push(SchemaLoadError::InvalidDueAxis {
2117                type_name: td.name.clone(),
2118                offender: lead.clone(),
2119                reason: "`lead_section` names no section of this type".to_string(),
2120            });
2121        }
2122    }
2123
2124    // Exactly one catch_all section
2125    if let Some(res) = &td.resolution {
2126        if !td.sections.iter().any(|s| s.key == res.condition_section) {
2127            errors.push(SchemaLoadError::InvalidResolutionAxis {
2128                type_name: td.name.clone(),
2129                offender: res.condition_section.clone(),
2130                reason: "`condition_section` names no section of this type".to_string(),
2131            });
2132        }
2133        match &res.status_field {
2134            None if !res.open_values.is_empty() => {
2135                errors.push(SchemaLoadError::InvalidResolutionAxis {
2136                    type_name: td.name.clone(),
2137                    offender: res.open_values.join(", "),
2138                    reason: "`open_values` given without a `status_field`".to_string(),
2139                });
2140            }
2141            None => {}
2142            Some(field) => match td.metadata_fields.iter().find(|f| f.key == *field) {
2143                None => errors.push(SchemaLoadError::InvalidResolutionAxis {
2144                    type_name: td.name.clone(),
2145                    offender: field.clone(),
2146                    reason: "`status_field` names no metadata field of this type".to_string(),
2147                }),
2148                Some(f) => match &f.enum_values {
2149                    None => errors.push(SchemaLoadError::InvalidResolutionAxis {
2150                        type_name: td.name.clone(),
2151                        offender: field.clone(),
2152                        reason: "`status_field` must name an enum-typed metadata field \
2153                                 (declare enum_values)"
2154                            .to_string(),
2155                    }),
2156                    Some(allowed) => {
2157                        if res.open_values.is_empty() {
2158                            errors.push(SchemaLoadError::InvalidResolutionAxis {
2159                                type_name: td.name.clone(),
2160                                offender: "(empty)".to_string(),
2161                                reason: "`open_values` must name at least one open status \
2162                                         value when `status_field` is declared"
2163                                    .to_string(),
2164                            });
2165                        }
2166                        for v in &res.open_values {
2167                            if !allowed.contains(v) {
2168                                errors.push(SchemaLoadError::InvalidResolutionAxis {
2169                                    type_name: td.name.clone(),
2170                                    offender: v.clone(),
2171                                    reason: format!(
2172                                        "`open_values` entry is not in `{field}`'s enum_values [{}]",
2173                                        allowed.join(", ")
2174                                    ),
2175                                });
2176                            }
2177                        }
2178                    }
2179                },
2180            },
2181        }
2182        if let Some(kind) = &res.check_kind {
2183            let well_formed = matches!(kind.as_str(), "verification" | "conformance")
2184                || (kind.strip_prefix("x-").is_some_and(|name| {
2185                    !name.is_empty()
2186                        && name
2187                            .chars()
2188                            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
2189                        && !name.starts_with('-')
2190                        && !name.ends_with('-')
2191                }));
2192            if !well_formed {
2193                errors.push(SchemaLoadError::InvalidResolutionAxis {
2194                    type_name: td.name.clone(),
2195                    offender: kind.clone(),
2196                    reason: "`check_kind` must be `verification`, `conformance`, or an `x-<name>` \
2197                             kind (lowercase letters, digits, hyphens)"
2198                        .to_string(),
2199                });
2200            }
2201        }
2202    }
2203
2204    let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
2205    if catch_all_count != 1 {
2206        errors.push(SchemaLoadError::CatchAllViolation {
2207            type_name: td.name.clone(),
2208            count: catch_all_count,
2209        });
2210    }
2211
2212    // Field-reference integrity
2213    let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
2214    let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
2215
2216    for f in &td.text_fields {
2217        // text_fields point at section content — not metadata.
2218        if !section_keys.contains(f.as_str()) {
2219            errors.push(SchemaLoadError::UnknownFieldReference {
2220                type_name: td.name.clone(),
2221                field: "text_fields",
2222                reference: f.clone(),
2223            });
2224        }
2225    }
2226    for f in &td.health_required_fields {
2227        if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2228            errors.push(SchemaLoadError::UnknownFieldReference {
2229                type_name: td.name.clone(),
2230                field: "health_required_fields",
2231                reference: f.clone(),
2232            });
2233        }
2234    }
2235    for f in &td.updatable_fields {
2236        // `title` is the entity's filename-derived title — always updatable.
2237        if f == "title" {
2238            continue;
2239        }
2240        if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2241            errors.push(SchemaLoadError::UnknownFieldReference {
2242                type_name: td.name.clone(),
2243                field: "updatable_fields",
2244                reference: f.clone(),
2245            });
2246        }
2247    }
2248
2249    // Metadata default_value must be a member of enum_values when both present.
2250    for m in &td.metadata_fields {
2251        if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
2252            && !allowed.contains(default)
2253        {
2254            errors.push(SchemaLoadError::DefaultValueNotInEnum {
2255                type_name: td.name.clone(),
2256                field: m.key.clone(),
2257                default: default.clone(),
2258                allowed: allowed.clone(),
2259            });
2260        }
2261        // A `value_pattern` must compile here, at install, so the write
2262        // path never meets a malformed regex.
2263        if let Some(pattern) = m.value_pattern.as_ref()
2264            && let Err(e) = regex::Regex::new(&format!("^(?:{pattern})$"))
2265        {
2266            errors.push(SchemaLoadError::InvalidFieldPattern {
2267                type_name: td.name.clone(),
2268                field: m.key.clone(),
2269                pattern: pattern.clone(),
2270                reason: e.to_string(),
2271            });
2272        }
2273    }
2274}
2275
2276fn check_rel(
2277    type_name: &str,
2278    field: &'static str,
2279    relationship: &str,
2280    rel_names: &HashSet<String>,
2281    available: &[String],
2282) -> Result<(), SchemaLoadError> {
2283    if rel_names.contains(relationship) {
2284        return Ok(());
2285    }
2286    Err(SchemaLoadError::UndeclaredRelationship {
2287        type_name: type_name.into(),
2288        field,
2289        relationship: relationship.into(),
2290        available: available.to_vec(),
2291    })
2292}