Skip to main content

salvor_graph/
validate.rs

1//! Strict, versioned validation of a graph document.
2//!
3//! [`validate`] runs a set of INDEPENDENT checks and collects EVERY error
4//! rather than stopping at the first, so an author sees the whole picture in one
5//! pass. Each check is its own function that reads the graph and pushes any
6//! failures onto a shared list, so a check can be added, relaxed, or removed
7//! without touching the others. The acyclic check in particular is isolated on
8//! purpose: the current design leans acyclic, and a future change that admits
9//! some cycles can drop that one function and leave the rest untouched.
10//!
11//! The errors are structured ([`GraphError`]): each names the offending node or
12//! edge and carries a clear message, so the CLI can print node/edge-level
13//! diagnostics rather than a bare "invalid".
14
15use std::collections::{BTreeSet, HashMap, HashSet};
16
17use serde_json::Value;
18
19use crate::document::{BranchCondition, FoldBody, FoldJoin, Graph, MapBody, Node, SCHEMA_VERSION};
20use crate::expr;
21
22/// The longest an optional node `name` may be, in characters. Mirrors the
23/// agent definition's own name bound
24/// (`salvor_cli::agent_config::MAX_NAME_LEN`); see [`crate::document`]'s "The
25/// optional node display name" section for why the two fields, though bounded
26/// alike, differ in whether they hash.
27pub const MAX_NODE_NAME_LEN: usize = 64;
28
29/// A single validation failure, naming the node or edge at fault.
30///
31/// `PartialEq` is derived so tests can assert on the exact error value. Each
32/// variant's `Display` (via `thiserror`) is the human message the CLI prints.
33#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
34pub enum GraphError {
35    /// The document declares a `schema_version` this build cannot understand
36    /// (greater than [`SCHEMA_VERSION`], or zero).
37    #[error("unsupported schema_version {found}: this build understands versions 1..={supported}")]
38    UnsupportedSchemaVersion {
39        /// The version the document declared.
40        found: u32,
41        /// The newest version this build understands.
42        supported: u32,
43    },
44
45    /// Two nodes share an id. Node ids must be unique within a document.
46    #[error("duplicate node id `{id}`")]
47    DuplicateNodeId {
48        /// The repeated id.
49        id: String,
50    },
51
52    /// An edge names a node id that does not exist.
53    #[error("edge `{from}` -> `{to}` references unknown node id `{missing}`{}", suggest(.suggestion))]
54    DanglingEdge {
55        /// The edge's declared source.
56        from: String,
57        /// The edge's declared destination.
58        to: String,
59        /// The endpoint id that does not exist (either `from` or `to`).
60        missing: String,
61        /// The nearest existing id, when one is close enough to suggest.
62        suggestion: Option<String>,
63    },
64
65    /// A `map` node's `node` body references a node id that does not exist.
66    #[error("map node `{id}` maps unknown node id `{missing}`{}", suggest(.suggestion))]
67    DanglingMapBody {
68        /// The map node's id.
69        id: String,
70        /// The referenced id that does not exist.
71        missing: String,
72        /// The nearest existing id, when one is close enough to suggest.
73        suggestion: Option<String>,
74    },
75
76    /// A `fold` node's `node` body references a node id that does not exist.
77    #[error("fold node `{id}` folds unknown node id `{missing}`{}", suggest(.suggestion))]
78    DanglingFoldBody {
79        /// The fold node's id.
80        id: String,
81        /// The referenced id that does not exist.
82        missing: String,
83        /// The nearest existing id, when one is close enough to suggest.
84        suggestion: Option<String>,
85    },
86
87    /// An `agent` node's hash is not a well-formed `sha256:<64 lowercase hex>`
88    /// string.
89    #[error("agent node `{id}`: `{hash}` is not a well-formed `sha256:<64 hex>` agent hash")]
90    MalformedAgentHash {
91        /// The agent node's id.
92        id: String,
93        /// The malformed hash string.
94        hash: String,
95    },
96
97    /// A `map` node's concurrency cap is not positive.
98    #[error("map node `{id}`: concurrency cap must be at least 1, found {found}")]
99    NonPositiveConcurrency {
100        /// The map node's id.
101        id: String,
102        /// The declared cap.
103        found: u32,
104    },
105
106    /// A `fold` node's iteration bound is not positive.
107    #[error("fold node `{id}`: max_iterations must be at least 1, found {found}")]
108    NonPositiveMaxIterations {
109        /// The fold node's id.
110        id: String,
111        /// The declared bound.
112        found: u32,
113    },
114
115    /// A `delay` node's wait is zero.
116    ///
117    /// Refused rather than accepted as a no-op, on the same reasoning
118    /// [`GraphError::NonPositiveMaxIterations`] rests on: the whole meaning of
119    /// the node is the wait, so a wait of nothing is an authoring mistake and
120    /// not an intent. A zero delay would still park nothing, record a clock
121    /// reading, a `SleepStarted`, and a `SleepCompleted` in every log forever,
122    /// and mean exactly what deleting the node means. Saying so at submit is
123    /// cheaper than leaving it to be noticed in a run log.
124    #[error("delay node `{id}`: seconds must be at least 1, found {found}")]
125    NonPositiveDelay {
126        /// The delay node's id.
127        id: String,
128        /// The declared wait.
129        found: u64,
130    },
131
132    /// A `gate` node's approval schema is not a JSON object.
133    #[error("gate node `{id}`: approval_schema must be a JSON object")]
134    ApprovalSchemaNotObject {
135        /// The gate node's id.
136        id: String,
137    },
138
139    /// The edge list contains a cycle. `path` renders it as `a -> b -> ... -> a`.
140    #[error("cycle detected: {path}")]
141    Cycle {
142        /// The cycle rendered as a node-id path, closing back on its start.
143        path: String,
144    },
145
146    /// An edge connects two nodes whose declared schemas do not match. See
147    /// [`check_edge_type_compat`] for the deliberately conservative rule.
148    #[error(
149        "edge `{from}` -> `{to}`: the output schema of `{from}` does not match the input schema of `{to}`"
150    )]
151    EdgeTypeMismatch {
152        /// The source node id (its output schema).
153        from: String,
154        /// The destination node id (its input schema).
155        to: String,
156    },
157
158    /// A `branch` node case carries an expression condition that does not parse
159    /// in the [`crate::expr`] condition language. Caught at submit so a bad
160    /// expression is never a run-time failure.
161    #[error("branch node `{node}`: case `{case}` has an invalid condition expression: {error}")]
162    InvalidBranchExpression {
163        /// The branch node's id.
164        node: String,
165        /// The name of the offending case.
166        case: String,
167        /// The parse error's message.
168        error: String,
169    },
170
171    /// A `branch` node carries a `model_decision` case but declares no
172    /// `agent_hash`, so the engine would have no agent to make the decision.
173    /// Caught at submit, node- and case-precise.
174    #[error(
175        "branch node `{node}`: case `{case}` is a model decision but the branch declares no `agent_hash`"
176    )]
177    ModelDecisionWithoutAgent {
178        /// The branch node's id.
179        node: String,
180        /// The name of the model-decision case with no agent.
181        case: String,
182    },
183
184    /// A `branch` node case names no outbound edge from that node: no edge's
185    /// `label` matches the case name. Caught at submit, because otherwise the
186    /// engine fires the case at run time, finds no live edge, skips every node
187    /// downstream of it, and the run completes as if that were the intended
188    /// path. See [`check_branch_case_edges`].
189    #[error(
190        "branch node `{node}`: case `{case}` has no outbound edge; point the case at a node, and point a route meant to end the run at a terminal node instead"
191    )]
192    BranchCaseWithoutEdge {
193        /// The branch node's id.
194        node: String,
195        /// The name of the case with no matching edge.
196        case: String,
197    },
198
199    /// An outbound edge from a `branch` node carries a `label` that matches no
200    /// case the branch declares. Caught at submit for the same reason as
201    /// [`GraphError::BranchCaseWithoutEdge`], from the other side: the engine
202    /// only ever takes a branch's edge by matching the fired case's name
203    /// against the label, so a label naming nothing can never fire and the
204    /// edge just sits there, dead. See [`check_branch_edge_labels`].
205    #[error(
206        "branch node `{node}`: outbound edge labeled `{label}` names no case; rename the label to match one of the branch's cases, or declare the case `{label}` on the branch"
207    )]
208    BranchEdgeWithoutCase {
209        /// The branch node's id.
210        node: String,
211        /// The edge label that matches no declared case.
212        label: String,
213    },
214
215    /// An outbound edge from a `branch` node carries no `label` at all.
216    /// Caught at submit for the same engine reason as
217    /// [`GraphError::BranchEdgeWithoutCase`]: `salvor_engine::is_live_inbound`
218    /// only ever takes a branch's edge by matching the fired case's name
219    /// against the edge's label, and a fired case's name is always `Some`, so
220    /// an edge whose label is `None` can never match it either. Such an edge
221    /// can never fire, exactly like one whose label matches no case, so it
222    /// gets the same treatment: a submit-time, node- and target-precise
223    /// error instead of a route that silently never gets taken. See
224    /// [`check_branch_edge_labels`].
225    #[error(
226        "branch node `{node}`: outbound edge to `{to}` has no label; label it with one of the branch's cases"
227    )]
228    BranchEdgeWithoutLabel {
229        /// The branch node's id.
230        node: String,
231        /// The edge's destination.
232        to: String,
233    },
234
235    /// A `fold` node's `stop_when` predicate does not parse in the
236    /// [`crate::expr`] condition language. Caught at submit so a bad predicate is
237    /// never a run-time failure, exactly like a branch case's expression.
238    #[error("fold node `{node}`: `stop_when` is not a valid condition expression: {error}")]
239    InvalidFoldStopExpression {
240        /// The fold node's id.
241        node: String,
242        /// The parse error's message.
243        error: String,
244    },
245
246    /// A `fold` node's `best_by` join reference is not a well-formed path in the
247    /// [`crate::expr`] language (a bare literal, or a malformed path). Caught at
248    /// submit, node-precise.
249    #[error(
250        "fold node `{node}`: the `best_by` join reference `{reference}` is not a valid path: {error}"
251    )]
252    InvalidFoldJoinReference {
253        /// The fold node's id.
254        node: String,
255        /// The malformed reference.
256        reference: String,
257        /// The parse error's message.
258        error: String,
259    },
260
261    /// A `fold` node's `stop_when` predicate reads a path the body node's
262    /// declared `output_schema` does not describe. See
263    /// [`check_fold_reference_shapes`] for when this fires and, more
264    /// importantly, when it stays quiet.
265    #[error(
266        "fold node `{node}`: `stop_when` reads `{path}`, which body node `{body}`'s declared output schema does not describe"
267    )]
268    FoldStopPathNotInBodySchema {
269        /// The fold node's id.
270        node: String,
271        /// The path the predicate reads, as the expression names it.
272        path: String,
273        /// The id of the body node whose schema does not describe it.
274        body: String,
275    },
276
277    /// A `fold` node's `best_by` join reference names a path the body node's
278    /// declared `output_schema` does not describe. The join half of
279    /// [`GraphError::FoldStopPathNotInBodySchema`], reported separately because
280    /// the two are fixed in different places.
281    #[error(
282        "fold node `{node}`: the `best_by` join reference `{reference}` is not described by body node `{body}`'s declared output schema"
283    )]
284    FoldJoinReferenceNotInBodySchema {
285        /// The fold node's id.
286        node: String,
287        /// The join reference, as the document writes it.
288        reference: String,
289        /// The id of the body node whose schema does not describe it.
290        body: String,
291    },
292
293    /// A node's optional `name` is over [`MAX_NODE_NAME_LEN`] characters.
294    #[error("node `{id}`: `name` is {len} characters, over the {max}-character cap")]
295    NodeNameTooLong {
296        /// The node's id.
297        id: String,
298        /// The name's length, in characters (`chars().count()`, not bytes).
299        len: usize,
300        /// [`MAX_NODE_NAME_LEN`], repeated here so the error is self-contained.
301        max: usize,
302    },
303
304    /// A node's optional `name` is set but empty or all whitespace.
305    #[error("node `{id}`: `name`, if set, must not be empty or all whitespace")]
306    BlankNodeName {
307        /// The node's id.
308        id: String,
309    },
310}
311
312/// Formats an optional nearest-name suggestion as a trailing clause, or empty.
313fn suggest(suggestion: &Option<String>) -> String {
314    match suggestion {
315        Some(name) => format!(" (did you mean `{name}`?)"),
316        None => String::new(),
317    }
318}
319
320/// A successful validation's summary of the graph's shape.
321///
322/// Entry nodes have no inbound edge; terminal nodes have no outbound edge. Both
323/// lists are sorted, so the CLI output is deterministic.
324#[derive(Clone, Debug, PartialEq, Eq)]
325pub struct GraphSummary {
326    /// Number of nodes in the document.
327    pub node_count: usize,
328    /// Number of edges in the document.
329    pub edge_count: usize,
330    /// Ids of nodes with no inbound edge, sorted.
331    pub entry_nodes: Vec<String>,
332    /// Ids of nodes with no outbound edge, sorted.
333    pub terminal_nodes: Vec<String>,
334}
335
336/// Validates a graph document, returning a summary on success or EVERY error on
337/// failure.
338///
339/// The checks are independent and all run: the returned `Vec` holds a failure
340/// from each check that found one, so an author fixes everything at once. The
341/// order of checks below is the order errors appear in.
342///
343/// # Errors
344///
345/// Returns the collected [`GraphError`]s when any check fails.
346pub fn validate(graph: &Graph) -> Result<GraphSummary, Vec<GraphError>> {
347    let mut errors = Vec::new();
348
349    check_schema_version(graph, &mut errors);
350    check_unique_node_ids(graph, &mut errors);
351    check_referential_integrity(graph, &mut errors);
352    check_node_fields(graph, &mut errors);
353    check_node_names(graph, &mut errors);
354    check_branch_expressions(graph, &mut errors);
355    check_branch_case_edges(graph, &mut errors);
356    check_branch_edge_labels(graph, &mut errors);
357    check_fold_expressions(graph, &mut errors);
358    check_fold_reference_shapes(graph, &mut errors);
359    check_acyclic(graph, &mut errors);
360    check_edge_type_compat(graph, &mut errors);
361
362    if errors.is_empty() {
363        Ok(summarize(graph))
364    } else {
365        Err(errors)
366    }
367}
368
369/// Rejects a `schema_version` from the future (or zero). This is the strict-in
370/// half of the version discipline; the additive-out half is that an
371/// older-or-equal version is accepted unchanged. See [`SCHEMA_VERSION`].
372fn check_schema_version(graph: &Graph, errors: &mut Vec<GraphError>) {
373    if graph.schema_version == 0 || graph.schema_version > SCHEMA_VERSION {
374        errors.push(GraphError::UnsupportedSchemaVersion {
375            found: graph.schema_version,
376            supported: SCHEMA_VERSION,
377        });
378    }
379}
380
381/// Rejects a document where two nodes share an id.
382fn check_unique_node_ids(graph: &Graph, errors: &mut Vec<GraphError>) {
383    let mut seen = HashSet::new();
384    for node in &graph.nodes {
385        if !seen.insert(node.id()) {
386            errors.push(GraphError::DuplicateNodeId {
387                id: node.id().to_owned(),
388            });
389        }
390    }
391}
392
393/// Every id an edge endpoint or a `map` body names must be a real node id.
394///
395/// When a named id is missing, the nearest existing id (by edit distance) is
396/// suggested if it is close enough to be a plausible typo.
397fn check_referential_integrity(graph: &Graph, errors: &mut Vec<GraphError>) {
398    let ids: BTreeSet<&str> = graph.nodes.iter().map(Node::id).collect();
399
400    for edge in &graph.edges {
401        if !ids.contains(edge.from.as_str()) {
402            errors.push(GraphError::DanglingEdge {
403                from: edge.from.clone(),
404                to: edge.to.clone(),
405                missing: edge.from.clone(),
406                suggestion: nearest(&edge.from, &ids),
407            });
408        }
409        if !ids.contains(edge.to.as_str()) {
410            errors.push(GraphError::DanglingEdge {
411                from: edge.from.clone(),
412                to: edge.to.clone(),
413                missing: edge.to.clone(),
414                suggestion: nearest(&edge.to, &ids),
415            });
416        }
417    }
418
419    for node in &graph.nodes {
420        if let Node::Map(map) = node
421            && let MapBody::Node(target) = &map.body
422            && !ids.contains(target.as_str())
423        {
424            errors.push(GraphError::DanglingMapBody {
425                id: map.id.clone(),
426                missing: target.clone(),
427                suggestion: nearest(target, &ids),
428            });
429        }
430        if let Node::Fold(fold) = node
431            && let FoldBody::Node(target) = &fold.body
432            && !ids.contains(target.as_str())
433        {
434            errors.push(GraphError::DanglingFoldBody {
435                id: fold.id.clone(),
436                missing: target.clone(),
437                suggestion: nearest(target, &ids),
438            });
439        }
440    }
441}
442
443/// Per-node required-field checks: an agent hash is well-formed, a map cap is
444/// positive, a gate's approval schema is an object, a delay waits for
445/// something. Each rule is a small, independent block so a rule can be relaxed
446/// on its own.
447fn check_node_fields(graph: &Graph, errors: &mut Vec<GraphError>) {
448    for node in &graph.nodes {
449        match node {
450            Node::Agent(agent) => {
451                if !is_well_formed_agent_hash(&agent.agent_hash) {
452                    errors.push(GraphError::MalformedAgentHash {
453                        id: agent.id.clone(),
454                        hash: agent.agent_hash.clone(),
455                    });
456                }
457            }
458            Node::Map(map) => {
459                if map.concurrency < 1 {
460                    errors.push(GraphError::NonPositiveConcurrency {
461                        id: map.id.clone(),
462                        found: map.concurrency,
463                    });
464                }
465            }
466            Node::Gate(gate) => {
467                if !gate.approval_schema.is_object() {
468                    errors.push(GraphError::ApprovalSchemaNotObject {
469                        id: gate.id.clone(),
470                    });
471                }
472            }
473            Node::Fold(fold) => {
474                if fold.max_iterations < 1 {
475                    errors.push(GraphError::NonPositiveMaxIterations {
476                        id: fold.id.clone(),
477                        found: fold.max_iterations,
478                    });
479                }
480            }
481            Node::Delay(delay) => {
482                if delay.seconds < 1 {
483                    errors.push(GraphError::NonPositiveDelay {
484                        id: delay.id.clone(),
485                        found: delay.seconds,
486                    });
487                }
488            }
489            // Tool and branch carry no field rule beyond the strict parse.
490            Node::Tool(_) | Node::Branch(_) => {}
491        }
492    }
493}
494
495/// A node's optional `name`, when set, must not be empty or all whitespace,
496/// and must be at most [`MAX_NODE_NAME_LEN`] characters
497/// (`chars().count()`, not bytes). Applies uniformly across all seven node
498/// kinds through [`Node::name`], mirroring the agent definition's own name
499/// rule.
500fn check_node_names(graph: &Graph, errors: &mut Vec<GraphError>) {
501    for node in &graph.nodes {
502        let Some(name) = node.name() else {
503            continue;
504        };
505        if name.trim().is_empty() {
506            errors.push(GraphError::BlankNodeName {
507                id: node.id().to_owned(),
508            });
509            continue;
510        }
511        let len = name.chars().count();
512        if len > MAX_NODE_NAME_LEN {
513            errors.push(GraphError::NodeNameTooLong {
514                id: node.id().to_owned(),
515                len,
516                max: MAX_NODE_NAME_LEN,
517            });
518        }
519    }
520}
521
522/// Every `branch` case is checked for the rule its condition kind implies.
523///
524/// This is where the opaque case conditions earn their meaning, AT SUBMIT, so a
525/// malformed branch is a node-precise error the author sees now, never a
526/// run-time failure inside a durable, replayed run:
527///
528/// - an `expression` condition must parse in the [`crate::expr`] condition
529///   language;
530/// - a `model_decision` condition requires the branch to declare an
531///   `agent_hash`, because the engine drives that agent to make the decision;
532/// - a declared `agent_hash` must be a well-formed `sha256:<64 hex>` string,
533///   exactly like an agent node's hash.
534///
535/// Each fault is one collected error naming the node (and, for a case fault, the
536/// case).
537fn check_branch_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
538    for node in &graph.nodes {
539        let Node::Branch(branch) = node else {
540            continue;
541        };
542        if let Some(hash) = &branch.agent_hash
543            && !is_well_formed_agent_hash(hash)
544        {
545            errors.push(GraphError::MalformedAgentHash {
546                id: branch.id.clone(),
547                hash: hash.clone(),
548            });
549        }
550        for case in &branch.cases {
551            match &case.when {
552                BranchCondition::Expression(source) => {
553                    if let Err(error) = expr::parse(source) {
554                        errors.push(GraphError::InvalidBranchExpression {
555                            node: branch.id.clone(),
556                            case: case.name.clone(),
557                            error: error.to_string(),
558                        });
559                    }
560                }
561                BranchCondition::ModelDecision => {
562                    if branch.agent_hash.is_none() {
563                        errors.push(GraphError::ModelDecisionWithoutAgent {
564                            node: branch.id.clone(),
565                            case: case.name.clone(),
566                        });
567                    }
568                }
569            }
570        }
571    }
572}
573
574/// Every `branch` case must label at least one outbound edge from that node.
575///
576/// The engine picks a branch's live outbound edge by matching the fired case's
577/// name against each edge's `label` (see `salvor_engine::is_live_inbound`). A
578/// case with no matching label can still fire: the branch evaluates its
579/// condition, records which case won, and only then discovers there is nowhere
580/// to route it. Every node downstream of that edge is then skipped, and the run
581/// completes having silently taken no path at all, exactly as if the missing
582/// edge had been the intended one. Caught here instead, node- and
583/// case-precise, so a misspelled or forgotten edge label is an authoring
584/// mistake seen at submit, not a run that finishes looking healthy.
585///
586/// The inverse, an outbound edge whose label names no case the branch
587/// declares, is checked separately by [`check_branch_edge_labels`]: it is a
588/// different mistake (the edge is dead outright, rather than silently making
589/// the run look like it took a path it did not), and each check has its own
590/// error variant so an author sees precisely which side of the label typo is
591/// theirs to fix.
592fn check_branch_case_edges(graph: &Graph, errors: &mut Vec<GraphError>) {
593    for node in &graph.nodes {
594        let Node::Branch(branch) = node else {
595            continue;
596        };
597        let labels: HashSet<&str> = graph
598            .edges
599            .iter()
600            .filter(|edge| edge.from == branch.id)
601            .filter_map(|edge| edge.label.as_deref())
602            .collect();
603        for case in &branch.cases {
604            if !labels.contains(case.name.as_str()) {
605                errors.push(GraphError::BranchCaseWithoutEdge {
606                    node: branch.id.clone(),
607                    case: case.name.clone(),
608                });
609            }
610        }
611    }
612}
613
614/// Every outbound edge from a `branch` node whose `label` is set must name one
615/// of the branch's own declared cases.
616///
617/// This is [`check_branch_case_edges`] read the other way. The engine takes a
618/// branch's live edge by matching the fired case's name against an edge's
619/// label (see `salvor_engine::is_live_inbound`); a label that names no case
620/// can never match anything a branch fires, so the edge is simply dead, no
621/// run ever takes it, and it sits in the document looking like a route that
622/// does not exist. In practice this is almost always the other half of
623/// exactly the same typo `check_branch_case_edges` catches: the case is
624/// spelled one way and the edge meant to realize it is spelled another, and
625/// one of the two is the mistake. Reporting both, node- and label-precise, is
626/// what lets an author see the mismatch and pick the correct spelling instead
627/// of guessing which side is wrong.
628///
629/// An outbound edge from a branch that carries NO label at all is a
630/// validation error too, node- and target-precise, reported by this same
631/// function as [`GraphError::BranchEdgeWithoutLabel`]:
632/// [`crate::document::Edge::label`] is optional in the document's shape, but
633/// the engine can only ever pick a branch's live edge by matching the fired
634/// case's name against that label (see `salvor_engine::is_live_inbound`), and
635/// a fired case's name is always `Some`, so a `None` label can never match it
636/// either, exactly like a label naming no case. The two get their own
637/// [`GraphError`] variants because the fix differs: a mismatched label is a
638/// typo to correct, a missing one has no case to point at until the author
639/// picks one.
640fn check_branch_edge_labels(graph: &Graph, errors: &mut Vec<GraphError>) {
641    for node in &graph.nodes {
642        let Node::Branch(branch) = node else {
643            continue;
644        };
645        let case_names: HashSet<&str> =
646            branch.cases.iter().map(|case| case.name.as_str()).collect();
647        for edge in &graph.edges {
648            if edge.from != branch.id {
649                continue;
650            }
651            match &edge.label {
652                Some(label) if !case_names.contains(label.as_str()) => {
653                    errors.push(GraphError::BranchEdgeWithoutCase {
654                        node: branch.id.clone(),
655                        label: label.clone(),
656                    });
657                }
658                None => {
659                    errors.push(GraphError::BranchEdgeWithoutLabel {
660                        node: branch.id.clone(),
661                        to: edge.to.clone(),
662                    });
663                }
664                Some(_) => {}
665            }
666        }
667    }
668}
669
670/// Every `fold` node's expression fields are checked AT SUBMIT, exactly like a
671/// branch's, so a malformed predicate or join reference is a node-precise error
672/// now rather than a run-time failure inside a durable, replayed run:
673///
674/// - the `stop_when` predicate must parse in the [`crate::expr`] condition
675///   language (the same one a branch case's expression uses);
676/// - a [`FoldJoin::BestBy`] reference must parse as an [`crate::expr`] path (a
677///   location in the accumulated value, never a bare literal).
678///
679/// The `last` and `all` join rules carry no expression to check. The iteration
680/// bound is checked in [`check_node_fields`], the body reference in
681/// [`check_referential_integrity`], keeping each rule independent.
682fn check_fold_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
683    for node in &graph.nodes {
684        let Node::Fold(fold) = node else {
685            continue;
686        };
687        if let Err(error) = expr::parse(&fold.stop_when) {
688            errors.push(GraphError::InvalidFoldStopExpression {
689                node: fold.id.clone(),
690                error: error.to_string(),
691            });
692        }
693        if let FoldJoin::BestBy(reference) = &fold.join
694            && let Err(error) = expr::parse_reference(reference)
695        {
696            errors.push(GraphError::InvalidFoldJoinReference {
697                node: fold.id.clone(),
698                reference: reference.clone(),
699                error: error.to_string(),
700            });
701        }
702    }
703}
704
705/// Every `fold` whose body names a node that DECLARES an output schema has its
706/// expression references read against that schema, AT SUBMIT.
707///
708/// A fold's accumulated value is what its body produced: the body's declared
709/// `output_schema` is therefore the shape `stop_when` and a `best_by` reference
710/// read, path for path, with no envelope or prefix in front of it. So a
711/// predicate reading `scoer >= 0.85` against a body that declares only `score`
712/// is a typo the author can be told about now rather than a loop that silently
713/// never stops.
714///
715/// # When this stays quiet
716///
717/// A path is reported ONLY when walking it POSITIVELY fails: a segment is
718/// absent from a `properties` map that exists and does not admit extra keys.
719/// Everything else is unjudged, and deliberately so, because a check that
720/// guessed would cost an author a legal document:
721///
722/// - a body that declares no `output_schema`, or a `subgraph` body, or a body
723///   id that names no node: nothing to read the path against;
724/// - a schema with no `properties` (`{"type": "object"}` on its own), or one
725///   whose declared `type` is not the kind the segment steps into;
726/// - a schema that composes its shape elsewhere (`$ref`, `anyOf`, `oneOf`,
727///   `allOf`, `not`), or admits extra keys (`additionalProperties` set to
728///   anything but `false`, or any `patternProperties`);
729/// - every segment past the first that could not be walked, since a walk that
730///   stopped knowing nothing cannot judge what comes after it.
731///
732/// The bound, the body reference, and the expressions' own parse are checked
733/// elsewhere ([`check_node_fields`], [`check_referential_integrity`],
734/// [`check_fold_expressions`]); a `stop_when` that does not parse is skipped
735/// here, because the parse error is the error worth printing.
736fn check_fold_reference_shapes(graph: &Graph, errors: &mut Vec<GraphError>) {
737    let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
738
739    for node in &graph.nodes {
740        let Node::Fold(fold) = node else {
741            continue;
742        };
743        let FoldBody::Node(body_id) = &fold.body else {
744            continue;
745        };
746        let Some(schema) = by_id
747            .get(body_id.as_str())
748            .and_then(|body| body.output_schema())
749        else {
750            continue;
751        };
752
753        if let Ok(predicate) = expr::parse(&fold.stop_when) {
754            for path in predicate.paths() {
755                if !schema_describes(schema, path) {
756                    errors.push(GraphError::FoldStopPathNotInBodySchema {
757                        node: fold.id.clone(),
758                        path: render_path(path),
759                        body: body_id.clone(),
760                    });
761                }
762            }
763        }
764
765        if let FoldJoin::BestBy(reference) = &fold.join
766            && let Ok(parsed) = expr::parse_reference(reference)
767            && !schema_describes(schema, parsed.segments())
768        {
769            errors.push(GraphError::FoldJoinReferenceNotInBodySchema {
770                node: fold.id.clone(),
771                reference: reference.clone(),
772                body: body_id.clone(),
773            });
774        }
775    }
776}
777
778/// Whether `schema` leaves `path` plausible: false ONLY when a step positively
779/// fails. A step that the schema says nothing about ends the walk in the
780/// author's favor.
781fn schema_describes(schema: &Value, path: &[expr::Segment]) -> bool {
782    let mut here = schema;
783    for segment in path {
784        match step_into(here, segment) {
785            Step::Into(next) => here = next,
786            Step::Unjudged => return true,
787            Step::Absent => return false,
788        }
789    }
790    true
791}
792
793/// What one step of a path finds in a schema.
794enum Step<'a> {
795    /// The sub-schema the step lands in, which the next step reads.
796    Into(&'a Value),
797    /// The schema does not say, so nothing after this point can be judged.
798    Unjudged,
799    /// The schema positively excludes this step.
800    Absent,
801}
802
803/// Takes one path step through a schema. The whole judgment of this check lives
804/// here; see [`check_fold_reference_shapes`] for why each `Unjudged` is one.
805fn step_into<'a>(schema: &'a Value, segment: &expr::Segment) -> Step<'a> {
806    let Some(object) = schema.as_object() else {
807        return Step::Unjudged;
808    };
809    // A schema that names its shape somewhere else is not one this walk reads.
810    if ["$ref", "anyOf", "oneOf", "allOf", "not"]
811        .iter()
812        .any(|keyword| object.contains_key(*keyword))
813    {
814        return Step::Unjudged;
815    }
816
817    match segment {
818        expr::Segment::Key(key) => {
819            if !admits_type(object, "object") {
820                return Step::Unjudged;
821            }
822            let Some(properties) = object.get("properties").and_then(Value::as_object) else {
823                return Step::Unjudged;
824            };
825            if let Some(property) = properties.get(key) {
826                return Step::Into(property);
827            }
828            if admits_extra_keys(object) {
829                Step::Unjudged
830            } else {
831                Step::Absent
832            }
833        }
834        expr::Segment::Index(index) => {
835            if !admits_type(object, "array") {
836                return Step::Unjudged;
837            }
838            match object.get("items") {
839                Some(items) if items.is_object() => Step::Into(items),
840                // The tuple form: an index inside it is that entry, an index
841                // past it is not something this check will call a mistake.
842                Some(Value::Array(entries)) => {
843                    entries.get(*index).map_or(Step::Unjudged, Step::Into)
844                }
845                _ => Step::Unjudged,
846            }
847        }
848    }
849}
850
851/// Whether a schema's declared `type`, if it declares one at all, admits
852/// `wanted`. A schema with no `type` is read as its `properties` describe it.
853fn admits_type(object: &serde_json::Map<String, Value>, wanted: &str) -> bool {
854    match object.get("type") {
855        None => true,
856        Some(Value::String(declared)) => declared == wanted,
857        Some(Value::Array(declared)) => declared.iter().any(|one| one.as_str() == Some(wanted)),
858        // A malformed `type` is not this check's to report.
859        Some(_) => true,
860    }
861}
862
863/// Whether a schema admits keys its `properties` does not name. A declared
864/// `properties` map is read as the author's statement of the shape, so silence
865/// about `additionalProperties` is CLOSED here: an author who means open says
866/// so, and that is the one reading under which this check can say anything at
867/// all.
868fn admits_extra_keys(object: &serde_json::Map<String, Value>) -> bool {
869    if object.contains_key("patternProperties") {
870        return true;
871    }
872    match object.get("additionalProperties") {
873        None | Some(Value::Bool(false)) => false,
874        Some(_) => true,
875    }
876}
877
878/// A path as its source spells it, for an error message: `review.scores.0`.
879fn render_path(path: &[expr::Segment]) -> String {
880    path.iter()
881        .map(|segment| match segment {
882            expr::Segment::Key(key) => key.clone(),
883            expr::Segment::Index(index) => index.to_string(),
884        })
885        .collect::<Vec<_>>()
886        .join(".")
887}
888
889/// An agent hash is `sha256:` followed by exactly 64 lowercase hex digits.
890fn is_well_formed_agent_hash(hash: &str) -> bool {
891    let Some(hex) = hash.strip_prefix("sha256:") else {
892        return false;
893    };
894    hex.len() == 64
895        && hex
896            .bytes()
897            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
898}
899
900/// Reports the first cycle found in the edge topology as a node-id path.
901///
902/// A depth-first walk colors nodes white (unseen), gray (on the current stack),
903/// or black (finished). Reaching a gray node closes a cycle, which is rebuilt
904/// from the current stack. This is the single isolated check that encodes the
905/// acyclic lean; a later change that admits cycles removes only this function.
906fn check_acyclic(graph: &Graph, errors: &mut Vec<GraphError>) {
907    // Adjacency by node id. Edges to unknown ids are skipped: referential
908    // integrity already reports those, and skipping keeps this walk in-bounds.
909    let ids: HashSet<&str> = graph.nodes.iter().map(Node::id).collect();
910    let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
911    for edge in &graph.edges {
912        if ids.contains(edge.from.as_str()) && ids.contains(edge.to.as_str()) {
913            adjacency
914                .entry(edge.from.as_str())
915                .or_default()
916                .push(edge.to.as_str());
917        }
918    }
919
920    #[derive(Clone, Copy, PartialEq)]
921    enum Color {
922        White,
923        Gray,
924        Black,
925    }
926    let mut color: HashMap<&str, Color> = ids.iter().map(|id| (*id, Color::White)).collect();
927    let mut stack: Vec<&str> = Vec::new();
928
929    // An explicit work stack instead of recursion, so a deep graph cannot blow
930    // the call stack. Each frame is a node and the index of the next neighbor
931    // to visit.
932    for start in graph.nodes.iter().map(Node::id) {
933        if color[start] != Color::White {
934            continue;
935        }
936        let mut frames: Vec<(&str, usize)> = vec![(start, 0)];
937        color.insert(start, Color::Gray);
938        stack.push(start);
939
940        while let Some(&mut (node, ref mut next)) = frames.last_mut() {
941            let neighbors = adjacency.get(node).map_or(&[][..], Vec::as_slice);
942            if *next < neighbors.len() {
943                let neighbor = neighbors[*next];
944                *next += 1;
945                match color[neighbor] {
946                    Color::White => {
947                        color.insert(neighbor, Color::Gray);
948                        stack.push(neighbor);
949                        frames.push((neighbor, 0));
950                    }
951                    Color::Gray => {
952                        // A back edge: the neighbor is on the current stack, so
953                        // the path from it to here, closed by this edge, is a
954                        // cycle.
955                        let start_at = stack.iter().position(|n| *n == neighbor).unwrap_or(0);
956                        let mut path: Vec<&str> = stack[start_at..].to_vec();
957                        path.push(neighbor);
958                        errors.push(GraphError::Cycle {
959                            path: path.join(" -> "),
960                        });
961                        return;
962                    }
963                    Color::Black => {}
964                }
965            } else {
966                color.insert(node, Color::Black);
967                stack.pop();
968                frames.pop();
969            }
970        }
971    }
972}
973
974/// Where both endpoints of an edge declare a schema, they must match.
975///
976/// # The rule, and its deliberate limitation
977///
978/// The check is exact structural equality of the two declared JSON Schema
979/// documents (`source.output_schema == target.input_schema`, a deep value
980/// comparison). Where either endpoint omits its schema, the edge passes
981/// unchecked.
982///
983/// This deliberately does NOT implement JSON Schema subtyping. Two schemas that
984/// are compatible but not identical (a subset/superset relationship, the same
985/// shape spelled differently, an added optional property) are reported as a
986/// mismatch, and the fix is to make the declared schemas equal. The trade is
987/// intentional: exact equality is pure, cheap, and easy to reason about, and it
988/// never claims a compatibility it cannot verify. A later change can relax
989/// equality to real schema compatibility by changing only this function.
990fn check_edge_type_compat(graph: &Graph, errors: &mut Vec<GraphError>) {
991    let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
992
993    for edge in &graph.edges {
994        let (Some(from), Some(to)) = (by_id.get(edge.from.as_str()), by_id.get(edge.to.as_str()))
995        else {
996            // A dangling edge; referential integrity already reported it.
997            continue;
998        };
999        if let (Some(out), Some(inp)) = (from.output_schema(), to.input_schema())
1000            && out != inp
1001        {
1002            errors.push(GraphError::EdgeTypeMismatch {
1003                from: edge.from.clone(),
1004                to: edge.to.clone(),
1005            });
1006        }
1007    }
1008}
1009
1010/// Builds the success summary: node and edge counts, plus entry (no inbound)
1011/// and terminal (no outbound) node ids, sorted.
1012fn summarize(graph: &Graph) -> GraphSummary {
1013    let has_inbound: HashSet<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
1014    let has_outbound: HashSet<&str> = graph.edges.iter().map(|e| e.from.as_str()).collect();
1015
1016    let mut entry_nodes: Vec<String> = graph
1017        .nodes
1018        .iter()
1019        .map(Node::id)
1020        .filter(|id| !has_inbound.contains(id))
1021        .map(str::to_owned)
1022        .collect();
1023    let mut terminal_nodes: Vec<String> = graph
1024        .nodes
1025        .iter()
1026        .map(Node::id)
1027        .filter(|id| !has_outbound.contains(id))
1028        .map(str::to_owned)
1029        .collect();
1030    entry_nodes.sort();
1031    terminal_nodes.sort();
1032
1033    GraphSummary {
1034        node_count: graph.nodes.len(),
1035        edge_count: graph.edges.len(),
1036        entry_nodes,
1037        terminal_nodes,
1038    }
1039}
1040
1041/// The nearest existing id to a missing one, if close enough to be a plausible
1042/// typo. Cheap: Levenshtein distance, suggested only when the distance is at
1043/// most a third of the longer id's length (and always the single closest).
1044fn nearest(missing: &str, ids: &BTreeSet<&str>) -> Option<String> {
1045    let mut best: Option<(usize, &str)> = None;
1046    for candidate in ids {
1047        let distance = levenshtein(missing, candidate);
1048        if best.is_none_or(|(d, _)| distance < d) {
1049            best = Some((distance, candidate));
1050        }
1051    }
1052    best.and_then(|(distance, candidate)| {
1053        let threshold = (missing.len().max(candidate.len()) / 3).max(1);
1054        (distance <= threshold).then(|| candidate.to_owned())
1055    })
1056}
1057
1058/// Classic Levenshtein edit distance over bytes, two-row rolling table. Ids are
1059/// short, so this stays trivially cheap.
1060fn levenshtein(a: &str, b: &str) -> usize {
1061    let a = a.as_bytes();
1062    let b = b.as_bytes();
1063    let mut previous: Vec<usize> = (0..=b.len()).collect();
1064    let mut current = vec![0usize; b.len() + 1];
1065    for (i, &ac) in a.iter().enumerate() {
1066        current[0] = i + 1;
1067        for (j, &bc) in b.iter().enumerate() {
1068            let cost = usize::from(ac != bc);
1069            current[j + 1] = (previous[j + 1] + 1)
1070                .min(current[j] + 1)
1071                .min(previous[j] + cost);
1072        }
1073        std::mem::swap(&mut previous, &mut current);
1074    }
1075    previous[b.len()]
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::document::{
1082        AgentNode, BranchCase, BranchCondition, BranchNode, DelayNode, Edge, FoldBody, FoldJoin,
1083        FoldNode, GateNode, MapBody, MapNode, ToolNode,
1084    };
1085    use serde_json::json;
1086    use std::collections::BTreeMap;
1087
1088    fn hash() -> String {
1089        format!("sha256:{}", "a".repeat(64))
1090    }
1091
1092    fn agent(id: &str) -> Node {
1093        Node::Agent(AgentNode {
1094            name: None,
1095            id: id.into(),
1096            agent_hash: hash(),
1097            input_schema: None,
1098            output_schema: None,
1099        })
1100    }
1101
1102    fn gate(id: &str) -> Node {
1103        Node::Gate(GateNode {
1104            name: None,
1105            id: id.into(),
1106            prompt: None,
1107            approval_schema: json!({"type": "object"}),
1108        })
1109    }
1110
1111    fn edge(from: &str, to: &str) -> Edge {
1112        Edge {
1113            from: from.into(),
1114            to: to.into(),
1115            label: None,
1116        }
1117    }
1118
1119    /// A branch's labeled outbound edge, the shape a case needs to route
1120    /// anywhere at all.
1121    fn labeled_edge(from: &str, to: &str, label: &str) -> Edge {
1122        Edge {
1123            from: from.into(),
1124            to: to.into(),
1125            label: Some(label.into()),
1126        }
1127    }
1128
1129    fn graph(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
1130        Graph {
1131            schema_version: SCHEMA_VERSION,
1132            nodes,
1133            edges,
1134        }
1135    }
1136
1137    /// A linear research -> review -> gate flow validates clean, with the right
1138    /// counts and entry/terminal nodes.
1139    #[test]
1140    fn valid_linear_graph_summarizes() {
1141        let g = graph(
1142            vec![agent("research"), agent("review"), gate("approve")],
1143            vec![edge("research", "review"), edge("review", "approve")],
1144        );
1145        let summary = validate(&g).expect("valid");
1146        assert_eq!(summary.node_count, 3);
1147        assert_eq!(summary.edge_count, 2);
1148        assert_eq!(summary.entry_nodes, vec!["research"]);
1149        assert_eq!(summary.terminal_nodes, vec!["approve"]);
1150    }
1151
1152    /// A dangling edge names the offending edge and the missing id, and
1153    /// suggests the near miss.
1154    #[test]
1155    fn dangling_edge_is_reported_with_suggestion() {
1156        let g = graph(vec![agent("research")], vec![edge("research", "reviewx")]);
1157        let errors = validate(&g).expect_err("invalid");
1158        assert!(
1159            errors.contains(&GraphError::DanglingEdge {
1160                from: "research".into(),
1161                to: "reviewx".into(),
1162                missing: "reviewx".into(),
1163                suggestion: Some("research".into()),
1164            }) || matches!(
1165                errors.first(),
1166                Some(GraphError::DanglingEdge { missing, .. }) if missing == "reviewx"
1167            )
1168        );
1169        let message = errors[0].to_string();
1170        assert!(
1171            message.contains("reviewx"),
1172            "names the missing id: {message}"
1173        );
1174    }
1175
1176    /// A malformed agent hash names the node.
1177    #[test]
1178    fn malformed_agent_hash_is_reported() {
1179        let g = graph(
1180            vec![Node::Agent(AgentNode {
1181                name: None,
1182                id: "research".into(),
1183                agent_hash: "sha256:not-hex".into(),
1184                input_schema: None,
1185                output_schema: None,
1186            })],
1187            vec![],
1188        );
1189        let errors = validate(&g).expect_err("invalid");
1190        assert_eq!(
1191            errors,
1192            vec![GraphError::MalformedAgentHash {
1193                id: "research".into(),
1194                hash: "sha256:not-hex".into(),
1195            }]
1196        );
1197    }
1198
1199    /// A zero concurrency cap on a map names the node.
1200    #[test]
1201    fn non_positive_concurrency_is_reported() {
1202        let g = graph(
1203            vec![
1204                agent("worker"),
1205                Node::Map(MapNode {
1206                    name: None,
1207                    id: "fanout".into(),
1208                    over: "items".into(),
1209                    concurrency: 0,
1210                    body: MapBody::Node("worker".into()),
1211                    output_schema: None,
1212                }),
1213            ],
1214            vec![],
1215        );
1216        let errors = validate(&g).expect_err("invalid");
1217        assert!(errors.contains(&GraphError::NonPositiveConcurrency {
1218            id: "fanout".into(),
1219            found: 0,
1220        }));
1221    }
1222
1223    /// A zero wait on a delay names the node, exactly as a zero iteration
1224    /// bound on a fold does. A one-second wait is the smallest legal one and
1225    /// passes, so the rule is a floor rather than a range.
1226    #[test]
1227    fn non_positive_delay_is_reported() {
1228        let g = graph(
1229            vec![Node::Delay(DelayNode {
1230                id: "cooloff".into(),
1231                name: None,
1232                seconds: 0,
1233            })],
1234            vec![],
1235        );
1236        let errors = validate(&g).expect_err("invalid");
1237        assert!(errors.contains(&GraphError::NonPositiveDelay {
1238            id: "cooloff".into(),
1239            found: 0,
1240        }));
1241
1242        let g = graph(
1243            vec![Node::Delay(DelayNode {
1244                id: "cooloff".into(),
1245                name: None,
1246                seconds: 1,
1247            })],
1248            vec![],
1249        );
1250        validate(&g).expect("a one-second wait is a legal wait");
1251    }
1252
1253    /// A map body that names a missing node is reported.
1254    #[test]
1255    fn dangling_map_body_is_reported() {
1256        let g = graph(
1257            vec![Node::Map(MapNode {
1258                name: None,
1259                id: "fanout".into(),
1260                over: "items".into(),
1261                concurrency: 2,
1262                body: MapBody::Node("ghost".into()),
1263                output_schema: None,
1264            })],
1265            vec![],
1266        );
1267        let errors = validate(&g).expect_err("invalid");
1268        assert!(errors.contains(&GraphError::DanglingMapBody {
1269            id: "fanout".into(),
1270            missing: "ghost".into(),
1271            suggestion: None,
1272        }));
1273    }
1274
1275    /// A cycle is reported with a path that closes on itself.
1276    #[test]
1277    fn cycle_is_reported_with_path() {
1278        let g = graph(
1279            vec![agent("a"), agent("b"), agent("c")],
1280            vec![edge("a", "b"), edge("b", "c"), edge("c", "a")],
1281        );
1282        let errors = validate(&g).expect_err("invalid");
1283        let cycle = errors
1284            .iter()
1285            .find_map(|e| match e {
1286                GraphError::Cycle { path } => Some(path.clone()),
1287                _ => None,
1288            })
1289            .expect("a cycle error");
1290        assert!(cycle.starts_with("a -> "), "path from a: {cycle}");
1291        assert!(cycle.ends_with("-> a"), "path closes on a: {cycle}");
1292    }
1293
1294    /// Edge type-compat: matching schemas pass, mismatched ones fail naming the
1295    /// edge.
1296    #[test]
1297    fn edge_type_mismatch_is_reported() {
1298        let producer = Node::Agent(AgentNode {
1299            name: None,
1300            id: "producer".into(),
1301            agent_hash: hash(),
1302            input_schema: None,
1303            output_schema: Some(json!({"type": "string"})),
1304        });
1305        let consumer = Node::Tool(ToolNode {
1306            name: None,
1307            id: "consumer".into(),
1308            tool: "t".into(),
1309            input: BTreeMap::new(),
1310            input_schema: Some(json!({"type": "number"})),
1311            output_schema: None,
1312        });
1313        let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
1314        let errors = validate(&g).expect_err("invalid");
1315        assert!(errors.contains(&GraphError::EdgeTypeMismatch {
1316            from: "producer".into(),
1317            to: "consumer".into(),
1318        }));
1319    }
1320
1321    /// Identical declared schemas are compatible.
1322    #[test]
1323    fn matching_edge_schemas_pass() {
1324        let producer = Node::Agent(AgentNode {
1325            name: None,
1326            id: "producer".into(),
1327            agent_hash: hash(),
1328            input_schema: None,
1329            output_schema: Some(json!({"type": "string"})),
1330        });
1331        let consumer = Node::Tool(ToolNode {
1332            name: None,
1333            id: "consumer".into(),
1334            tool: "t".into(),
1335            input: BTreeMap::new(),
1336            input_schema: Some(json!({"type": "string"})),
1337            output_schema: None,
1338        });
1339        let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
1340        assert!(validate(&g).is_ok());
1341    }
1342
1343    /// A future schema version is rejected; an equal one is accepted.
1344    #[test]
1345    fn future_schema_version_is_rejected() {
1346        let mut g = graph(vec![agent("a")], vec![]);
1347        g.schema_version = SCHEMA_VERSION + 1;
1348        let errors = validate(&g).expect_err("invalid");
1349        assert!(errors.contains(&GraphError::UnsupportedSchemaVersion {
1350            found: SCHEMA_VERSION + 1,
1351            supported: SCHEMA_VERSION,
1352        }));
1353    }
1354
1355    /// Every check runs: a document with several independent faults returns all
1356    /// of them, not just the first.
1357    #[test]
1358    fn all_errors_are_collected() {
1359        let g = graph(
1360            vec![
1361                Node::Agent(AgentNode {
1362                    name: None,
1363                    id: "bad".into(),
1364                    agent_hash: "nope".into(),
1365                    input_schema: None,
1366                    output_schema: None,
1367                }),
1368                agent("bad"), // duplicate id
1369            ],
1370            vec![edge("bad", "missing")],
1371        );
1372        let errors = validate(&g).expect_err("invalid");
1373        assert!(
1374            errors.len() >= 3,
1375            "duplicate id, malformed hash, and dangling edge: {errors:?}"
1376        );
1377    }
1378
1379    /// A duplicate node id is reported.
1380    #[test]
1381    fn duplicate_node_id_is_reported() {
1382        let g = graph(vec![agent("dup"), gate("dup")], vec![]);
1383        let errors = validate(&g).expect_err("invalid");
1384        assert!(errors.contains(&GraphError::DuplicateNodeId { id: "dup".into() }));
1385    }
1386
1387    /// A branch node whose expression condition is well-formed validates clean;
1388    /// a `model_decision` case carries no expression to check.
1389    #[test]
1390    fn valid_branch_expression_passes() {
1391        let branch = Node::Branch(BranchNode {
1392            name: None,
1393            id: "route".into(),
1394            on: Some("score".into()),
1395            agent_hash: Some(hash()),
1396            cases: vec![
1397                BranchCase {
1398                    name: "high".into(),
1399                    when: BranchCondition::Expression("score > 0.8".into()),
1400                },
1401                BranchCase {
1402                    name: "review".into(),
1403                    when: BranchCondition::ModelDecision,
1404                },
1405            ],
1406        });
1407        let g = graph(
1408            vec![
1409                agent("score"),
1410                branch,
1411                agent("high_target"),
1412                agent("review_target"),
1413            ],
1414            vec![
1415                edge("score", "route"),
1416                labeled_edge("route", "high_target", "high"),
1417                labeled_edge("route", "review_target", "review"),
1418            ],
1419        );
1420        assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1421    }
1422
1423    /// A branch case whose expression does not parse is a node-precise error
1424    /// naming the node and the case; a sibling `model_decision` case is skipped.
1425    /// Both cases carry an edge, so the new case-without-edge check stays quiet
1426    /// and this test isolates the expression check alone.
1427    #[test]
1428    fn invalid_branch_expression_is_reported() {
1429        let branch = Node::Branch(BranchNode {
1430            name: None,
1431            id: "route".into(),
1432            on: None,
1433            agent_hash: Some(hash()),
1434            cases: vec![
1435                BranchCase {
1436                    name: "broken".into(),
1437                    when: BranchCondition::Expression("score >".into()),
1438                },
1439                BranchCase {
1440                    name: "fallback".into(),
1441                    when: BranchCondition::ModelDecision,
1442                },
1443            ],
1444        });
1445        let g = graph(
1446            vec![branch, agent("broken_target"), agent("fallback_target")],
1447            vec![
1448                labeled_edge("route", "broken_target", "broken"),
1449                labeled_edge("route", "fallback_target", "fallback"),
1450            ],
1451        );
1452        let errors = validate(&g).expect_err("invalid");
1453        assert!(
1454            matches!(
1455                errors.as_slice(),
1456                [GraphError::InvalidBranchExpression { node, case, .. }]
1457                    if node == "route" && case == "broken"
1458            ),
1459            "one node/case-precise expression error: {errors:?}"
1460        );
1461    }
1462
1463    /// A branch case with no outbound edge realizing it is a node/case-precise
1464    /// error, distinct from and reported alongside a sibling case that does
1465    /// have one: the mistake this catches is exactly a misspelled edge label
1466    /// (the case name and the label must match character for character), and
1467    /// the message says what to do about it. The misspelled edge itself, `lst`,
1468    /// is also reported by [`check_branch_edge_labels`]'s mirror check: a
1469    /// single typo names no case on one side and realizes none on the other,
1470    /// so both halves of it are named.
1471    #[test]
1472    fn branch_case_without_edge_is_reported() {
1473        let branch = Node::Branch(BranchNode {
1474            name: None,
1475            id: "route".into(),
1476            on: None,
1477            agent_hash: None,
1478            cases: vec![
1479                BranchCase {
1480                    name: "won".into(),
1481                    when: BranchCondition::Expression("outcome == \"won\"".into()),
1482                },
1483                BranchCase {
1484                    name: "lost".into(),
1485                    when: BranchCondition::Expression("outcome == \"lost\"".into()),
1486                },
1487            ],
1488        });
1489        let g = graph(
1490            vec![branch, agent("celebrate")],
1491            // The edge realizing `lost` is misspelled `lst`, exactly the
1492            // mistake this check exists to catch.
1493            vec![
1494                labeled_edge("route", "celebrate", "won"),
1495                labeled_edge("route", "celebrate", "lst"),
1496            ],
1497        );
1498        let errors = validate(&g).expect_err("invalid");
1499        assert_eq!(
1500            errors,
1501            vec![
1502                GraphError::BranchCaseWithoutEdge {
1503                    node: "route".into(),
1504                    case: "lost".into(),
1505                },
1506                GraphError::BranchEdgeWithoutCase {
1507                    node: "route".into(),
1508                    label: "lst".into(),
1509                },
1510            ],
1511            "names the unrouted case AND the mislabeled edge that caused it, nothing else: {errors:?}"
1512        );
1513        let message = errors[0].to_string();
1514        assert!(
1515            message.contains("route") && message.contains("lost"),
1516            "{message}"
1517        );
1518        assert!(
1519            message.contains("terminal node"),
1520            "says what to do about a route meant to end the run: {message}"
1521        );
1522    }
1523
1524    /// An outbound edge from a branch labeled with a name no case declares is
1525    /// a node/label-precise error, the mirror of
1526    /// [`branch_case_without_edge_is_reported`]: the label `lst` matches
1527    /// neither of the branch's declared cases (`lost`, `paid`), so the edge is
1528    /// dead and the message says what to do about it.
1529    #[test]
1530    fn branch_edge_without_case_is_reported() {
1531        let branch = Node::Branch(BranchNode {
1532            name: None,
1533            id: "route".into(),
1534            on: None,
1535            agent_hash: None,
1536            cases: vec![
1537                BranchCase {
1538                    name: "lost".into(),
1539                    when: BranchCondition::Expression("outcome == \"lost\"".into()),
1540                },
1541                BranchCase {
1542                    name: "paid".into(),
1543                    when: BranchCondition::Expression("outcome == \"paid\"".into()),
1544                },
1545            ],
1546        });
1547        let g = graph(
1548            vec![branch, agent("celebrate"), agent("close")],
1549            // The edge meant to realize `lost` is misspelled `lst`, exactly
1550            // the mistake this check exists to catch, from the edge's side.
1551            vec![
1552                labeled_edge("route", "close", "lst"),
1553                labeled_edge("route", "celebrate", "paid"),
1554            ],
1555        );
1556        let errors = validate(&g).expect_err("invalid");
1557        assert!(
1558            errors.contains(&GraphError::BranchEdgeWithoutCase {
1559                node: "route".into(),
1560                label: "lst".into(),
1561            }),
1562            "names the node and the offending label: {errors:?}"
1563        );
1564        // The sibling `lost` case, which now has no realizing edge either, is
1565        // reported separately by the other check: both sides of the same
1566        // typo are named.
1567        assert!(
1568            errors.contains(&GraphError::BranchCaseWithoutEdge {
1569                node: "route".into(),
1570                case: "lost".into(),
1571            }),
1572            "the case left unrouted by the typo is also named: {errors:?}"
1573        );
1574        let message = errors
1575            .iter()
1576            .find_map(|e| match e {
1577                GraphError::BranchEdgeWithoutCase { .. } => Some(e.to_string()),
1578                _ => None,
1579            })
1580            .expect("a BranchEdgeWithoutCase error");
1581        assert!(
1582            message.contains("route") && message.contains("lst"),
1583            "{message}"
1584        );
1585        assert!(
1586            message.contains("case"),
1587            "says what to do about the mismatched label: {message}"
1588        );
1589    }
1590
1591    /// An outbound edge from a branch that carries no label at all can never
1592    /// fire either, by the same engine rule as a mismatched label: it is a
1593    /// node/target-precise error distinct from `BranchEdgeWithoutCase`. The
1594    /// branch declares `paid` and `lost`; the `paid` edge is correctly
1595    /// labelled and passes, while the second edge names no label at all.
1596    #[test]
1597    fn branch_edge_without_label_is_reported() {
1598        let branch = Node::Branch(BranchNode {
1599            name: None,
1600            id: "route".into(),
1601            on: None,
1602            agent_hash: None,
1603            cases: vec![
1604                BranchCase {
1605                    name: "paid".into(),
1606                    when: BranchCondition::Expression("outcome == \"paid\"".into()),
1607                },
1608                BranchCase {
1609                    name: "lost".into(),
1610                    when: BranchCondition::Expression("outcome == \"lost\"".into()),
1611                },
1612            ],
1613        });
1614        let g = graph(
1615            vec![branch, agent("celebrate"), agent("close")],
1616            vec![
1617                labeled_edge("route", "celebrate", "paid"),
1618                edge("route", "close"),
1619            ],
1620        );
1621        let errors = validate(&g).expect_err("invalid");
1622        assert!(
1623            errors.contains(&GraphError::BranchEdgeWithoutLabel {
1624                node: "route".into(),
1625                to: "close".into(),
1626            }),
1627            "names the branch node and the unlabelled edge's target: {errors:?}"
1628        );
1629        let message = errors
1630            .iter()
1631            .find_map(|e| match e {
1632                GraphError::BranchEdgeWithoutLabel { .. } => Some(e.to_string()),
1633                _ => None,
1634            })
1635            .expect("a BranchEdgeWithoutLabel error");
1636        assert!(
1637            message.contains("route") && message.contains("close"),
1638            "{message}"
1639        );
1640        assert!(
1641            message.contains("label"),
1642            "says what to do about the unlabelled edge: {message}"
1643        );
1644    }
1645
1646    /// A `model_decision` case on a branch that declares no `agent_hash` is a
1647    /// node/case-precise error: the engine would have no agent to make the
1648    /// decision.
1649    #[test]
1650    fn model_decision_without_agent_is_reported() {
1651        let branch = Node::Branch(BranchNode {
1652            name: None,
1653            id: "route".into(),
1654            on: None,
1655            agent_hash: None,
1656            cases: vec![BranchCase {
1657                name: "ask".into(),
1658                when: BranchCondition::ModelDecision,
1659            }],
1660        });
1661        let g = graph(vec![branch], vec![]);
1662        let errors = validate(&g).expect_err("invalid");
1663        assert!(
1664            errors.contains(&GraphError::ModelDecisionWithoutAgent {
1665                node: "route".into(),
1666                case: "ask".into(),
1667            }),
1668            "names the node and case: {errors:?}"
1669        );
1670    }
1671
1672    /// A branch that declares an `agent_hash` must spell it `sha256:<64 hex>`,
1673    /// exactly like an agent node's hash.
1674    #[test]
1675    fn malformed_branch_agent_hash_is_reported() {
1676        let branch = Node::Branch(BranchNode {
1677            name: None,
1678            id: "route".into(),
1679            on: None,
1680            agent_hash: Some("sha256:not-hex".into()),
1681            cases: vec![BranchCase {
1682                name: "ask".into(),
1683                when: BranchCondition::ModelDecision,
1684            }],
1685        });
1686        let g = graph(vec![branch], vec![]);
1687        let errors = validate(&g).expect_err("invalid");
1688        assert!(
1689            errors.contains(&GraphError::MalformedAgentHash {
1690                id: "route".into(),
1691                hash: "sha256:not-hex".into(),
1692            }),
1693            "names the branch node and its malformed hash: {errors:?}"
1694        );
1695    }
1696
1697    /// Builds a fold node over an existing body node, with the given bound,
1698    /// stop predicate, and join, for the fold validator tests.
1699    fn fold(id: &str, body: &str, max_iterations: u32, stop_when: &str, join: FoldJoin) -> Node {
1700        Node::Fold(FoldNode {
1701            id: id.into(),
1702            name: None,
1703            body: FoldBody::Node(body.into()),
1704            max_iterations,
1705            stop_when: stop_when.into(),
1706            join,
1707            on_bound: None,
1708            accumulator_schema: None,
1709        })
1710    }
1711
1712    /// A well-formed fold (positive bound, existing body, parseable predicate,
1713    /// valid `best_by` path) validates clean.
1714    #[test]
1715    fn valid_fold_node_passes() {
1716        let g = graph(
1717            vec![
1718                agent("tailor"),
1719                fold(
1720                    "refine",
1721                    "tailor",
1722                    3,
1723                    "score >= 0.85",
1724                    FoldJoin::BestBy("score".into()),
1725                ),
1726            ],
1727            vec![],
1728        );
1729        assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1730    }
1731
1732    /// The `last` and `all` joins carry no reference to check, so a fold using
1733    /// them validates without a `best_by` path.
1734    #[test]
1735    fn fold_with_unit_joins_passes() {
1736        for join in [FoldJoin::Last, FoldJoin::All] {
1737            let g = graph(
1738                vec![agent("tailor"), fold("refine", "tailor", 2, "done", join)],
1739                vec![],
1740            );
1741            assert!(validate(&g).is_ok());
1742        }
1743    }
1744
1745    /// A zero iteration bound on a fold names the node.
1746    #[test]
1747    fn non_positive_max_iterations_is_reported() {
1748        let g = graph(
1749            vec![
1750                agent("tailor"),
1751                fold("refine", "tailor", 0, "done", FoldJoin::Last),
1752            ],
1753            vec![],
1754        );
1755        let errors = validate(&g).expect_err("invalid");
1756        assert!(errors.contains(&GraphError::NonPositiveMaxIterations {
1757            id: "refine".into(),
1758            found: 0,
1759        }));
1760    }
1761
1762    /// A fold body that names a missing node is reported, distinct from a map
1763    /// body.
1764    #[test]
1765    fn dangling_fold_body_is_reported() {
1766        let g = graph(
1767            vec![fold("refine", "ghost", 2, "done", FoldJoin::Last)],
1768            vec![],
1769        );
1770        let errors = validate(&g).expect_err("invalid");
1771        assert!(errors.contains(&GraphError::DanglingFoldBody {
1772            id: "refine".into(),
1773            missing: "ghost".into(),
1774            suggestion: None,
1775        }));
1776    }
1777
1778    /// A fold whose `stop_when` does not parse is a node-precise error.
1779    #[test]
1780    fn invalid_fold_stop_expression_is_reported() {
1781        let g = graph(
1782            vec![
1783                agent("tailor"),
1784                fold("refine", "tailor", 2, "score >", FoldJoin::Last),
1785            ],
1786            vec![],
1787        );
1788        let errors = validate(&g).expect_err("invalid");
1789        assert!(
1790            matches!(
1791                errors.as_slice(),
1792                [GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
1793            ),
1794            "one node-precise stop-expression error: {errors:?}"
1795        );
1796    }
1797
1798    /// A `best_by` join whose reference is a bare literal (not a path) is a
1799    /// node-precise error naming the reference.
1800    #[test]
1801    fn invalid_fold_join_reference_is_reported() {
1802        let g = graph(
1803            vec![
1804                agent("tailor"),
1805                fold("refine", "tailor", 2, "done", FoldJoin::BestBy("42".into())),
1806            ],
1807            vec![],
1808        );
1809        let errors = validate(&g).expect_err("invalid");
1810        assert!(
1811            errors.iter().any(
1812                |e| matches!(e, GraphError::InvalidFoldJoinReference { node, reference, .. }
1813                    if node == "refine" && reference == "42")
1814            ),
1815            "names the node and the bad reference: {errors:?}"
1816        );
1817    }
1818
1819    // --- A fold's references against the shape its body declares. ---
1820
1821    /// An agent node declaring the given output schema, to be a fold's body.
1822    fn scorer(id: &str, output_schema: Value) -> Node {
1823        Node::Agent(AgentNode {
1824            name: None,
1825            id: id.into(),
1826            agent_hash: hash(),
1827            input_schema: None,
1828            output_schema: Some(output_schema),
1829        })
1830    }
1831
1832    /// The object schema a scored pass declares: one numeric `score`.
1833    fn score_schema() -> Value {
1834        json!({
1835            "type": "object",
1836            "properties": { "score": { "type": "number" } },
1837            "required": ["score"]
1838        })
1839    }
1840
1841    /// A predicate and a join reference that both name a declared property
1842    /// validate clean, at any depth the schema actually describes.
1843    #[test]
1844    fn fold_references_inside_the_body_schema_pass() {
1845        let schema = json!({
1846            "type": "object",
1847            "properties": {
1848                "score": { "type": "number" },
1849                "review": {
1850                    "type": "object",
1851                    "properties": {
1852                        "overall_score": { "type": "number" },
1853                        "notes": {
1854                            "type": "array",
1855                            "items": { "type": "object", "properties": { "text": { "type": "string" } } }
1856                        }
1857                    }
1858                }
1859            }
1860        });
1861        let g = graph(
1862            vec![
1863                scorer("tailor", schema),
1864                fold(
1865                    "refine",
1866                    "tailor",
1867                    3,
1868                    "score >= 0.85 && review.notes.0.text != \"\"",
1869                    FoldJoin::BestBy("review.overall_score".into()),
1870                ),
1871            ],
1872            vec![],
1873        );
1874        assert!(validate(&g).is_ok(), "{:?}", validate(&g));
1875    }
1876
1877    /// A `stop_when` path the body's schema positively excludes is reported,
1878    /// naming the path and the body node. This is the typo the check exists
1879    /// for: `scoer` never resolves, so the loop would never stop.
1880    #[test]
1881    fn fold_stop_path_outside_the_body_schema_is_reported() {
1882        let g = graph(
1883            vec![
1884                scorer("tailor", score_schema()),
1885                fold(
1886                    "refine",
1887                    "tailor",
1888                    3,
1889                    "scoer >= 0.85",
1890                    FoldJoin::BestBy("score".into()),
1891                ),
1892            ],
1893            vec![],
1894        );
1895        let errors = validate(&g).expect_err("invalid");
1896        assert_eq!(
1897            errors,
1898            vec![GraphError::FoldStopPathNotInBodySchema {
1899                node: "refine".into(),
1900                path: "scoer".into(),
1901                body: "tailor".into(),
1902            }]
1903        );
1904        let message = errors[0].to_string();
1905        assert!(
1906            message.contains("scoer") && message.contains("tailor"),
1907            "names the path and the body node: {message}"
1908        );
1909    }
1910
1911    /// A nested `stop_when` path that leaves the declared shape partway down is
1912    /// reported by the whole path, not by the segment that failed, because the
1913    /// path is what the author wrote.
1914    #[test]
1915    fn fold_nested_stop_path_outside_the_body_schema_is_reported() {
1916        let schema = json!({
1917            "type": "object",
1918            "properties": {
1919                "review": { "type": "object", "properties": { "score": { "type": "number" } } }
1920            }
1921        });
1922        let g = graph(
1923            vec![
1924                scorer("tailor", schema),
1925                fold("refine", "tailor", 3, "review.rating > 3", FoldJoin::Last),
1926            ],
1927            vec![],
1928        );
1929        let errors = validate(&g).expect_err("invalid");
1930        assert!(
1931            errors.contains(&GraphError::FoldStopPathNotInBodySchema {
1932                node: "refine".into(),
1933                path: "review.rating".into(),
1934                body: "tailor".into(),
1935            }),
1936            "{errors:?}"
1937        );
1938    }
1939
1940    /// A `best_by` reference outside the declared shape is its own error,
1941    /// naming the reference as the document writes it.
1942    #[test]
1943    fn fold_join_reference_outside_the_body_schema_is_reported() {
1944        let g = graph(
1945            vec![
1946                scorer("tailor", score_schema()),
1947                fold(
1948                    "refine",
1949                    "tailor",
1950                    3,
1951                    "score >= 0.85",
1952                    FoldJoin::BestBy("review.overall_score".into()),
1953                ),
1954            ],
1955            vec![],
1956        );
1957        let errors = validate(&g).expect_err("invalid");
1958        assert_eq!(
1959            errors,
1960            vec![GraphError::FoldJoinReferenceNotInBodySchema {
1961                node: "refine".into(),
1962                reference: "review.overall_score".into(),
1963                body: "tailor".into(),
1964            }]
1965        );
1966    }
1967
1968    /// Every failing path is collected, the predicate's and the join's alike,
1969    /// so an author fixes them in one pass.
1970    #[test]
1971    fn every_fold_reference_fault_is_collected() {
1972        let g = graph(
1973            vec![
1974                scorer("tailor", score_schema()),
1975                fold(
1976                    "refine",
1977                    "tailor",
1978                    3,
1979                    "scoer >= 0.85 || rating > 3",
1980                    FoldJoin::BestBy("overall".into()),
1981                ),
1982            ],
1983            vec![],
1984        );
1985        let errors = validate(&g).expect_err("invalid");
1986        assert_eq!(errors.len(), 3, "{errors:?}");
1987    }
1988
1989    /// The silence rules, each one a document this check must not touch: a body
1990    /// declaring no schema, a schema with no `properties`, a non-object schema,
1991    /// a schema that admits extra keys, one that names its shape elsewhere, and
1992    /// a subgraph body, which has no single node to read a schema from.
1993    #[test]
1994    fn fold_references_go_unjudged_where_the_schema_says_nothing() {
1995        let quiet: Vec<Option<Value>> = vec![
1996            None,
1997            Some(json!({ "type": "object" })),
1998            Some(json!({ "type": "string" })),
1999            Some(json!({
2000                "type": "object",
2001                "properties": { "score": { "type": "number" } },
2002                "additionalProperties": true
2003            })),
2004            Some(json!({
2005                "type": "object",
2006                "properties": { "score": { "type": "number" } },
2007                "patternProperties": { "^x_": { "type": "string" } }
2008            })),
2009            Some(json!({ "$ref": "#/$defs/pass" })),
2010            Some(json!({
2011                "anyOf": [{ "type": "object", "properties": { "score": { "type": "number" } } }]
2012            })),
2013        ];
2014        for schema in quiet {
2015            let body = match schema {
2016                Some(schema) => scorer("tailor", schema),
2017                None => agent("tailor"),
2018            };
2019            let g = graph(
2020                vec![
2021                    body,
2022                    fold(
2023                        "refine",
2024                        "tailor",
2025                        3,
2026                        "anything.at.all >= 0.85",
2027                        FoldJoin::BestBy("nothing.declared".into()),
2028                    ),
2029                ],
2030                vec![],
2031            );
2032            assert!(validate(&g).is_ok(), "{:?}", validate(&g));
2033        }
2034
2035        let subgraph = Node::Fold(FoldNode {
2036            name: None,
2037            id: "refine".into(),
2038            body: FoldBody::Subgraph(Box::new(graph(
2039                vec![scorer("tailor", score_schema())],
2040                vec![],
2041            ))),
2042            max_iterations: 3,
2043            stop_when: "anything.at.all >= 0.85".into(),
2044            join: FoldJoin::BestBy("nothing.declared".into()),
2045            on_bound: None,
2046            accumulator_schema: None,
2047        });
2048        assert!(validate(&graph(vec![subgraph], vec![])).is_ok());
2049    }
2050
2051    /// A schema that closes itself with `additionalProperties: false` is read
2052    /// exactly as one that stays silent about extra keys: a declared
2053    /// `properties` map is the shape either way.
2054    #[test]
2055    fn a_closed_body_schema_reports_the_same_missing_path() {
2056        let schema = json!({
2057            "type": "object",
2058            "properties": { "score": { "type": "number" } },
2059            "additionalProperties": false
2060        });
2061        let g = graph(
2062            vec![
2063                scorer("tailor", schema),
2064                fold("refine", "tailor", 3, "scoer >= 0.85", FoldJoin::Last),
2065            ],
2066            vec![],
2067        );
2068        let errors = validate(&g).expect_err("invalid");
2069        assert!(errors.contains(&GraphError::FoldStopPathNotInBodySchema {
2070            node: "refine".into(),
2071            path: "scoer".into(),
2072            body: "tailor".into(),
2073        }));
2074    }
2075
2076    /// A `stop_when` that does not parse is reported once, by the parse check
2077    /// alone: there are no segments to walk, so this check says nothing.
2078    #[test]
2079    fn an_unparseable_stop_predicate_is_not_also_a_shape_error() {
2080        let g = graph(
2081            vec![
2082                scorer("tailor", score_schema()),
2083                fold("refine", "tailor", 3, "score >", FoldJoin::Last),
2084            ],
2085            vec![],
2086        );
2087        let errors = validate(&g).expect_err("invalid");
2088        assert!(
2089            matches!(
2090                errors.as_slice(),
2091                [GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
2092            ),
2093            "only the parse error: {errors:?}"
2094        );
2095    }
2096
2097    /// A node `name` at exactly the character cap is valid; a node with no
2098    /// `name` set is unaffected by the check.
2099    #[test]
2100    fn node_name_at_the_cap_is_valid() {
2101        let mut named = agent("research");
2102        if let Node::Agent(a) = &mut named {
2103            a.name = Some("a".repeat(MAX_NODE_NAME_LEN));
2104        }
2105        let g = graph(
2106            vec![named, agent("review")],
2107            vec![edge("research", "review")],
2108        );
2109        assert!(validate(&g).is_ok());
2110    }
2111
2112    /// A node `name` over the character cap is a node-precise error, counting
2113    /// characters rather than bytes (a multi-byte character over the cap is
2114    /// still one character over, not several).
2115    #[test]
2116    fn node_name_too_long_is_reported() {
2117        let mut named = agent("research");
2118        let long_name = "é".repeat(MAX_NODE_NAME_LEN + 1);
2119        if let Node::Agent(a) = &mut named {
2120            a.name = Some(long_name.clone());
2121        }
2122        let g = graph(vec![named], vec![]);
2123        let errors = validate(&g).expect_err("invalid");
2124        assert!(
2125            errors.contains(&GraphError::NodeNameTooLong {
2126                id: "research".into(),
2127                len: MAX_NODE_NAME_LEN + 1,
2128                max: MAX_NODE_NAME_LEN,
2129            }),
2130            "names the node and the character count, not the byte count: {errors:?}"
2131        );
2132    }
2133
2134    /// An empty or all-whitespace `name` is rejected, node-precise, across
2135    /// every node kind.
2136    #[test]
2137    fn blank_node_name_is_reported() {
2138        for blank in ["", "   ", "\t\n"] {
2139            let mut named = gate("approve");
2140            if let Node::Gate(g) = &mut named {
2141                g.name = Some(blank.to_owned());
2142            }
2143            let g = graph(vec![named], vec![]);
2144            let errors = validate(&g).expect_err("invalid");
2145            assert!(
2146                errors.contains(&GraphError::BlankNodeName {
2147                    id: "approve".into(),
2148                }),
2149                "blank name {blank:?} should be reported: {errors:?}"
2150            );
2151        }
2152    }
2153
2154    /// Every check runs together: a document with both a blank name on one
2155    /// node and an oversized name on another reports both, collect-all style.
2156    #[test]
2157    fn multiple_node_name_errors_are_all_collected() {
2158        let mut blank = agent("research");
2159        if let Node::Agent(a) = &mut blank {
2160            a.name = Some("   ".into());
2161        }
2162        let mut long = gate("approve");
2163        if let Node::Gate(g) = &mut long {
2164            g.name = Some("x".repeat(MAX_NODE_NAME_LEN + 5));
2165        }
2166        let g = graph(vec![blank, long], vec![]);
2167        let errors = validate(&g).expect_err("invalid");
2168        assert!(
2169            errors.contains(&GraphError::BlankNodeName {
2170                id: "research".into(),
2171            }),
2172            "{errors:?}"
2173        );
2174        assert!(
2175            errors.contains(&GraphError::NodeNameTooLong {
2176                id: "approve".into(),
2177                len: MAX_NODE_NAME_LEN + 5,
2178                max: MAX_NODE_NAME_LEN,
2179            }),
2180            "{errors:?}"
2181        );
2182    }
2183}