Skip to main content

corpora_rules/
schema.rs

1//! Schema-table validator: **kind↔authority** for every record, and **status↔lifecycle**
2//! for decisions. Field presence/types are enforced earlier by the parser; this rule
3//! covers the cross-field semantic consistency that only makes sense on a built record.
4
5use corpora_core::{Authority, Diagnostic, Facet, Graph, Kind, Lifecycle, Status};
6
7use crate::Rule;
8
9pub struct Schema;
10
11impl Rule for Schema {
12    fn code(&self) -> &'static str {
13        "SCHEMA"
14    }
15
16    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
17        for r in g.records() {
18            let allowed = allowed_authorities(r.kind);
19            if !allowed.contains(&r.authority) {
20                out.push(Diagnostic::error(
21                    "SCHEMA",
22                    &r.path,
23                    format!(
24                        "authority {:?} is not allowed for kind {:?} (expected one of {:?})",
25                        r.authority, r.kind, allowed
26                    ),
27                ));
28            }
29
30            if let Facet::Decision(d) = &r.facet {
31                let lifecycles = allowed_lifecycles(d.status);
32                if !lifecycles.contains(&r.lifecycle) {
33                    out.push(Diagnostic::error(
34                        "SCHEMA",
35                        &r.path,
36                        format!(
37                            "status {:?} is inconsistent with lifecycle {:?} (expected one of {:?})",
38                            d.status, r.lifecycle, lifecycles
39                        ),
40                    ));
41                }
42            }
43        }
44    }
45}
46
47fn allowed_authorities(kind: Kind) -> &'static [Authority] {
48    use Authority::*;
49    match kind {
50        Kind::Decision => &[Normative],
51        Kind::Axiom => &[Axiomatic, Normative],
52        Kind::Invariant => &[Normative],
53        Kind::Architecture => &[Normative],
54        Kind::Current => &[Descriptive],
55        Kind::Roadmap => &[Prospective],
56        Kind::Milestone => &[Operational, Prospective],
57        Kind::Evidence => &[Evidence],
58        Kind::ReviewLog => &[Historical],
59        Kind::Evolution => &[Historical],
60        Kind::Handoff => &[Operational],
61        Kind::Explainer => &[Explanatory],
62        Kind::Index => &[Navigational],
63    }
64}
65
66/// Allowed lifecycles per adoption status, following schema-v0 §3 semantics: pending
67/// choices (open/proposed) are drafts; an accepted choice is the live `current`; a
68/// superseded one is `superseded`; a dropped (`deprecated`) or never-adopted (`rejected`)
69/// choice is retired to `historical` (a rejected draft may also stay `draft`).
70fn allowed_lifecycles(status: Status) -> &'static [Lifecycle] {
71    use Lifecycle::*;
72    match status {
73        Status::Open => &[Draft],
74        Status::Proposed => &[Draft],
75        Status::Accepted => &[Current],
76        Status::Superseded => &[Superseded],
77        Status::Deprecated => &[Historical],
78        Status::Rejected => &[Historical, Draft],
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use corpora_core::*;
86    use std::sync::Arc;
87
88    fn build(records: Vec<Record>) -> Graph {
89        Graph::build(records.into_iter().map(Arc::new).collect()).0
90    }
91
92    fn decision(status: Status, lifecycle: Lifecycle, authority: Authority) -> Record {
93        Record::minimal(
94            Some(Id("D1".into())),
95            DocPath("d1.md".into()),
96            Kind::Decision,
97            lifecycle,
98            authority,
99            Facet::Decision(DecisionFacet {
100                status,
101                date: Date("2026-06-21".into()),
102                implementation: None,
103                fork: None,
104                realized_by: vec![],
105            }),
106        )
107    }
108
109    #[test]
110    fn wrong_authority_for_kind_flagged() {
111        let g = build(vec![decision(Status::Accepted, Lifecycle::Current, Authority::Descriptive)]);
112        let mut out = Vec::new();
113        Schema.check(&g, &mut out);
114        assert_eq!(out.len(), 1);
115        assert_eq!(out[0].code, "SCHEMA");
116    }
117
118    #[test]
119    fn status_lifecycle_mismatch_flagged() {
120        // accepted ⇒ current; pairing it with draft is inconsistent.
121        let g = build(vec![decision(Status::Accepted, Lifecycle::Draft, Authority::Normative)]);
122        let mut out = Vec::new();
123        Schema.check(&g, &mut out);
124        assert!(out.iter().any(|d| d.message.contains("lifecycle")), "{out:?}");
125    }
126
127    #[test]
128    fn consistent_decision_passes() {
129        let g = build(vec![decision(Status::Accepted, Lifecycle::Current, Authority::Normative)]);
130        let mut out = Vec::new();
131        Schema.check(&g, &mut out);
132        assert!(out.is_empty(), "{out:?}");
133    }
134}