Skip to main content

salvor_graph/
document.rs

1//! The graph document format: the `Graph` envelope, the six node kinds, the
2//! edges that connect them, and the small payload types a node carries.
3//!
4//! Everything here is pure data. No type reads the clock, draws randomness, or
5//! performs IO. That purity is deliberate: this crate is a leaf that the CLI,
6//! the control plane, and (later) a wasm dashboard projection all parse graph
7//! documents with, so it must drag in no runtime and no host dependency.
8//!
9//! # Two postures, one format
10//!
11//! A graph is a CONTROL document, not a data payload. A silently dropped field
12//! could drop a gate or an unenforced budget, so parsing is STRICT: every
13//! struct and the node enum carry `#[serde(deny_unknown_fields)]`, and a stray
14//! key is rejected rather than ignored. That is the opposite posture from the
15//! event log, which stays forward-tolerant because it carries recorded data.
16//!
17//! The one concession the two share is the additive `schema_version`
18//! discipline (see [`SCHEMA_VERSION`]): a graph that was recorded under an
19//! older build must still parse and validate under a newer one. Strict in,
20//! additive-tolerant out.
21//!
22//! # The optional node display name, and why it hashes unlike an agent's
23//!
24//! Every node payload carries an optional `name`: a short, purely
25//! presentational label ("Approve the draft") an author can hang on a node so
26//! a rendered graph reads by intent instead of by id. Bounds mirror the
27//! precedent set by the agent definition's own `name`
28//! (`salvor_cli::agent_config::MAX_NAME_LEN`): at most 64 CHARACTERS
29//! (`chars().count()`, not bytes), and, when set, not empty or all
30//! whitespace. [`crate::validate`] enforces both, node-precise.
31//!
32//! An agent's `name` is deliberately excluded from its `agent_def_hash`: an
33//! agent is a long-lived identity that a run keeps replaying under the same
34//! hash while an operator relabels it, so a rename must not mint a new
35//! identity (see `salvor_runtime::Agent::def_hash`). A graph document has no
36//! such identity to protect — it IS its hash, the whole reason `POST
37//! /v1/graphs` stores it content-addressed. So a node's `name` gets NO
38//! special treatment: it is an ordinary field on the payload struct, present
39//! on the wire exactly when set (`skip_serializing_if = "Option::is_none"`),
40//! and folds into the canonical JSON `salvor_engine::graph_hash` hashes like
41//! any other field. Renaming a node is therefore authoring a new document
42//! version, by design, the same way changing a `prompt` or an `over`
43//! reference is.
44
45use std::collections::BTreeMap;
46
47use schemars::JsonSchema;
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51/// The schema version stamped onto every graph document.
52///
53/// Present from the first document ever written, so an old document is always
54/// self-describing and a future reader can branch on it. Start at 1.
55///
56/// # Why adding a node kind or an optional field does not bump this
57///
58/// This mirrors the reasoning in `salvor-replay`'s event `SCHEMA_VERSION`.
59/// `schema_version` exists so a reader knows how to interpret documents that
60/// were already recorded. Adding a variant to [`Node`] changes nothing about
61/// how any previously written document is encoded: a document written before
62/// the addition contains none of the new kinds, and every node in it parses to
63/// the identical value under the new build. An additive optional field follows
64/// the same rule when it carries `#[serde(default, skip_serializing_if =
65/// "...")]`: with the field absent the wire form is byte for byte what it was
66/// before the field existed, and an old document deserializes with the field
67/// defaulted.
68///
69/// A bump is reserved for a change that alters the meaning or shape of a node
70/// or edge a version-1 writer may already have produced: renaming a field,
71/// changing the node envelope, or re-encoding a payload.
72///
73/// # The strict-in direction
74///
75/// Because a graph is submitted, not just replayed, [`crate::validate`] also
76/// rejects a document whose `schema_version` is FROM THE FUTURE (greater than
77/// this constant): a current build cannot promise to understand a shape a newer
78/// writer invented. An older-or-equal version is accepted, which is the
79/// additive-tolerant-out promise recorded graphs rely on.
80pub const SCHEMA_VERSION: u32 = 1;
81
82/// A graph document: the control document authored once, submitted, hashed into
83/// a run, and then frozen. It carries the schema version, the set of nodes
84/// (each with a stable string id), and the edges that connect them.
85///
86/// Serializing a `Graph` always includes `schema_version`, so the wire form is
87/// self-describing. Unknown top-level keys are rejected.
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct Graph {
91    /// The document schema version. Always [`SCHEMA_VERSION`] for documents
92    /// this build writes; an older value may appear when reading a recorded
93    /// document.
94    pub schema_version: u32,
95    /// The nodes, each identified by a stable string id unique within the
96    /// document.
97    pub nodes: Vec<Node>,
98    /// The directed edges connecting nodes. A document with a single node and
99    /// no edges is legal, so this defaults to empty.
100    #[serde(default)]
101    pub edges: Vec<Edge>,
102}
103
104/// One node in a graph: exactly one of the six kinds the runtime knows how to
105/// execute.
106///
107/// Adjacently tagged like the event enum: each node serializes as `{"kind":
108/// "...", "payload": {...}}`. The tag (`kind`) and content (`payload`) live in
109/// separate keys, which never collides with a payload field and does not force
110/// payloads to be JSON objects. `deny_unknown_fields` on the enum rejects any
111/// key other than `kind` and `payload`.
112///
113/// The stable node id lives inside each payload (every payload struct carries
114/// an `id`), reachable generically through [`Node::id`]. Keeping the id in the
115/// payload is what lets the outer shape stay exactly the two-key adjacent
116/// tagging the event log uses, with no third common field to special-case.
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
118#[serde(
119    tag = "kind",
120    content = "payload",
121    rename_all = "snake_case",
122    deny_unknown_fields
123)]
124pub enum Node {
125    /// A full agent loop (model, prompt, tools, budget). The whole v0.1 product
126    /// becomes one node kind. It references its agent definition BY CONTENT
127    /// HASH, never an embedded definition, so this crate stays a leaf that does
128    /// not depend on the agent-definition schema.
129    Agent(AgentNode),
130    /// A single direct tool invocation, no model in the loop.
131    Tool(ToolNode),
132    /// Human approval: suspends the graph run and renders in the approval
133    /// inbox.
134    Gate(GateNode),
135    /// Routes on a typed output. The cases (conditions and their names) are
136    /// recorded as DATA here; this crate never evaluates them.
137    Branch(BranchNode),
138    /// Fan-out: spawn a sub-run per element of a typed list, join on
139    /// completion, with a concurrency cap.
140    Map(MapNode),
141    /// Bounded iteration: run a body repeatedly, accumulating across passes,
142    /// until a stop predicate holds or an iteration bound is reached, then join
143    /// the passes into one value. Models an adversarial refine loop (draft,
144    /// score, review, revise) as one node. Execution is not implemented: the
145    /// fold exists in the format, the validator, the projection, and the
146    /// canvas; the engine records a typed refusal for it.
147    Fold(FoldNode),
148}
149
150impl Node {
151    /// The node's stable id, whatever its kind.
152    #[must_use]
153    pub fn id(&self) -> &str {
154        match self {
155            Node::Agent(n) => &n.id,
156            Node::Tool(n) => &n.id,
157            Node::Gate(n) => &n.id,
158            Node::Branch(n) => &n.id,
159            Node::Map(n) => &n.id,
160            Node::Fold(n) => &n.id,
161        }
162    }
163
164    /// The kind name (`"agent"`, `"tool"`, ...), for error messages.
165    #[must_use]
166    pub fn kind_name(&self) -> &'static str {
167        match self {
168            Node::Agent(_) => "agent",
169            Node::Tool(_) => "tool",
170            Node::Gate(_) => "gate",
171            Node::Branch(_) => "branch",
172            Node::Map(_) => "map",
173            Node::Fold(_) => "fold",
174        }
175    }
176
177    /// The node's optional display name, whatever its kind. See the module
178    /// docs' "The optional node display name" section.
179    #[must_use]
180    pub fn name(&self) -> Option<&str> {
181        match self {
182            Node::Agent(n) => n.name.as_deref(),
183            Node::Tool(n) => n.name.as_deref(),
184            Node::Gate(n) => n.name.as_deref(),
185            Node::Branch(n) => n.name.as_deref(),
186            Node::Map(n) => n.name.as_deref(),
187            Node::Fold(n) => n.name.as_deref(),
188        }
189    }
190
191    /// The JSON Schema this node declares for the payload it CONSUMES, if any.
192    /// Absent means the node does not declare an input type, and an edge into
193    /// it passes the type-compatibility check unchecked.
194    #[must_use]
195    pub fn input_schema(&self) -> Option<&Value> {
196        match self {
197            Node::Agent(n) => n.input_schema.as_ref(),
198            Node::Tool(n) => n.input_schema.as_ref(),
199            // Gate, branch, map, and fold do not declare a consumed type;
200            // they pass typed payloads through untyped. A fold's
201            // `accumulator_schema` is data only, deliberately not wired into
202            // the edge type-compatibility check while its execution is not
203            // implemented.
204            Node::Gate(_) | Node::Branch(_) | Node::Map(_) | Node::Fold(_) => None,
205        }
206    }
207
208    /// The JSON Schema this node declares for the payload it PRODUCES, if any.
209    /// Absent means the node does not declare an output type, and an edge out
210    /// of it passes the type-compatibility check unchecked.
211    #[must_use]
212    pub fn output_schema(&self) -> Option<&Value> {
213        match self {
214            Node::Agent(n) => n.output_schema.as_ref(),
215            Node::Tool(n) => n.output_schema.as_ref(),
216            Node::Map(n) => n.output_schema.as_ref(),
217            // A fold's produced-value type is not implemented with its
218            // execution: its `accumulator_schema` is data only and does not
219            // gate outbound edges.
220            Node::Gate(_) | Node::Branch(_) | Node::Fold(_) => None,
221        }
222    }
223}
224
225/// An `agent` node: a full agent loop referenced by content hash.
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
227#[serde(deny_unknown_fields)]
228pub struct AgentNode {
229    /// The node's stable id, unique within the document.
230    pub id: String,
231    /// Content hash of the agent definition (model, prompt, tools, budget) this
232    /// node runs, in `sha256:<64 lowercase hex>` form. A hash, never an
233    /// embedded definition: that keeps this crate independent of the
234    /// agent-definition schema and lets the same definition be shared across
235    /// nodes and runs by identity.
236    pub agent_hash: String,
237    /// Optional short display label for this node. See the module docs' "The
238    /// optional node display name" section for the bound and the deliberate
239    /// hash-inclusion contrast with the agent `name` field. Additive: absent
240    /// on the wire when unset.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub name: Option<String>,
243    /// Optional JSON Schema for the payload this node consumes. Additive: absent
244    /// on the wire when unset.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub input_schema: Option<Value>,
247    /// Optional JSON Schema for the payload this node produces.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub output_schema: Option<Value>,
250}
251
252/// A `tool` node: one direct tool invocation.
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
254#[serde(deny_unknown_fields)]
255pub struct ToolNode {
256    /// The node's stable id, unique within the document.
257    pub id: String,
258    /// The tool's name, as registered with the runtime.
259    pub tool: String,
260    /// Optional short display label for this node. See the module docs' "The
261    /// optional node display name" section for the bound and the deliberate
262    /// hash-inclusion contrast with the agent `name` field. Additive: absent
263    /// on the wire when unset.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub name: Option<String>,
266    /// The input mapping: tool input field name to an opaque source reference.
267    /// Recorded as DATA; this crate does not resolve or evaluate the references.
268    /// Additive: omitted on the wire when empty.
269    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
270    pub input: BTreeMap<String, String>,
271    /// Optional JSON Schema for the payload this node consumes.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub input_schema: Option<Value>,
274    /// Optional JSON Schema for the payload this node produces.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub output_schema: Option<Value>,
277}
278
279/// A `gate` node: human approval that suspends the run.
280#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
281#[serde(deny_unknown_fields)]
282pub struct GateNode {
283    /// The node's stable id, unique within the document.
284    pub id: String,
285    /// Optional short display label for this node. See the module docs' "The
286    /// optional node display name" section for the bound and the deliberate
287    /// hash-inclusion contrast with the agent `name` field. Additive: absent
288    /// on the wire when unset.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub name: Option<String>,
291    /// Optional human-readable prompt shown in the approval inbox.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub prompt: Option<String>,
294    /// JSON Schema the human approval input must satisfy, mirroring the recorded
295    /// `Suspended` event's `input_schema`. Required: a gate with no declared
296    /// approval shape is meaningless.
297    pub approval_schema: Value,
298}
299
300/// A `branch` node: routes on a typed output.
301#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
302#[serde(deny_unknown_fields)]
303pub struct BranchNode {
304    /// The node's stable id, unique within the document.
305    pub id: String,
306    /// Optional short display label for this node. See the module docs' "The
307    /// optional node display name" section for the bound and the deliberate
308    /// hash-inclusion contrast with the agent `name` field. Additive: absent
309    /// on the wire when unset.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub name: Option<String>,
312    /// Optional opaque reference to the typed value the branch routes on.
313    /// Recorded as DATA; not resolved in this crate.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub on: Option<String>,
316    /// Content hash of the agent that decides a [`BranchCondition::ModelDecision`]
317    /// case, in `sha256:<64 lowercase hex>` form. Present only on a branch that
318    /// carries a model-decision case: the engine drives this agent with the
319    /// routed value and maps its reply to a case name. Additive: absent on the
320    /// wire when unset, so a purely expression-driven branch (and every document
321    /// written before this field existed) serializes byte for byte as before.
322    /// [`crate::validate`] reports a model-decision case with no agent here as a
323    /// node-precise error.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub agent_hash: Option<String>,
326    /// The cases, each a named condition. An expression condition is evaluated
327    /// against the routed value; a model-decision condition is resolved by the
328    /// node's [`agent_hash`](Self::agent_hash) agent. The first matching case in
329    /// author order wins, and the engine records the choice as a
330    /// [`crate::document`]-external `BranchTaken` event.
331    pub cases: Vec<BranchCase>,
332}
333
334/// One case of a [`BranchNode`]: a name and the condition that selects it.
335///
336/// The realized routing (which downstream node a fired case flows to) is
337/// carried by an [`Edge`] whose `label` matches the case `name`, so topology
338/// stays entirely in the edge list and a branch has real outbound edges.
339#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
340#[serde(deny_unknown_fields)]
341pub struct BranchCase {
342    /// The case name. An edge labeled with this name realizes the route.
343    pub name: String,
344    /// The condition that selects this case. Data only, never evaluated here.
345    pub when: BranchCondition,
346}
347
348/// How a [`BranchCase`] is selected. Modeled as data; not evaluated in this
349/// crate.
350///
351/// Adjacently tagged (`{"kind": "...", "value": ...}`) so it stays additive:
352/// a future condition kind is a new variant, which does not change how an
353/// existing document encodes.
354#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
355#[serde(
356    tag = "kind",
357    content = "value",
358    rename_all = "snake_case",
359    deny_unknown_fields
360)]
361pub enum BranchCondition {
362    /// A constrained boolean expression over the routed value, recorded as an
363    /// opaque string. NOT parsed or evaluated in this crate.
364    Expression(String),
365    /// The case is chosen by a model decision at run time, recorded as an event.
366    /// Carries no author-time data.
367    ModelDecision,
368}
369
370/// A `map` node: fan-out a sub-run per element of a typed list, with a
371/// concurrency cap.
372#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
373#[serde(deny_unknown_fields)]
374pub struct MapNode {
375    /// The node's stable id, unique within the document.
376    pub id: String,
377    /// Optional short display label for this node. See the module docs' "The
378    /// optional node display name" section for the bound and the deliberate
379    /// hash-inclusion contrast with the agent `name` field. Additive: absent
380    /// on the wire when unset.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub name: Option<String>,
383    /// Opaque reference to the typed list this node fans out over. Data only,
384    /// not resolved in this crate.
385    pub over: String,
386    /// The maximum number of sub-runs in flight at once. Must be at least 1;
387    /// [`crate::validate`] reports a non-positive cap by node id.
388    pub concurrency: u32,
389    /// What each element is mapped through: a node already in this document, or
390    /// an embedded sub-graph.
391    pub body: MapBody,
392    /// Optional JSON Schema for the joined list this node produces.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub output_schema: Option<Value>,
395}
396
397/// The body a [`MapNode`] maps each element through.
398///
399/// Adjacently tagged, so adding a third form later is additive. A `node` body
400/// names an existing node by id (checked for existence during validation); a
401/// `subgraph` body embeds a whole [`Graph`].
402#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
403#[serde(
404    tag = "kind",
405    content = "value",
406    rename_all = "snake_case",
407    deny_unknown_fields
408)]
409pub enum MapBody {
410    /// Map each element through an existing node in this document, by id.
411    Node(String),
412    /// Map each element through an embedded sub-graph. Boxed because a `Graph`
413    /// contains nodes, one of which may be a `map`, so the type is recursive.
414    Subgraph(Box<Graph>),
415}
416
417/// A `fold` node: bounded iteration that accumulates across passes.
418///
419/// Models an adversarial refine loop as one node: a `body` is run up to
420/// `max_iterations` times, each pass folding into an accumulated value, and the
421/// loop stops when `stop_when` holds over that value (or the bound is reached).
422/// The `join` rule then selects the value the node produces. Every field is
423/// author-time data; this crate never runs the loop.
424///
425/// Grounded in the AARG tailor loop the graph wiring models: bounded revisions
426/// (`max_iterations`), a stop predicate over the accumulated score
427/// (`stop_when`, an expression in the same language a branch case uses), and an
428/// argmax winner (`join` = [`FoldJoin::BestBy`] over the score). See the
429/// crate-level docs and the graph wiring plan.
430#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
431#[serde(deny_unknown_fields)]
432pub struct FoldNode {
433    /// The node's stable id, unique within the document.
434    pub id: String,
435    /// Optional short display label for this node. See the module docs' "The
436    /// optional node display name" section for the bound and the deliberate
437    /// hash-inclusion contrast with the agent `name` field. Additive: absent
438    /// on the wire when unset.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub name: Option<String>,
441    /// What each pass runs: a node already in this document, or an embedded
442    /// sub-graph. Not implemented exactly as [`MapBody`]'s subgraph form is not —
443    /// the shape is legal, but no engine runs it yet.
444    pub body: FoldBody,
445    /// The iteration bound: the most passes the loop may run. Must be at least
446    /// 1; [`crate::validate`] reports a zero bound by node id.
447    pub max_iterations: u32,
448    /// A boolean expression over the accumulated value that stops the loop when
449    /// it holds. Written in the [`crate::expr`] condition language, the same one
450    /// a [`BranchCondition::Expression`] uses, and validated at submit so a
451    /// malformed predicate is a node-precise error, never a run-time failure.
452    pub stop_when: String,
453    /// How the passes are folded into the value the node produces.
454    pub join: FoldJoin,
455    /// Optional JSON Schema for the accumulated value the loop carries and
456    /// produces. Data only, like an [`AgentNode`]'s `output_schema`: recorded
457    /// for authoring and tooling, never wired into the edge type-compatibility
458    /// check (a fold's produced-value semantics are not implemented with
459    /// its execution). Additive: absent on the wire when unset.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub accumulator_schema: Option<Value>,
462}
463
464/// The body a [`FoldNode`] runs each pass. Adjacently tagged, mirroring
465/// [`MapBody`], so adding a third form later stays additive. A `node` body names
466/// an existing node by id (checked for existence during validation); a
467/// `subgraph` body embeds a whole [`Graph`] and is deferred exactly as the map's
468/// subgraph body is.
469#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
470#[serde(
471    tag = "kind",
472    content = "value",
473    rename_all = "snake_case",
474    deny_unknown_fields
475)]
476pub enum FoldBody {
477    /// Run each pass through an existing node in this document, by id.
478    Node(String),
479    /// Run each pass through an embedded sub-graph. Boxed because a `Graph`
480    /// contains nodes, one of which may itself be a `fold`, so the type is
481    /// recursive.
482    Subgraph(Box<Graph>),
483}
484
485/// How a [`FoldNode`] folds its passes into the single value it produces.
486///
487/// Adjacently tagged (`{"kind": "...", "value": ...}` for the variant that
488/// carries data, `{"kind": "..."}` for the unit variants) so a future join rule
489/// is a new variant that does not change how an existing document encodes,
490/// exactly like [`BranchCondition`].
491///
492/// The variants are grounded in what the AARG loop actually needs. `best_by` is
493/// the argmax winner the loop's "best draft wins, never the last pass" rule
494/// requires; `last` and `all` are the two obvious simpler folds a different
495/// consumer might want.
496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
497#[serde(
498    tag = "kind",
499    content = "value",
500    rename_all = "snake_case",
501    deny_unknown_fields
502)]
503pub enum FoldJoin {
504    /// Produce the pass whose value MAXIMIZES the given reference (a path into
505    /// the accumulated value, `score` or `review.overall_score`). This is the
506    /// argmax the AARG loop needs: the best draft wins, never the last. The
507    /// reference is parsed at submit like a [`crate::expr`] path, so a malformed
508    /// one is a node-precise error.
509    BestBy(String),
510    /// Produce the value of the last pass the loop ran.
511    Last,
512    /// Produce every pass's value as a list, in pass order.
513    All,
514}
515
516/// A directed edge: a typed payload flows from one node to another.
517///
518/// Edges are the single source of graph topology. Referential integrity, the
519/// acyclic check, and the entry/terminal summary all read the edge list. No
520/// ports are modeled here; a `port` pair is a documented additive
521/// follow-up.
522#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
523#[serde(deny_unknown_fields)]
524pub struct Edge {
525    /// The source node id.
526    pub from: String,
527    /// The destination node id.
528    pub to: String,
529    /// Optional label. When the source is a [`BranchNode`], this names the
530    /// [`BranchCase`] this edge realizes. Data only.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub label: Option<String>,
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use serde_json::json;
539
540    /// A small, valid document used across the round-trip tests.
541    fn sample() -> Graph {
542        Graph {
543            schema_version: SCHEMA_VERSION,
544            nodes: vec![
545                Node::Agent(AgentNode {
546                    id: "research".into(),
547                    agent_hash: format!("sha256:{}", "a".repeat(64)),
548                    name: None,
549                    input_schema: None,
550                    output_schema: Some(json!({"type": "object"})),
551                }),
552                Node::Gate(GateNode {
553                    id: "approve".into(),
554                    name: None,
555                    prompt: Some("Approve publication?".into()),
556                    approval_schema: json!({"type": "object"}),
557                }),
558            ],
559            edges: vec![Edge {
560                from: "research".into(),
561                to: "approve".into(),
562                label: None,
563            }],
564        }
565    }
566
567    /// Serializing then deserializing a document yields an equal value.
568    #[test]
569    fn round_trips_through_json() {
570        let original = sample();
571        let json = serde_json::to_string(&original).expect("serialize");
572        let restored: Graph = serde_json::from_str(&json).expect("deserialize");
573        assert_eq!(original, restored, "round trip changed the value: {json}");
574    }
575
576    /// A node serializes with the adjacent `kind`/`payload` shape, and the id
577    /// rides inside the payload. No `name` was set, so none appears on the
578    /// wire: this is the byte-stability guarantee the optional node name
579    /// must not disturb.
580    #[test]
581    fn node_uses_adjacent_kind_payload_shape() {
582        let node = Node::Tool(ToolNode {
583            id: "publish".into(),
584            tool: "http_post".into(),
585            name: None,
586            input: BTreeMap::new(),
587            input_schema: None,
588            output_schema: None,
589        });
590        let json = serde_json::to_string(&node).expect("serialize");
591        assert_eq!(
592            json,
593            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post"}}"#
594        );
595    }
596
597    /// Setting a node's `name` puts it on the wire; leaving it unset keeps the
598    /// payload byte-identical to a document written before the field existed.
599    #[test]
600    fn node_name_is_present_only_when_set() {
601        let named = Node::Tool(ToolNode {
602            id: "publish".into(),
603            tool: "http_post".into(),
604            name: Some("Publish the draft".into()),
605            input: BTreeMap::new(),
606            input_schema: None,
607            output_schema: None,
608        });
609        let json = serde_json::to_string(&named).expect("serialize");
610        assert_eq!(
611            json,
612            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post","name":"Publish the draft"}}"#
613        );
614
615        let unnamed = Node::Tool(ToolNode {
616            id: "publish".into(),
617            tool: "http_post".into(),
618            name: None,
619            input: BTreeMap::new(),
620            input_schema: None,
621            output_schema: None,
622        });
623        assert_eq!(
624            serde_json::to_string(&unnamed).expect("serialize"),
625            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post"}}"#,
626            "an unset name must not appear on the wire"
627        );
628    }
629
630    /// A fold node round-trips and serializes with the adjacent kind/payload
631    /// shape, its `join` as an adjacently tagged sub-object, and an unset
632    /// `accumulator_schema` staying off the wire.
633    #[test]
634    fn fold_node_serializes_with_join_and_body_shapes() {
635        let node = Node::Fold(FoldNode {
636            id: "refine".into(),
637            name: None,
638            body: FoldBody::Node("tailor".into()),
639            max_iterations: 3,
640            stop_when: "score >= 0.85".into(),
641            join: FoldJoin::BestBy("score".into()),
642            accumulator_schema: None,
643        });
644        let json = serde_json::to_string(&node).expect("serialize");
645        assert_eq!(
646            json,
647            r#"{"kind":"fold","payload":{"id":"refine","body":{"kind":"node","value":"tailor"},"max_iterations":3,"stop_when":"score >= 0.85","join":{"kind":"best_by","value":"score"}}}"#
648        );
649        let restored: Node = serde_json::from_str(&json).expect("deserialize");
650        assert_eq!(node, restored, "fold round trip changed the value: {json}");
651    }
652
653    /// The two unit `join` variants serialize with just their `kind` tag, no
654    /// `value`; this is the additive-tagged shape a future join rule extends.
655    #[test]
656    fn fold_join_unit_variants_carry_only_the_kind_tag() {
657        assert_eq!(
658            serde_json::to_string(&FoldJoin::Last).expect("serialize"),
659            r#"{"kind":"last"}"#
660        );
661        assert_eq!(
662            serde_json::to_string(&FoldJoin::All).expect("serialize"),
663            r#"{"kind":"all"}"#
664        );
665    }
666
667    /// A stray top-level key on the document is rejected, not ignored: strict
668    /// in, because a graph is a control document.
669    #[test]
670    fn unknown_document_field_is_rejected() {
671        let text = r#"{"schema_version":1,"nodes":[],"edges":[],"surprise":true}"#;
672        let error = serde_json::from_str::<Graph>(text).expect_err("must reject");
673        assert!(
674            error.to_string().contains("surprise"),
675            "error should name the stray field: {error}"
676        );
677    }
678
679    /// A stray key inside a node payload is rejected too.
680    #[test]
681    fn unknown_payload_field_is_rejected() {
682        let text = r#"{"kind":"gate","payload":{"id":"g","approval_schema":{},"oops":1}}"#;
683        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
684        assert!(
685            error.to_string().contains("oops"),
686            "error should name the stray field: {error}"
687        );
688    }
689
690    /// A key other than `kind`/`payload` alongside a node is rejected.
691    #[test]
692    fn unknown_node_envelope_field_is_rejected() {
693        let text = r#"{"kind":"gate","payload":{"id":"g","approval_schema":{}},"extra":1}"#;
694        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
695        assert!(
696            error.to_string().contains("extra"),
697            "error should name the stray key: {error}"
698        );
699    }
700}