1use 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
260 DuplicateCrossMemToSchema { to_schema: String },
261
262 #[error("sealed schema package carries no schema.yaml")]
266 SealedPackageMissingManifest,
267
268 #[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 #[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 #[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 #[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 #[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 #[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 #[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
382fn 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#[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
418pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
431 let mut violations = Vec::new();
432 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
457pub fn reserved_section_keys() -> &'static [&'static str] {
461 &["relationships"]
462}
463
464pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
472 &["type", "mem", "id"]
473}
474
475pub 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
523pub 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 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 MetadataPolarityFormat::RequiredOptIn,
569 )
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum MetadataPolarityFormat {
586 Legacy,
588 RequiredOptIn,
590}
591
592pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
610
611pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
613
614pub 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
639pub 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
659pub 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 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
708pub 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
724pub fn load_authoring_package_from_memory(
731 manifest_yaml: &str,
732 types_yamls: &[(String, String)],
733) -> Result<Schema, SchemaLoadError> {
734 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
747pub 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 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 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 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 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 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 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 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 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 let mut seen_to_schemas: HashSet<String> = HashSet::new();
970 for entry in &manifest.cross_mem_relationships {
971 if entry.to_schema == "*" {
972 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 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 errors.push(SchemaLoadError::TypeFileMismatch {
1031 declared,
1032 found: found_stems,
1033 });
1034 return Err(collapse(errors));
1035 }
1036
1037 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 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 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 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 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 for field in &mut td.metadata_fields {
1153 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 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
1181
1182 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 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 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 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 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 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 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 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
1355pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1361 name_shape(name)
1362}
1363
1364fn 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
1385fn compile_section_formats(td: &mut TypeDefinition) {
1393 use crate::content_expr::ContentExpr;
1394 for section in &mut td.sections {
1395 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 §ion.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) = §ion.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) = §ion.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
1477pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1485 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 for section in &td.sections {
1533 if reserved_section_keys().contains(§ion.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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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}