Skip to main content

salvor_graph/
builder.rs

1//! A fluent, typed builder for a graph [`Graph`] document.
2//!
3//! The document model in [`crate::document`] is the wire format: strict,
4//! adjacently tagged, and easy to get wrong by hand (a stray key, a payload
5//! nested under the wrong tag, an edge that names a field instead of a node).
6//! This builder is the author-facing front door. It never invents a new format;
7//! it constructs the same [`Graph`] the model already defines, so whatever this
8//! builder emits parses and validates exactly as a hand-written document would.
9//!
10//! # What the types buy you
11//!
12//! Each node kind has its own spec type ([`AgentSpec`], [`ToolSpec`],
13//! [`GateSpec`], [`BranchSpec`], [`MapSpec`], [`FoldSpec`]) whose constructor demands the
14//! fields that kind cannot do without: an agent needs its hash, a tool needs its
15//! name, a gate needs its approval schema. A field that belongs to one kind is
16//! not reachable on another, so "a gate with an agent_hash" is not a runtime
17//! error, it is a shape you cannot write. The optional fields are chained
18//! methods, present only where the model allows them. The result is that a
19//! STRUCTURALLY malformed document is hard to express.
20//!
21//! # Where the builder stops
22//!
23//! Typed construction stops at structure. It does NOT check that an agent hash
24//! is 64 hex digits, that a map's concurrency is positive, that edges name real
25//! nodes, or that the graph is acyclic. Those are SEMANTIC rules, and they stay
26//! with [`crate::validate`], which runs over the built `Graph` the same way it
27//! runs over a parsed one. Build to get a well-shaped document; validate to
28//! learn whether it is a legal one.
29//!
30//! # Authoring the canonical flow
31//!
32//! ```
33//! use salvor_graph::{AgentSpec, GateSpec, GraphBuilder, ToolSpec};
34//! use serde_json::json;
35//!
36//! let draft = json!({
37//!     "type": "object",
38//!     "properties": { "draft": { "type": "string" } },
39//!     "required": ["draft"]
40//! });
41//!
42//! let graph = GraphBuilder::new()
43//!     .agent(
44//!         AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
45//!             .output_schema(draft.clone()),
46//!     )
47//!     .agent(
48//!         AgentSpec::new("review", format!("sha256:{}", "2".repeat(64)))
49//!             .input_schema(draft.clone())
50//!             .output_schema(draft),
51//!     )
52//!     .gate(
53//!         GateSpec::new(
54//!             "approve",
55//!             json!({
56//!                 "type": "object",
57//!                 "properties": { "approved": { "type": "boolean" } },
58//!                 "required": ["approved"]
59//!             }),
60//!         )
61//!         .prompt("Approve this draft for publication?"),
62//!     )
63//!     .tool(
64//!         ToolSpec::new("publish", "http_post")
65//!             .input("body", "approve.draft")
66//!             .input("url", "config.publish_url"),
67//!     )
68//!     .edge("research", "review")
69//!     .edge("review", "approve")
70//!     .edge("approve", "publish")
71//!     .build();
72//!
73//! // Structure is done; semantics are a separate pass.
74//! let summary = salvor_graph::validate(&graph).expect("the canonical flow is valid");
75//! assert_eq!(summary.entry_nodes, ["research"]);
76//! assert_eq!(summary.terminal_nodes, ["publish"]);
77//! ```
78
79use serde_json::Value;
80
81use crate::document::{
82    AgentNode, BranchCase, BranchCondition, BranchNode, Edge, FoldBody, FoldJoin, FoldNode,
83    GateNode, Graph, MapBody, MapNode, Node, SCHEMA_VERSION, ToolNode,
84};
85
86/// Accumulates nodes and edges, then freezes them into a [`Graph`].
87///
88/// Every `node`-adding method takes a per-kind spec and returns `self`, so a
89/// whole document reads as one chain ending in [`build`](GraphBuilder::build).
90/// The builder stamps [`SCHEMA_VERSION`] onto the document, so an author never
91/// writes the version by hand.
92#[derive(Clone, Debug, Default)]
93pub struct GraphBuilder {
94    nodes: Vec<Node>,
95    edges: Vec<Edge>,
96}
97
98impl GraphBuilder {
99    /// Starts an empty builder.
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Adds an `agent` node from its [`AgentSpec`].
106    #[must_use]
107    pub fn agent(mut self, spec: AgentSpec) -> Self {
108        self.nodes.push(spec.into_node());
109        self
110    }
111
112    /// Adds a `tool` node from its [`ToolSpec`].
113    #[must_use]
114    pub fn tool(mut self, spec: ToolSpec) -> Self {
115        self.nodes.push(spec.into_node());
116        self
117    }
118
119    /// Adds a `gate` node from its [`GateSpec`].
120    #[must_use]
121    pub fn gate(mut self, spec: GateSpec) -> Self {
122        self.nodes.push(spec.into_node());
123        self
124    }
125
126    /// Adds a `branch` node from its [`BranchSpec`].
127    #[must_use]
128    pub fn branch(mut self, spec: BranchSpec) -> Self {
129        self.nodes.push(spec.into_node());
130        self
131    }
132
133    /// Adds a `map` node from its [`MapSpec`].
134    #[must_use]
135    pub fn map(mut self, spec: MapSpec) -> Self {
136        self.nodes.push(spec.into_node());
137        self
138    }
139
140    /// Adds a `fold` node from its [`FoldSpec`].
141    #[must_use]
142    pub fn fold(mut self, spec: FoldSpec) -> Self {
143        self.nodes.push(spec.into_node());
144        self
145    }
146
147    /// Adds a plain edge from one node id to another.
148    #[must_use]
149    pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
150        self.edges.push(Edge {
151            from: from.into(),
152            to: to.into(),
153            label: None,
154        });
155        self
156    }
157
158    /// Adds a labeled edge. The label names the [`BranchCase`] this edge
159    /// realizes when the source is a `branch`.
160    #[must_use]
161    pub fn labeled_edge(
162        mut self,
163        from: impl Into<String>,
164        to: impl Into<String>,
165        label: impl Into<String>,
166    ) -> Self {
167        self.edges.push(Edge {
168            from: from.into(),
169            to: to.into(),
170            label: Some(label.into()),
171        });
172        self
173    }
174
175    /// Freezes the accumulated nodes and edges into a [`Graph`], stamping the
176    /// current [`SCHEMA_VERSION`].
177    ///
178    /// Structure only: run [`crate::validate`] on the result to check the
179    /// semantic rules (hash shape, referential integrity, acyclicity, and the
180    /// rest).
181    #[must_use]
182    pub fn build(self) -> Graph {
183        Graph {
184            schema_version: SCHEMA_VERSION,
185            nodes: self.nodes,
186            edges: self.edges,
187        }
188    }
189}
190
191/// The spec for an `agent` node: a full agent loop referenced by content hash.
192#[derive(Clone, Debug)]
193pub struct AgentSpec {
194    id: String,
195    agent_hash: String,
196    name: Option<String>,
197    input_schema: Option<Value>,
198    output_schema: Option<Value>,
199}
200
201impl AgentSpec {
202    /// Starts an agent spec with its two required fields: the node id and the
203    /// `sha256:<64 hex>` agent hash. The hash form is checked by
204    /// [`crate::validate`], not here.
205    pub fn new(id: impl Into<String>, agent_hash: impl Into<String>) -> Self {
206        Self {
207            id: id.into(),
208            agent_hash: agent_hash.into(),
209            name: None,
210            input_schema: None,
211            output_schema: None,
212        }
213    }
214
215    /// Sets a short display label for this node. Bounds (a 64-character cap,
216    /// not empty or all whitespace) are checked by [`crate::validate`], not
217    /// here; see [`crate::document`]'s "The optional node display name"
218    /// section for why this field, unlike an agent's own `name`, is part of
219    /// the graph's content hash.
220    #[must_use]
221    pub fn name(mut self, name: impl Into<String>) -> Self {
222        self.name = Some(name.into());
223        self
224    }
225
226    /// Declares the JSON Schema for the payload this agent consumes.
227    #[must_use]
228    pub fn input_schema(mut self, schema: Value) -> Self {
229        self.input_schema = Some(schema);
230        self
231    }
232
233    /// Declares the JSON Schema for the payload this agent produces.
234    #[must_use]
235    pub fn output_schema(mut self, schema: Value) -> Self {
236        self.output_schema = Some(schema);
237        self
238    }
239
240    fn into_node(self) -> Node {
241        Node::Agent(AgentNode {
242            id: self.id,
243            agent_hash: self.agent_hash,
244            name: self.name,
245            input_schema: self.input_schema,
246            output_schema: self.output_schema,
247        })
248    }
249}
250
251/// The spec for a `tool` node: one direct tool invocation.
252#[derive(Clone, Debug)]
253pub struct ToolSpec {
254    id: String,
255    tool: String,
256    name: Option<String>,
257    input: std::collections::BTreeMap<String, String>,
258    input_schema: Option<Value>,
259    output_schema: Option<Value>,
260}
261
262impl ToolSpec {
263    /// Starts a tool spec with its required fields: the node id and the
264    /// registered tool name.
265    pub fn new(id: impl Into<String>, tool: impl Into<String>) -> Self {
266        Self {
267            id: id.into(),
268            tool: tool.into(),
269            name: None,
270            input: std::collections::BTreeMap::new(),
271            input_schema: None,
272            output_schema: None,
273        }
274    }
275
276    /// Sets a short display label for this node. Bounds (a 64-character cap,
277    /// not empty or all whitespace) are checked by [`crate::validate`], not
278    /// here; see [`crate::document`]'s "The optional node display name"
279    /// section for why this field, unlike an agent's own `name`, is part of
280    /// the graph's content hash.
281    #[must_use]
282    pub fn name(mut self, name: impl Into<String>) -> Self {
283        self.name = Some(name.into());
284        self
285    }
286
287    /// Adds one input mapping: a tool input field name to an opaque source
288    /// reference. Recorded as data; not resolved by this crate.
289    #[must_use]
290    pub fn input(mut self, field: impl Into<String>, source: impl Into<String>) -> Self {
291        self.input.insert(field.into(), source.into());
292        self
293    }
294
295    /// Declares the JSON Schema for the payload this tool consumes.
296    #[must_use]
297    pub fn input_schema(mut self, schema: Value) -> Self {
298        self.input_schema = Some(schema);
299        self
300    }
301
302    /// Declares the JSON Schema for the payload this tool produces.
303    #[must_use]
304    pub fn output_schema(mut self, schema: Value) -> Self {
305        self.output_schema = Some(schema);
306        self
307    }
308
309    fn into_node(self) -> Node {
310        Node::Tool(ToolNode {
311            id: self.id,
312            tool: self.tool,
313            name: self.name,
314            input: self.input,
315            input_schema: self.input_schema,
316            output_schema: self.output_schema,
317        })
318    }
319}
320
321/// The spec for a `gate` node: human approval that suspends the run.
322#[derive(Clone, Debug)]
323pub struct GateSpec {
324    id: String,
325    name: Option<String>,
326    prompt: Option<String>,
327    approval_schema: Value,
328}
329
330impl GateSpec {
331    /// Starts a gate spec with its required fields: the node id and the JSON
332    /// Schema the approval input must satisfy. A gate with no declared approval
333    /// shape is meaningless, so the schema is not optional.
334    pub fn new(id: impl Into<String>, approval_schema: Value) -> Self {
335        Self {
336            id: id.into(),
337            name: None,
338            prompt: None,
339            approval_schema,
340        }
341    }
342
343    /// Sets a short display label for this node. Bounds (a 64-character cap,
344    /// not empty or all whitespace) are checked by [`crate::validate`], not
345    /// here; see [`crate::document`]'s "The optional node display name"
346    /// section for why this field, unlike an agent's own `name`, is part of
347    /// the graph's content hash.
348    #[must_use]
349    pub fn name(mut self, name: impl Into<String>) -> Self {
350        self.name = Some(name.into());
351        self
352    }
353
354    /// Sets the human-readable prompt shown in the approval inbox.
355    #[must_use]
356    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
357        self.prompt = Some(prompt.into());
358        self
359    }
360
361    fn into_node(self) -> Node {
362        Node::Gate(GateNode {
363            id: self.id,
364            name: self.name,
365            prompt: self.prompt,
366            approval_schema: self.approval_schema,
367        })
368    }
369}
370
371/// The spec for a `branch` node: routes on a typed output.
372#[derive(Clone, Debug)]
373pub struct BranchSpec {
374    id: String,
375    name: Option<String>,
376    on: Option<String>,
377    agent_hash: Option<String>,
378    cases: Vec<BranchCase>,
379}
380
381impl BranchSpec {
382    /// Starts a branch spec with its required node id. Cases are added with
383    /// [`case`](BranchSpec::case).
384    pub fn new(id: impl Into<String>) -> Self {
385        Self {
386            id: id.into(),
387            name: None,
388            on: None,
389            agent_hash: None,
390            cases: Vec::new(),
391        }
392    }
393
394    /// Sets a short display label for this node. Bounds (a 64-character cap,
395    /// not empty or all whitespace) are checked by [`crate::validate`], not
396    /// here; see [`crate::document`]'s "The optional node display name"
397    /// section for why this field, unlike an agent's own `name`, is part of
398    /// the graph's content hash.
399    #[must_use]
400    pub fn name(mut self, name: impl Into<String>) -> Self {
401        self.name = Some(name.into());
402        self
403    }
404
405    /// Sets the opaque reference to the typed value the branch routes on.
406    #[must_use]
407    pub fn on(mut self, on: impl Into<String>) -> Self {
408        self.on = Some(on.into());
409        self
410    }
411
412    /// Sets the `sha256:<64 hex>` hash of the agent that decides a
413    /// [`BranchCondition::ModelDecision`] case. Required by [`crate::validate`]
414    /// on any branch that carries a model-decision case; the hash form is
415    /// checked there, not here.
416    #[must_use]
417    pub fn agent_hash(mut self, agent_hash: impl Into<String>) -> Self {
418        self.agent_hash = Some(agent_hash.into());
419        self
420    }
421
422    /// Adds a named case selected by the given condition. The route it realizes
423    /// is a [`labeled_edge`](GraphBuilder::labeled_edge) whose label matches the
424    /// case name.
425    #[must_use]
426    pub fn case(mut self, name: impl Into<String>, when: BranchCondition) -> Self {
427        self.cases.push(BranchCase {
428            name: name.into(),
429            when,
430        });
431        self
432    }
433
434    fn into_node(self) -> Node {
435        Node::Branch(BranchNode {
436            id: self.id,
437            name: self.name,
438            on: self.on,
439            agent_hash: self.agent_hash,
440            cases: self.cases,
441        })
442    }
443}
444
445/// The spec for a `map` node: fan-out a sub-run per element of a typed list,
446/// with a concurrency cap.
447#[derive(Clone, Debug)]
448pub struct MapSpec {
449    id: String,
450    name: Option<String>,
451    over: String,
452    concurrency: u32,
453    body: MapBody,
454    output_schema: Option<Value>,
455}
456
457impl MapSpec {
458    /// Starts a map spec with its required fields: the node id, the opaque
459    /// reference to the list it fans out over, the concurrency cap, and the
460    /// [`MapBody`] each element is mapped through.
461    pub fn new(
462        id: impl Into<String>,
463        over: impl Into<String>,
464        concurrency: u32,
465        body: MapBody,
466    ) -> Self {
467        Self {
468            id: id.into(),
469            name: None,
470            over: over.into(),
471            concurrency,
472            body,
473            output_schema: None,
474        }
475    }
476
477    /// Sets a short display label for this node. Bounds (a 64-character cap,
478    /// not empty or all whitespace) are checked by [`crate::validate`], not
479    /// here; see [`crate::document`]'s "The optional node display name"
480    /// section for why this field, unlike an agent's own `name`, is part of
481    /// the graph's content hash.
482    #[must_use]
483    pub fn name(mut self, name: impl Into<String>) -> Self {
484        self.name = Some(name.into());
485        self
486    }
487
488    /// Declares the JSON Schema for the joined list this node produces.
489    #[must_use]
490    pub fn output_schema(mut self, schema: Value) -> Self {
491        self.output_schema = Some(schema);
492        self
493    }
494
495    fn into_node(self) -> Node {
496        Node::Map(MapNode {
497            id: self.id,
498            name: self.name,
499            over: self.over,
500            concurrency: self.concurrency,
501            body: self.body,
502            output_schema: self.output_schema,
503        })
504    }
505}
506
507/// The spec for a `fold` node: bounded iteration that accumulates across passes.
508#[derive(Clone, Debug)]
509pub struct FoldSpec {
510    id: String,
511    name: Option<String>,
512    body: FoldBody,
513    max_iterations: u32,
514    stop_when: String,
515    join: FoldJoin,
516    accumulator_schema: Option<Value>,
517}
518
519impl FoldSpec {
520    /// Starts a fold spec with its required fields: the node id, the
521    /// [`FoldBody`] each pass runs, the iteration bound, the `stop_when`
522    /// predicate, and the [`FoldJoin`] rule. The bound's positivity, the
523    /// predicate's parse, and a `best_by` reference's shape are checked by
524    /// [`crate::validate`], not here.
525    pub fn new(
526        id: impl Into<String>,
527        body: FoldBody,
528        max_iterations: u32,
529        stop_when: impl Into<String>,
530        join: FoldJoin,
531    ) -> Self {
532        Self {
533            id: id.into(),
534            name: None,
535            body,
536            max_iterations,
537            stop_when: stop_when.into(),
538            join,
539            accumulator_schema: None,
540        }
541    }
542
543    /// Sets a short display label for this node. Bounds (a 64-character cap,
544    /// not empty or all whitespace) are checked by [`crate::validate`], not
545    /// here; see [`crate::document`]'s "The optional node display name"
546    /// section for why this field, unlike an agent's own `name`, is part of
547    /// the graph's content hash.
548    #[must_use]
549    pub fn name(mut self, name: impl Into<String>) -> Self {
550        self.name = Some(name.into());
551        self
552    }
553
554    /// Declares the JSON Schema for the accumulated value the loop carries and
555    /// produces. Data only, like an agent's `output_schema`.
556    #[must_use]
557    pub fn accumulator_schema(mut self, schema: Value) -> Self {
558        self.accumulator_schema = Some(schema);
559        self
560    }
561
562    fn into_node(self) -> Node {
563        Node::Fold(FoldNode {
564            id: self.id,
565            name: self.name,
566            body: self.body,
567            max_iterations: self.max_iterations,
568            stop_when: self.stop_when,
569            join: self.join,
570            accumulator_schema: self.accumulator_schema,
571        })
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use serde_json::json;
579
580    /// A schema shape reused by several nodes in the canonical flow.
581    fn draft_schema() -> Value {
582        json!({
583            "type": "object",
584            "properties": { "draft": { "type": "string" } },
585            "required": ["draft"]
586        })
587    }
588
589    /// Builds the exact research -> review -> approve -> publish flow the
590    /// canonical fixture records.
591    fn canonical_flow() -> Graph {
592        GraphBuilder::new()
593            .agent(
594                AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
595                    .output_schema(draft_schema()),
596            )
597            .agent(
598                AgentSpec::new("review", format!("sha256:{}", "2".repeat(64)))
599                    .input_schema(draft_schema())
600                    .output_schema(draft_schema()),
601            )
602            .gate(
603                GateSpec::new(
604                    "approve",
605                    json!({
606                        "type": "object",
607                        "properties": { "approved": { "type": "boolean" } },
608                        "required": ["approved"]
609                    }),
610                )
611                .prompt("Approve this draft for publication?"),
612            )
613            .tool(
614                ToolSpec::new("publish", "http_post")
615                    .input("body", "approve.draft")
616                    .input("url", "config.publish_url"),
617            )
618            .edge("research", "review")
619            .edge("review", "approve")
620            .edge("approve", "publish")
621            .build()
622    }
623
624    /// The builder emits a document structurally equal to the canonical fixture
625    /// that keeps all three language builders honest.
626    #[test]
627    fn builds_the_canonical_document() {
628        let built = serde_json::to_value(canonical_flow()).expect("serialize built graph");
629        let canonical: Value = serde_json::from_str(include_str!(
630            "../../../examples/graphs/research-review-publish.json"
631        ))
632        .expect("parse canonical fixture");
633        assert_eq!(
634            built, canonical,
635            "builder output must match the canonical fixture exactly"
636        );
637    }
638
639    /// Builds the exact fold-refine flow the cross-language `fold-refine`
640    /// fixture records, so the Rust, TypeScript, and Python fold builders all
641    /// reduce to one canonical document.
642    fn fold_flow() -> Graph {
643        use crate::document::{FoldBody, FoldJoin};
644
645        let score_schema = json!({
646            "type": "object",
647            "properties": { "score": { "type": "number" } },
648            "required": ["score"]
649        });
650        GraphBuilder::new()
651            .agent(
652                AgentSpec::new("tailor", format!("sha256:{}", "3".repeat(64)))
653                    .output_schema(score_schema.clone()),
654            )
655            .fold(
656                FoldSpec::new(
657                    "refine",
658                    FoldBody::Node("tailor".into()),
659                    3,
660                    "score >= 0.85",
661                    FoldJoin::BestBy("score".into()),
662                )
663                .name("Refine to threshold")
664                .accumulator_schema(score_schema),
665            )
666            .build()
667    }
668
669    /// The builder emits a fold document structurally equal to the shared
670    /// fold-refine fixture that keeps all three language builders honest.
671    #[test]
672    fn builds_the_fold_document() {
673        let built = serde_json::to_value(fold_flow()).expect("serialize built graph");
674        let canonical: Value =
675            serde_json::from_str(include_str!("../../../examples/graphs/fold-refine.json"))
676                .expect("parse fold fixture");
677        assert_eq!(
678            built, canonical,
679            "builder output must match the fold fixture exactly"
680        );
681    }
682
683    /// The built fold flow passes semantic validation.
684    #[test]
685    fn fold_document_validates() {
686        let summary = crate::validate(&fold_flow()).expect("fold flow is valid");
687        assert_eq!(summary.node_count, 2);
688        assert_eq!(summary.edge_count, 0);
689    }
690
691    /// The built canonical flow passes semantic validation.
692    #[test]
693    fn canonical_document_validates() {
694        let summary = crate::validate(&canonical_flow()).expect("canonical flow is valid");
695        assert_eq!(summary.node_count, 4);
696        assert_eq!(summary.edge_count, 3);
697        assert_eq!(summary.entry_nodes, vec!["research"]);
698        assert_eq!(summary.terminal_nodes, vec!["publish"]);
699    }
700
701    /// The branch and map specs build the shapes the model expects: an unset
702    /// optional field stays off the wire, and a labeled edge realizes a case.
703    #[test]
704    fn branch_and_map_specs_build_expected_shapes() {
705        let graph = GraphBuilder::new()
706            .agent(AgentSpec::new(
707                "score",
708                format!("sha256:{}", "a".repeat(64)),
709            ))
710            .branch(
711                BranchSpec::new("route")
712                    .on("score.value")
713                    .case("high", BranchCondition::Expression("score > 0.8".into()))
714                    .case("ask", BranchCondition::ModelDecision),
715            )
716            .agent(AgentSpec::new(
717                "worker",
718                format!("sha256:{}", "b".repeat(64)),
719            ))
720            .map(MapSpec::new(
721                "fanout",
722                "route.items",
723                4,
724                MapBody::Node("worker".into()),
725            ))
726            .edge("score", "route")
727            .labeled_edge("route", "fanout", "high")
728            .build();
729
730        // The map node serializes with no output_schema key, because the spec
731        // never set one.
732        let value = serde_json::to_value(&graph).expect("serialize");
733        let map_payload = value["nodes"][3]["payload"].clone();
734        assert!(
735            map_payload.get("output_schema").is_none(),
736            "unset optional field must stay off the wire: {map_payload}"
737        );
738        assert_eq!(value["edges"][1]["label"], json!("high"));
739    }
740
741    /// The fold spec builds the shape the model expects: the body, bound, stop
742    /// predicate, and join land in the payload, an unset `accumulator_schema`
743    /// stays off the wire, and the document validates.
744    #[test]
745    fn fold_spec_builds_expected_shape() {
746        use crate::document::{FoldBody, FoldJoin};
747
748        let graph = GraphBuilder::new()
749            .agent(AgentSpec::new(
750                "tailor",
751                format!("sha256:{}", "a".repeat(64)),
752            ))
753            .fold(
754                FoldSpec::new(
755                    "refine",
756                    FoldBody::Node("tailor".into()),
757                    3,
758                    "score >= 0.85",
759                    FoldJoin::BestBy("score".into()),
760                )
761                .name("Refine to threshold"),
762            )
763            .build();
764
765        let summary = crate::validate(&graph).expect("the fold flow is valid");
766        assert_eq!(summary.node_count, 2);
767
768        let value = serde_json::to_value(&graph).expect("serialize");
769        let payload = &value["nodes"][1]["payload"];
770        assert_eq!(value["nodes"][1]["kind"], json!("fold"));
771        assert_eq!(payload["max_iterations"], json!(3));
772        assert_eq!(payload["stop_when"], json!("score >= 0.85"));
773        assert_eq!(
774            payload["join"],
775            json!({"kind": "best_by", "value": "score"})
776        );
777        assert_eq!(payload["body"], json!({"kind": "node", "value": "tailor"}));
778        assert_eq!(payload["name"], json!("Refine to threshold"));
779        assert!(
780            payload.get("accumulator_schema").is_none(),
781            "unset optional field must stay off the wire: {payload}"
782        );
783    }
784
785    /// `.name(...)` is available on every node kind's spec, puts the name on
786    /// the wire when set, and validates clean; a sibling node with no `.name`
787    /// call carries none, proving the two coexist in one document.
788    #[test]
789    fn every_spec_kind_accepts_a_display_name() {
790        let graph = GraphBuilder::new()
791            .agent(
792                AgentSpec::new("research", format!("sha256:{}", "1".repeat(64)))
793                    .name("Research the topic"),
794            )
795            .tool(ToolSpec::new("publish", "http_post").name("Publish the draft"))
796            .gate(GateSpec::new("approve", json!({"type": "object"})).name("Approve the draft"))
797            .branch(
798                BranchSpec::new("route")
799                    .name("Route on confidence")
800                    .case("high", BranchCondition::Expression("score > 0.8".into())),
801            )
802            .map(
803                MapSpec::new("fanout", "route.items", 2, MapBody::Node("research".into()))
804                    .name("Notify each watcher"),
805            )
806            .edge("research", "publish")
807            .build();
808
809        let summary = crate::validate(&graph).expect("named nodes still validate");
810        assert_eq!(summary.node_count, 5);
811
812        let value = serde_json::to_value(&graph).expect("serialize");
813        for (index, expected) in [
814            (0, "Research the topic"),
815            (1, "Publish the draft"),
816            (2, "Approve the draft"),
817            (3, "Route on confidence"),
818            (4, "Notify each watcher"),
819        ] {
820            assert_eq!(
821                value["nodes"][index]["payload"]["name"],
822                json!(expected),
823                "node {index} carries its display name on the wire"
824            );
825        }
826    }
827}