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::de::Error as _;
49use serde::{Deserialize, Deserializer, Serialize};
50use serde_json::Value;
51
52/// The schema version stamped onto every graph document.
53///
54/// Present from the first document ever written, so an old document is always
55/// self-describing and a future reader can branch on it. Start at 1.
56///
57/// # Why adding a node kind or an optional field does not bump this
58///
59/// This mirrors the reasoning in `salvor-replay`'s event `SCHEMA_VERSION`.
60/// `schema_version` exists so a reader knows how to interpret documents that
61/// were already recorded. Adding a variant to `Node` changes nothing about
62/// how any previously written document is encoded: a document written before
63/// the addition contains none of the new kinds, and every node in it parses to
64/// the identical value under the new build. An additive optional field follows
65/// the same rule when it carries `#[serde(default, skip_serializing_if =
66/// "...")]`: with the field absent the wire form is byte for byte what it was
67/// before the field existed, and an old document deserializes with the field
68/// defaulted.
69///
70/// A bump is reserved for a change that alters the meaning or shape of a node
71/// or edge a version-1 writer may already have produced: renaming a field,
72/// changing the node envelope, or re-encoding a payload.
73///
74/// # The strict-in direction
75///
76/// Because a graph is submitted, not just replayed, `crate::validate` also
77/// rejects a document whose `schema_version` is FROM THE FUTURE (greater than
78/// this constant): a current build cannot promise to understand a shape a newer
79/// writer invented. An older-or-equal version is accepted, which is the
80/// additive-tolerant-out promise recorded graphs rely on.
81pub const SCHEMA_VERSION: u32 = 1;
82
83/// A graph document: the control document authored once, submitted, hashed into
84/// a run, and then frozen. It carries the schema version, the set of nodes
85/// (each with a stable string id), and the edges that connect them.
86///
87/// Serializing a `Graph` always includes `schema_version`, so the wire form is
88/// self-describing. Unknown top-level keys are rejected.
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct Graph {
92    /// The document schema version. Always `SCHEMA_VERSION` for documents
93    /// this build writes; an older value may appear when reading a recorded
94    /// document.
95    pub schema_version: u32,
96    /// The nodes, each identified by a stable string id unique within the
97    /// document.
98    pub nodes: Vec<Node>,
99    /// The directed edges connecting nodes. A document with a single node and
100    /// no edges is legal, so this defaults to empty.
101    #[serde(default)]
102    pub edges: Vec<Edge>,
103}
104
105/// One node in a graph: exactly one of the six kinds the runtime knows how to
106/// execute.
107///
108/// Adjacently tagged like the event enum: each node serializes as `{"kind":
109/// "...", "payload": {...}}`. The tag (`kind`) and content (`payload`) live in
110/// separate keys, which never collides with a payload field and does not force
111/// payloads to be JSON objects. `deny_unknown_fields` on the enum rejects any
112/// key other than `kind` and `payload`.
113///
114/// The stable node id lives inside each payload (every payload struct carries
115/// an `id`), reachable generically through `Node::id`. Keeping the id in the
116/// payload is what lets the outer shape stay exactly the two-key adjacent
117/// tagging the event log uses, with no third common field to special-case.
118#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
119#[serde(
120    tag = "kind",
121    content = "payload",
122    rename_all = "snake_case",
123    deny_unknown_fields
124)]
125pub enum Node {
126    /// A full agent loop (model, prompt, tools, budget). The whole v0.1 product
127    /// becomes one node kind. It references its agent definition BY CONTENT
128    /// HASH, never an embedded definition, so this crate stays a leaf that does
129    /// not depend on the agent-definition schema.
130    Agent(AgentNode),
131    /// A single direct tool invocation, no model in the loop.
132    Tool(ToolNode),
133    /// Human approval: suspends the graph run and renders in the approval
134    /// inbox.
135    Gate(GateNode),
136    /// Routes on a typed output. The cases (conditions and their names) are
137    /// recorded as DATA here; this crate never evaluates them.
138    Branch(BranchNode),
139    /// Fan-out: spawn a sub-run per element of a typed list, join on
140    /// completion, with a concurrency cap.
141    Map(MapNode),
142    /// Bounded iteration: run a body repeatedly, accumulating across passes,
143    /// until a stop predicate holds or an iteration bound is reached, then join
144    /// the passes into one value. Models an adversarial refine loop (draft,
145    /// score, review, revise) as one node. Execution is not implemented: the
146    /// fold exists in the format, the validator, the projection, and the
147    /// canvas; the engine records a typed refusal for it.
148    Fold(FoldNode),
149}
150
151impl Node {
152    /// The node's stable id, whatever its kind.
153    #[must_use]
154    pub fn id(&self) -> &str {
155        match self {
156            Node::Agent(n) => &n.id,
157            Node::Tool(n) => &n.id,
158            Node::Gate(n) => &n.id,
159            Node::Branch(n) => &n.id,
160            Node::Map(n) => &n.id,
161            Node::Fold(n) => &n.id,
162        }
163    }
164
165    /// The kind name (`"agent"`, `"tool"`, ...), for error messages.
166    #[must_use]
167    pub fn kind_name(&self) -> &'static str {
168        match self {
169            Node::Agent(_) => "agent",
170            Node::Tool(_) => "tool",
171            Node::Gate(_) => "gate",
172            Node::Branch(_) => "branch",
173            Node::Map(_) => "map",
174            Node::Fold(_) => "fold",
175        }
176    }
177
178    /// The node's optional display name, whatever its kind. See the module
179    /// docs' "The optional node display name" section.
180    #[must_use]
181    pub fn name(&self) -> Option<&str> {
182        match self {
183            Node::Agent(n) => n.name.as_deref(),
184            Node::Tool(n) => n.name.as_deref(),
185            Node::Gate(n) => n.name.as_deref(),
186            Node::Branch(n) => n.name.as_deref(),
187            Node::Map(n) => n.name.as_deref(),
188            Node::Fold(n) => n.name.as_deref(),
189        }
190    }
191
192    /// The JSON Schema this node declares for the payload it CONSUMES, if any.
193    /// Absent means the node does not declare an input type, and an edge into
194    /// it passes the type-compatibility check unchecked.
195    #[must_use]
196    pub fn input_schema(&self) -> Option<&Value> {
197        match self {
198            Node::Agent(n) => n.input_schema.as_ref(),
199            Node::Tool(n) => n.input_schema.as_ref(),
200            // Gate, branch, map, and fold do not declare a consumed type;
201            // they pass typed payloads through untyped. A fold's
202            // `accumulator_schema` is data only, deliberately not wired into
203            // the edge type-compatibility check while its execution is not
204            // implemented.
205            Node::Gate(_) | Node::Branch(_) | Node::Map(_) | Node::Fold(_) => None,
206        }
207    }
208
209    /// The JSON Schema this node declares for the payload it PRODUCES, if any.
210    /// Absent means the node does not declare an output type, and an edge out
211    /// of it passes the type-compatibility check unchecked.
212    #[must_use]
213    pub fn output_schema(&self) -> Option<&Value> {
214        match self {
215            Node::Agent(n) => n.output_schema.as_ref(),
216            Node::Tool(n) => n.output_schema.as_ref(),
217            Node::Map(n) => n.output_schema.as_ref(),
218            // A fold's produced-value type is not implemented with its
219            // execution: its `accumulator_schema` is data only and does not
220            // gate outbound edges.
221            Node::Gate(_) | Node::Branch(_) | Node::Fold(_) => None,
222        }
223    }
224}
225
226/// An `agent` node: a full agent loop referenced by content hash.
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
228#[serde(deny_unknown_fields)]
229pub struct AgentNode {
230    /// The node's stable id, unique within the document.
231    pub id: String,
232    /// Content hash of the agent definition (model, prompt, tools, budget) this
233    /// node runs, in `sha256:<64 lowercase hex>` form. A hash, never an
234    /// embedded definition: that keeps this crate independent of the
235    /// agent-definition schema and lets the same definition be shared across
236    /// nodes and runs by identity.
237    pub agent_hash: String,
238    /// Optional short display label for this node. See the module docs' "The
239    /// optional node display name" section for the bound and the deliberate
240    /// hash-inclusion contrast with the agent `name` field. Additive: absent
241    /// on the wire when unset.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub name: Option<String>,
244    /// Optional JSON Schema for the payload this node consumes. Additive: absent
245    /// on the wire when unset.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub input_schema: Option<Value>,
248    /// Optional JSON Schema for the payload this node produces.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub output_schema: Option<Value>,
251}
252
253/// A `tool` node: one direct tool invocation.
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct ToolNode {
257    /// The node's stable id, unique within the document.
258    pub id: String,
259    /// The tool's name, as registered with the runtime.
260    pub tool: String,
261    /// Optional short display label for this node. See the module docs' "The
262    /// optional node display name" section for the bound and the deliberate
263    /// hash-inclusion contrast with the agent `name` field. Additive: absent
264    /// on the wire when unset.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub name: Option<String>,
267    /// The input mapping: tool input field name to an opaque source reference.
268    /// Recorded as DATA; this crate does not resolve or evaluate the references.
269    /// Additive: omitted on the wire when empty.
270    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
271    pub input: BTreeMap<String, String>,
272    /// Optional JSON Schema for the payload this node consumes.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub input_schema: Option<Value>,
275    /// Optional JSON Schema for the payload this node produces.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub output_schema: Option<Value>,
278}
279
280/// A `gate` node: human approval that suspends the run.
281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
282#[serde(deny_unknown_fields)]
283pub struct GateNode {
284    /// The node's stable id, unique within the document.
285    pub id: String,
286    /// Optional short display label for this node. See the module docs' "The
287    /// optional node display name" section for the bound and the deliberate
288    /// hash-inclusion contrast with the agent `name` field. Additive: absent
289    /// on the wire when unset.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub name: Option<String>,
292    /// Optional human-readable prompt shown in the approval inbox.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub prompt: Option<String>,
295    /// JSON Schema the human approval input must satisfy, mirroring the recorded
296    /// `Suspended` event's `input_schema`. Required: a gate with no declared
297    /// approval shape is meaningless.
298    pub approval_schema: Value,
299}
300
301/// A `branch` node: routes on a typed output.
302#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
303#[serde(deny_unknown_fields)]
304pub struct BranchNode {
305    /// The node's stable id, unique within the document.
306    pub id: String,
307    /// Optional short display label for this node. See the module docs' "The
308    /// optional node display name" section for the bound and the deliberate
309    /// hash-inclusion contrast with the agent `name` field. Additive: absent
310    /// on the wire when unset.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub name: Option<String>,
313    /// Optional opaque reference to the typed value the branch routes on.
314    /// Recorded as DATA; not resolved in this crate.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub on: Option<String>,
317    /// Content hash of the agent that decides a `BranchCondition::ModelDecision`
318    /// case, in `sha256:<64 lowercase hex>` form. Present only on a branch that
319    /// carries a model-decision case: the engine drives this agent with the
320    /// routed value and maps its reply to a case name. Additive: absent on the
321    /// wire when unset, so a purely expression-driven branch (and every document
322    /// written before this field existed) serializes byte for byte as before.
323    /// `crate::validate` reports a model-decision case with no agent here as a
324    /// node-precise error.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub agent_hash: Option<String>,
327    /// The cases, each a named condition. An expression condition is evaluated
328    /// against the routed value; a model-decision condition is resolved by the
329    /// node's `agent_hash` agent. The first matching case in
330    /// author order wins, and the engine records the choice as a
331    /// `crate::document`-external `BranchTaken` event.
332    pub cases: Vec<BranchCase>,
333}
334
335/// One case of a `BranchNode`: a name and the condition that selects it.
336///
337/// The realized routing (which downstream node a fired case flows to) is
338/// carried by an `Edge` whose `label` matches the case `name`, so topology
339/// stays entirely in the edge list and a branch has real outbound edges.
340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
341#[serde(deny_unknown_fields)]
342pub struct BranchCase {
343    /// The case name. An edge labeled with this name realizes the route.
344    pub name: String,
345    /// The condition that selects this case. Data only, never evaluated here.
346    pub when: BranchCondition,
347}
348
349/// How a `BranchCase` is selected. Modeled as data; not evaluated in this
350/// crate.
351///
352/// Adjacently tagged (`{"kind": "...", "value": ...}`) so it stays additive:
353/// a future condition kind is a new variant, which does not change how an
354/// existing document encodes.
355#[derive(Clone, Debug, PartialEq, Serialize, JsonSchema)]
356#[serde(
357    tag = "kind",
358    content = "value",
359    rename_all = "snake_case",
360    deny_unknown_fields
361)]
362pub enum BranchCondition {
363    /// A constrained boolean expression over the routed value, recorded as an
364    /// opaque string. NOT parsed or evaluated in this crate.
365    Expression(String),
366    /// The case is chosen by a model decision at run time, recorded as an event.
367    /// Carries no author-time data.
368    ModelDecision,
369}
370
371/// The wire shape `BranchCondition` parses as: the exact same
372/// tag/content/deny_unknown_fields attributes as the type it mirrors, so
373/// what this accepts and rejects is unchanged. It exists only as a target for
374/// `BranchCondition`'s hand-written `Deserialize` impl below, so a
375/// malformed `when` can be reported in product language instead of serde's
376/// "adjacently tagged enum" internals.
377#[derive(Deserialize)]
378#[serde(
379    tag = "kind",
380    content = "value",
381    rename_all = "snake_case",
382    deny_unknown_fields
383)]
384enum BranchConditionShape {
385    Expression(String),
386    ModelDecision,
387}
388
389impl From<BranchConditionShape> for BranchCondition {
390    fn from(shape: BranchConditionShape) -> Self {
391        match shape {
392            BranchConditionShape::Expression(expr) => BranchCondition::Expression(expr),
393            BranchConditionShape::ModelDecision => BranchCondition::ModelDecision,
394        }
395    }
396}
397
398impl<'de> Deserialize<'de> for BranchCondition {
399    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
400    where
401        D: Deserializer<'de>,
402    {
403        // Buffer as generic JSON first, then parse that buffer through the
404        // identical adjacently tagged shape the derive would have used.
405        // Acceptance does not change: a value `BranchConditionShape`
406        // rejects was always rejected. Only the failure message changes, from
407        // serde's enum-internals wording to a product-language description of
408        // the two accepted shapes with the offending value echoed back.
409        let value = Value::deserialize(deserializer)?;
410        serde_json::from_value::<BranchConditionShape>(value.clone())
411            .map(Into::into)
412            .map_err(|_| D::Error::custom(describe_branch_condition_error(&value)))
413    }
414}
415
416/// Build the error text for a `when` that failed to parse as a
417/// `BranchCondition`: the two accepted shapes, then what was actually
418/// found, so a bare string (the obvious skim-and-adapt mistake, writing
419/// `"when": "value > 10000"` instead of the object form) reads as data
420/// against the expected shape rather than a serde internals error.
421fn describe_branch_condition_error(value: &Value) -> String {
422    format!(
423        "a branch condition must be an object shaped \
424         `{{\"kind\": \"expression\", \"value\": \"<expr>\"}}` or \
425         `{{\"kind\": \"model_decision\"}}`; got {}",
426        describe_json_value(value)
427    )
428}
429
430/// Describe a JSON value's shape and content for an error message: a bare
431/// string echoes as `a bare string "..."`, an object as `an object {...}`,
432/// and so on.
433fn describe_json_value(value: &Value) -> String {
434    let text = serde_json::to_string(value).unwrap_or_else(|_| "<unrepresentable>".to_string());
435    match value {
436        Value::Null => "null".to_string(),
437        Value::Bool(_) => format!("a bare boolean {text}"),
438        Value::Number(_) => format!("a bare number {text}"),
439        Value::String(_) => format!("a bare string {text}"),
440        Value::Array(_) => format!("a bare array {text}"),
441        Value::Object(_) => format!("an object {text}"),
442    }
443}
444
445/// A `map` node: fan-out a sub-run per element of a typed list, with a
446/// concurrency cap.
447#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
448#[serde(deny_unknown_fields)]
449pub struct MapNode {
450    /// The node's stable id, unique within the document.
451    pub id: String,
452    /// Optional short display label for this node. See the module docs' "The
453    /// optional node display name" section for the bound and the deliberate
454    /// hash-inclusion contrast with the agent `name` field. Additive: absent
455    /// on the wire when unset.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub name: Option<String>,
458    /// Opaque reference to the typed list this node fans out over. Data only,
459    /// not resolved in this crate.
460    pub over: String,
461    /// The maximum number of sub-runs in flight at once. Must be at least 1;
462    /// `crate::validate` reports a non-positive cap by node id.
463    pub concurrency: u32,
464    /// What each element is mapped through: a node already in this document, or
465    /// an embedded sub-graph.
466    pub body: MapBody,
467    /// Optional JSON Schema for the joined list this node produces.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub output_schema: Option<Value>,
470}
471
472/// The body a `MapNode` maps each element through.
473///
474/// Adjacently tagged, so adding a third form later is additive. A `node` body
475/// names an existing node by id (checked for existence during validation); a
476/// `subgraph` body embeds a whole `Graph`.
477#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
478#[serde(
479    tag = "kind",
480    content = "value",
481    rename_all = "snake_case",
482    deny_unknown_fields
483)]
484pub enum MapBody {
485    /// Map each element through an existing node in this document, by id.
486    Node(String),
487    /// Map each element through an embedded sub-graph. Boxed because a `Graph`
488    /// contains nodes, one of which may be a `map`, so the type is recursive.
489    Subgraph(Box<Graph>),
490}
491
492/// A `fold` node: bounded iteration that accumulates across passes.
493///
494/// Models an adversarial refine loop as one node: a `body` is run up to
495/// `max_iterations` times, each pass folding into an accumulated value, and the
496/// loop stops when `stop_when` holds over that value (or the bound is reached).
497/// The `join` rule then selects the value the node produces. Every field is
498/// author-time data; this crate never runs the loop.
499///
500/// Grounded in the AARG tailor loop the graph wiring models: bounded revisions
501/// (`max_iterations`), a stop predicate over the accumulated score
502/// (`stop_when`, an expression in the same language a branch case uses), and an
503/// argmax winner (`join` = `FoldJoin::BestBy` over the score). See the
504/// crate-level docs and the graph wiring plan.
505#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
506#[serde(deny_unknown_fields)]
507pub struct FoldNode {
508    /// The node's stable id, unique within the document.
509    pub id: String,
510    /// Optional short display label for this node. See the module docs' "The
511    /// optional node display name" section for the bound and the deliberate
512    /// hash-inclusion contrast with the agent `name` field. Additive: absent
513    /// on the wire when unset.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub name: Option<String>,
516    /// What each pass runs: a node already in this document, or an embedded
517    /// sub-graph. Not implemented exactly as `MapBody`'s subgraph form is not:
518    /// the shape is legal, but no engine runs it yet.
519    pub body: FoldBody,
520    /// The iteration bound: the most passes the loop may run. Must be at least
521    /// 1; `crate::validate` reports a zero bound by node id.
522    pub max_iterations: u32,
523    /// A boolean expression over the accumulated value that stops the loop when
524    /// it holds. Written in the `crate::expr` condition language, the same one
525    /// a `BranchCondition::Expression` uses, and validated at submit so a
526    /// malformed predicate is a node-precise error, never a run-time failure.
527    pub stop_when: String,
528    /// How the passes are folded into the value the node produces.
529    pub join: FoldJoin,
530    /// Optional JSON Schema for the accumulated value the loop carries and
531    /// produces. Data only, like an `AgentNode`'s `output_schema`: recorded
532    /// for authoring and tooling, never wired into the edge type-compatibility
533    /// check (a fold's produced-value semantics are not implemented with
534    /// its execution). Additive: absent on the wire when unset.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub accumulator_schema: Option<Value>,
537}
538
539/// The body a `FoldNode` runs each pass. Adjacently tagged, mirroring
540/// `MapBody`, so adding a third form later stays additive. A `node` body names
541/// an existing node by id (checked for existence during validation); a
542/// `subgraph` body embeds a whole `Graph` and is deferred exactly as the map's
543/// subgraph body is.
544#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
545#[serde(
546    tag = "kind",
547    content = "value",
548    rename_all = "snake_case",
549    deny_unknown_fields
550)]
551pub enum FoldBody {
552    /// Run each pass through an existing node in this document, by id.
553    Node(String),
554    /// Run each pass through an embedded sub-graph. Boxed because a `Graph`
555    /// contains nodes, one of which may itself be a `fold`, so the type is
556    /// recursive.
557    Subgraph(Box<Graph>),
558}
559
560/// How a `FoldNode` folds its passes into the single value it produces.
561///
562/// Adjacently tagged (`{"kind": "...", "value": ...}` for the variant that
563/// carries data, `{"kind": "..."}` for the unit variants) so a future join rule
564/// is a new variant that does not change how an existing document encodes,
565/// exactly like `BranchCondition`.
566///
567/// The variants are grounded in what the AARG loop actually needs. `best_by` is
568/// the argmax winner the loop's "best draft wins, never the last pass" rule
569/// requires; `last` and `all` are the two obvious simpler folds a different
570/// consumer might want.
571#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
572#[serde(
573    tag = "kind",
574    content = "value",
575    rename_all = "snake_case",
576    deny_unknown_fields
577)]
578pub enum FoldJoin {
579    /// Produce the pass whose value MAXIMIZES the given reference (a path into
580    /// the accumulated value, `score` or `review.overall_score`). This is the
581    /// argmax the AARG loop needs: the best draft wins, never the last. The
582    /// reference is parsed at submit like a `crate::expr` path, so a malformed
583    /// one is a node-precise error.
584    BestBy(String),
585    /// Produce the value of the last pass the loop ran.
586    Last,
587    /// Produce every pass's value as a list, in pass order.
588    All,
589}
590
591/// A directed edge: a typed payload flows from one node to another.
592///
593/// Edges are the single source of graph topology. Referential integrity, the
594/// acyclic check, and the entry/terminal summary all read the edge list. No
595/// ports are modeled here; a `port` pair is a documented additive
596/// follow-up.
597#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
598#[serde(deny_unknown_fields)]
599pub struct Edge {
600    /// The source node id.
601    pub from: String,
602    /// The destination node id.
603    pub to: String,
604    /// Optional label. When the source is a `BranchNode`, this names the
605    /// `BranchCase` this edge realizes. Data only.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub label: Option<String>,
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use serde_json::json;
614
615    /// A small, valid document used across the round-trip tests.
616    fn sample() -> Graph {
617        Graph {
618            schema_version: SCHEMA_VERSION,
619            nodes: vec![
620                Node::Agent(AgentNode {
621                    id: "research".into(),
622                    agent_hash: format!("sha256:{}", "a".repeat(64)),
623                    name: None,
624                    input_schema: None,
625                    output_schema: Some(json!({"type": "object"})),
626                }),
627                Node::Gate(GateNode {
628                    id: "approve".into(),
629                    name: None,
630                    prompt: Some("Approve publication?".into()),
631                    approval_schema: json!({"type": "object"}),
632                }),
633            ],
634            edges: vec![Edge {
635                from: "research".into(),
636                to: "approve".into(),
637                label: None,
638            }],
639        }
640    }
641
642    /// Serializing then deserializing a document yields an equal value.
643    #[test]
644    fn round_trips_through_json() {
645        let original = sample();
646        let json = serde_json::to_string(&original).expect("serialize");
647        let restored: Graph = serde_json::from_str(&json).expect("deserialize");
648        assert_eq!(original, restored, "round trip changed the value: {json}");
649    }
650
651    /// A node serializes with the adjacent `kind`/`payload` shape, and the id
652    /// rides inside the payload. No `name` was set, so none appears on the
653    /// wire: this is the byte-stability guarantee the optional node name
654    /// must not disturb.
655    #[test]
656    fn node_uses_adjacent_kind_payload_shape() {
657        let node = Node::Tool(ToolNode {
658            id: "publish".into(),
659            tool: "http_post".into(),
660            name: None,
661            input: BTreeMap::new(),
662            input_schema: None,
663            output_schema: None,
664        });
665        let json = serde_json::to_string(&node).expect("serialize");
666        assert_eq!(
667            json,
668            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post"}}"#
669        );
670    }
671
672    /// Setting a node's `name` puts it on the wire; leaving it unset keeps the
673    /// payload byte-identical to a document written before the field existed.
674    #[test]
675    fn node_name_is_present_only_when_set() {
676        let named = Node::Tool(ToolNode {
677            id: "publish".into(),
678            tool: "http_post".into(),
679            name: Some("Publish the draft".into()),
680            input: BTreeMap::new(),
681            input_schema: None,
682            output_schema: None,
683        });
684        let json = serde_json::to_string(&named).expect("serialize");
685        assert_eq!(
686            json,
687            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post","name":"Publish the draft"}}"#
688        );
689
690        let unnamed = Node::Tool(ToolNode {
691            id: "publish".into(),
692            tool: "http_post".into(),
693            name: None,
694            input: BTreeMap::new(),
695            input_schema: None,
696            output_schema: None,
697        });
698        assert_eq!(
699            serde_json::to_string(&unnamed).expect("serialize"),
700            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post"}}"#,
701            "an unset name must not appear on the wire"
702        );
703    }
704
705    /// A fold node round-trips and serializes with the adjacent kind/payload
706    /// shape, its `join` as an adjacently tagged sub-object, and an unset
707    /// `accumulator_schema` staying off the wire.
708    #[test]
709    fn fold_node_serializes_with_join_and_body_shapes() {
710        let node = Node::Fold(FoldNode {
711            id: "refine".into(),
712            name: None,
713            body: FoldBody::Node("tailor".into()),
714            max_iterations: 3,
715            stop_when: "score >= 0.85".into(),
716            join: FoldJoin::BestBy("score".into()),
717            accumulator_schema: None,
718        });
719        let json = serde_json::to_string(&node).expect("serialize");
720        assert_eq!(
721            json,
722            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"}}}"#
723        );
724        let restored: Node = serde_json::from_str(&json).expect("deserialize");
725        assert_eq!(node, restored, "fold round trip changed the value: {json}");
726    }
727
728    /// The two unit `join` variants serialize with just their `kind` tag, no
729    /// `value`; this is the additive-tagged shape a future join rule extends.
730    #[test]
731    fn fold_join_unit_variants_carry_only_the_kind_tag() {
732        assert_eq!(
733            serde_json::to_string(&FoldJoin::Last).expect("serialize"),
734            r#"{"kind":"last"}"#
735        );
736        assert_eq!(
737            serde_json::to_string(&FoldJoin::All).expect("serialize"),
738            r#"{"kind":"all"}"#
739        );
740    }
741
742    /// A stray top-level key on the document is rejected, not ignored: strict
743    /// in, because a graph is a control document.
744    #[test]
745    fn unknown_document_field_is_rejected() {
746        let text = r#"{"schema_version":1,"nodes":[],"edges":[],"surprise":true}"#;
747        let error = serde_json::from_str::<Graph>(text).expect_err("must reject");
748        assert!(
749            error.to_string().contains("surprise"),
750            "error should name the stray field: {error}"
751        );
752    }
753
754    /// A stray key inside a node payload is rejected too.
755    #[test]
756    fn unknown_payload_field_is_rejected() {
757        let text = r#"{"kind":"gate","payload":{"id":"g","approval_schema":{},"oops":1}}"#;
758        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
759        assert!(
760            error.to_string().contains("oops"),
761            "error should name the stray field: {error}"
762        );
763    }
764
765    /// A key other than `kind`/`payload` alongside a node is rejected.
766    #[test]
767    fn unknown_node_envelope_field_is_rejected() {
768        let text = r#"{"kind":"gate","payload":{"id":"g","approval_schema":{}},"extra":1}"#;
769        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
770        assert!(
771            error.to_string().contains("extra"),
772            "error should name the stray key: {error}"
773        );
774    }
775
776    /// A `when` written as a bare string, the obvious skim-and-adapt mistake
777    /// (copying `"value > 10000"` straight in instead of wrapping it in the
778    /// `{"kind": "expression", "value": ...}` object), gets a product-language
779    /// error naming both accepted shapes and echoing the string back, not
780    /// serde's "adjacently tagged enum" internals.
781    #[test]
782    fn bare_string_branch_condition_names_accepted_shapes() {
783        let text = r#"{"name":"big","when":"value > 10000"}"#;
784        let error = serde_json::from_str::<BranchCase>(text).expect_err("must reject");
785        let message = error.to_string();
786        assert!(
787            !message.contains("adjacently tagged enum"),
788            "error should not leak serde internals: {message}"
789        );
790        assert!(
791            message.contains(r#"{"kind": "expression", "value": "<expr>"}"#)
792                && message.contains(r#"{"kind": "model_decision"}"#),
793            "error should name both accepted shapes: {message}"
794        );
795        assert!(
796            message.contains(r#"a bare string "value > 10000""#),
797            "error should echo the offending value: {message}"
798        );
799    }
800
801    /// A `when` written as some other wrong type (here, a bare number) also
802    /// reads sensibly: same two accepted shapes, with the actual value
803    /// described instead of a string-specific phrasing.
804    #[test]
805    fn non_string_branch_condition_also_names_accepted_shapes() {
806        let text = r#"{"name":"big","when":10000}"#;
807        let error = serde_json::from_str::<BranchCase>(text).expect_err("must reject");
808        let message = error.to_string();
809        assert!(
810            !message.contains("adjacently tagged enum"),
811            "error should not leak serde internals: {message}"
812        );
813        assert!(
814            message.contains("a bare number 10000"),
815            "error should describe the actual value: {message}"
816        );
817    }
818
819    /// A `when` object with an unrecognized `kind` (a wrong-shaped object,
820    /// not a bare scalar) still reads as an error about the object found,
821    /// not serde internals.
822    #[test]
823    fn wrong_kind_branch_condition_names_accepted_shapes() {
824        let text = r#"{"name":"big","when":{"kind":"regex","value":"x"}}"#;
825        let error = serde_json::from_str::<BranchCase>(text).expect_err("must reject");
826        let message = error.to_string();
827        assert!(
828            !message.contains("adjacently tagged enum"),
829            "error should not leak serde internals: {message}"
830        );
831        assert!(
832            message.contains("an object"),
833            "error should describe the value as an object: {message}"
834        );
835    }
836
837    /// The two valid `when` shapes still parse exactly as before: the custom
838    /// `Deserialize` impl only changes the message on rejection, never
839    /// acceptance.
840    #[test]
841    fn valid_branch_conditions_still_round_trip() {
842        let expression = BranchCase {
843            name: "big".into(),
844            when: BranchCondition::Expression("value > 10000".into()),
845        };
846        let json = serde_json::to_string(&expression).expect("serialize");
847        assert_eq!(
848            json,
849            r#"{"name":"big","when":{"kind":"expression","value":"value > 10000"}}"#
850        );
851        let restored: BranchCase = serde_json::from_str(&json).expect("deserialize");
852        assert_eq!(expression, restored, "round trip changed the value: {json}");
853
854        let model_decision = BranchCase {
855            name: "review".into(),
856            when: BranchCondition::ModelDecision,
857        };
858        let json = serde_json::to_string(&model_decision).expect("serialize");
859        assert_eq!(
860            json,
861            r#"{"name":"review","when":{"kind":"model_decision"}}"#
862        );
863        let restored: BranchCase = serde_json::from_str(&json).expect("deserialize");
864        assert_eq!(
865            model_decision, restored,
866            "round trip changed the value: {json}"
867        );
868    }
869
870    /// A full document carrying a branch node serializes to the same bytes
871    /// before and after this change, since only the `Deserialize` error path
872    /// moved; `Serialize` is still the plain derive.
873    #[test]
874    fn branch_node_document_serializes_byte_identical() {
875        let mut graph = sample();
876        graph.nodes.push(Node::Branch(BranchNode {
877            id: "route".into(),
878            name: None,
879            on: None,
880            agent_hash: None,
881            cases: vec![
882                BranchCase {
883                    name: "big".into(),
884                    when: BranchCondition::Expression("value > 10000".into()),
885                },
886                BranchCase {
887                    name: "small".into(),
888                    when: BranchCondition::ModelDecision,
889                },
890            ],
891        }));
892        let json = serde_json::to_string(&graph).expect("serialize");
893        let restored: Graph = serde_json::from_str(&json).expect("deserialize");
894        assert_eq!(graph, restored, "round trip changed the value: {json}");
895        let json_again = serde_json::to_string(&restored).expect("re-serialize");
896        assert_eq!(
897            json, json_again,
898            "serialization is not byte-identical across a round trip"
899        );
900    }
901}