Skip to main content

memstead_schema/
loader.rs

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