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
346 #[error(
356 "schema has {} violations:\n{}",
357 errors.len(),
358 format_multiple(errors)
359 )]
360 Multiple { errors: Vec<SchemaLoadError> },
361}
362
363fn format_multiple(errors: &[SchemaLoadError]) -> String {
364 errors
365 .iter()
366 .enumerate()
367 .map(|(i, e)| format!(" {}. {e}", i + 1))
368 .collect::<Vec<_>>()
369 .join("\n")
370}
371
372fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
377 debug_assert!(!errors.is_empty());
378 if errors.len() == 1 {
379 errors.remove(0)
380 } else {
381 SchemaLoadError::Multiple { errors }
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct HeadingKeyViolation {
389 pub type_name: String,
390 pub key: String,
391 pub heading: String,
392 pub derived_key: String,
393}
394
395fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
396 violations
397 .iter()
398 .map(|v| {
399 format!(
400 "type '{}' section key '{}' has heading '{}' (derives to '{}')",
401 v.type_name, v.key, v.heading, v.derived_key
402 )
403 })
404 .collect::<Vec<_>>()
405 .join("; ")
406}
407
408pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
421 let mut violations = Vec::new();
422 let mut type_names: Vec<&String> = schema.types.keys().collect();
425 type_names.sort();
426 for type_name in type_names {
427 let t = &schema.types[type_name];
428 for s in &t.sections {
429 let derived_key = crate::types::derive_section_key(&s.heading);
430 if derived_key != s.key {
431 violations.push(HeadingKeyViolation {
432 type_name: type_name.clone(),
433 key: s.key.clone(),
434 heading: s.heading.clone(),
435 derived_key,
436 });
437 }
438 }
439 }
440 if violations.is_empty() {
441 Ok(())
442 } else {
443 Err(SchemaLoadError::SectionHeadingMismatch { violations })
444 }
445}
446
447pub fn reserved_section_keys() -> &'static [&'static str] {
451 &["relationships"]
452}
453
454pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
462 &["type", "mem", "id"]
463}
464
465pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
478 for td in schema.types.values() {
479 for key in &td.declared_metadata_keys {
480 if reserved_metadata_field_keys().contains(&key.as_str()) {
481 return Err(SchemaLoadError::ReservedSchemaKey {
482 type_name: td.name.clone(),
483 kind: "metadata_field",
484 offending_key: key.clone(),
485 reserved_keys: reserved_metadata_field_keys()
486 .iter()
487 .map(|s| s.to_string())
488 .collect(),
489 });
490 }
491 }
492 }
493 Ok(())
494}
495
496fn format_suggestion(needle: &str, candidates: &[String]) -> String {
497 let mut best: Option<(usize, &String)> = None;
498 for cand in candidates {
499 let d = strsim::levenshtein(needle, cand);
500 match best {
501 Some((bd, _)) if bd <= d => {}
502 _ => best = Some((d, cand)),
503 }
504 }
505 match best {
506 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
507 format!("Did you mean '{cand}'?")
508 }
509 _ => String::new(),
510 }
511}
512
513pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
515 let manifest_path = path.join("schema.yaml");
516 let manifest_text =
517 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
518 path: manifest_path.clone(),
519 source: e,
520 })?;
521
522 let types_dir = path.join("types");
523 let mut type_files: Vec<(String, String)> = Vec::new();
524 if types_dir.is_dir() {
525 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
526 path: types_dir.clone(),
527 source: e,
528 })?;
529 for entry in entries {
530 let entry = entry.map_err(|e| SchemaLoadError::Io {
531 path: types_dir.clone(),
532 source: e,
533 })?;
534 let p = entry.path();
535 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
536 continue;
537 }
538 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
539 continue;
540 };
541 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
542 path: p.clone(),
543 source: e,
544 })?;
545 type_files.push((stem, contents));
546 }
547 }
548 type_files.sort_by(|a, b| a.0.cmp(&b.0));
551
552 load_with_context(
553 &manifest_text,
554 &type_files,
555 Some(&manifest_path),
556 Some(&types_dir),
557 MetadataPolarityFormat::RequiredOptIn,
559 )
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum MetadataPolarityFormat {
576 Legacy,
578 RequiredOptIn,
580}
581
582pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
600
601pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
603
604pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
617 if !files
618 .iter()
619 .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
620 {
621 files.push((
622 SCHEMA_FORMAT_MARKER_FILE.to_string(),
623 SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
624 ));
625 }
626 files
627}
628
629pub fn load_schema_from_memory(
637 manifest_yaml: &str,
638 types_yamls: &[(String, String)],
639) -> Result<Schema, SchemaLoadError> {
640 load_with_context(
641 manifest_yaml,
642 types_yamls,
643 None,
644 None,
645 MetadataPolarityFormat::Legacy,
646 )
647}
648
649pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
668 let mut manifest: Option<String> = None;
669 let mut types: Vec<(String, String)> = Vec::new();
670 let mut marked = false;
671 for (rel, bytes) in files {
672 if rel == "schema.yaml" {
673 manifest = Some(String::from_utf8_lossy(bytes).into_owned());
674 } else if rel == SCHEMA_FORMAT_MARKER_FILE {
675 marked = true;
676 } else if let Some(stem) = rel
677 .strip_prefix("types/")
678 .and_then(|f| f.strip_suffix(".yaml"))
679 {
680 types.push((
681 stem.to_string(),
682 String::from_utf8_lossy(bytes).into_owned(),
683 ));
684 }
685 }
686 let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
687 types.sort_by(|a, b| a.0.cmp(&b.0));
690 let format = if marked {
691 MetadataPolarityFormat::RequiredOptIn
692 } else {
693 MetadataPolarityFormat::Legacy
694 };
695 load_with_context(&manifest, &types, None, None, format)
696}
697
698pub fn check_package_reauthorable(
708 manifest_yaml: &str,
709 types_yamls: &[(String, String)],
710) -> Result<(), SchemaLoadError> {
711 load_authoring_package_from_memory(manifest_yaml, types_yamls).map(|_| ())
712}
713
714pub fn load_authoring_package_from_memory(
721 manifest_yaml: &str,
722 types_yamls: &[(String, String)],
723) -> Result<Schema, SchemaLoadError> {
724 let strict_context = Path::new("<authoring package>");
728 load_with_context(
729 manifest_yaml,
730 types_yamls,
731 Some(strict_context),
732 Some(strict_context),
733 MetadataPolarityFormat::RequiredOptIn,
734 )
735}
736
737pub fn load_schema_from_memory_with_format(
741 manifest_yaml: &str,
742 types_yamls: &[(String, String)],
743 format: MetadataPolarityFormat,
744) -> Result<Schema, SchemaLoadError> {
745 load_with_context(manifest_yaml, types_yamls, None, None, format)
746}
747
748fn load_with_context(
749 manifest_yaml: &str,
750 types_yamls: &[(String, String)],
751 manifest_path: Option<&Path>,
752 types_dir: Option<&Path>,
753 format: MetadataPolarityFormat,
754) -> Result<Schema, SchemaLoadError> {
755 let mut errors: Vec<SchemaLoadError> = Vec::new();
764
765 let mut manifest: SchemaManifest =
766 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
767 path: manifest_path
768 .map(Path::to_path_buf)
769 .unwrap_or_else(|| PathBuf::from("<memory>")),
770 source: e,
771 })?;
772
773 if let Err(e) = validate_name(&manifest.name) {
774 errors.push(e);
775 }
776
777 let version = match semver::Version::parse(&manifest.version) {
781 Ok(v) => Some(v),
782 Err(_) => {
783 errors.push(SchemaLoadError::InvalidVersion {
784 value: manifest.version.clone(),
785 });
786 None
787 }
788 };
789
790 let mut rel_names: HashSet<String> = HashSet::new();
792 for def in &manifest.relationships.definitions {
793 if !rel_names.insert(def.name.clone()) {
794 errors.push(SchemaLoadError::DuplicateRelationship {
795 name: def.name.clone(),
796 });
797 }
798 }
799 if !rel_names.contains("_default") {
800 errors.push(SchemaLoadError::MissingDefaultWeight);
801 }
802 let available_rels: Vec<String> = manifest
803 .relationships
804 .definitions
805 .iter()
806 .map(|d| d.name.clone())
807 .collect();
808
809 if let Some(target) = &manifest.alias_target_rel_type
814 && !rel_names.contains(target)
815 {
816 let mut declared = available_rels.clone();
817 declared.sort();
818 errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
819 schema: manifest.name.clone(),
820 target: target.clone(),
821 declared,
822 });
823 }
824
825 let mut acyclic_set_member_seen: HashSet<&str> = HashSet::new();
831 for set in &manifest.relationships.acyclic_sets {
832 if set.len() < 2 {
833 errors.push(SchemaLoadError::InvalidAcyclicSet {
834 offender: set.join(", "),
835 reason: "a set needs at least two rel-types (a single member is the \
836 per-definition `acyclic` flag)"
837 .to_string(),
838 });
839 }
840 for name in set {
841 if !rel_names.contains(name.as_str()) {
842 errors.push(SchemaLoadError::InvalidAcyclicSet {
843 offender: name.clone(),
844 reason: "names no declared relationship".to_string(),
845 });
846 }
847 if !acyclic_set_member_seen.insert(name.as_str()) {
848 errors.push(SchemaLoadError::InvalidAcyclicSet {
849 offender: name.clone(),
850 reason: "a rel-type may appear in at most one acyclicity set".to_string(),
851 });
852 }
853 }
854 }
855
856 if let Some(lab) = &manifest.relationships.labelling {
861 if lab.attack.is_empty() {
862 errors.push(SchemaLoadError::InvalidLabelling {
863 offender: "(empty)".to_string(),
864 reason: "`labelling.attack` must name at least one rel-type".to_string(),
865 });
866 }
867 for name in &lab.attack {
868 if !rel_names.contains(name.as_str()) {
869 errors.push(SchemaLoadError::InvalidLabelling {
870 offender: name.clone(),
871 reason: "`labelling.attack` entry names no declared relationship".to_string(),
872 });
873 }
874 }
875 if let Some(sup) = &lab.support {
876 if sup.relationships.is_empty() {
877 errors.push(SchemaLoadError::InvalidLabelling {
878 offender: "(empty)".to_string(),
879 reason: "`labelling.support.relationships` must name at least one rel-type"
880 .to_string(),
881 });
882 }
883 for name in &sup.relationships {
884 if !rel_names.contains(name.as_str()) {
885 errors.push(SchemaLoadError::InvalidLabelling {
886 offender: name.clone(),
887 reason: "`labelling.support.relationships` entry names no declared \
888 relationship"
889 .to_string(),
890 });
891 }
892 }
893 }
894 }
895
896 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
915 for def in &mut manifest.relationships.definitions {
916 if def.name == pointer {
917 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
918 }
919 }
920 }
921
922 for def in &manifest.relationships.definitions {
927 for t in &def.source_types {
928 if !manifest.types.iter().any(|d| d == t) {
929 errors.push(SchemaLoadError::UndeclaredRelationshipType {
930 relationship: def.name.clone(),
931 field: "source_types",
932 reference: t.clone(),
933 declared: manifest.types.clone(),
934 });
935 }
936 }
937 for t in &def.target_types {
938 if !manifest.types.iter().any(|d| d == t) {
939 errors.push(SchemaLoadError::UndeclaredRelationshipType {
940 relationship: def.name.clone(),
941 field: "target_types",
942 reference: t.clone(),
943 declared: manifest.types.clone(),
944 });
945 }
946 }
947 }
948
949 let mut seen_to_schemas: HashSet<String> = HashSet::new();
960 for entry in &manifest.cross_mem_relationships {
961 if entry.to_schema == "*" {
962 match manifest.alias_target_rel_type.as_deref() {
969 None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
970 Some(alias) => {
971 for def in &entry.definitions {
972 if def.name != alias {
973 errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
974 rel_type: def.name.clone(),
975 alias_target: alias.to_string(),
976 });
977 }
978 }
979 }
980 }
981 } else if entry.to_schema.contains('@') {
982 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
983 value: entry.to_schema.clone(),
984 reason: "must not carry a version or range".into(),
985 });
986 } else if let Err(reason) = name_shape(&entry.to_schema) {
987 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
988 value: entry.to_schema.clone(),
989 reason: reason.into(),
990 });
991 }
992 if !seen_to_schemas.insert(entry.to_schema.clone()) {
993 errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
994 to_schema: entry.to_schema.clone(),
995 });
996 }
997 for def in &entry.definitions {
998 for t in &def.source_types {
999 if !manifest.types.iter().any(|d| d == t) {
1000 errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
1001 to_schema: entry.to_schema.clone(),
1002 relationship: def.name.clone(),
1003 reference: t.clone(),
1004 declared: manifest.types.clone(),
1005 });
1006 }
1007 }
1008 }
1009 }
1010
1011 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
1013 found_stems.sort();
1014 let mut declared = manifest.types.clone();
1015 declared.sort();
1016 if found_stems != declared {
1017 errors.push(SchemaLoadError::TypeFileMismatch {
1021 declared,
1022 found: found_stems,
1023 });
1024 return Err(collapse(errors));
1025 }
1026
1027 let defaults: IndexMap<String, f32> = manifest
1029 .relationships
1030 .definitions
1031 .iter()
1032 .map(|d| (d.name.clone(), d.default_weight))
1033 .collect();
1034
1035 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
1036 let mut had_type_parse_failure = false;
1037
1038 for (stem, text) in types_yamls {
1039 let type_path = types_dir
1040 .map(|d| d.join(format!("{stem}.yaml")))
1041 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
1042
1043 let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
1044 Ok(td) => td,
1045 Err(e) => {
1046 errors.push(SchemaLoadError::ParseType {
1051 path: type_path.clone(),
1052 source: e,
1053 });
1054 had_type_parse_failure = true;
1055 continue;
1056 }
1057 };
1058
1059 if td.name != *stem {
1060 errors.push(SchemaLoadError::TypeNameMismatch {
1061 file: stem.clone(),
1062 declared: td.name.clone(),
1063 });
1064 }
1065
1066 if let Some(legacy) = td.legacy_propagating_relationships.take() {
1074 if types_dir.is_some() {
1075 errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
1076 type_name: td.name.clone(),
1077 });
1078 } else if td.no_self_loop_relationships.is_empty() {
1079 td.no_self_loop_relationships = legacy;
1080 }
1081 }
1082
1083 if td.legacy_examples.take().is_some() && types_dir.is_some() {
1089 errors.push(SchemaLoadError::ExamplesRetired {
1090 type_name: td.name.clone(),
1091 });
1092 }
1093
1094 if let Some(ex) = td.exemplar.as_mut() {
1103 let mut retired_spelling = false;
1104 let mut incomplete = false;
1105 for rel in &mut ex.relations {
1106 let legacy_to = rel.legacy_to.take();
1107 let legacy_type = rel.legacy_type.take();
1108 if legacy_to.is_some() || legacy_type.is_some() {
1109 if types_dir.is_some() {
1110 retired_spelling = true;
1111 continue;
1112 }
1113 if rel.target.is_none() {
1114 rel.target = legacy_to;
1115 }
1116 if rel.rel_type.is_none() {
1117 rel.rel_type = legacy_type;
1118 }
1119 }
1120 if rel.target.is_none() || rel.rel_type.is_none() {
1121 incomplete = true;
1122 }
1123 }
1124 if retired_spelling {
1125 errors.push(SchemaLoadError::ExemplarRelationSpellingRetired {
1126 type_name: td.name.clone(),
1127 });
1128 }
1129 if incomplete {
1130 errors.push(SchemaLoadError::ExemplarRelationIncomplete {
1131 type_name: td.name.clone(),
1132 });
1133 }
1134 }
1135
1136 for field in &mut td.metadata_fields {
1143 if matches!(format, MetadataPolarityFormat::RequiredOptIn)
1147 && field.legacy_optional.is_some()
1148 {
1149 errors.push(SchemaLoadError::OptionalRetired {
1150 type_name: td.name.clone(),
1151 field: field.key.clone(),
1152 });
1153 }
1154 field.required_resolved = match (field.required, field.legacy_optional.take()) {
1155 (Some(required), _) => required,
1156 (None, Some(optional)) => !optional,
1157 (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
1158 };
1159 }
1160
1161 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
1171
1172 for field in &td.metadata_fields {
1177 if base_metadata::is_base_key(&field.key)
1178 && !reserved_metadata_field_keys().contains(&field.key.as_str())
1179 {
1180 errors.push(SchemaLoadError::RedeclaredBaseField {
1181 type_name: td.name.clone(),
1182 field: field.key.clone(),
1183 });
1184 }
1185 }
1186
1187 let mut merged = base_metadata::prefix_fields();
1190 merged.append(&mut td.metadata_fields);
1191 merged.extend(base_metadata::suffix_fields());
1192 td.metadata_fields = merged;
1193
1194 compile_section_formats(&mut td);
1195 validate_type(&td, &rel_names, &available_rels, &mut errors);
1196
1197 let mut weights = defaults.clone();
1199 for (k, v) in &td.edge_weight_overrides {
1200 weights.insert(k.clone(), *v);
1201 }
1202 td.edge_weights = weights;
1203
1204 types_map.insert(stem.clone(), Arc::new(td));
1205 }
1206
1207 if !had_type_parse_failure {
1215 let all_section_keys: HashSet<&str> = types_map
1216 .values()
1217 .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1218 .collect();
1219 let mut type_names: Vec<&String> = types_map.keys().collect();
1222 type_names.sort();
1223 for type_name in type_names {
1224 let td = &types_map[type_name];
1225 for c in &td.constraints {
1226 if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1227 && !all_section_keys.contains(section.as_str())
1228 {
1229 errors.push(SchemaLoadError::InvalidConstraint {
1230 type_name: td.name.clone(),
1231 kind: "enum_from_neighbour",
1232 offender: section.clone(),
1233 reason: "`section` names a section key no type of this schema declares"
1234 .to_string(),
1235 });
1236 }
1237 }
1238 for ob in &td.must_reach {
1241 for t in &ob.terminal_types {
1242 if !types_map.contains_key(t.as_str()) {
1243 errors.push(SchemaLoadError::InvalidConstraint {
1244 type_name: td.name.clone(),
1245 kind: "must_reach",
1246 offender: t.clone(),
1247 reason: "`terminal_types` entry names no type of this schema"
1248 .to_string(),
1249 });
1250 }
1251 }
1252 }
1253 for sig in &td.signals {
1259 if let (Some(field), Some(value)) = (&sig.neighbour_field, &sig.neighbour_value) {
1260 let declaring: Vec<&crate::types::MetadataFieldDef> = types_map
1261 .values()
1262 .flat_map(|t| t.metadata_fields.iter())
1263 .filter(|f| f.key == *field && f.enum_values.is_some())
1264 .collect();
1265 if declaring.is_empty() {
1266 errors.push(SchemaLoadError::InvalidConstraint {
1267 type_name: td.name.clone(),
1268 kind: "signal",
1269 offender: field.clone(),
1270 reason: "`neighbour_field` is declared with `enum_values` on no \
1271 type of this schema"
1272 .to_string(),
1273 });
1274 } else if !declaring.iter().any(|f| {
1275 f.enum_values
1276 .as_ref()
1277 .is_some_and(|allowed| allowed.contains(value))
1278 }) {
1279 errors.push(SchemaLoadError::InvalidConstraint {
1280 type_name: td.name.clone(),
1281 kind: "signal",
1282 offender: value.clone(),
1283 reason: format!(
1284 "`neighbour_value` is outside `{field}`'s enum_values on every \
1285 declaring type"
1286 ),
1287 });
1288 }
1289 }
1290 }
1291 }
1292 }
1293
1294 if !had_type_parse_failure
1299 && let Some(lab) = &manifest.relationships.labelling
1300 && let Some(sup) = &lab.support
1301 {
1302 for t in &sup.terminal_types {
1303 if !types_map.contains_key(t.as_str()) {
1304 errors.push(SchemaLoadError::InvalidLabelling {
1305 offender: t.clone(),
1306 reason: "`labelling.support.terminal_types` entry names no type of this \
1307 schema"
1308 .to_string(),
1309 });
1310 }
1311 }
1312 }
1313
1314 if !errors.is_empty() {
1315 return Err(collapse(errors));
1316 }
1317
1318 Ok(Schema {
1319 manifest,
1320 version: version.expect("version parse failure would have accumulated an error"),
1321 types: types_map,
1322 })
1323}
1324
1325fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1326 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1327 value: name.into(),
1328 reason,
1329 })
1330}
1331
1332pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1338 name_shape(name)
1339}
1340
1341fn name_shape(name: &str) -> Result<(), &'static str> {
1346 if name.is_empty() {
1347 return Err("must not be empty");
1348 }
1349 let mut chars = name.chars();
1350 let first = chars.next().unwrap();
1351 if !first.is_ascii_lowercase() {
1352 return Err("must start with a lowercase letter");
1353 }
1354 for c in chars {
1355 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1356 return Err("must contain only lowercase letters, digits, and hyphens");
1357 }
1358 }
1359 Ok(())
1360}
1361
1362fn compile_section_formats(td: &mut TypeDefinition) {
1370 use crate::content_expr::ContentExpr;
1371 for section in &mut td.sections {
1372 let declares_any = section.content.is_some()
1378 || section.item_pattern.is_some()
1379 || section.table.is_some()
1380 || section.example.is_some()
1381 || section.format_severity != crate::types::ConstraintSeverity::Block;
1382 if !declares_any {
1383 continue;
1384 }
1385 let mut problems: Vec<String> = Vec::new();
1386
1387 let compiled = match §ion.content {
1388 None => {
1389 problems.push(
1390 "`item_pattern` / `table` / `example` require a `content` declaration"
1391 .to_string(),
1392 );
1393 None
1394 }
1395 Some(expr_src) => match ContentExpr::parse(expr_src) {
1396 Ok(expr) => Some(expr),
1397 Err(e) => {
1398 problems.push(format!("`content` is invalid: {e}"));
1399 None
1400 }
1401 },
1402 };
1403
1404 if let Some(pattern) = §ion.item_pattern {
1405 if let Err(e) = regex::Regex::new(pattern) {
1406 problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1407 }
1408 if let Some(expr) = &compiled {
1409 let names = expr.mentioned_names();
1410 let has_list = names.contains(&"list");
1411 let has_paragraph = names.contains(&"paragraph");
1412 if has_list == has_paragraph {
1413 problems.push(
1414 "`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
1415 .to_string(),
1416 );
1417 }
1418 }
1419 }
1420
1421 if let Some(table) = §ion.table {
1422 if let Some(expr) = &compiled
1423 && !expr.mentioned_names().contains(&"table")
1424 {
1425 problems.push(
1426 "`table` block is only legal when `content` contains `table`".to_string(),
1427 );
1428 }
1429 if table.columns.is_empty() {
1430 problems.push("`table.columns` must name at least one column".to_string());
1431 }
1432 for (column, pattern) in &table.column_patterns {
1433 if !table.columns.contains(column) {
1434 problems.push(format!(
1435 "`column_patterns` names '{column}', which is not in `columns`"
1436 ));
1437 }
1438 if let Err(e) = regex::Regex::new(pattern) {
1439 problems.push(format!(
1440 "`column_patterns.{column}` is not a valid regex: {e}"
1441 ));
1442 }
1443 }
1444 }
1445
1446 if problems.is_empty() {
1447 section.compiled_content = compiled;
1448 } else {
1449 section.format_problems = problems;
1450 }
1451 }
1452}
1453
1454pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1462 let mut first: Option<(String, String)> = None;
1467 let mut problems: Vec<String> = Vec::new();
1468 for td in schema.types.values() {
1469 for section in &td.sections {
1470 if section.format_problems.is_empty() {
1471 continue;
1472 }
1473 if first.is_none() {
1474 first = Some((td.name.clone(), section.key.clone()));
1475 problems.extend(section.format_problems.iter().cloned());
1476 } else {
1477 problems.extend(
1478 section
1479 .format_problems
1480 .iter()
1481 .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1482 );
1483 }
1484 }
1485 }
1486 match first {
1487 Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1488 type_name,
1489 section,
1490 problems,
1491 }),
1492 None => Ok(()),
1493 }
1494}
1495
1496fn validate_type(
1497 td: &TypeDefinition,
1498 rel_names: &HashSet<String>,
1499 available_rels: &[String],
1500 errors: &mut Vec<SchemaLoadError>,
1501) {
1502 for section in &td.sections {
1510 if reserved_section_keys().contains(§ion.key.as_str()) {
1511 errors.push(SchemaLoadError::ReservedSchemaKey {
1512 type_name: td.name.clone(),
1513 kind: "section",
1514 offending_key: section.key.clone(),
1515 reserved_keys: reserved_section_keys()
1516 .iter()
1517 .map(|s| s.to_string())
1518 .collect(),
1519 });
1520 }
1521 }
1522
1523 if let Err(e) = check_rel(
1524 &td.name,
1525 "hierarchy_relationship",
1526 &td.hierarchy_relationship,
1527 rel_names,
1528 available_rels,
1529 ) {
1530 errors.push(e);
1531 }
1532 for r in &td.no_self_loop_relationships {
1533 if let Err(e) = check_rel(
1534 &td.name,
1535 "no_self_loop_relationships",
1536 r,
1537 rel_names,
1538 available_rels,
1539 ) {
1540 errors.push(e);
1541 }
1542 }
1543 for r in td.edge_weight_overrides.keys() {
1544 if let Err(e) = check_rel(
1545 &td.name,
1546 "edge_weight_overrides",
1547 r,
1548 rel_names,
1549 available_rels,
1550 ) {
1551 errors.push(e);
1552 }
1553 }
1554 for block in &td.required_outgoing {
1555 for r in &block.relationships {
1556 if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1557 errors.push(e);
1558 }
1559 }
1560 match (&block.when_field, &block.when_value) {
1568 (None, None) => {}
1569 (Some(f), None) => {
1570 errors.push(SchemaLoadError::InvalidConstraint {
1571 type_name: td.name.clone(),
1572 kind: "required_outgoing",
1573 offender: f.clone(),
1574 reason: "`when_field` requires `when_value` alongside it".to_string(),
1575 });
1576 }
1577 (None, Some(v)) => {
1578 errors.push(SchemaLoadError::InvalidConstraint {
1579 type_name: td.name.clone(),
1580 kind: "required_outgoing",
1581 offender: v.clone(),
1582 reason: "`when_value` requires `when_field` alongside it".to_string(),
1583 });
1584 }
1585 (Some(f), Some(v)) => match td.metadata_fields.iter().find(|mf| mf.key == *f) {
1586 None => {
1587 errors.push(SchemaLoadError::InvalidConstraint {
1588 type_name: td.name.clone(),
1589 kind: "required_outgoing",
1590 offender: f.clone(),
1591 reason: "`when_field` names no metadata field of this type".to_string(),
1592 });
1593 }
1594 Some(when_def) => match &when_def.enum_values {
1595 None => {
1596 errors.push(SchemaLoadError::InvalidConstraint {
1597 type_name: td.name.clone(),
1598 kind: "required_outgoing",
1599 offender: f.clone(),
1600 reason: format!(
1601 "`when_field` must name a metadata field with `enum_values`; `{f}` declares none"
1602 ),
1603 });
1604 }
1605 Some(allowed) if !allowed.contains(v) => {
1606 errors.push(SchemaLoadError::InvalidConstraint {
1607 type_name: td.name.clone(),
1608 kind: "required_outgoing",
1609 offender: v.clone(),
1610 reason: format!(
1611 "`when_value` is not in `{f}`'s enum_values [{}]",
1612 allowed.join(", ")
1613 ),
1614 });
1615 }
1616 Some(_) => {}
1617 },
1618 },
1619 }
1620 }
1621
1622 for ob in &td.must_reach {
1625 if ob.relationships.is_empty() {
1626 errors.push(SchemaLoadError::InvalidConstraint {
1627 type_name: td.name.clone(),
1628 kind: "must_reach",
1629 offender: "(empty)".to_string(),
1630 reason: "`relationships` must name at least one relationship".to_string(),
1631 });
1632 }
1633 for r in &ob.relationships {
1634 if let Err(e) = check_rel(&td.name, "must_reach", r, rel_names, available_rels) {
1635 errors.push(e);
1636 }
1637 }
1638 if ob.terminal_types.is_empty() {
1639 errors.push(SchemaLoadError::InvalidConstraint {
1640 type_name: td.name.clone(),
1641 kind: "must_reach",
1642 offender: "(empty)".to_string(),
1643 reason: "`terminal_types` must name at least one type".to_string(),
1644 });
1645 }
1646 if ob.max_depth == Some(0) {
1647 errors.push(SchemaLoadError::InvalidConstraint {
1648 type_name: td.name.clone(),
1649 kind: "must_reach",
1650 offender: "0".to_string(),
1651 reason: "`max_depth` must be at least 1 — nothing is reachable in zero hops"
1652 .to_string(),
1653 });
1654 }
1655 if ob.severity == crate::types::ConstraintSeverity::Block {
1656 errors.push(SchemaLoadError::InvalidConstraint {
1661 type_name: td.name.clone(),
1662 kind: "must_reach",
1663 offender: "block".to_string(),
1664 reason: "must_reach is always warn-tier — a reachability gap is created by \
1665 writes on other entities, so no single write can be refused for it"
1666 .to_string(),
1667 });
1668 }
1669 }
1670
1671 let mut signal_names_seen: HashSet<&str> = HashSet::new();
1675 for sig in &td.signals {
1676 let name_ok = sig
1677 .name
1678 .chars()
1679 .next()
1680 .is_some_and(|c| c.is_ascii_lowercase())
1681 && sig
1682 .name
1683 .chars()
1684 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1685 if !name_ok {
1686 errors.push(SchemaLoadError::InvalidConstraint {
1687 type_name: td.name.clone(),
1688 kind: "signal",
1689 offender: sig.name.clone(),
1690 reason: "`name` must match [a-z][a-z0-9_]*".to_string(),
1691 });
1692 }
1693 if !signal_names_seen.insert(sig.name.as_str()) {
1694 errors.push(SchemaLoadError::InvalidConstraint {
1695 type_name: td.name.clone(),
1696 kind: "signal",
1697 offender: sig.name.clone(),
1698 reason: "duplicate signal name on this type".to_string(),
1699 });
1700 }
1701 if sig.relationships.is_empty() {
1702 errors.push(SchemaLoadError::InvalidConstraint {
1703 type_name: td.name.clone(),
1704 kind: "signal",
1705 offender: sig.name.clone(),
1706 reason: "`relationships` must name at least one relationship".to_string(),
1707 });
1708 }
1709 for r in &sig.relationships {
1710 if let Err(e) = check_rel(&td.name, "signals", r, rel_names, available_rels) {
1711 errors.push(e);
1712 }
1713 }
1714 if sig.thresholds.is_empty() {
1715 errors.push(SchemaLoadError::InvalidConstraint {
1716 type_name: td.name.clone(),
1717 kind: "signal",
1718 offender: sig.name.clone(),
1719 reason: "`thresholds` must declare at least one step".to_string(),
1720 });
1721 }
1722 for pair in sig.thresholds.windows(2) {
1723 if pair[1].at_least <= pair[0].at_least {
1724 errors.push(SchemaLoadError::InvalidConstraint {
1725 type_name: td.name.clone(),
1726 kind: "signal",
1727 offender: pair[1].at_least.to_string(),
1728 reason: "`thresholds` must have strictly increasing `at_least` values"
1729 .to_string(),
1730 });
1731 }
1732 }
1733 match (&sig.neighbour_field, &sig.neighbour_value) {
1734 (None, None) | (Some(_), Some(_)) => {}
1735 (Some(f), None) => {
1736 errors.push(SchemaLoadError::InvalidConstraint {
1737 type_name: td.name.clone(),
1738 kind: "signal",
1739 offender: f.clone(),
1740 reason: "`neighbour_field` requires `neighbour_value` alongside it".to_string(),
1741 });
1742 }
1743 (None, Some(v)) => {
1744 errors.push(SchemaLoadError::InvalidConstraint {
1745 type_name: td.name.clone(),
1746 kind: "signal",
1747 offender: v.clone(),
1748 reason: "`neighbour_value` requires `neighbour_field` alongside it".to_string(),
1749 });
1750 }
1751 }
1752 }
1753
1754 let field_keys: std::collections::HashSet<&str> =
1758 td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1759 let section_keys: std::collections::HashSet<&str> =
1760 td.sections.iter().map(|sec| sec.key.as_str()).collect();
1761 for c in &td.constraints {
1762 match c {
1763 crate::types::ConstraintDef::RequiresWhen {
1764 field,
1765 when_field,
1766 when_value,
1767 ..
1768 } => {
1769 if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1770 errors.push(SchemaLoadError::InvalidConstraint {
1771 type_name: td.name.clone(),
1772 kind: "requires_when",
1773 offender: field.clone(),
1774 reason: "`field` names neither a metadata field nor a section of this type"
1775 .to_string(),
1776 });
1777 }
1778 let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1779 else {
1780 errors.push(SchemaLoadError::InvalidConstraint {
1781 type_name: td.name.clone(),
1782 kind: "requires_when",
1783 offender: when_field.clone(),
1784 reason: "`when_field` names no metadata field of this type".to_string(),
1785 });
1786 continue;
1787 };
1788 if let Some(allowed) = &when_def.enum_values
1789 && !allowed.contains(when_value)
1790 {
1791 errors.push(SchemaLoadError::InvalidConstraint {
1792 type_name: td.name.clone(),
1793 kind: "requires_when",
1794 offender: when_value.clone(),
1795 reason: format!(
1796 "`when_value` is not in `{when_field}`'s enum_values [{}]",
1797 allowed.join(", ")
1798 ),
1799 });
1800 }
1801 }
1802 crate::types::ConstraintDef::Unique { fields, .. } => {
1803 if fields.is_empty() {
1804 errors.push(SchemaLoadError::InvalidConstraint {
1805 type_name: td.name.clone(),
1806 kind: "unique",
1807 offender: "(empty)".to_string(),
1808 reason: "`fields` must name at least one metadata field".to_string(),
1809 });
1810 }
1811 for f in fields {
1812 if !field_keys.contains(f.as_str()) {
1813 errors.push(SchemaLoadError::InvalidConstraint {
1814 type_name: td.name.clone(),
1815 kind: "unique",
1816 offender: f.clone(),
1817 reason: "`fields` entry names no metadata field of this type"
1818 .to_string(),
1819 });
1820 }
1821 }
1822 }
1823 crate::types::ConstraintDef::EnumFromNeighbour {
1824 field, rel_type, ..
1825 } => {
1826 if !field_keys.contains(field.as_str()) {
1827 errors.push(SchemaLoadError::InvalidConstraint {
1828 type_name: td.name.clone(),
1829 kind: "enum_from_neighbour",
1830 offender: field.clone(),
1831 reason: "`field` names no metadata field of this type".to_string(),
1832 });
1833 }
1834 if !rel_names.contains(rel_type) {
1835 errors.push(SchemaLoadError::InvalidConstraint {
1836 type_name: td.name.clone(),
1837 kind: "enum_from_neighbour",
1838 offender: rel_type.clone(),
1839 reason: "`rel_type` is not in the schema's relationship vocabulary"
1840 .to_string(),
1841 });
1842 }
1843 }
1847 crate::types::ConstraintDef::TransitionRequiresChecks {
1848 field,
1849 to_value,
1850 relationships,
1851 ..
1852 } => {
1853 match td.metadata_fields.iter().find(|f| f.key == *field) {
1854 None => {
1855 errors.push(SchemaLoadError::InvalidConstraint {
1856 type_name: td.name.clone(),
1857 kind: "transition_requires_checks",
1858 offender: field.clone(),
1859 reason: "`field` names no metadata field of this type".to_string(),
1860 });
1861 }
1862 Some(field_def) => {
1863 if let Some(allowed) = &field_def.enum_values
1864 && !allowed.contains(to_value)
1865 {
1866 errors.push(SchemaLoadError::InvalidConstraint {
1867 type_name: td.name.clone(),
1868 kind: "transition_requires_checks",
1869 offender: to_value.clone(),
1870 reason: format!(
1871 "`to_value` is not in `{field}`'s enum_values [{}]",
1872 allowed.join(", ")
1873 ),
1874 });
1875 }
1876 }
1877 }
1878 if relationships.is_empty() {
1879 errors.push(SchemaLoadError::InvalidConstraint {
1880 type_name: td.name.clone(),
1881 kind: "transition_requires_checks",
1882 offender: "(empty)".to_string(),
1883 reason: "`relationships` must name at least one declared relationship"
1884 .to_string(),
1885 });
1886 }
1887 for rel in relationships {
1888 if !rel_names.contains(rel) {
1889 errors.push(SchemaLoadError::InvalidConstraint {
1890 type_name: td.name.clone(),
1891 kind: "transition_requires_checks",
1892 offender: rel.clone(),
1893 reason: "`relationships` entry is not in the schema's relationship \
1894 vocabulary"
1895 .to_string(),
1896 });
1897 }
1898 }
1899 }
1900 crate::types::ConstraintDef::StatusPropagation {
1901 field,
1902 value,
1903 rel_type,
1904 rel_types,
1905 severity,
1906 ..
1907 } => {
1908 match td.metadata_fields.iter().find(|f| f.key == *field) {
1909 None => {
1910 errors.push(SchemaLoadError::InvalidConstraint {
1911 type_name: td.name.clone(),
1912 kind: "status_propagation",
1913 offender: field.clone(),
1914 reason: "`field` names no metadata field of this type".to_string(),
1915 });
1916 }
1917 Some(field_def) => {
1918 if let Some(allowed) = &field_def.enum_values
1919 && !allowed.contains(value)
1920 {
1921 errors.push(SchemaLoadError::InvalidConstraint {
1922 type_name: td.name.clone(),
1923 kind: "status_propagation",
1924 offender: value.clone(),
1925 reason: format!(
1926 "`value` is not in `{field}`'s enum_values [{}]",
1927 allowed.join(", ")
1928 ),
1929 });
1930 }
1931 }
1932 }
1933 match (rel_type, rel_types) {
1936 (Some(_), Some(_)) => {
1937 errors.push(SchemaLoadError::InvalidConstraint {
1938 type_name: td.name.clone(),
1939 kind: "status_propagation",
1940 offender: "rel_type".to_string(),
1941 reason: "declare `rel_type` or `rel_types`, not both".to_string(),
1942 });
1943 }
1944 (None, None) => {
1945 errors.push(SchemaLoadError::InvalidConstraint {
1946 type_name: td.name.clone(),
1947 kind: "status_propagation",
1948 offender: "(missing)".to_string(),
1949 reason: "one of `rel_type` / `rel_types` is required".to_string(),
1950 });
1951 }
1952 (Some(single), None) => {
1953 if !rel_names.contains(single) {
1954 errors.push(SchemaLoadError::InvalidConstraint {
1955 type_name: td.name.clone(),
1956 kind: "status_propagation",
1957 offender: single.clone(),
1958 reason: "`rel_type` is not in the schema's relationship vocabulary"
1959 .to_string(),
1960 });
1961 }
1962 }
1963 (None, Some(set)) => {
1964 if set.is_empty() {
1965 errors.push(SchemaLoadError::InvalidConstraint {
1966 type_name: td.name.clone(),
1967 kind: "status_propagation",
1968 offender: "(empty)".to_string(),
1969 reason: "`rel_types` must name at least one relationship"
1970 .to_string(),
1971 });
1972 }
1973 for name in set {
1974 if !rel_names.contains(name) {
1975 errors.push(SchemaLoadError::InvalidConstraint {
1976 type_name: td.name.clone(),
1977 kind: "status_propagation",
1978 offender: name.clone(),
1979 reason: "`rel_types` entry is not in the schema's \
1980 relationship vocabulary"
1981 .to_string(),
1982 });
1983 }
1984 }
1985 }
1986 }
1987 if *severity == crate::types::ConstraintSeverity::Block {
1988 errors.push(SchemaLoadError::InvalidConstraint {
1994 type_name: td.name.clone(),
1995 kind: "status_propagation",
1996 offender: "block".to_string(),
1997 reason: "status_propagation is always warn-tier — a parent falling after \
1998 the child was written cannot retroactively make the child's \
1999 write illegal"
2000 .to_string(),
2001 });
2002 }
2003 }
2004 }
2005 }
2006
2007 if let Some(due) = &td.due {
2010 match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
2011 None => errors.push(SchemaLoadError::InvalidDueAxis {
2012 type_name: td.name.clone(),
2013 offender: due.date_field.clone(),
2014 reason: "`date_field` names no metadata field of this type".to_string(),
2015 }),
2016 Some(f) if f.field_type != crate::types::FieldType::Date => {
2017 errors.push(SchemaLoadError::InvalidDueAxis {
2018 type_name: td.name.clone(),
2019 offender: due.date_field.clone(),
2020 reason: "`date_field` must name a date-typed metadata field".to_string(),
2021 })
2022 }
2023 Some(_) => {}
2024 }
2025 match td
2026 .metadata_fields
2027 .iter()
2028 .find(|f| f.key == due.status_field)
2029 {
2030 None => errors.push(SchemaLoadError::InvalidDueAxis {
2031 type_name: td.name.clone(),
2032 offender: due.status_field.clone(),
2033 reason: "`status_field` names no metadata field of this type".to_string(),
2034 }),
2035 Some(f) => match &f.enum_values {
2036 None => errors.push(SchemaLoadError::InvalidDueAxis {
2037 type_name: td.name.clone(),
2038 offender: due.status_field.clone(),
2039 reason: "`status_field` must name an enum-typed metadata field \
2040 (declare enum_values)"
2041 .to_string(),
2042 }),
2043 Some(allowed) => {
2044 for v in &due.open_values {
2045 if !allowed.contains(v) {
2046 errors.push(SchemaLoadError::InvalidDueAxis {
2047 type_name: td.name.clone(),
2048 offender: v.clone(),
2049 reason: format!(
2050 "`open_values` entry is not in `{}`'s enum_values [{}]",
2051 due.status_field,
2052 allowed.join(", ")
2053 ),
2054 });
2055 }
2056 }
2057 }
2058 },
2059 }
2060 if due.open_values.is_empty() {
2061 errors.push(SchemaLoadError::InvalidDueAxis {
2062 type_name: td.name.clone(),
2063 offender: "(empty)".to_string(),
2064 reason: "`open_values` must name at least one open status value".to_string(),
2065 });
2066 }
2067 if let Some(lead) = &due.lead_section
2068 && !td.sections.iter().any(|s| s.key == *lead)
2069 {
2070 errors.push(SchemaLoadError::InvalidDueAxis {
2071 type_name: td.name.clone(),
2072 offender: lead.clone(),
2073 reason: "`lead_section` names no section of this type".to_string(),
2074 });
2075 }
2076 }
2077
2078 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
2080 if catch_all_count != 1 {
2081 errors.push(SchemaLoadError::CatchAllViolation {
2082 type_name: td.name.clone(),
2083 count: catch_all_count,
2084 });
2085 }
2086
2087 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
2089 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
2090
2091 for f in &td.text_fields {
2092 if !section_keys.contains(f.as_str()) {
2094 errors.push(SchemaLoadError::UnknownFieldReference {
2095 type_name: td.name.clone(),
2096 field: "text_fields",
2097 reference: f.clone(),
2098 });
2099 }
2100 }
2101 for f in &td.health_required_fields {
2102 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2103 errors.push(SchemaLoadError::UnknownFieldReference {
2104 type_name: td.name.clone(),
2105 field: "health_required_fields",
2106 reference: f.clone(),
2107 });
2108 }
2109 }
2110 for f in &td.updatable_fields {
2111 if f == "title" {
2113 continue;
2114 }
2115 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2116 errors.push(SchemaLoadError::UnknownFieldReference {
2117 type_name: td.name.clone(),
2118 field: "updatable_fields",
2119 reference: f.clone(),
2120 });
2121 }
2122 }
2123
2124 for m in &td.metadata_fields {
2126 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
2127 && !allowed.contains(default)
2128 {
2129 errors.push(SchemaLoadError::DefaultValueNotInEnum {
2130 type_name: td.name.clone(),
2131 field: m.key.clone(),
2132 default: default.clone(),
2133 allowed: allowed.clone(),
2134 });
2135 }
2136 }
2137}
2138
2139fn check_rel(
2140 type_name: &str,
2141 field: &'static str,
2142 relationship: &str,
2143 rel_names: &HashSet<String>,
2144 available: &[String],
2145) -> Result<(), SchemaLoadError> {
2146 if rel_names.contains(relationship) {
2147 return Ok(());
2148 }
2149 Err(SchemaLoadError::UndeclaredRelationship {
2150 type_name: type_name.into(),
2151 field,
2152 relationship: relationship.into(),
2153 available: available.to_vec(),
2154 })
2155}