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