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    /// The retired `optional:` metadata-field key was used in an
91    /// authoring context. The polarity flipped (first-author-path
92    /// plan 07): a field is optional unless it declares
93    /// `required: true` — the same rule sections follow. Sealed
94    /// content keeps loading with the old key inverted; only
95    /// authoring refuses, so the fix is one mechanical edit.
96    #[error(
97        "type '{type_name}' metadata field '{field}' declares the retired `optional:` key — \
98         fields are optional unless they declare `required: true`. Fix: delete `optional: true`; \
99         replace `optional: false` with `required: true`. Then retry."
100    )]
101    OptionalRetired { type_name: String, field: String },
102
103    /// A `due:` declaration referencing fields the type does not have
104    /// in the required shapes. `offender` names the bad reference,
105    /// `reason` states the shape rule it violates — the due axis is
106    /// spec (an external declaration), so its references validate at
107    /// load with the loader's usual recovery quality.
108    #[error("type '{type_name}' due axis is invalid: {reason} — offending name: '{offender}'")]
109    InvalidDueAxis {
110        type_name: String,
111        offender: String,
112        reason: String,
113    },
114
115    #[error("schema relationship vocabulary must include a '_default' definition")]
116    MissingDefaultWeight,
117
118    #[error("duplicate relationship definition: '{name}'")]
119    DuplicateRelationship { name: String },
120
121    #[error(
122        "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
123        available.join(", "),
124        format_suggestion(relationship, available)
125    )]
126    UndeclaredRelationship {
127        type_name: String,
128        field: &'static str,
129        relationship: String,
130        available: Vec<String>,
131    },
132
133    #[error(
134        "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
135    )]
136    CatchAllViolation { type_name: String, count: usize },
137
138    #[error(
139        "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
140    )]
141    UnknownFieldReference {
142        type_name: String,
143        field: &'static str,
144        reference: String,
145    },
146
147    #[error(
148        "type '{type_name}' constraint ({kind}) is invalid: {reason} — offending name: '{offender}'"
149    )]
150    InvalidConstraint {
151        type_name: String,
152        kind: &'static str,
153        offender: String,
154        reason: String,
155    },
156
157    #[error(
158        "type '{type_name}' section '{section}' format declaration is invalid: {}",
159        problems.join("; ")
160    )]
161    InvalidSectionFormat {
162        type_name: String,
163        section: String,
164        /// EVERY problem of the section's declaration — the loader
165        /// names all offenders, never the first only, so one repair
166        /// pass fixes the schema.
167        problems: Vec<String>,
168    },
169
170    #[error(
171        "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
172        allowed.join(", ")
173    )]
174    DefaultValueNotInEnum {
175        type_name: String,
176        field: String,
177        default: String,
178        allowed: Vec<String>,
179    },
180
181    #[error(
182        "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
183    )]
184    RedeclaredBaseField { type_name: String, field: String },
185
186    #[error(
187        "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
188        declared.join(", "),
189        format_suggestion(reference, declared)
190    )]
191    UndeclaredRelationshipType {
192        relationship: String,
193        field: &'static str,
194        reference: String,
195        declared: Vec<String>,
196    },
197
198    /// Schema declares a regular section or metadata field whose key
199    /// collides with an engine-invariant key. The reserved set covers
200    /// section key `relationships` (the parser's auto-managed
201    /// `## Relationships` section) and the metadata identity/
202    /// discriminator triple `type` / `mem` / `id`
203    /// ([`reserved_metadata_field_keys`]). Sections refuse at load;
204    /// metadata keys refuse on the install/strict-validation path
205    /// ([`check_reserved_metadata_keys`]) so sealed schemas keep
206    /// booting.
207    #[error(
208        "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
209        reserved_keys.join(", ")
210    )]
211    ReservedSchemaKey {
212        type_name: String,
213        kind: &'static str,
214        offending_key: String,
215        reserved_keys: Vec<String>,
216    },
217
218    /// A `cross_mem_relationships:` entry's `to_schema:` field is
219    /// not a bare schema name. Cross-mem eligibility is name-based —
220    /// versioned (`software@1.0.0`) and range (`software@^1.0`) forms
221    /// are refused so a version component can never silently re-enter
222    /// the eligibility path.
223    #[error(
224        "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
225    )]
226    InvalidCrossMemToSchema { value: String, reason: String },
227
228    /// Two `cross_mem_relationships:` entries declare the same
229    /// `to_schema:`. A schema declares each target-schema at most once
230    /// — the second entry would otherwise silently shadow or split
231    /// the vocabulary.
232    #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
233    DuplicateCrossMemToSchema { to_schema: String },
234
235    /// A sealed package's file list carries no `schema.yaml`. Only
236    /// reachable through [`load_sealed_package`] — the directory
237    /// loader surfaces a missing manifest as [`Self::Io`] instead.
238    #[error("sealed schema package carries no schema.yaml")]
239    SealedPackageMissingManifest,
240
241    /// A `to_schema: "*"` wildcard entry in a schema that declares no
242    /// `alias_target_rel_type`. The wildcard is BOUND to the
243    /// alias-synthesised rel-type — a schema that has not opted into
244    /// alias synthesis has made no decision the wildcard could extend.
245    #[error(
246        "cross_mem_relationships declares to_schema '*' but the schema declares no \
247         alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
248         declare alias_target_rel_type, or name each destination schema explicitly"
249    )]
250    CrossMemWildcardWithoutAliasTarget,
251
252    /// A `to_schema: "*"` wildcard entry declaring a rel-type other
253    /// than the schema's `alias_target_rel_type`. Hand-authored
254    /// structural edges keep requiring a per-destination-schema
255    /// declaration — the wildcard only extends the soft, auto-emitted
256    /// alias references the author already permitted.
257    #[error(
258        "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
259         wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
260         hand-authored structural edges need a per-destination-schema declaration"
261    )]
262    CrossMemWildcardNonAliasRelType {
263        rel_type: String,
264        alias_target: String,
265    },
266
267    /// A `cross_mem_relationships[].definitions[*].source_types` entry
268    /// references a type name not declared in the source schema's
269    /// `types` list. Source types belong to the source schema's
270    /// namespace; unknown names raise this error at load time.
271    /// (Target types are accepted as opaque strings — they belong to
272    /// the target schema's namespace, which is not in scope here.)
273    #[error(
274        "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
275        declared.join(", "),
276        format_suggestion(reference, declared)
277    )]
278    UndeclaredCrossMemSourceType {
279        to_schema: String,
280        relationship: String,
281        reference: String,
282        declared: Vec<String>,
283    },
284
285    /// The schema's `alias_target_rel_type:` pointer names a rel-type
286    /// not declared in `relationships.definitions`. Surfaces at
287    /// schema-load time so the alias-synthesis pass can trust the
288    /// pointer is resolvable at every later mutation call.
289    #[error(
290        "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
291        declared.join(", "),
292        format_suggestion(target, declared)
293    )]
294    AliasTargetRelTypeNotDeclared {
295        schema: String,
296        target: String,
297        declared: Vec<String>,
298    },
299
300    /// One or more declared section headings do not derive back to their
301    /// declared keys (`derive_section_key(heading) != key`), so content
302    /// written under the heading could never be parsed back into the
303    /// section — it would silently fork into a second heading or fall
304    /// through to the catch-all. Raised by
305    /// [`check_section_heading_roundtrip`] on the authoring/installation
306    /// path only; a schema already sealed into a mem-repo keeps loading
307    /// and surfaces the condition through health instead.
308    #[error(
309        "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
310         Fix: make each heading derive to its key — lowercasing the heading and replacing \
311         spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
312         `Current State`)",
313        format_heading_violations(violations)
314    )]
315    SectionHeadingMismatch {
316        violations: Vec<HeadingKeyViolation>,
317    },
318
319    /// Two or more independent semantic violations found in one load
320    /// pass. The loader accumulates every violation it can prove on
321    /// successfully parsed structure and refuses once, so the author
322    /// fixes the whole set in one edit instead of one violation per
323    /// validate round. Structural failures (a manifest that does not
324    /// parse, a declared-vs-found type-file mismatch) still
325    /// short-circuit — everything downstream of them would be noise
326    /// derived from a value that does not exist. A single violation is
327    /// returned bare, never as a one-element list.
328    #[error(
329        "schema has {} violations:\n{}",
330        errors.len(),
331        format_multiple(errors)
332    )]
333    Multiple { errors: Vec<SchemaLoadError> },
334}
335
336fn format_multiple(errors: &[SchemaLoadError]) -> String {
337    errors
338        .iter()
339        .enumerate()
340        .map(|(i, e)| format!("  {}. {e}", i + 1))
341        .collect::<Vec<_>>()
342        .join("\n")
343}
344
345/// Fold an accumulated violation list into one error: a single
346/// violation stays bare (the common case keeps today's message shape),
347/// several wrap in [`SchemaLoadError::Multiple`]. Callers guarantee
348/// the list is non-empty.
349fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
350    debug_assert!(!errors.is_empty());
351    if errors.len() == 1 {
352        errors.remove(0)
353    } else {
354        SchemaLoadError::Multiple { errors }
355    }
356}
357
358/// One `(type, key, heading, derived_key)` tuple in a
359/// [`SchemaLoadError::SectionHeadingMismatch`] refusal.
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub struct HeadingKeyViolation {
362    pub type_name: String,
363    pub key: String,
364    pub heading: String,
365    pub derived_key: String,
366}
367
368fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
369    violations
370        .iter()
371        .map(|v| {
372            format!(
373                "type '{}' section key '{}' has heading '{}' (derives to '{}')",
374                v.type_name, v.key, v.heading, v.derived_key
375            )
376        })
377        .collect::<Vec<_>>()
378        .join("; ")
379}
380
381/// Refuse a schema in which any declared section's heading does not
382/// derive back to that section's declared key. Collects **every**
383/// offending `(type, key, heading, derived_key)` tuple — a schema with
384/// one good and one bad section is refused whole, and the author sees
385/// the complete list in one round.
386///
387/// Installation-path gate only: callers are the schema-authoring
388/// surfaces (CLI `schema validate` / `schema install`, the engine's
389/// `install_schema` primitive). Boot and sealed-schema loads must NOT
390/// call this — a schema already sealed on `__MEMSTEAD` that violates
391/// the rule keeps loading, and the violation surfaces as a health
392/// finding, never as a boot failure.
393pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
394    let mut violations = Vec::new();
395    // Deterministic report order: sort type names (Schema.types is a
396    // HashMap); sections keep declaration order within a type.
397    let mut type_names: Vec<&String> = schema.types.keys().collect();
398    type_names.sort();
399    for type_name in type_names {
400        let t = &schema.types[type_name];
401        for s in &t.sections {
402            let derived_key = crate::types::derive_section_key(&s.heading);
403            if derived_key != s.key {
404                violations.push(HeadingKeyViolation {
405                    type_name: type_name.clone(),
406                    key: s.key.clone(),
407                    heading: s.heading.clone(),
408                    derived_key,
409                });
410            }
411        }
412    }
413    if violations.is_empty() {
414        Ok(())
415    } else {
416        Err(SchemaLoadError::SectionHeadingMismatch { violations })
417    }
418}
419
420/// Engine-invariant section keys reserved against schema use. The
421/// parser's auto-managed `## Relationships` section is the only entry
422/// today.
423pub fn reserved_section_keys() -> &'static [&'static str] {
424    &["relationships"]
425}
426
427/// Engine-invariant metadata-field keys reserved against schema use —
428/// the entity's identity/discriminator triple. `type` is the engine-set
429/// frontmatter discriminator; `mem` and `id` are the entity's
430/// structural identity, owned by the engine's id grammar and mount
431/// routing. One reservation, one behaviour: no installable schema may
432/// declare any of them (see [`check_reserved_metadata_keys`]), and the
433/// engine write paths refuse them as caller-supplied metadata.
434pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
435    &["type", "mem", "id"]
436}
437
438/// Author/install-path check: refuse a schema whose types **declare**
439/// an engine-reserved metadata-field key (`type` / `mem` / `id`).
440/// Reads the raw pre-merge declaration list the loader records
441/// (`TypeDefinition::declared_metadata_keys`) so the engine-injected
442/// base fields never false-positive.
443///
444/// Same posture as [`check_section_heading_roundtrip`]: install and
445/// strict validation call this and refuse; boot and sealed-schema
446/// loads must NOT — a schema already sealed that violates the rule
447/// keeps loading (refusing at boot would brick the workspace), and the
448/// engine's write-path refusals keep the reserved keys unwritable
449/// regardless.
450pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
451    for td in schema.types.values() {
452        for key in &td.declared_metadata_keys {
453            if reserved_metadata_field_keys().contains(&key.as_str()) {
454                return Err(SchemaLoadError::ReservedSchemaKey {
455                    type_name: td.name.clone(),
456                    kind: "metadata_field",
457                    offending_key: key.clone(),
458                    reserved_keys: reserved_metadata_field_keys()
459                        .iter()
460                        .map(|s| s.to_string())
461                        .collect(),
462                });
463            }
464        }
465    }
466    Ok(())
467}
468
469fn format_suggestion(needle: &str, candidates: &[String]) -> String {
470    let mut best: Option<(usize, &String)> = None;
471    for cand in candidates {
472        let d = strsim::levenshtein(needle, cand);
473        match best {
474            Some((bd, _)) if bd <= d => {}
475            _ => best = Some((d, cand)),
476        }
477    }
478    match best {
479        Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
480            format!("Did you mean '{cand}'?")
481        }
482        _ => String::new(),
483    }
484}
485
486/// Load a schema from a directory containing `schema.yaml` and `types/*.yaml`.
487pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
488    let manifest_path = path.join("schema.yaml");
489    let manifest_text =
490        std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
491            path: manifest_path.clone(),
492            source: e,
493        })?;
494
495    let types_dir = path.join("types");
496    let mut type_files: Vec<(String, String)> = Vec::new();
497    if types_dir.is_dir() {
498        let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
499            path: types_dir.clone(),
500            source: e,
501        })?;
502        for entry in entries {
503            let entry = entry.map_err(|e| SchemaLoadError::Io {
504                path: types_dir.clone(),
505                source: e,
506            })?;
507            let p = entry.path();
508            if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
509                continue;
510            }
511            let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
512                continue;
513            };
514            let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
515                path: p.clone(),
516                source: e,
517            })?;
518            type_files.push((stem, contents));
519        }
520    }
521    // read_dir order is filesystem-dependent; accumulated violations
522    // must report in a stable order across runs on the same input.
523    type_files.sort_by(|a, b| a.0.cmp(&b.0));
524
525    load_with_context(
526        &manifest_text,
527        &type_files,
528        Some(&manifest_path),
529        Some(&types_dir),
530        // Authoring context — the author writes the current language.
531        MetadataPolarityFormat::RequiredOptIn,
532    )
533}
534
535/// The metadata-field polarity generation of a sealed package —
536/// decided by the presence of the package's format marker
537/// (`schema-format.json`), never by heuristics over the document body.
538///
539/// Under the pre-flip language an absent `required`/`optional` key
540/// meant **required**; under the current language absence means
541/// **optional**. The two are syntactically indistinguishable, so an
542/// unmarked sealed package reads with [`Self::Legacy`] semantics —
543/// its effective behaviour conserved — while packages sealed from
544/// this change on carry the marker and read as
545/// [`Self::RequiredOptIn`]. Directory (authoring) loads are always
546/// `RequiredOptIn`: the author writes against the current language.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum MetadataPolarityFormat {
549    /// Pre-flip sealed content: an absent key means required.
550    Legacy,
551    /// Current language: an absent key means optional.
552    RequiredOptIn,
553}
554
555/// The format-marker file name sealed alongside a schema package on
556/// the genuinely sealed surfaces — the `__MEMSTEAD` ref, published
557/// `.mem` archives (the export carries it, the archive validator
558/// admits it, the archive loader honors it), and new builtin version
559/// directories. Presence ⇒ [`MetadataPolarityFormat::RequiredOptIn`];
560/// absence ⇒ legacy. Content is informative JSON; presence is the
561/// contract.
562///
563/// Directory loads (`load_schema_from_dir`: workspace
564/// `.memstead/schemas/`, cache extractions, `schema validate` /
565/// `install`) are the AUTHORING tier and never consult the marker —
566/// they always read the current language, with the retired
567/// `optional:` key refusing loudly. A pre-flip directory package
568/// relying on absent-key-means-required flips soft (fields become
569/// optional — admits more, refuses nothing); that is the fail-soft
570/// direction by design, with `health_required_fields` and
571/// constraints as the data-quality backstop.
572pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
573
574/// The marker file's canonical content.
575pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
576
577/// Append the format marker to a package's file list if absent —
578/// the seal-path helper every installer runs so sealed copies carry
579/// their generation.
580pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
581    if !files
582        .iter()
583        .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
584    {
585        files.push((
586            SCHEMA_FORMAT_MARKER_FILE.to_string(),
587            SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
588        ));
589    }
590    files
591}
592
593/// Load a schema from in-memory YAML strings — **legacy sealed
594/// semantics** (an absent required/optional key means required).
595/// Use [`load_schema_from_memory_with_format`] when the caller knows
596/// the package's format generation from its marker.
597///
598/// `types_yamls` is a slice of `(filename_stem, contents)` tuples — the stems
599/// must match `manifest.types` exactly.
600pub fn load_schema_from_memory(
601    manifest_yaml: &str,
602    types_yamls: &[(String, String)],
603) -> Result<Schema, SchemaLoadError> {
604    load_with_context(
605        manifest_yaml,
606        types_yamls,
607        None,
608        None,
609        MetadataPolarityFormat::Legacy,
610    )
611}
612
613/// Load a **sealed** schema package from its `(relative-path, bytes)`
614/// file list — the shape every sealed surface carries: a published
615/// `.mem` archive's `.memstead/schema/` tree, the git-branch
616/// `__MEMSTEAD:schemas/<name>@<version>/` tree, a built-in version
617/// directory. Paths are package-relative (`schema.yaml`,
618/// `types/<stem>.yaml`, `schema-format.json`); anything else is
619/// ignored.
620///
621/// One function so the surfaces that *admit* a sealed package (the
622/// archive validator) and the surfaces that later *read it back* (the
623/// local schema source the install stages into) can never disagree —
624/// a package valid to publish stays valid to install. The metadata
625/// polarity comes from the package's own format marker, and keys
626/// retired after the package was sealed read with their written
627/// meaning instead of refusing.
628///
629/// Authoring content takes [`load_schema_from_dir`] instead, which
630/// refuses retired keys loudly so the author can act.
631pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
632    let mut manifest: Option<String> = None;
633    let mut types: Vec<(String, String)> = Vec::new();
634    let mut marked = false;
635    for (rel, bytes) in files {
636        if rel == "schema.yaml" {
637            manifest = Some(String::from_utf8_lossy(bytes).into_owned());
638        } else if rel == SCHEMA_FORMAT_MARKER_FILE {
639            marked = true;
640        } else if let Some(stem) = rel
641            .strip_prefix("types/")
642            .and_then(|f| f.strip_suffix(".yaml"))
643        {
644            types.push((
645                stem.to_string(),
646                String::from_utf8_lossy(bytes).into_owned(),
647            ));
648        }
649    }
650    let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
651    // Deterministic order regardless of how the caller enumerated the
652    // package — accumulated violations must report the same way twice.
653    types.sort_by(|a, b| a.0.cmp(&b.0));
654    let format = if marked {
655        MetadataPolarityFormat::RequiredOptIn
656    } else {
657        MetadataPolarityFormat::Legacy
658    };
659    load_with_context(&manifest, &types, None, None, format)
660}
661
662/// Load a schema from in-memory YAML strings with an explicit
663/// metadata-polarity format generation (from the sealed package's
664/// format marker).
665pub fn load_schema_from_memory_with_format(
666    manifest_yaml: &str,
667    types_yamls: &[(String, String)],
668    format: MetadataPolarityFormat,
669) -> Result<Schema, SchemaLoadError> {
670    load_with_context(manifest_yaml, types_yamls, None, None, format)
671}
672
673fn load_with_context(
674    manifest_yaml: &str,
675    types_yamls: &[(String, String)],
676    manifest_path: Option<&Path>,
677    types_dir: Option<&Path>,
678    format: MetadataPolarityFormat,
679) -> Result<Schema, SchemaLoadError> {
680    // Semantic-violation accumulator. Every check operating on
681    // successfully parsed structure pushes here instead of returning,
682    // so the author sees the complete violation set in one refusal;
683    // only structural failures short-circuit (see
684    // [`SchemaLoadError::Multiple`]). Order is deterministic:
685    // manifest checks in declaration order, then type files in
686    // `types_yamls` order (sorted by stem when loaded from a
687    // directory).
688    let mut errors: Vec<SchemaLoadError> = Vec::new();
689
690    let mut manifest: SchemaManifest =
691        serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
692            path: manifest_path
693                .map(Path::to_path_buf)
694                .unwrap_or_else(|| PathBuf::from("<memory>")),
695            source: e,
696        })?;
697
698    if let Err(e) = validate_name(&manifest.name) {
699        errors.push(e);
700    }
701
702    // `None` only ever coexists with a non-empty accumulator, so the
703    // `Schema` construction at the bottom (reached only when the
704    // accumulator is empty) can unwrap.
705    let version = match semver::Version::parse(&manifest.version) {
706        Ok(v) => Some(v),
707        Err(_) => {
708            errors.push(SchemaLoadError::InvalidVersion {
709                value: manifest.version.clone(),
710            });
711            None
712        }
713    };
714
715    // Relationship vocabulary: unique names + _default present
716    let mut rel_names: HashSet<String> = HashSet::new();
717    for def in &manifest.relationships.definitions {
718        if !rel_names.insert(def.name.clone()) {
719            errors.push(SchemaLoadError::DuplicateRelationship {
720                name: def.name.clone(),
721            });
722        }
723    }
724    if !rel_names.contains("_default") {
725        errors.push(SchemaLoadError::MissingDefaultWeight);
726    }
727    let available_rels: Vec<String> = manifest
728        .relationships
729        .definitions
730        .iter()
731        .map(|d| d.name.clone())
732        .collect();
733
734    // Validate that the schema-level alias_target_rel_type pointer (if
735    // set) names a declared rel-type. The synthesis pass later relies
736    // on this invariant — running it as a load-time check keeps the
737    // mutation path's hot loop free of resolution failures.
738    if let Some(target) = &manifest.alias_target_rel_type
739        && !rel_names.contains(target)
740    {
741        let mut declared = available_rels.clone();
742        declared.sort();
743        errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
744            schema: manifest.name.clone(),
745            target: target.clone(),
746            declared,
747        });
748    }
749
750    // Option C coupling — auto-force `manual_authoring: forbidden` on
751    // the rel-type named by `alias_target_rel_type`. Schemas setting
752    // the pointer opt the named rel-type out of explicit authoring;
753    // the only path to a relation of that rel-type is via the
754    // alias-synthesis pass that emits one per body wiki-link. This
755    // closes the explicit/synthesised coexistence question: with the
756    // coupling in place, edges of the pointer rel-type are always
757    // engine-emitted, so `EdgeSource::BodyLink` is unambiguous and
758    // GC can drop pointer-rel-type relations without risking
759    // explicit-author data.
760    //
761    // The coupling is silent — a schema that writes
762    // `manual_authoring: allow` (or `warn`) on the named rel-type
763    // gets overridden to `forbidden` at load. The override is the
764    // schema-strictness contract; explicit `allow`/`warn` on the
765    // pointer rel-type is meaningless under the design and would
766    // surprise the validator at runtime, so the loader corrects it
767    // here.
768    if let Some(pointer) = manifest.alias_target_rel_type.clone() {
769        for def in &mut manifest.relationships.definitions {
770            if def.name == pointer {
771                def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
772            }
773        }
774    }
775
776    // Cross-check `source_types` / `target_types` on each relationship
777    // definition against the manifest's declared type list. Unknown
778    // names raise `UndeclaredRelationshipType` with a "did you mean"
779    // suggestion — the schema-author equivalent of `INVALID_REL_SHAPE`.
780    for def in &manifest.relationships.definitions {
781        for t in &def.source_types {
782            if !manifest.types.iter().any(|d| d == t) {
783                errors.push(SchemaLoadError::UndeclaredRelationshipType {
784                    relationship: def.name.clone(),
785                    field: "source_types",
786                    reference: t.clone(),
787                    declared: manifest.types.clone(),
788                });
789            }
790        }
791        for t in &def.target_types {
792            if !manifest.types.iter().any(|d| d == t) {
793                errors.push(SchemaLoadError::UndeclaredRelationshipType {
794                    relationship: def.name.clone(),
795                    field: "target_types",
796                    reference: t.clone(),
797                    declared: manifest.types.clone(),
798                });
799            }
800        }
801    }
802
803    // Cross-mem relationships: validate `to_schema` is a bare schema
804    // name (cross-mem eligibility is name-based — a version suffix or
805    // range refuses), refuse duplicate target schemas, and cross-check
806    // `source_types` against the source schema's types. `target_types`
807    // are accepted as opaque strings — they belong to the target
808    // schema's namespace, which is out of scope at source-schema load
809    // time. The target schema may not even be present in the workspace
810    // when the source schema loads (and cross-mem declarations
811    // targeting absent schemas are legitimate for portable library
812    // schemas).
813    let mut seen_to_schemas: HashSet<String> = HashSet::new();
814    for entry in &manifest.cross_mem_relationships {
815        if entry.to_schema == "*" {
816            // Wildcard destination — bound to the alias-synthesised
817            // rel-type. The binding IS the safety argument: the author
818            // already permitted soft auto-emitted references of that
819            // type; the wildcard extends that decision across the mem
820            // boundary and introduces no new permission. Structural
821            // rel-types stay per-destination-schema.
822            match manifest.alias_target_rel_type.as_deref() {
823                None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
824                Some(alias) => {
825                    for def in &entry.definitions {
826                        if def.name != alias {
827                            errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
828                                rel_type: def.name.clone(),
829                                alias_target: alias.to_string(),
830                            });
831                        }
832                    }
833                }
834            }
835        } else if entry.to_schema.contains('@') {
836            errors.push(SchemaLoadError::InvalidCrossMemToSchema {
837                value: entry.to_schema.clone(),
838                reason: "must not carry a version or range".into(),
839            });
840        } else if let Err(reason) = name_shape(&entry.to_schema) {
841            errors.push(SchemaLoadError::InvalidCrossMemToSchema {
842                value: entry.to_schema.clone(),
843                reason: reason.into(),
844            });
845        }
846        if !seen_to_schemas.insert(entry.to_schema.clone()) {
847            errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
848                to_schema: entry.to_schema.clone(),
849            });
850        }
851        for def in &entry.definitions {
852            for t in &def.source_types {
853                if !manifest.types.iter().any(|d| d == t) {
854                    errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
855                        to_schema: entry.to_schema.clone(),
856                        relationship: def.name.clone(),
857                        reference: t.clone(),
858                        declared: manifest.types.clone(),
859                    });
860                }
861            }
862        }
863    }
864
865    // Type file cross-check: declared vs found, set equality (order-insensitive)
866    let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
867    found_stems.sort();
868    let mut declared = manifest.types.clone();
869    declared.sort();
870    if found_stems != declared {
871        // Structural: the per-type pass below would run against a file
872        // set the manifest never described. Refuse now, together with
873        // every manifest-level violation already proven.
874        errors.push(SchemaLoadError::TypeFileMismatch {
875            declared,
876            found: found_stems,
877        });
878        return Err(collapse(errors));
879    }
880
881    // Per-type defaults map for edge_weights resolution
882    let defaults: IndexMap<String, f32> = manifest
883        .relationships
884        .definitions
885        .iter()
886        .map(|d| (d.name.clone(), d.default_weight))
887        .collect();
888
889    let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
890    let mut had_type_parse_failure = false;
891
892    for (stem, text) in types_yamls {
893        let type_path = types_dir
894            .map(|d| d.join(format!("{stem}.yaml")))
895            .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
896
897        let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
898            Ok(td) => td,
899            Err(e) => {
900                // A type file that does not parse cannot be checked
901                // semantically — record the parse failure, keep
902                // checking the other files, and skip the cross-type
903                // pass below (the missing type would make it noise).
904                errors.push(SchemaLoadError::ParseType {
905                    path: type_path.clone(),
906                    source: e,
907                });
908                had_type_parse_failure = true;
909                continue;
910            }
911        };
912
913        if td.name != *stem {
914            errors.push(SchemaLoadError::TypeNameMismatch {
915                file: stem.clone(),
916                declared: td.name.clone(),
917            });
918        }
919
920        // Legacy-key gate: authoring contexts (loaded from a
921        // directory — `types_dir` is `Some`) refuse the retired
922        // `propagating_relationships` key with the rename error;
923        // sealed contexts (in-memory: built-ins, installed refs)
924        // translate it so shipped content keeps loading (install-time
925        // strict, sealed-tolerant — the section-heading round-trip
926        // doctrine).
927        if let Some(legacy) = td.legacy_propagating_relationships.take() {
928            if types_dir.is_some() {
929                errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
930                    type_name: td.name.clone(),
931                });
932            } else if td.no_self_loop_relationships.is_empty() {
933                td.no_self_loop_relationships = legacy;
934            }
935        }
936
937        // Retired `examples:` list (agent-trust plan 09): dead
938        // vocabulary — never validated, never served. Authoring
939        // contexts refuse with the pointer at `exemplar:`; sealed
940        // contexts tolerate and drop (nothing consumed it, so
941        // dropping is lossless).
942        if td.legacy_examples.take().is_some() && types_dir.is_some() {
943            errors.push(SchemaLoadError::ExamplesRetired {
944                type_name: td.name.clone(),
945            });
946        }
947
948        // Metadata-required polarity (first-author-path plan 07):
949        // authoring refuses the retired `optional:` key naming the
950        // inversion; sealed content inverts it. An absent key resolves
951        // by the package's format generation — legacy sealed content
952        // reads absence as required (its written meaning), everything
953        // else as optional.
954        for field in &mut td.metadata_fields {
955            // Current-language contexts (directory/authoring loads and
956            // install validation, both RequiredOptIn) refuse the
957            // retired key; only Legacy sealed loads invert silently.
958            if matches!(format, MetadataPolarityFormat::RequiredOptIn)
959                && field.legacy_optional.is_some()
960            {
961                errors.push(SchemaLoadError::OptionalRetired {
962                    type_name: td.name.clone(),
963                    field: field.key.clone(),
964                });
965            }
966            field.required_resolved = match (field.required, field.legacy_optional.take()) {
967                (Some(required), _) => required,
968                (None, Some(optional)) => !optional,
969                (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
970            };
971        }
972
973        // Record the raw author-declared metadata keys BEFORE the
974        // base-metadata merge, so the install-path reserved-key check
975        // ([`check_reserved_metadata_keys`]) can tell a declared
976        // `type`/`mem`/`id` from the engine-injected base fields. The
977        // check itself deliberately does NOT run here: this loader
978        // serves boot and sealed-schema reads too, and a schema sealed
979        // before the reservation widened must keep loading (heading-
980        // round-trip posture — refusal fires on the authoring/install
981        // path, never at boot).
982        td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
983
984        // Reject redeclarations of remaining engine-implicit base metadata
985        // (`created_date`, `last_modified`, `tags`). The reserved `type`
986        // case is excluded here — it refuses with the typed reserved-key
987        // error on the install path instead.
988        for field in &td.metadata_fields {
989            if base_metadata::is_base_key(&field.key)
990                && !reserved_metadata_field_keys().contains(&field.key.as_str())
991            {
992                errors.push(SchemaLoadError::RedeclaredBaseField {
993                    type_name: td.name.clone(),
994                    field: field.key.clone(),
995                });
996            }
997        }
998
999        // Merge base metadata around the type-declared fields. Canonical
1000        // order: type, created_date, last_modified, <declared>, tags.
1001        let mut merged = base_metadata::prefix_fields();
1002        merged.append(&mut td.metadata_fields);
1003        merged.extend(base_metadata::suffix_fields());
1004        td.metadata_fields = merged;
1005
1006        compile_section_formats(&mut td);
1007        validate_type(&td, &rel_names, &available_rels, &mut errors);
1008
1009        // Resolve edge_weights: start with schema defaults, apply overrides.
1010        let mut weights = defaults.clone();
1011        for (k, v) in &td.edge_weight_overrides {
1012            weights.insert(k.clone(), *v);
1013        }
1014        td.edge_weights = weights;
1015
1016        types_map.insert(stem.clone(), Arc::new(td));
1017    }
1018
1019    // Schema-level constraint pass — checks that need every type
1020    // loaded. `enum_from_neighbour.section` names a section on the
1021    // *reached* entity, whose type this schema cannot pin statically;
1022    // requiring the key to exist on at least one declared type catches
1023    // the typo class without over-constraining the endpoint. Skipped
1024    // when a type file failed to parse — the missing type's sections
1025    // would make the existence check report noise.
1026    if !had_type_parse_failure {
1027        let all_section_keys: HashSet<&str> = types_map
1028            .values()
1029            .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1030            .collect();
1031        // Deterministic report order: sort type names (types_map is a
1032        // HashMap); constraints keep declaration order within a type.
1033        let mut type_names: Vec<&String> = types_map.keys().collect();
1034        type_names.sort();
1035        for type_name in type_names {
1036            let td = &types_map[type_name];
1037            for c in &td.constraints {
1038                if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1039                    && !all_section_keys.contains(section.as_str())
1040                {
1041                    errors.push(SchemaLoadError::InvalidConstraint {
1042                        type_name: td.name.clone(),
1043                        kind: "enum_from_neighbour",
1044                        offender: section.clone(),
1045                        reason: "`section` names a section key no type of this schema declares"
1046                            .to_string(),
1047                    });
1048                }
1049            }
1050        }
1051    }
1052
1053    if !errors.is_empty() {
1054        return Err(collapse(errors));
1055    }
1056
1057    Ok(Schema {
1058        manifest,
1059        version: version.expect("version parse failure would have accumulated an error"),
1060        types: types_map,
1061    })
1062}
1063
1064fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1065    name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1066        value: name.into(),
1067        reason,
1068    })
1069}
1070
1071/// Author-time access to the schema-name shape rule — the same check
1072/// the loader runs on a manifest's `name:`. Exposed so scaffolding
1073/// tooling (`memstead schema new`) can refuse a bad name up front with
1074/// the loader's own reason string instead of a drifting copy of the
1075/// grammar.
1076pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1077    name_shape(name)
1078}
1079
1080/// Shared shape rule for schema names — the manifest's own `name:` and
1081/// every `cross_mem_relationships[].to_schema` follow the same
1082/// grammar; the two callers wrap violations in their field-specific
1083/// error variants.
1084fn name_shape(name: &str) -> Result<(), &'static str> {
1085    if name.is_empty() {
1086        return Err("must not be empty");
1087    }
1088    let mut chars = name.chars();
1089    let first = chars.next().unwrap();
1090    if !first.is_ascii_lowercase() {
1091        return Err("must start with a lowercase letter");
1092    }
1093    for c in chars {
1094        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1095            return Err("must contain only lowercase letters, digits, and hyphens");
1096        }
1097    }
1098    Ok(())
1099}
1100
1101/// Validate and compile the section-format declarations of a type
1102/// (plan 08). LENIENT at load: problems are recorded on the section
1103/// (`format_problems`) instead of refusing, so a sealed schema
1104/// carrying a bad declaration keeps loading — install and strict
1105/// validation refuse via [`check_section_formats`], and a defective
1106/// declaration is never enforced (`compiled_content` stays `None`). A
1107/// valid `content` expression is compiled once and cached.
1108fn compile_section_formats(td: &mut TypeDefinition) {
1109    use crate::content_expr::ContentExpr;
1110    for section in &mut td.sections {
1111        // A non-default severity only deserializes from an explicit
1112        // declaration, so a lone `format_severity: warn` without
1113        // `content` is detectable — and would otherwise load and be
1114        // silently ignored (`format_severity: block` alone equals the
1115        // default and is inherently a no-op).
1116        let declares_any = section.content.is_some()
1117            || section.item_pattern.is_some()
1118            || section.table.is_some()
1119            || section.example.is_some()
1120            || section.format_severity != crate::types::ConstraintSeverity::Block;
1121        if !declares_any {
1122            continue;
1123        }
1124        let mut problems: Vec<String> = Vec::new();
1125
1126        let compiled = match &section.content {
1127            None => {
1128                problems.push(
1129                    "`item_pattern` / `table` / `example` require a `content` declaration"
1130                        .to_string(),
1131                );
1132                None
1133            }
1134            Some(expr_src) => match ContentExpr::parse(expr_src) {
1135                Ok(expr) => Some(expr),
1136                Err(e) => {
1137                    problems.push(format!("`content` is invalid: {e}"));
1138                    None
1139                }
1140            },
1141        };
1142
1143        if let Some(pattern) = &section.item_pattern {
1144            if let Err(e) = regex::Regex::new(pattern) {
1145                problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1146            }
1147            if let Some(expr) = &compiled {
1148                let names = expr.mentioned_names();
1149                let has_list = names.contains(&"list");
1150                let has_paragraph = names.contains(&"paragraph");
1151                if has_list == has_paragraph {
1152                    problems.push(
1153                        "`item_pattern` requires a `content` expression containing exactly                          one of `list` / `paragraph` (tables use `column_patterns`)"
1154                            .to_string(),
1155                    );
1156                }
1157            }
1158        }
1159
1160        if let Some(table) = &section.table {
1161            if let Some(expr) = &compiled
1162                && !expr.mentioned_names().contains(&"table")
1163            {
1164                problems.push(
1165                    "`table` block is only legal when `content` contains `table`".to_string(),
1166                );
1167            }
1168            if table.columns.is_empty() {
1169                problems.push("`table.columns` must name at least one column".to_string());
1170            }
1171            for (column, pattern) in &table.column_patterns {
1172                if !table.columns.contains(column) {
1173                    problems.push(format!(
1174                        "`column_patterns` names '{column}', which is not in `columns`"
1175                    ));
1176                }
1177                if let Err(e) = regex::Regex::new(pattern) {
1178                    problems.push(format!(
1179                        "`column_patterns.{column}` is not a valid regex: {e}"
1180                    ));
1181                }
1182            }
1183        }
1184
1185        if problems.is_empty() {
1186            section.compiled_content = compiled;
1187        } else {
1188            section.format_problems = problems;
1189        }
1190    }
1191}
1192
1193/// Refuse a schema whose section-format declarations are defective —
1194/// the install / strict-validation half of the loader-honesty rule.
1195/// Same posture as [`check_reserved_metadata_keys`]: install and
1196/// strict validation call this and refuse (naming EVERY problem of
1197/// the first defective section); boot and sealed-schema loads must
1198/// NOT — the recorded `format_problems` surface as health findings
1199/// instead, and the defective declaration is never enforced.
1200pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1201    // Aggregate EVERY defective section across every type — the
1202    // refusal names all offenders, never the first only. `type_name`
1203    // / `section` carry the first offender; entries beyond it are
1204    // prefixed with their own type/section inside `problems`.
1205    let mut first: Option<(String, String)> = None;
1206    let mut problems: Vec<String> = Vec::new();
1207    for td in schema.types.values() {
1208        for section in &td.sections {
1209            if section.format_problems.is_empty() {
1210                continue;
1211            }
1212            if first.is_none() {
1213                first = Some((td.name.clone(), section.key.clone()));
1214                problems.extend(section.format_problems.iter().cloned());
1215            } else {
1216                problems.extend(
1217                    section
1218                        .format_problems
1219                        .iter()
1220                        .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1221                );
1222            }
1223        }
1224    }
1225    match first {
1226        Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1227            type_name,
1228            section,
1229            problems,
1230        }),
1231        None => Ok(()),
1232    }
1233}
1234
1235fn validate_type(
1236    td: &TypeDefinition,
1237    rel_names: &HashSet<String>,
1238    available_rels: &[String],
1239    errors: &mut Vec<SchemaLoadError>,
1240) {
1241    // Reserved-section check. Section key `relationships` collides
1242    // with the parser's auto-managed `## Relationships` section.
1243    // Domain conventions (`identity`, `purpose`, ...) are NOT reserved.
1244    // The reserved-metadata-key check runs earlier in
1245    // `load_with_context` against the *raw* author-declared field list
1246    // so the base-metadata merge doesn't false-positive against
1247    // engine-injected keys.
1248    for section in &td.sections {
1249        if reserved_section_keys().contains(&section.key.as_str()) {
1250            errors.push(SchemaLoadError::ReservedSchemaKey {
1251                type_name: td.name.clone(),
1252                kind: "section",
1253                offending_key: section.key.clone(),
1254                reserved_keys: reserved_section_keys()
1255                    .iter()
1256                    .map(|s| s.to_string())
1257                    .collect(),
1258            });
1259        }
1260    }
1261
1262    if let Err(e) = check_rel(
1263        &td.name,
1264        "hierarchy_relationship",
1265        &td.hierarchy_relationship,
1266        rel_names,
1267        available_rels,
1268    ) {
1269        errors.push(e);
1270    }
1271    for r in &td.no_self_loop_relationships {
1272        if let Err(e) = check_rel(
1273            &td.name,
1274            "no_self_loop_relationships",
1275            r,
1276            rel_names,
1277            available_rels,
1278        ) {
1279            errors.push(e);
1280        }
1281    }
1282    for r in td.edge_weight_overrides.keys() {
1283        if let Err(e) = check_rel(
1284            &td.name,
1285            "edge_weight_overrides",
1286            r,
1287            rel_names,
1288            available_rels,
1289        ) {
1290            errors.push(e);
1291        }
1292    }
1293    for block in &td.required_outgoing {
1294        for r in &block.relationships {
1295            if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1296                errors.push(e);
1297            }
1298        }
1299    }
1300
1301    // Constraint vocabulary (loader honesty: a malformed declaration
1302    // refuses with a typed error naming the offender — never
1303    // load-and-ignore).
1304    let field_keys: std::collections::HashSet<&str> =
1305        td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1306    let section_keys: std::collections::HashSet<&str> =
1307        td.sections.iter().map(|sec| sec.key.as_str()).collect();
1308    for c in &td.constraints {
1309        match c {
1310            crate::types::ConstraintDef::RequiresWhen {
1311                field,
1312                when_field,
1313                when_value,
1314                ..
1315            } => {
1316                if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1317                    errors.push(SchemaLoadError::InvalidConstraint {
1318                        type_name: td.name.clone(),
1319                        kind: "requires_when",
1320                        offender: field.clone(),
1321                        reason: "`field` names neither a metadata field nor a section of this type"
1322                            .to_string(),
1323                    });
1324                }
1325                let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1326                else {
1327                    errors.push(SchemaLoadError::InvalidConstraint {
1328                        type_name: td.name.clone(),
1329                        kind: "requires_when",
1330                        offender: when_field.clone(),
1331                        reason: "`when_field` names no metadata field of this type".to_string(),
1332                    });
1333                    continue;
1334                };
1335                if let Some(allowed) = &when_def.enum_values
1336                    && !allowed.contains(when_value)
1337                {
1338                    errors.push(SchemaLoadError::InvalidConstraint {
1339                        type_name: td.name.clone(),
1340                        kind: "requires_when",
1341                        offender: when_value.clone(),
1342                        reason: format!(
1343                            "`when_value` is not in `{when_field}`'s enum_values [{}]",
1344                            allowed.join(", ")
1345                        ),
1346                    });
1347                }
1348            }
1349            crate::types::ConstraintDef::Unique { fields, .. } => {
1350                if fields.is_empty() {
1351                    errors.push(SchemaLoadError::InvalidConstraint {
1352                        type_name: td.name.clone(),
1353                        kind: "unique",
1354                        offender: "(empty)".to_string(),
1355                        reason: "`fields` must name at least one metadata field".to_string(),
1356                    });
1357                }
1358                for f in fields {
1359                    if !field_keys.contains(f.as_str()) {
1360                        errors.push(SchemaLoadError::InvalidConstraint {
1361                            type_name: td.name.clone(),
1362                            kind: "unique",
1363                            offender: f.clone(),
1364                            reason: "`fields` entry names no metadata field of this type"
1365                                .to_string(),
1366                        });
1367                    }
1368                }
1369            }
1370            crate::types::ConstraintDef::EnumFromNeighbour {
1371                field, rel_type, ..
1372            } => {
1373                if !field_keys.contains(field.as_str()) {
1374                    errors.push(SchemaLoadError::InvalidConstraint {
1375                        type_name: td.name.clone(),
1376                        kind: "enum_from_neighbour",
1377                        offender: field.clone(),
1378                        reason: "`field` names no metadata field of this type".to_string(),
1379                    });
1380                }
1381                if !rel_names.contains(rel_type) {
1382                    errors.push(SchemaLoadError::InvalidConstraint {
1383                        type_name: td.name.clone(),
1384                        kind: "enum_from_neighbour",
1385                        offender: rel_type.clone(),
1386                        reason: "`rel_type` is not in the schema's relationship vocabulary"
1387                            .to_string(),
1388                    });
1389                }
1390                // `section` names a key on the *reached* type, which
1391                // this per-type pass cannot see — the schema-level
1392                // pass after all types load checks it.
1393            }
1394            crate::types::ConstraintDef::StatusPropagation {
1395                field,
1396                value,
1397                rel_type,
1398                severity,
1399                ..
1400            } => {
1401                match td.metadata_fields.iter().find(|f| f.key == *field) {
1402                    None => {
1403                        errors.push(SchemaLoadError::InvalidConstraint {
1404                            type_name: td.name.clone(),
1405                            kind: "status_propagation",
1406                            offender: field.clone(),
1407                            reason: "`field` names no metadata field of this type".to_string(),
1408                        });
1409                    }
1410                    Some(field_def) => {
1411                        if let Some(allowed) = &field_def.enum_values
1412                            && !allowed.contains(value)
1413                        {
1414                            errors.push(SchemaLoadError::InvalidConstraint {
1415                                type_name: td.name.clone(),
1416                                kind: "status_propagation",
1417                                offender: value.clone(),
1418                                reason: format!(
1419                                    "`value` is not in `{field}`'s enum_values [{}]",
1420                                    allowed.join(", ")
1421                                ),
1422                            });
1423                        }
1424                    }
1425                }
1426                if !rel_names.contains(rel_type) {
1427                    errors.push(SchemaLoadError::InvalidConstraint {
1428                        type_name: td.name.clone(),
1429                        kind: "status_propagation",
1430                        offender: rel_type.clone(),
1431                        reason: "`rel_type` is not in the schema's relationship vocabulary"
1432                            .to_string(),
1433                    });
1434                }
1435                if *severity == crate::types::ConstraintSeverity::Block {
1436                    // Propagation can never refuse a write (the taint
1437                    // arises from the ancestor's later change), so a
1438                    // `block` declaration would be a promise the
1439                    // engine will not keep — refuse it rather than
1440                    // load-and-downgrade.
1441                    errors.push(SchemaLoadError::InvalidConstraint {
1442                        type_name: td.name.clone(),
1443                        kind: "status_propagation",
1444                        offender: "block".to_string(),
1445                        reason: "status_propagation is always warn-tier — a parent falling after \
1446                                 the child was written cannot retroactively make the child's \
1447                                 write illegal"
1448                            .to_string(),
1449                    });
1450                }
1451            }
1452        }
1453    }
1454
1455    // Due axis (first-author-path plan 08): the declaration's
1456    // references must exist on this type in the declared shapes.
1457    if let Some(due) = &td.due {
1458        match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
1459            None => errors.push(SchemaLoadError::InvalidDueAxis {
1460                type_name: td.name.clone(),
1461                offender: due.date_field.clone(),
1462                reason: "`date_field` names no metadata field of this type".to_string(),
1463            }),
1464            Some(f) if f.field_type != crate::types::FieldType::Date => {
1465                errors.push(SchemaLoadError::InvalidDueAxis {
1466                    type_name: td.name.clone(),
1467                    offender: due.date_field.clone(),
1468                    reason: "`date_field` must name a date-typed metadata field".to_string(),
1469                })
1470            }
1471            Some(_) => {}
1472        }
1473        match td
1474            .metadata_fields
1475            .iter()
1476            .find(|f| f.key == due.status_field)
1477        {
1478            None => errors.push(SchemaLoadError::InvalidDueAxis {
1479                type_name: td.name.clone(),
1480                offender: due.status_field.clone(),
1481                reason: "`status_field` names no metadata field of this type".to_string(),
1482            }),
1483            Some(f) => match &f.enum_values {
1484                None => errors.push(SchemaLoadError::InvalidDueAxis {
1485                    type_name: td.name.clone(),
1486                    offender: due.status_field.clone(),
1487                    reason: "`status_field` must name an enum-typed metadata field \
1488                             (declare enum_values)"
1489                        .to_string(),
1490                }),
1491                Some(allowed) => {
1492                    for v in &due.open_values {
1493                        if !allowed.contains(v) {
1494                            errors.push(SchemaLoadError::InvalidDueAxis {
1495                                type_name: td.name.clone(),
1496                                offender: v.clone(),
1497                                reason: format!(
1498                                    "`open_values` entry is not in `{}`'s enum_values [{}]",
1499                                    due.status_field,
1500                                    allowed.join(", ")
1501                                ),
1502                            });
1503                        }
1504                    }
1505                }
1506            },
1507        }
1508        if due.open_values.is_empty() {
1509            errors.push(SchemaLoadError::InvalidDueAxis {
1510                type_name: td.name.clone(),
1511                offender: "(empty)".to_string(),
1512                reason: "`open_values` must name at least one open status value".to_string(),
1513            });
1514        }
1515        if let Some(lead) = &due.lead_section
1516            && !td.sections.iter().any(|s| s.key == *lead)
1517        {
1518            errors.push(SchemaLoadError::InvalidDueAxis {
1519                type_name: td.name.clone(),
1520                offender: lead.clone(),
1521                reason: "`lead_section` names no section of this type".to_string(),
1522            });
1523        }
1524    }
1525
1526    // Exactly one catch_all section
1527    let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
1528    if catch_all_count != 1 {
1529        errors.push(SchemaLoadError::CatchAllViolation {
1530            type_name: td.name.clone(),
1531            count: catch_all_count,
1532        });
1533    }
1534
1535    // Field-reference integrity
1536    let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
1537    let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
1538
1539    for f in &td.text_fields {
1540        // text_fields point at section content — not metadata.
1541        if !section_keys.contains(f.as_str()) {
1542            errors.push(SchemaLoadError::UnknownFieldReference {
1543                type_name: td.name.clone(),
1544                field: "text_fields",
1545                reference: f.clone(),
1546            });
1547        }
1548    }
1549    for f in &td.health_required_fields {
1550        if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1551            errors.push(SchemaLoadError::UnknownFieldReference {
1552                type_name: td.name.clone(),
1553                field: "health_required_fields",
1554                reference: f.clone(),
1555            });
1556        }
1557    }
1558    for f in &td.updatable_fields {
1559        // `title` is the entity's filename-derived title — always updatable.
1560        if f == "title" {
1561            continue;
1562        }
1563        if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1564            errors.push(SchemaLoadError::UnknownFieldReference {
1565                type_name: td.name.clone(),
1566                field: "updatable_fields",
1567                reference: f.clone(),
1568            });
1569        }
1570    }
1571
1572    // Metadata default_value must be a member of enum_values when both present.
1573    for m in &td.metadata_fields {
1574        if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
1575            && !allowed.contains(default)
1576        {
1577            errors.push(SchemaLoadError::DefaultValueNotInEnum {
1578                type_name: td.name.clone(),
1579                field: m.key.clone(),
1580                default: default.clone(),
1581                allowed: allowed.clone(),
1582            });
1583        }
1584    }
1585}
1586
1587fn check_rel(
1588    type_name: &str,
1589    field: &'static str,
1590    relationship: &str,
1591    rel_names: &HashSet<String>,
1592    available: &[String],
1593) -> Result<(), SchemaLoadError> {
1594    if rel_names.contains(relationship) {
1595        return Ok(());
1596    }
1597    Err(SchemaLoadError::UndeclaredRelationship {
1598        type_name: type_name.into(),
1599        field,
1600        relationship: relationship.into(),
1601        available: available.to_vec(),
1602    })
1603}