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::hours::{self, HoursError};
18use crate::model::{Flow, Node, Prompt};
19use crate::model_ext::NodeId;
20use crate::SUPPORTED_SCHEMA_VERSIONS;
21
22/// Longest a spoken (text) prompt may be. A backstop against a pasted-essay
23/// prompt that would trap a caller under minutes of TTS; generous enough that
24/// no real greeting hits it. Audio-asset prompts are unbounded here. Twin:
25/// `packages/flow-schema/src/model.ts` `MAX_PROMPT_CHARS`.
26const MAX_PROMPT_CHARS: usize = 2000;
27
28/// The DTMF keys a `menu` option may be keyed by. Twin:
29/// `packages/flow-schema/src/model.ts` `VALID_DIGITS`.
30const VALID_DIGITS: &[&str] = &["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
31
32/// One thing wrong with a flow document. Carries enough context (node id,
33/// exit name, offending value) for the editor to point at it.
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum ValidationError {
36    #[error("schema_version {0} is not supported by this app")]
37    UnsupportedSchemaVersion(i64),
38
39    #[error("flow has no nodes")]
40    EmptyFlow,
41
42    #[error("entry {0:?} is not a node")]
43    MissingEntry(NodeId),
44
45    #[error("node {node:?} exit {exit:?} points at {target:?}, which is not a node")]
46    UnknownExitTarget {
47        node: NodeId,
48        exit: String,
49        target: NodeId,
50    },
51
52    #[error("node {node:?} ({kind}) is missing required exits {missing:?}")]
53    MissingExits {
54        node: NodeId,
55        kind: &'static str,
56        missing: Vec<String>,
57    },
58
59    #[error("node {node:?} ({kind}) has exits {unexpected:?} it does not define")]
60    UnexpectedExits {
61        node: NodeId,
62        kind: &'static str,
63        unexpected: Vec<String>,
64    },
65
66    #[error("menu node {node:?} offers no options")]
67    EmptyMenu { node: NodeId },
68
69    #[error("menu node {node:?} option key {key:?} is not a DTMF digit (0-9, *, #)")]
70    BadDigit { node: NodeId, key: String },
71
72    #[error("hours node {node:?}: {source}")]
73    Hours { node: NodeId, source: HoursError },
74
75    #[error("transfer node {node:?} has an empty target")]
76    EmptyTransferTarget { node: NodeId },
77
78    #[error("node {node:?} prompt is {len} chars (max {MAX_PROMPT_CHARS})")]
79    PromptTooLong { node: NodeId, len: usize },
80
81    #[error("node {node:?} is unreachable from entry")]
82    Unreachable { node: NodeId },
83
84    #[error("node {node:?} can never reach a way to end the call (caller trapped)")]
85    Trapped { node: NodeId },
86}
87
88impl ValidationError {
89    /// Stable snake_case code, shared with the TypeScript validator and the
90    /// conformance corpus (`conformance/v1/**/*.expected.json` `semantic`).
91    /// `unknown_target` matches the corpus (the pre-consolidation platform
92    /// copy called it `unknown_exit_target`; the frozen corpus is
93    /// authoritative, so both languages emit `unknown_target`).
94    pub fn code(&self) -> &'static str {
95        match self {
96            ValidationError::UnsupportedSchemaVersion(_) => "unsupported_schema_version",
97            ValidationError::EmptyFlow => "empty_flow",
98            ValidationError::MissingEntry(_) => "missing_entry",
99            ValidationError::UnknownExitTarget { .. } => "unknown_target",
100            ValidationError::MissingExits { .. } => "missing_exits",
101            ValidationError::UnexpectedExits { .. } => "unexpected_exits",
102            ValidationError::EmptyMenu { .. } => "empty_menu",
103            ValidationError::BadDigit { .. } => "bad_digit",
104            // Delegate to the inner hours code so both languages report the
105            // same code for a hours defect (TS surfaces it directly).
106            ValidationError::Hours { source, .. } => source.code(),
107            ValidationError::EmptyTransferTarget { .. } => "empty_transfer_target",
108            ValidationError::PromptTooLong { .. } => "prompt_too_long",
109            ValidationError::Unreachable { .. } => "unreachable",
110            ValidationError::Trapped { .. } => "trapped",
111        }
112    }
113}
114
115/// Validate a parsed flow. `Ok(())` means safe to publish / execute; `Err`
116/// carries every problem found.
117pub fn validate(flow: &Flow) -> Result<(), Vec<ValidationError>> {
118    let mut errs = Vec::new();
119
120    if !SUPPORTED_SCHEMA_VERSIONS
121        .iter()
122        .any(|&v| i64::from(v) == flow.schema_version)
123    {
124        errs.push(ValidationError::UnsupportedSchemaVersion(
125            flow.schema_version,
126        ));
127    }
128
129    if flow.nodes.is_empty() {
130        errs.push(ValidationError::EmptyFlow);
131        return Err(errs); // graph checks below would be meaningless
132    }
133
134    let entry_exists = flow.nodes.contains_key(&flow.entry);
135    if !entry_exists {
136        errs.push(ValidationError::MissingEntry(flow.entry.clone()));
137    }
138
139    for (id, node) in &flow.nodes {
140        check_exits(id, node, flow, &mut errs);
141        check_node(id, node, &mut errs);
142    }
143
144    // Reachability and trap analysis need a real entry to walk from.
145    if entry_exists {
146        check_graph(flow, &mut errs);
147    }
148
149    if errs.is_empty() {
150        Ok(())
151    } else {
152        Err(errs)
153    }
154}
155
156/// Exit keys must be exactly the set the node's kind defines, and every
157/// target must exist.
158fn check_exits(id: &NodeId, node: &Node, flow: &Flow, errs: &mut Vec<ValidationError>) {
159    let kind = node.kind();
160    let required: BTreeSet<String> = node.required_exits().into_iter().collect();
161    let present: BTreeSet<String> = node
162        .exits()
163        .map(|e| e.keys().cloned().collect())
164        .unwrap_or_default();
165
166    let missing: Vec<String> = required.difference(&present).cloned().collect();
167    if !missing.is_empty() {
168        errs.push(ValidationError::MissingExits {
169            node: id.clone(),
170            kind,
171            missing,
172        });
173    }
174    let unexpected: Vec<String> = present.difference(&required).cloned().collect();
175    if !unexpected.is_empty() {
176        errs.push(ValidationError::UnexpectedExits {
177            node: id.clone(),
178            kind,
179            unexpected,
180        });
181    }
182
183    for (exit, target) in node.exits().into_iter().flatten() {
184        if !flow.nodes.contains_key(target) {
185            errs.push(ValidationError::UnknownExitTarget {
186                node: id.clone(),
187                exit: exit.clone(),
188                target: target.clone(),
189            });
190        }
191    }
192}
193
194/// Per-kind config sanity.
195fn check_node(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
196    match node {
197        Node::Greeting { prompt, .. } => check_prompt(id, prompt, errs),
198        Node::Hours {
199            schedule,
200            timezone,
201            exceptions,
202            ..
203        } => {
204            if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
205                errs.push(ValidationError::Hours {
206                    node: id.clone(),
207                    source,
208                });
209            }
210        }
211        Node::Menu {
212            prompt, options, ..
213        } => {
214            check_prompt(id, prompt, errs);
215            if options.is_empty() {
216                errs.push(ValidationError::EmptyMenu { node: id.clone() });
217            }
218            for key in options.keys() {
219                if !VALID_DIGITS.contains(&key.as_str()) {
220                    errs.push(ValidationError::BadDigit {
221                        node: id.clone(),
222                        key: key.clone(),
223                    });
224                }
225            }
226        }
227        Node::Ring { .. } => {}
228        Node::Message { prompt, .. } => check_prompt(id, prompt, errs),
229        Node::Transfer { target, .. } => {
230            if target.trim().is_empty() {
231                errs.push(ValidationError::EmptyTransferTarget { node: id.clone() });
232            }
233        }
234        Node::Hangup { prompt, .. } => {
235            if let Some(p) = prompt {
236                check_prompt(id, p, errs);
237            }
238        }
239    }
240}
241
242fn check_prompt(id: &NodeId, prompt: &Prompt, errs: &mut Vec<ValidationError>) {
243    if let Some(text) = prompt.as_text() {
244        let len = text.chars().count();
245        if len > MAX_PROMPT_CHARS {
246            errs.push(ValidationError::PromptTooLong {
247                node: id.clone(),
248                len,
249            });
250        }
251    }
252}
253
254/// Reachability from `entry`, and the "no caller is trapped" guarantee.
255fn check_graph(flow: &Flow, errs: &mut Vec<ValidationError>) {
256    // BFS from entry over exit edges.
257    let mut reachable: BTreeSet<&str> = BTreeSet::new();
258    let mut queue: VecDeque<&str> = VecDeque::new();
259    queue.push_back(flow.entry.as_str());
260    reachable.insert(flow.entry.as_str());
261    while let Some(id) = queue.pop_front() {
262        let Some(node) = flow.nodes.get(id) else {
263            continue;
264        };
265        for (_, target) in node.exits().into_iter().flatten() {
266            if flow.nodes.contains_key(target) && reachable.insert(target.as_str()) {
267                queue.push_back(target.as_str());
268            }
269        }
270    }
271
272    for id in flow.nodes.keys() {
273        if !reachable.contains(id.as_str()) {
274            errs.push(ValidationError::Unreachable { node: id.clone() });
275        }
276    }
277
278    // "Can reach a terminal" by backward fixpoint from terminal-capable
279    // nodes. A node qualifies if it is itself terminal or any exit target
280    // qualifies.
281    let mut can_end: BTreeSet<&str> = flow
282        .nodes
283        .iter()
284        .filter(|(_, n)| n.is_terminal())
285        .map(|(id, _)| id.as_str())
286        .collect();
287    loop {
288        let mut grew = false;
289        for (id, node) in &flow.nodes {
290            if can_end.contains(id.as_str()) {
291                continue;
292            }
293            if node
294                .exits()
295                .into_iter()
296                .flatten()
297                .any(|(_, t)| can_end.contains(t.as_str()))
298                && can_end.insert(id.as_str())
299            {
300                grew = true;
301            }
302        }
303        if !grew {
304            break;
305        }
306    }
307
308    // A reachable node that can never reach a terminal traps the caller.
309    // (Report only reachable ones — an unreachable trap is already flagged as
310    // unreachable and would be noise.)
311    for id in &reachable {
312        if !can_end.contains(id) {
313            errs.push(ValidationError::Trapped {
314                node: (*id).to_string(),
315            });
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    // The doc 48 example, reused across tests. Kept in sync with the one in
325    // the TS suite on purpose — both must stay valid.
326    const LUIGIS: &str = r#"
327schema_version: 1
328id: flow_9f2
329name: Luigi's
330entry: welcome
331nodes:
332  welcome:
333    kind: greeting
334    prompt: Thanks for calling Luigi's!
335    exits: { next: check_hours }
336  check_hours:
337    kind: hours
338    timezone: America/New_York
339    schedule:
340      tue: [{ open: "11:00", close: "22:00" }]
341    exits: { open: front_desk, closed: night_menu }
342  front_desk:
343    kind: ring
344    timeout_secs: 25
345    exits: { no_answer: take_message }
346  night_menu:
347    kind: menu
348    prompt: We're closed. Press 1 for hours.
349    options: { "1": Hours }
350    exits: { "1": say_hours, no_input: take_message, invalid: take_message }
351  say_hours:
352    kind: greeting
353    prompt: Open Tuesday to Sunday.
354    exits: { next: take_message }
355  take_message:
356    kind: message
357    prompt: Leave a message after the tone.
358"#;
359
360    fn parse(src: &str) -> Flow {
361        Flow::from_yaml(src).expect("test flow should parse")
362    }
363
364    #[test]
365    fn the_documented_example_validates() {
366        assert_eq!(validate(&parse(LUIGIS)), Ok(()));
367    }
368
369    #[test]
370    fn rejects_unsupported_schema_version() {
371        let f = parse(&LUIGIS.replace("schema_version: 1", "schema_version: 99"));
372        let errs = validate(&f).unwrap_err();
373        assert!(errs.contains(&ValidationError::UnsupportedSchemaVersion(99)));
374    }
375
376    #[test]
377    fn rejects_missing_entry() {
378        let f = parse(&LUIGIS.replace("entry: welcome", "entry: nope"));
379        let errs = validate(&f).unwrap_err();
380        assert!(errs
381            .iter()
382            .any(|e| matches!(e, ValidationError::MissingEntry(n) if n == "nope")));
383    }
384
385    #[test]
386    fn rejects_dangling_exit_target() {
387        let f = parse(&LUIGIS.replace("next: check_hours", "next: ghost"));
388        let errs = validate(&f).unwrap_err();
389        assert!(errs.iter().any(|e| matches!(
390            e,
391            ValidationError::UnknownExitTarget { target, .. } if target == "ghost"
392        )));
393    }
394
395    #[test]
396    fn rejects_missing_required_exit() {
397        // A greeting with no `next`.
398        let src = r#"
399schema_version: 1
400id: f
401name: n
402entry: g
403nodes:
404  g:
405    kind: greeting
406    prompt: hi
407  bye:
408    kind: hangup
409"#;
410        let errs = validate(&parse(src)).unwrap_err();
411        assert!(errs.iter().any(|e| matches!(
412            e,
413            ValidationError::MissingExits { node, .. } if node == "g"
414        )));
415    }
416
417    #[test]
418    fn rejects_unexpected_exit_on_terminal() {
419        let src = r#"
420schema_version: 1
421id: f
422name: n
423entry: g
424nodes:
425  g:
426    kind: hangup
427    exits: { next: g }
428"#;
429        let errs = validate(&parse(src)).unwrap_err();
430        assert!(errs.iter().any(|e| matches!(
431            e,
432            ValidationError::UnexpectedExits { node, .. } if node == "g"
433        )));
434    }
435
436    #[test]
437    fn rejects_unreachable_node() {
438        let src = r#"
439schema_version: 1
440id: f
441name: n
442entry: g
443nodes:
444  g:
445    kind: hangup
446  orphan:
447    kind: hangup
448"#;
449        let errs = validate(&parse(src)).unwrap_err();
450        assert!(errs.iter().any(|e| matches!(
451            e,
452            ValidationError::Unreachable { node } if node == "orphan"
453        )));
454    }
455
456    #[test]
457    fn rejects_trapping_cycle() {
458        // a -> b -> a, with no terminal anywhere: every caller is stuck.
459        let src = r#"
460schema_version: 1
461id: f
462name: n
463entry: a
464nodes:
465  a:
466    kind: greeting
467    prompt: one
468    exits: { next: b }
469  b:
470    kind: greeting
471    prompt: two
472    exits: { next: a }
473"#;
474        let errs = validate(&parse(src)).unwrap_err();
475        assert!(
476            errs.iter()
477                .any(|e| matches!(e, ValidationError::Trapped { .. })),
478            "a terminal-less loop must be flagged as trapping: {errs:?}"
479        );
480    }
481
482    #[test]
483    fn a_loop_with_an_escape_is_fine() {
484        // Menu loops back to a greeting but no_input/invalid escape to a
485        // terminal — not trapped.
486        let src = r#"
487schema_version: 1
488id: f
489name: n
490entry: m
491nodes:
492  m:
493    kind: menu
494    prompt: press one
495    options: { "1": again }
496    exits: { "1": g, no_input: bye, invalid: bye }
497  g:
498    kind: greeting
499    prompt: again
500    exits: { next: m }
501  bye:
502    kind: hangup
503"#;
504        assert_eq!(validate(&parse(src)), Ok(()));
505    }
506
507    #[test]
508    fn rejects_empty_menu_and_bad_digit() {
509        let empty = r#"
510schema_version: 1
511id: f
512name: n
513entry: m
514nodes:
515  m:
516    kind: menu
517    prompt: hi
518    options: {}
519    exits: { no_input: bye, invalid: bye }
520  bye:
521    kind: hangup
522"#;
523        assert!(validate(&parse(empty))
524            .unwrap_err()
525            .iter()
526            .any(|e| matches!(e, ValidationError::EmptyMenu { .. })));
527
528        let bad = r#"
529schema_version: 1
530id: f
531name: n
532entry: m
533nodes:
534  m:
535    kind: menu
536    prompt: hi
537    options: { A: nope }
538    exits: { A: bye, no_input: bye, invalid: bye }
539  bye:
540    kind: hangup
541"#;
542        assert!(validate(&parse(bad))
543            .unwrap_err()
544            .iter()
545            .any(|e| matches!(e, ValidationError::BadDigit { key, .. } if key == "A")));
546    }
547
548    #[test]
549    fn rejects_empty_transfer_target() {
550        let src = r#"
551schema_version: 1
552id: f
553name: n
554entry: t
555nodes:
556  t:
557    kind: transfer
558    target: "   "
559"#;
560        assert!(validate(&parse(src))
561            .unwrap_err()
562            .iter()
563            .any(|e| matches!(e, ValidationError::EmptyTransferTarget { .. })));
564    }
565
566    #[test]
567    fn rejects_overlong_prompt() {
568        let long = "a".repeat(MAX_PROMPT_CHARS + 1);
569        let src = format!(
570            r#"
571schema_version: 1
572id: f
573name: n
574entry: g
575nodes:
576  g:
577    kind: greeting
578    prompt: {long}
579    exits: {{ next: bye }}
580  bye:
581    kind: hangup
582"#
583        );
584        assert!(validate(&parse(&src))
585            .unwrap_err()
586            .iter()
587            .any(|e| matches!(e, ValidationError::PromptTooLong { .. })));
588    }
589
590    #[test]
591    fn surfaces_hours_config_errors() {
592        let src = r#"
593schema_version: 1
594id: f
595name: n
596entry: h
597nodes:
598  h:
599    kind: hours
600    timezone: Mars/Base
601    schedule: {}
602    exits: { open: bye, closed: bye }
603  bye:
604    kind: hangup
605"#;
606        assert!(validate(&parse(src))
607            .unwrap_err()
608            .iter()
609            .any(|e| matches!(e, ValidationError::Hours { .. })));
610    }
611}