Skip to main content

wavekat_flow/
validate.rs

1//! Publish-time / load-time validation — the gate that keeps a bad document
2//! from ever becoming a live phone line (doc 48). The platform runs the same
3//! checks before publish (`packages/flow-schema/src/validate.ts`); the daemon
4//! re-runs them on load so a corrupted cache or a version mismatch fails safe
5//! rather than executing undefined behavior. Every rule here has a TypeScript
6//! twin, and both are pinned by the shared conformance corpus.
7//!
8//! Structural guarantees enforced here: the schema version is one this engine
9//! runs; every exit is wired to an existing node and matches the node's exit
10//! set; every node is reachable from `entry`; **no caller can be trapped** —
11//! every reachable node can reach a terminal; prompt-length and menu/hours
12//! sanity caps. All errors are collected (not fail-fast) so the editor can
13//! show every problem at once.
14
15use std::collections::{BTreeSet, VecDeque};
16
17use crate::book::{self};
18use crate::hours::{self, HoursError};
19use crate::model::{Flow, Node, Prompt};
20use crate::model_ext::NodeId;
21use crate::SUPPORTED_SCHEMA_VERSIONS;
22
23/// Longest a spoken (text) prompt may be. A backstop against a pasted-essay
24/// prompt that would trap a caller under minutes of TTS; generous enough that
25/// no real greeting hits it. Audio-asset prompts are unbounded here. Twin:
26/// `packages/flow-schema/src/model.ts` `MAX_PROMPT_CHARS`.
27const MAX_PROMPT_CHARS: usize = 2000;
28
29/// The DTMF keys a `menu` option may be keyed by. Twin:
30/// `packages/flow-schema/src/model.ts` `VALID_DIGITS`.
31const VALID_DIGITS: &[&str] = &["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
32
33/// One thing wrong with a flow document. Carries enough context (node id,
34/// exit name, offending value) for the editor to point at it.
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36pub enum ValidationError {
37    #[error("schema_version {0} is not supported by this app")]
38    UnsupportedSchemaVersion(i64),
39
40    #[error("flow has no nodes")]
41    EmptyFlow,
42
43    #[error("entry {0:?} is not a node")]
44    MissingEntry(NodeId),
45
46    #[error("node {node:?} exit {exit:?} points at {target:?}, which is not a node")]
47    UnknownExitTarget {
48        node: NodeId,
49        exit: String,
50        target: NodeId,
51    },
52
53    #[error("node {node:?} ({kind}) is missing required exits {missing:?}")]
54    MissingExits {
55        node: NodeId,
56        kind: &'static str,
57        missing: Vec<String>,
58    },
59
60    #[error("node {node:?} ({kind}) has exits {unexpected:?} it does not define")]
61    UnexpectedExits {
62        node: NodeId,
63        kind: &'static str,
64        unexpected: Vec<String>,
65    },
66
67    #[error("menu node {node:?} offers no options")]
68    EmptyMenu { node: NodeId },
69
70    #[error("menu node {node:?} option key {key:?} is not a DTMF digit (0-9, *, #)")]
71    BadDigit { node: NodeId, key: String },
72
73    #[error("hours node {node:?}: {source}")]
74    Hours { node: NodeId, source: HoursError },
75
76    #[error("transfer node {node:?} has an empty target")]
77    EmptyTransferTarget { node: NodeId },
78
79    #[error("node {node:?} prompt is {len} chars (max {MAX_PROMPT_CHARS})")]
80    PromptTooLong { node: NodeId, len: usize },
81
82    #[error("node {node:?} is unreachable from entry")]
83    Unreachable { node: NodeId },
84
85    #[error("node {node:?} can never reach a way to end the call (caller trapped)")]
86    Trapped { node: NodeId },
87
88    #[error("book node {node:?} {field} is {value} (allowed: {min}–{max})")]
89    BookOutOfRange {
90        node: NodeId,
91        field: &'static str,
92        value: u64,
93        min: u64,
94        max: u64,
95    },
96
97    #[error(
98        "book node {node:?} can never offer an appointment: no opening leaves room for {duration} minutes"
99    )]
100    BookNeverOpen { node: NodeId, duration: u64 },
101
102    #[error(
103        "node {node:?} is a {kind} step, which needs schema_version {required} (this document declares {declared})"
104    )]
105    KindRequiresNewerSchema {
106        node: NodeId,
107        kind: &'static str,
108        required: i64,
109        declared: i64,
110    },
111}
112
113impl ValidationError {
114    /// Stable snake_case code, shared with the TypeScript validator and the
115    /// conformance corpus (`conformance/v1/**/*.expected.json` `semantic`).
116    /// `unknown_target` matches the corpus (the pre-consolidation platform
117    /// copy called it `unknown_exit_target`; the frozen corpus is
118    /// authoritative, so both languages emit `unknown_target`).
119    pub fn code(&self) -> &'static str {
120        match self {
121            ValidationError::UnsupportedSchemaVersion(_) => "unsupported_schema_version",
122            ValidationError::EmptyFlow => "empty_flow",
123            ValidationError::MissingEntry(_) => "missing_entry",
124            ValidationError::UnknownExitTarget { .. } => "unknown_target",
125            ValidationError::MissingExits { .. } => "missing_exits",
126            ValidationError::UnexpectedExits { .. } => "unexpected_exits",
127            ValidationError::EmptyMenu { .. } => "empty_menu",
128            ValidationError::BadDigit { .. } => "bad_digit",
129            // Delegate to the inner hours code so both languages report the
130            // same code for a hours defect (TS surfaces it directly).
131            ValidationError::Hours { source, .. } => source.code(),
132            ValidationError::EmptyTransferTarget { .. } => "empty_transfer_target",
133            ValidationError::PromptTooLong { .. } => "prompt_too_long",
134            ValidationError::Unreachable { .. } => "unreachable",
135            ValidationError::Trapped { .. } => "trapped",
136            ValidationError::BookOutOfRange { .. } => "book_out_of_range",
137            ValidationError::BookNeverOpen { .. } => "book_never_open",
138            ValidationError::KindRequiresNewerSchema { .. } => "kind_requires_newer_schema",
139        }
140    }
141}
142
143/// The oldest `schema_version` that may carry each component — the whole
144/// of what "a version bump" means in this format, since versions grow by
145/// gaining components and nothing else so far. Documents are never
146/// rewritten, so a v1 flow keeps working forever; it simply may not use
147/// `book`. Twin: `model.ts` `KIND_MIN_SCHEMA_VERSION`.
148fn kind_min_schema_version(kind: &str) -> i64 {
149    match kind {
150        "book" => 2,
151        _ => 1,
152    }
153}
154
155/// Validate a parsed flow. `Ok(())` means safe to publish / execute; `Err`
156/// carries every problem found.
157pub fn validate(flow: &Flow) -> Result<(), Vec<ValidationError>> {
158    let mut errs = Vec::new();
159
160    if !SUPPORTED_SCHEMA_VERSIONS
161        .iter()
162        .any(|&v| i64::from(v) == flow.schema_version)
163    {
164        errs.push(ValidationError::UnsupportedSchemaVersion(
165            flow.schema_version,
166        ));
167    }
168
169    if flow.nodes.is_empty() {
170        errs.push(ValidationError::EmptyFlow);
171        return Err(errs); // graph checks below would be meaningless
172    }
173
174    let entry_exists = flow.nodes.contains_key(&flow.entry);
175    if !entry_exists {
176        errs.push(ValidationError::MissingEntry(flow.entry.clone()));
177    }
178
179    for (id, node) in &flow.nodes {
180        check_exits(id, node, flow, &mut errs);
181        check_node(id, node, &mut errs);
182
183        // A component the document's own version doesn't have. Checked
184        // per node rather than once per flow so the editor points at the
185        // step that has to change, and kept separate from the parser's
186        // `unknown_kind` (which a build predating the component reports
187        // for the same document) because the fix differs: bump the
188        // version, don't fix the typo.
189        let required = kind_min_schema_version(node.kind());
190        if flow.schema_version < required {
191            errs.push(ValidationError::KindRequiresNewerSchema {
192                node: id.clone(),
193                kind: node.kind(),
194                required,
195                declared: flow.schema_version,
196            });
197        }
198    }
199
200    // Reachability and trap analysis need a real entry to walk from.
201    if entry_exists {
202        check_graph(flow, &mut errs);
203    }
204
205    if errs.is_empty() {
206        Ok(())
207    } else {
208        Err(errs)
209    }
210}
211
212/// Exit keys must be exactly the set the node's kind defines, and every
213/// target must exist.
214fn check_exits(id: &NodeId, node: &Node, flow: &Flow, errs: &mut Vec<ValidationError>) {
215    let kind = node.kind();
216    let required: BTreeSet<String> = node.required_exits().into_iter().collect();
217    let present: BTreeSet<String> = node
218        .exits()
219        .map(|e| e.keys().cloned().collect())
220        .unwrap_or_default();
221
222    let missing: Vec<String> = required.difference(&present).cloned().collect();
223    if !missing.is_empty() {
224        errs.push(ValidationError::MissingExits {
225            node: id.clone(),
226            kind,
227            missing,
228        });
229    }
230    let unexpected: Vec<String> = present.difference(&required).cloned().collect();
231    if !unexpected.is_empty() {
232        errs.push(ValidationError::UnexpectedExits {
233            node: id.clone(),
234            kind,
235            unexpected,
236        });
237    }
238
239    for (exit, target) in node.exits().into_iter().flatten() {
240        if !flow.nodes.contains_key(target) {
241            errs.push(ValidationError::UnknownExitTarget {
242                node: id.clone(),
243                exit: exit.clone(),
244                target: target.clone(),
245            });
246        }
247    }
248}
249
250/// Per-kind config sanity.
251fn check_node(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
252    match node {
253        Node::Greeting { prompt, .. } => check_prompt(id, prompt, errs),
254        Node::Hours {
255            schedule,
256            timezone,
257            exceptions,
258            ..
259        } => {
260            if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
261                errs.push(ValidationError::Hours {
262                    node: id.clone(),
263                    source,
264                });
265            }
266        }
267        Node::Menu {
268            prompt, options, ..
269        } => {
270            check_prompt(id, prompt, errs);
271            if options.is_empty() {
272                errs.push(ValidationError::EmptyMenu { node: id.clone() });
273            }
274            for key in options.keys() {
275                if !VALID_DIGITS.contains(&key.as_str()) {
276                    errs.push(ValidationError::BadDigit {
277                        node: id.clone(),
278                        key: key.clone(),
279                    });
280                }
281            }
282        }
283        Node::Ring { .. } => {}
284        Node::Message { prompt, .. } => check_prompt(id, prompt, errs),
285        Node::Transfer { target, .. } => {
286            if target.trim().is_empty() {
287                errs.push(ValidationError::EmptyTransferTarget { node: id.clone() });
288            }
289        }
290        Node::Hangup { prompt, .. } => {
291            if let Some(p) = prompt {
292                check_prompt(id, p, errs);
293            }
294        }
295        Node::Book {
296            prompt,
297            confirm_prompt,
298            schedule,
299            timezone,
300            exceptions,
301            ..
302        } => {
303            check_prompt(id, prompt, errs);
304            check_prompt(id, confirm_prompt, errs);
305            if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
306                errs.push(ValidationError::Hours {
307                    node: id.clone(),
308                    source,
309                });
310            }
311            check_book_bounds(id, node, errs);
312        }
313    }
314}
315
316/// The `book` node's numeric bounds, and the one structural question a
317/// schedule can fail: whether it leaves room for a single appointment.
318///
319/// A node that can never offer anything is worth an error rather than a
320/// shrug — "we're open 9:00 to 9:30 and appointments run an hour" is a
321/// flow whose every caller falls out the `no_slots` exit, and the author
322/// will read that as a broken calendar connection, not as arithmetic.
323/// [`book::vocabulary_refs`] answers it for free: no time refs, no times.
324fn check_book_bounds(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
325    let Node::Book {
326        duration_mins,
327        buffer_mins,
328        lead_mins,
329        horizon_days,
330        max_offers,
331        ..
332    } = node
333    else {
334        return;
335    };
336
337    let mut range = |field: &'static str, value: u64, min: u64, max: u64| {
338        if value < min || value > max {
339            errs.push(ValidationError::BookOutOfRange {
340                node: id.clone(),
341                field,
342                value,
343                min,
344                max,
345            });
346        }
347    };
348    range(
349        "duration_mins",
350        *duration_mins,
351        book::MIN_BOOK_DURATION_MINS,
352        book::MAX_BOOK_DURATION_MINS,
353    );
354    range("buffer_mins", *buffer_mins, 0, book::MAX_BOOK_BUFFER_MINS);
355    range("lead_mins", *lead_mins, 0, book::MAX_BOOK_LEAD_MINS);
356    range(
357        "horizon_days",
358        *horizon_days,
359        1,
360        book::MAX_BOOK_HORIZON_DAYS,
361    );
362    range("max_offers", *max_offers, 1, book::MAX_BOOK_OFFERS);
363
364    let speaks_a_time = book::vocabulary_refs(node).iter().any(|r| {
365        matches!(
366            book::parse_vocabulary_ref(r),
367            Some(book::VocabularyRef::Time { .. })
368        )
369    });
370    if !speaks_a_time {
371        errs.push(ValidationError::BookNeverOpen {
372            node: id.clone(),
373            duration: *duration_mins,
374        });
375    }
376}
377
378fn check_prompt(id: &NodeId, prompt: &Prompt, errs: &mut Vec<ValidationError>) {
379    if let Some(text) = prompt.as_text() {
380        let len = text.chars().count();
381        if len > MAX_PROMPT_CHARS {
382            errs.push(ValidationError::PromptTooLong {
383                node: id.clone(),
384                len,
385            });
386        }
387    }
388}
389
390/// Reachability from `entry`, and the "no caller is trapped" guarantee.
391fn check_graph(flow: &Flow, errs: &mut Vec<ValidationError>) {
392    // BFS from entry over exit edges.
393    let mut reachable: BTreeSet<&str> = BTreeSet::new();
394    let mut queue: VecDeque<&str> = VecDeque::new();
395    queue.push_back(flow.entry.as_str());
396    reachable.insert(flow.entry.as_str());
397    while let Some(id) = queue.pop_front() {
398        let Some(node) = flow.nodes.get(id) else {
399            continue;
400        };
401        for (_, target) in node.exits().into_iter().flatten() {
402            if flow.nodes.contains_key(target) && reachable.insert(target.as_str()) {
403                queue.push_back(target.as_str());
404            }
405        }
406    }
407
408    for id in flow.nodes.keys() {
409        if !reachable.contains(id.as_str()) {
410            errs.push(ValidationError::Unreachable { node: id.clone() });
411        }
412    }
413
414    // "Can reach a terminal" by backward fixpoint from terminal-capable
415    // nodes. A node qualifies if it is itself terminal or any exit target
416    // qualifies.
417    let mut can_end: BTreeSet<&str> = flow
418        .nodes
419        .iter()
420        .filter(|(_, n)| n.is_terminal())
421        .map(|(id, _)| id.as_str())
422        .collect();
423    loop {
424        let mut grew = false;
425        for (id, node) in &flow.nodes {
426            if can_end.contains(id.as_str()) {
427                continue;
428            }
429            if node
430                .exits()
431                .into_iter()
432                .flatten()
433                .any(|(_, t)| can_end.contains(t.as_str()))
434                && can_end.insert(id.as_str())
435            {
436                grew = true;
437            }
438        }
439        if !grew {
440            break;
441        }
442    }
443
444    // A reachable node that can never reach a terminal traps the caller.
445    // (Report only reachable ones — an unreachable trap is already flagged as
446    // unreachable and would be noise.)
447    for id in &reachable {
448        if !can_end.contains(id) {
449            errs.push(ValidationError::Trapped {
450                node: (*id).to_string(),
451            });
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    // The doc 48 example, reused across tests. Kept in sync with the one in
461    // the TS suite on purpose — both must stay valid.
462    const LUIGIS: &str = r#"
463schema_version: 1
464id: flow_9f2
465name: Luigi's
466entry: welcome
467nodes:
468  welcome:
469    kind: greeting
470    prompt: Thanks for calling Luigi's!
471    exits: { next: check_hours }
472  check_hours:
473    kind: hours
474    timezone: America/New_York
475    schedule:
476      tue: [{ open: "11:00", close: "22:00" }]
477    exits: { open: front_desk, closed: night_menu }
478  front_desk:
479    kind: ring
480    timeout_secs: 25
481    exits: { no_answer: take_message }
482  night_menu:
483    kind: menu
484    prompt: We're closed. Press 1 for hours.
485    options: { "1": Hours }
486    exits: { "1": say_hours, no_input: take_message, invalid: take_message }
487  say_hours:
488    kind: greeting
489    prompt: Open Tuesday to Sunday.
490    exits: { next: take_message }
491  take_message:
492    kind: message
493    prompt: Leave a message after the tone.
494"#;
495
496    fn parse(src: &str) -> Flow {
497        Flow::from_yaml(src).expect("test flow should parse")
498    }
499
500    #[test]
501    fn the_documented_example_validates() {
502        assert_eq!(validate(&parse(LUIGIS)), Ok(()));
503    }
504
505    #[test]
506    fn rejects_unsupported_schema_version() {
507        let f = parse(&LUIGIS.replace("schema_version: 1", "schema_version: 99"));
508        let errs = validate(&f).unwrap_err();
509        assert!(errs.contains(&ValidationError::UnsupportedSchemaVersion(99)));
510    }
511
512    #[test]
513    fn rejects_missing_entry() {
514        let f = parse(&LUIGIS.replace("entry: welcome", "entry: nope"));
515        let errs = validate(&f).unwrap_err();
516        assert!(errs
517            .iter()
518            .any(|e| matches!(e, ValidationError::MissingEntry(n) if n == "nope")));
519    }
520
521    #[test]
522    fn rejects_dangling_exit_target() {
523        let f = parse(&LUIGIS.replace("next: check_hours", "next: ghost"));
524        let errs = validate(&f).unwrap_err();
525        assert!(errs.iter().any(|e| matches!(
526            e,
527            ValidationError::UnknownExitTarget { target, .. } if target == "ghost"
528        )));
529    }
530
531    #[test]
532    fn rejects_missing_required_exit() {
533        // A greeting with no `next`.
534        let src = r#"
535schema_version: 1
536id: f
537name: n
538entry: g
539nodes:
540  g:
541    kind: greeting
542    prompt: hi
543  bye:
544    kind: hangup
545"#;
546        let errs = validate(&parse(src)).unwrap_err();
547        assert!(errs.iter().any(|e| matches!(
548            e,
549            ValidationError::MissingExits { node, .. } if node == "g"
550        )));
551    }
552
553    #[test]
554    fn rejects_unexpected_exit_on_terminal() {
555        let src = r#"
556schema_version: 1
557id: f
558name: n
559entry: g
560nodes:
561  g:
562    kind: hangup
563    exits: { next: g }
564"#;
565        let errs = validate(&parse(src)).unwrap_err();
566        assert!(errs.iter().any(|e| matches!(
567            e,
568            ValidationError::UnexpectedExits { node, .. } if node == "g"
569        )));
570    }
571
572    #[test]
573    fn rejects_unreachable_node() {
574        let src = r#"
575schema_version: 1
576id: f
577name: n
578entry: g
579nodes:
580  g:
581    kind: hangup
582  orphan:
583    kind: hangup
584"#;
585        let errs = validate(&parse(src)).unwrap_err();
586        assert!(errs.iter().any(|e| matches!(
587            e,
588            ValidationError::Unreachable { node } if node == "orphan"
589        )));
590    }
591
592    #[test]
593    fn rejects_trapping_cycle() {
594        // a -> b -> a, with no terminal anywhere: every caller is stuck.
595        let src = r#"
596schema_version: 1
597id: f
598name: n
599entry: a
600nodes:
601  a:
602    kind: greeting
603    prompt: one
604    exits: { next: b }
605  b:
606    kind: greeting
607    prompt: two
608    exits: { next: a }
609"#;
610        let errs = validate(&parse(src)).unwrap_err();
611        assert!(
612            errs.iter()
613                .any(|e| matches!(e, ValidationError::Trapped { .. })),
614            "a terminal-less loop must be flagged as trapping: {errs:?}"
615        );
616    }
617
618    #[test]
619    fn a_loop_with_an_escape_is_fine() {
620        // Menu loops back to a greeting but no_input/invalid escape to a
621        // terminal — not trapped.
622        let src = r#"
623schema_version: 1
624id: f
625name: n
626entry: m
627nodes:
628  m:
629    kind: menu
630    prompt: press one
631    options: { "1": again }
632    exits: { "1": g, no_input: bye, invalid: bye }
633  g:
634    kind: greeting
635    prompt: again
636    exits: { next: m }
637  bye:
638    kind: hangup
639"#;
640        assert_eq!(validate(&parse(src)), Ok(()));
641    }
642
643    #[test]
644    fn rejects_empty_menu_and_bad_digit() {
645        let empty = r#"
646schema_version: 1
647id: f
648name: n
649entry: m
650nodes:
651  m:
652    kind: menu
653    prompt: hi
654    options: {}
655    exits: { no_input: bye, invalid: bye }
656  bye:
657    kind: hangup
658"#;
659        assert!(validate(&parse(empty))
660            .unwrap_err()
661            .iter()
662            .any(|e| matches!(e, ValidationError::EmptyMenu { .. })));
663
664        let bad = r#"
665schema_version: 1
666id: f
667name: n
668entry: m
669nodes:
670  m:
671    kind: menu
672    prompt: hi
673    options: { A: nope }
674    exits: { A: bye, no_input: bye, invalid: bye }
675  bye:
676    kind: hangup
677"#;
678        assert!(validate(&parse(bad))
679            .unwrap_err()
680            .iter()
681            .any(|e| matches!(e, ValidationError::BadDigit { key, .. } if key == "A")));
682    }
683
684    #[test]
685    fn rejects_empty_transfer_target() {
686        let src = r#"
687schema_version: 1
688id: f
689name: n
690entry: t
691nodes:
692  t:
693    kind: transfer
694    target: "   "
695"#;
696        assert!(validate(&parse(src))
697            .unwrap_err()
698            .iter()
699            .any(|e| matches!(e, ValidationError::EmptyTransferTarget { .. })));
700    }
701
702    #[test]
703    fn rejects_overlong_prompt() {
704        let long = "a".repeat(MAX_PROMPT_CHARS + 1);
705        let src = format!(
706            r#"
707schema_version: 1
708id: f
709name: n
710entry: g
711nodes:
712  g:
713    kind: greeting
714    prompt: {long}
715    exits: {{ next: bye }}
716  bye:
717    kind: hangup
718"#
719        );
720        assert!(validate(&parse(&src))
721            .unwrap_err()
722            .iter()
723            .any(|e| matches!(e, ValidationError::PromptTooLong { .. })));
724    }
725
726    #[test]
727    fn surfaces_hours_config_errors() {
728        let src = r#"
729schema_version: 1
730id: f
731name: n
732entry: h
733nodes:
734  h:
735    kind: hours
736    timezone: Mars/Base
737    schedule: {}
738    exits: { open: bye, closed: bye }
739  bye:
740    kind: hangup
741"#;
742        assert!(validate(&parse(src))
743            .unwrap_err()
744            .iter()
745            .any(|e| matches!(e, ValidationError::Hours { .. })));
746    }
747}