Skip to main content

kyyn_core/
validate.rs

1//! Registry-driven generic validation — everything a schema DECLARES, the
2//! engine can CHECK without the schema crate hand-writing it:
3//!
4//! - routing + parse: storage patterns say where records live; typed decode
5//!   (dates, decimals, enums, links) says whether one reads back (Error).
6//! - placeholder consistency: a field named like a storage placeholder must
7//!   match its path segment — `id` vs filename, `person` vs directory (Error).
8//! - link policy: `Link { allowed }` fields reject other kinds (Error);
9//!   KB-native links that do not resolve warn; declared external systems
10//!   pass; unrecognized heads warn. Enum-payload links included.
11//! - `refers_to`: a plain-string slug must name a known record (Warning).
12//! - inline `[[…]]` links in every Markdown field resolve or warn.
13//!
14//! A schema crate calls [`against_registry`] and appends only its own
15//! cross-record invariants — the registry IS the declarative source; this
16//! module is its interpreter. Severity doctrine: malformed shape and policy
17//! breaches block an accept (Error); unresolvable references warn.
18
19use std::collections::{HashMap, HashSet};
20
21use crate::link::Link;
22use crate::registry::{Field, FieldType, Kind, Registry};
23use crate::storage::{match_storage, route};
24use crate::value::{Value, decode};
25use crate::violation::{Severity, Violation};
26
27/// Validate every entry a registry can judge. `entries` are (repo-relative
28/// path, RON text) pairs; paths outside `facts/` are ignored.
29pub fn against_registry(
30    registry: &Registry,
31    entries: &[(String, String)],
32    systems: &[&str],
33) -> Vec<Violation> {
34    let mut out = Vec::new();
35    let id_kinds: Vec<&str> = registry
36        .kinds
37        .iter()
38        .filter(|k| k.storage.contains('{'))
39        .map(|k| k.name.as_str())
40        .collect();
41    let singleton_kinds: Vec<&str> = registry
42        .kinds
43        .iter()
44        .filter(|k| !k.storage.contains('{'))
45        .map(|k| k.name.as_str())
46        .collect();
47
48    // Pass 1: route and decode. A facts/ path no pattern claims is an Error;
49    // so is a record that fails its kind's typed decode (malformed dates and
50    // decimals fail HERE — shape is parse, not a later step).
51    struct Parsed<'r> {
52        path: String,
53        kind: &'r Kind,
54        id: Option<String>,
55        value: std::collections::BTreeMap<String, Value>,
56    }
57    let mut parsed: Vec<Parsed> = Vec::new();
58    for (path, text) in entries {
59        if !path.starts_with("facts/") {
60            continue;
61        }
62        match route(registry, path) {
63            None => out.push(Violation {
64                path: path.clone(),
65                severity: Severity::Error,
66                message: format!(
67                    "not a recognised KB path for this schema (expected one of: {})",
68                    registry
69                        .kinds
70                        .iter()
71                        .map(|k| k.storage.as_str())
72                        .collect::<Vec<_>>()
73                        .join(", ")
74                ),
75            }),
76            Some((kind, id)) => match decode(kind, text) {
77                Err(e) => out.push(Violation {
78                    path: path.clone(),
79                    severity: Severity::Error,
80                    message: format!("does not parse as a {}: {e}", kind.name),
81                }),
82                Ok(value) => parsed.push(Parsed {
83                    path: path.clone(),
84                    kind,
85                    id,
86                    value,
87                }),
88            },
89        }
90    }
91
92    // The id universes links resolve against — route-derived, so they are
93    // exactly the ids other records can address.
94    let mut universe = Universe {
95        ids: id_kinds
96            .iter()
97            .map(|k| (k.to_string(), HashSet::new()))
98            .collect(),
99        singletons_present: HashSet::new(),
100    };
101    for p in &parsed {
102        match &p.id {
103            Some(id) => {
104                universe
105                    .ids
106                    .get_mut(p.kind.name.as_str())
107                    .expect("seeded")
108                    .insert(id.clone());
109            }
110            None => {
111                universe.singletons_present.insert(p.kind.name.clone());
112            }
113        }
114    }
115
116    // Pass 2: per-record invariants, walked from the declared field types.
117    for p in &parsed {
118        let mut check = Checker {
119            path: &p.path,
120            universe: &universe,
121            systems,
122            id_kinds: &id_kinds,
123            singleton_kinds: &singleton_kinds,
124            out: &mut out,
125        };
126        // Placeholder consistency: each `{name}` binds a path segment; a
127        // record field of the same name must agree with it.
128        if let Some(id) = &p.id
129            && let Some(bindings) = match_storage(&p.kind.storage, &p.path)
130        {
131            let names = placeholder_names(&p.kind.storage);
132            let n = names.len();
133            for (i, (name, segment)) in names
134                .iter()
135                .zip(&bindings)
136                .collect::<Vec<_>>()
137                .into_iter()
138                .enumerate()
139            {
140                let Some(v) = p.value.get(name.as_str()) else {
141                    continue;
142                };
143                let rendered = match v {
144                    Value::Str(s) => s.clone(),
145                    Value::Date(d) => d.to_string(),
146                    _ => continue,
147                };
148                if &rendered != segment {
149                    let position = if i + 1 == n { "filename" } else { "directory" };
150                    check.error(format!(
151                        "{name} '{rendered}' does not match {position} '{segment}'"
152                    ));
153                }
154            }
155            let _ = id;
156        }
157        for field in &p.kind.fields {
158            if let Some(v) = p.value.get(&field.name) {
159                check.walk(&field.name, &field.ty, v, field.refers_to.as_deref());
160            }
161        }
162    }
163    out
164}
165
166/// The names of a storage pattern's placeholders, in path order.
167fn placeholder_names(pattern: &str) -> Vec<String> {
168    pattern
169        .split('/')
170        .filter_map(|seg| {
171            let open = seg.find('{')?;
172            let close = seg.find('}')?;
173            (open < close).then(|| seg[open + 1..close].to_string())
174        })
175        .collect()
176}
177
178/// The id universes KB links resolve against.
179struct Universe {
180    ids: HashMap<String, HashSet<String>>,
181    singletons_present: HashSet<String>,
182}
183
184impl Universe {
185    /// If `link` is KB-native and does not resolve, say why (a Warning).
186    /// External links (a declared system) are not locally resolvable.
187    fn unresolved(&self, link: &Link, systems: &[&str]) -> Option<String> {
188        let p = link.parts(systems);
189        if p.is_external() {
190            return None;
191        }
192        if let Some(set) = self.ids.get(p.kind) {
193            return match p.id {
194                Some(id) if set.contains(id) => None,
195                Some(_) => Some(format!(
196                    "link '{link}' does not resolve to a known {}",
197                    p.kind
198                )),
199                None => Some(format!(
200                    "link '{link}' names kind '{}' without an id",
201                    p.kind
202                )),
203            };
204        }
205        Some(format!("unrecognized link '{link}'"))
206    }
207
208    fn unresolved_singleton(&self, link: &Link, kind: &str, id: Option<&str>) -> Option<String> {
209        match id {
210            None if self.singletons_present.contains(kind) => None,
211            None => Some(format!(
212                "link '{link}' does not resolve: no {kind} record present"
213            )),
214            Some(_) => Some(format!("singleton kind '{kind}' takes no id")),
215        }
216    }
217}
218
219/// Per-file check helper carrying the path, universe and declared systems.
220struct Checker<'a> {
221    path: &'a str,
222    universe: &'a Universe,
223    systems: &'a [&'a str],
224    id_kinds: &'a [&'a str],
225    singleton_kinds: &'a [&'a str],
226    out: &'a mut Vec<Violation>,
227}
228
229impl Checker<'_> {
230    fn push(&mut self, severity: Severity, message: String) {
231        self.out.push(Violation {
232            path: self.path.to_string(),
233            severity,
234            message,
235        });
236    }
237
238    fn error(&mut self, message: String) {
239        self.push(Severity::Error, message);
240    }
241
242    fn warn(&mut self, message: String) {
243        self.push(Severity::Warning, message);
244    }
245
246    /// Resolution with singleton awareness — the one seam Universe splits on.
247    fn resolve(&mut self, prefix: &str, link: &Link) {
248        let p = link.parts(self.systems);
249        let msg = if self.singleton_kinds.contains(&p.kind) && !p.is_external() {
250            self.universe.unresolved_singleton(link, p.kind, p.id)
251        } else {
252            self.universe.unresolved(link, self.systems)
253        };
254        if let Some(msg) = msg {
255            self.warn(format!("{prefix}{msg}"));
256        }
257    }
258
259    /// The uniform link policy: a disallowed kind is an Error; an
260    /// unresolvable / unrecognized link is a Warning.
261    fn link(&mut self, field: &str, link: &Link, allowed: Option<&[String]>) {
262        let p = link.parts(self.systems);
263        if let Some(kinds) = allowed {
264            // Policy matches the namespace (system for external links, kind
265            // for native ones). An *unrecognized* head isn't a kind violation
266            // — it's an unknown, warned below.
267            if p.recognized(self.id_kinds, self.singleton_kinds)
268                && !kinds.iter().any(|k| k == p.namespace())
269            {
270                self.error(format!(
271                    "{field} '{link}' has kind '{}' (allowed: {})",
272                    p.namespace(),
273                    kinds.join(", ")
274                ));
275                return;
276            }
277        }
278        self.resolve(&format!("{field}: "), link);
279    }
280
281    /// Inline `[[…]]` links in prose: an unresolvable KB-kind link is a
282    /// Warning. Bracketed text whose head is no known kind is left alone —
283    /// prose is prose.
284    fn inline(&mut self, text: &str) {
285        for l in Link::find_inline(text) {
286            let p = l.parts(self.systems);
287            if !p.recognized(self.id_kinds, self.singleton_kinds) {
288                continue;
289            }
290            self.resolve("inline ", &l);
291        }
292    }
293
294    /// Walk a decoded value with its declared type, checking every link,
295    /// prose store and refers_to slug — enum payloads and nested structs
296    /// included, so a Link is checked wherever the schema can put one.
297    fn walk(&mut self, field: &str, ty: &FieldType, v: &Value, refers_to: Option<&str>) {
298        match (ty, v) {
299            (FieldType::Link { allowed }, Value::Link(l)) => {
300                self.link(field, l, allowed.as_deref());
301            }
302            (FieldType::Markdown, Value::Str(s)) => self.inline(s),
303            (FieldType::Str, Value::Str(s)) => {
304                if let Some(target) = refers_to
305                    && let Some(set) = self.universe.ids.get(target)
306                    && !set.contains(s)
307                {
308                    self.warn(format!("{field} '{s}' is not a known {target}"));
309                }
310            }
311            (FieldType::Option(inner), v) if !matches!(v, Value::Null) => {
312                self.walk(field, inner, v, refers_to);
313            }
314            (FieldType::List(inner), Value::List(vs)) => {
315                for v in vs {
316                    self.walk(field, inner, v, refers_to);
317                }
318            }
319            (FieldType::Struct(fields), Value::Struct(map)) => {
320                self.walk_fields(fields, map);
321            }
322            (FieldType::Enum(variants), Value::Enum { variant, fields }) => {
323                if let Some(decl) = variants.iter().find(|d| &d.name == variant) {
324                    self.walk_fields(&decl.fields, fields);
325                }
326            }
327            _ => {}
328        }
329    }
330
331    fn walk_fields(&mut self, decls: &[Field], values: &std::collections::BTreeMap<String, Value>) {
332        for f in decls {
333            if let Some(v) = values.get(&f.name) {
334                self.walk(&f.name, &f.ty, v, f.refers_to.as_deref());
335            }
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::registry::Variant;
344
345    fn field(name: &str, ty: FieldType) -> Field {
346        Field {
347            name: name.into(),
348            doc: String::new(),
349            ty,
350            role: None,
351            refers_to: None,
352        }
353    }
354
355    fn unit(name: &str) -> Variant {
356        Variant {
357            name: name.into(),
358            doc: String::new(),
359            fields: Vec::new(),
360            tone: None,
361        }
362    }
363
364    /// A todo/charter/self-shaped registry, built by hand: validation is
365    /// registry-driven, so the fixture is a registry, not a schema crate.
366    fn reg() -> Registry {
367        Registry {
368            schema_hash: String::new(),
369            kinds: vec![
370                Kind {
371                    name: "todo".into(),
372                    doc: String::new(),
373                    storage: "facts/todos/{id}.ron".into(),
374                    fields: vec![
375                        field("id", FieldType::Str),
376                        field("title", FieldType::Str),
377                        field("status", FieldType::Enum(vec![unit("Open"), unit("Done")])),
378                        field("due", FieldType::Option(Box::new(FieldType::Date))),
379                        field(
380                            "blocked_by",
381                            FieldType::Option(Box::new(FieldType::Link {
382                                allowed: Some(vec!["todo".into()]),
383                            })),
384                        ),
385                        field(
386                            "sources",
387                            FieldType::List(Box::new(FieldType::Link { allowed: None })),
388                        ),
389                        field("notes", FieldType::Markdown),
390                    ],
391                },
392                Kind {
393                    name: "charter".into(),
394                    doc: String::new(),
395                    storage: "facts/charter.ron".into(),
396                    fields: vec![
397                        field("purpose", FieldType::Str),
398                        field("objectives", FieldType::List(Box::new(FieldType::Markdown))),
399                        field("notes", FieldType::Markdown),
400                    ],
401                },
402                Kind {
403                    name: "self".into(),
404                    doc: String::new(),
405                    storage: "facts/self.ron".into(),
406                    fields: vec![
407                        field("name", FieldType::Str),
408                        field("role", FieldType::Str),
409                        field("notes", FieldType::Markdown),
410                    ],
411                },
412            ],
413            queries: vec![],
414            roles: vec![],
415        }
416    }
417
418    const SYSTEMS: &[&str] = &["graph", "sharepoint"];
419
420    fn v(entries: &[(&str, &str)]) -> Vec<Violation> {
421        let entries: Vec<(String, String)> = entries
422            .iter()
423            .map(|(p, t)| (p.to_string(), t.to_string()))
424            .collect();
425        against_registry(&reg(), &entries, SYSTEMS)
426    }
427
428    fn errors(v: &[Violation]) -> Vec<&Violation> {
429        v.iter().filter(|x| x.severity == Severity::Error).collect()
430    }
431
432    fn warnings(v: &[Violation]) -> Vec<&Violation> {
433        v.iter()
434            .filter(|x| x.severity == Severity::Warning)
435            .collect()
436    }
437
438    #[test]
439    fn happy_path_is_clean() {
440        let out = v(&[
441            (
442                "facts/todos/declare-source.ron",
443                r#"(id: "declare-source", title: "Declare", status: Open)"#,
444            ),
445            (
446                "facts/todos/ship-it.ron",
447                r#"(id: "ship-it", title: "Ship", status: Open, due: Some("2026-07-31"),
448                    blocked_by: Some("todo:declare-source"),
449                    sources: ["graph:email:AAMk=="],
450                    notes: "see [[charter]] and [[todo:declare-source]]")"#,
451            ),
452            ("facts/charter.ron", r#"(purpose: "P", objectives: ["o"])"#),
453            ("facts/self.ron", r#"(name: "Tom", role: "CTO")"#),
454        ]);
455        assert!(out.is_empty(), "{out:?}");
456    }
457
458    #[test]
459    fn routing_parse_and_placeholder_mismatches_are_errors() {
460        let out = v(&[
461            ("facts/notes/x.ron", "(id: \"x\")"),
462            ("facts/todos/readme.txt", "hi"),
463            ("facts/todos/broken.ron", "(id: "),
464            ("facts/todos/ship-it.ron", r#"(id: "ship", title: "T")"#),
465            (
466                "facts/todos/bad-date.ron",
467                r#"(id: "bad-date", title: "T", due: Some("someday"))"#,
468            ),
469            ("tools/elsewhere.txt", "ignored"),
470        ]);
471        let e = errors(&out);
472        assert_eq!(e.len(), 5, "{out:?}");
473        assert!(
474            e.iter()
475                .any(|x| x.message.contains("not a recognised KB path")
476                    && x.message.contains("facts/todos/{id}.ron"))
477        );
478        assert!(
479            e.iter().any(
480                |x| x.path.contains("broken") && x.message.contains("does not parse as a todo")
481            )
482        );
483        assert!(e.iter().any(|x| {
484            x.message
485                .contains("id 'ship' does not match filename 'ship-it'")
486        }));
487        assert!(
488            e.iter()
489                .any(|x| x.path.contains("bad-date") && x.message.contains("does not parse"))
490        );
491    }
492
493    #[test]
494    fn multi_segment_placeholders_check_each_position() {
495        let mut registry = reg();
496        registry.kinds.push(Kind {
497            name: "checkin".into(),
498            doc: String::new(),
499            storage: "facts/checkins/{person}/{date}.ron".into(),
500            fields: vec![
501                field("person", FieldType::Str),
502                field("date", FieldType::Date),
503            ],
504        });
505        let entries = vec![(
506            "facts/checkins/jane-doe/2026-07-03.ron".to_string(),
507            r#"(person: "bob-ray", date: "2026-07-04")"#.to_string(),
508        )];
509        let out = against_registry(&registry, &entries, SYSTEMS);
510        let e = errors(&out);
511        assert!(e.iter().any(|x| {
512            x.message
513                .contains("person 'bob-ray' does not match directory 'jane-doe'")
514        }));
515        assert!(e.iter().any(|x| {
516            x.message
517                .contains("date '2026-07-04' does not match filename '2026-07-03'")
518        }));
519    }
520
521    #[test]
522    fn link_policy_rejects_disallowed_kinds() {
523        let out = v(&[
524            (
525                "facts/todos/b.ron",
526                r#"(id: "b", title: "B", blocked_by: Some("charter"))"#,
527            ),
528            ("facts/charter.ron", r#"(purpose: "P")"#),
529        ]);
530        let e = errors(&out);
531        assert_eq!(e.len(), 1, "{out:?}");
532        assert!(e[0].message.contains("blocked_by") && e[0].message.contains("kind 'charter'"));
533    }
534
535    #[test]
536    fn dangling_and_unrecognized_links_warn_externals_pass() {
537        let out = v(&[(
538            "facts/todos/b.ron",
539            r#"(id: "b", title: "B", blocked_by: Some("todo:ghost"),
540                sources: ["jira:EV-12", "graph:email:fine=="])"#,
541        )]);
542        assert!(errors(&out).is_empty(), "{out:?}");
543        let w = warnings(&out);
544        assert_eq!(w.len(), 2, "{w:?}");
545        assert!(
546            w.iter()
547                .any(|x| x.message.contains("does not resolve to a known todo"))
548        );
549        assert!(
550            w.iter()
551                .any(|x| x.message.contains("unrecognized link 'jira:EV-12'"))
552        );
553    }
554
555    #[test]
556    fn singletons_resolve_iff_present_and_take_no_id() {
557        let todo = (
558            "facts/todos/b.ron",
559            r#"(id: "b", title: "B", notes: "per [[charter]] and [[charter:2026]]")"#,
560        );
561        let out = v(&[todo, ("facts/charter.ron", r#"(purpose: "P")"#)]);
562        assert!(errors(&out).is_empty());
563        let w = warnings(&out);
564        assert_eq!(w.len(), 1, "{w:?}");
565        assert!(
566            w[0].message
567                .contains("singleton kind 'charter' takes no id")
568        );
569
570        let out = v(&[todo]);
571        assert!(
572            warnings(&out)
573                .iter()
574                .any(|x| x.message.contains("no charter record present"))
575        );
576    }
577
578    #[test]
579    fn inline_links_are_scanned_in_every_markdown_store() {
580        let out = v(&[
581            (
582                "facts/todos/b.ron",
583                r#"(id: "b", title: "B", notes: "see [[todo:ghost1]]; [[not a link]] is prose")"#,
584            ),
585            (
586                "facts/charter.ron",
587                r#"(purpose: "P", objectives: ["see [[todo:ghost2]]"], notes: "see [[todo:ghost3]]")"#,
588            ),
589            (
590                "facts/self.ron",
591                r#"(name: "T", role: "R", notes: "see [[todo:ghost4]]")"#,
592            ),
593        ]);
594        assert!(errors(&out).is_empty());
595        let inline: Vec<_> = warnings(&out)
596            .into_iter()
597            .filter(|x| x.message.starts_with("inline"))
598            .collect();
599        assert_eq!(inline.len(), 4, "{out:?}");
600    }
601
602    #[test]
603    fn refers_to_slugs_warn_when_unknown() {
604        let mut registry = reg();
605        let mut person_field = field("person", FieldType::Str);
606        person_field.refers_to = Some("todo".into());
607        registry.kinds.push(Kind {
608            name: "checkin".into(),
609            doc: String::new(),
610            storage: "facts/checkins/{person}/{date}.ron".into(),
611            fields: vec![person_field, field("date", FieldType::Date)],
612        });
613        let entries = vec![(
614            "facts/checkins/ghost/2026-07-01.ron".to_string(),
615            r#"(person: "ghost", date: "2026-07-01")"#.to_string(),
616        )];
617        let out = against_registry(&registry, &entries, SYSTEMS);
618        assert!(errors(&out).is_empty(), "{out:?}");
619        assert!(
620            warnings(&out)
621                .iter()
622                .any(|x| x.message.contains("person 'ghost' is not a known todo"))
623        );
624    }
625
626    /// SOL finding 18's generic descendant: a Link inside an enum VARIANT
627    /// payload gets the same policy and resolution as a top-level field.
628    #[test]
629    fn enum_payload_links_are_checked() {
630        let mut registry = reg();
631        registry.kinds.push(Kind {
632            name: "probe".into(),
633            doc: String::new(),
634            storage: "facts/probes/{id}.ron".into(),
635            fields: vec![
636                field("id", FieldType::Str),
637                Field {
638                    name: "client".into(),
639                    doc: String::new(),
640                    ty: FieldType::Enum(vec![
641                        unit("Unknown"),
642                        Variant {
643                            name: "Known".into(),
644                            doc: String::new(),
645                            fields: vec![field(
646                                "todo",
647                                FieldType::Link {
648                                    allowed: Some(vec!["todo".into()]),
649                                },
650                            )],
651                            tone: None,
652                        },
653                    ]),
654                    role: None,
655                    refers_to: None,
656                },
657            ],
658        });
659        let entries = vec![(
660            "facts/probes/p1.ron".to_string(),
661            r#"(id: "p1", client: Known(todo: "charter"))"#.to_string(),
662        )];
663        let out = against_registry(&registry, &entries, SYSTEMS);
664        assert!(
665            errors(&out)
666                .iter()
667                .any(|x| x.message.contains("todo") && x.message.contains("kind 'charter'")),
668            "{out:?}"
669        );
670    }
671}