Skip to main content

salvor_graph/
document.rs

1//! The graph document format: the `Graph` envelope, the seven 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 seven 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    /// A durable wait: parks the run on a timer, then continues the walk. It
150    /// transforms nothing, so its output is its input verbatim.
151    Delay(DelayNode),
152}
153
154impl Node {
155    /// The node's stable id, whatever its kind.
156    #[must_use]
157    pub fn id(&self) -> &str {
158        match self {
159            Node::Agent(n) => &n.id,
160            Node::Tool(n) => &n.id,
161            Node::Gate(n) => &n.id,
162            Node::Branch(n) => &n.id,
163            Node::Map(n) => &n.id,
164            Node::Fold(n) => &n.id,
165            Node::Delay(n) => &n.id,
166        }
167    }
168
169    /// The kind name (`"agent"`, `"tool"`, ...), for error messages.
170    #[must_use]
171    pub fn kind_name(&self) -> &'static str {
172        match self {
173            Node::Agent(_) => "agent",
174            Node::Tool(_) => "tool",
175            Node::Gate(_) => "gate",
176            Node::Branch(_) => "branch",
177            Node::Map(_) => "map",
178            Node::Fold(_) => "fold",
179            Node::Delay(_) => "delay",
180        }
181    }
182
183    /// The node's optional display name, whatever its kind. See the module
184    /// docs' "The optional node display name" section.
185    #[must_use]
186    pub fn name(&self) -> Option<&str> {
187        match self {
188            Node::Agent(n) => n.name.as_deref(),
189            Node::Tool(n) => n.name.as_deref(),
190            Node::Gate(n) => n.name.as_deref(),
191            Node::Branch(n) => n.name.as_deref(),
192            Node::Map(n) => n.name.as_deref(),
193            Node::Fold(n) => n.name.as_deref(),
194            Node::Delay(n) => n.name.as_deref(),
195        }
196    }
197
198    /// The JSON Schema this node declares for the payload it CONSUMES, if any.
199    /// Absent means the node does not declare an input type, and an edge into
200    /// it passes the type-compatibility check unchecked.
201    #[must_use]
202    pub fn input_schema(&self) -> Option<&Value> {
203        match self {
204            Node::Agent(n) => n.input_schema.as_ref(),
205            Node::Tool(n) => n.input_schema.as_ref(),
206            // Gate, branch, map, fold, and delay do not declare a consumed
207            // type; they pass typed payloads through untyped. A fold's
208            // `accumulator_schema` is data only, deliberately not wired into
209            // the edge type-compatibility check while its execution is not
210            // implemented.
211            Node::Gate(_) | Node::Branch(_) | Node::Map(_) | Node::Fold(_) | Node::Delay(_) => None,
212        }
213    }
214
215    /// The JSON Schema this node declares for the payload it PRODUCES, if any.
216    /// Absent means the node does not declare an output type, and an edge out
217    /// of it passes the type-compatibility check unchecked.
218    #[must_use]
219    pub fn output_schema(&self) -> Option<&Value> {
220        match self {
221            Node::Agent(n) => n.output_schema.as_ref(),
222            Node::Tool(n) => n.output_schema.as_ref(),
223            Node::Map(n) => n.output_schema.as_ref(),
224            // A fold's produced-value type is not implemented with its
225            // execution: its `accumulator_schema` is data only and does not
226            // gate outbound edges. A delay declares no type in either
227            // direction: it produces exactly what it consumed, and the format
228            // has no vocabulary for "whatever came in", so declaring the
229            // pass-through would mean copying a schema an author would then
230            // have to keep in step with the node upstream. The same reasoning
231            // a branch, which is also a pure pass-through, already rests on.
232            Node::Gate(_) | Node::Branch(_) | Node::Fold(_) | Node::Delay(_) => None,
233        }
234    }
235}
236
237/// An `agent` node: a full agent loop referenced by content hash.
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
239#[serde(deny_unknown_fields)]
240pub struct AgentNode {
241    /// The node's stable id, unique within the document.
242    pub id: String,
243    /// Content hash of the agent definition (model, prompt, tools, budget) this
244    /// node runs, in `sha256:<64 lowercase hex>` form. A hash, never an
245    /// embedded definition: that keeps this crate independent of the
246    /// agent-definition schema and lets the same definition be shared across
247    /// nodes and runs by identity.
248    pub agent_hash: String,
249    /// Optional short display label for this node. See the module docs' "The
250    /// optional node display name" section for the bound and the deliberate
251    /// hash-inclusion contrast with the agent `name` field. Additive: absent
252    /// on the wire when unset.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub name: Option<String>,
255    /// Optional JSON Schema for the payload this node consumes. Additive: absent
256    /// on the wire when unset.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub input_schema: Option<Value>,
259    /// Optional JSON Schema for the payload this node produces.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub output_schema: Option<Value>,
262}
263
264/// A `tool` node: one direct tool invocation.
265#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
266#[serde(deny_unknown_fields)]
267pub struct ToolNode {
268    /// The node's stable id, unique within the document.
269    pub id: String,
270    /// The tool's name, as registered with the runtime.
271    pub tool: String,
272    /// Optional short display label for this node. See the module docs' "The
273    /// optional node display name" section for the bound and the deliberate
274    /// hash-inclusion contrast with the agent `name` field. Additive: absent
275    /// on the wire when unset.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub name: Option<String>,
278    /// The input mapping: tool input field name to an opaque source reference.
279    /// Recorded as DATA; this crate does not resolve or evaluate the references.
280    /// Additive: omitted on the wire when empty.
281    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
282    pub input: BTreeMap<String, String>,
283    /// Optional JSON Schema for the payload this node consumes.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub input_schema: Option<Value>,
286    /// Optional JSON Schema for the payload this node produces.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub output_schema: Option<Value>,
289}
290
291/// A `gate` node: human approval that suspends the run.
292#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
293#[serde(deny_unknown_fields)]
294pub struct GateNode {
295    /// The node's stable id, unique within the document.
296    pub id: String,
297    /// Optional short display label for this node. See the module docs' "The
298    /// optional node display name" section for the bound and the deliberate
299    /// hash-inclusion contrast with the agent `name` field. Additive: absent
300    /// on the wire when unset.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub name: Option<String>,
303    /// Optional human-readable prompt shown in the approval inbox.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub prompt: Option<String>,
306    /// JSON Schema the human approval input must satisfy, mirroring the recorded
307    /// `Suspended` event's `input_schema`. Required: a gate with no declared
308    /// approval shape is meaningless.
309    pub approval_schema: Value,
310}
311
312/// A `branch` node: routes on a typed output.
313#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
314#[serde(deny_unknown_fields)]
315pub struct BranchNode {
316    /// The node's stable id, unique within the document.
317    pub id: String,
318    /// Optional short display label for this node. See the module docs' "The
319    /// optional node display name" section for the bound and the deliberate
320    /// hash-inclusion contrast with the agent `name` field. Additive: absent
321    /// on the wire when unset.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub name: Option<String>,
324    /// Optional opaque reference to the typed value the branch routes on.
325    /// Recorded as DATA; not resolved in this crate.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub on: Option<String>,
328    /// Content hash of the agent that decides a `BranchCondition::ModelDecision`
329    /// case, in `sha256:<64 lowercase hex>` form. Present only on a branch that
330    /// carries a model-decision case: the engine drives this agent with the
331    /// routed value and maps its reply to a case name. Additive: absent on the
332    /// wire when unset, so a purely expression-driven branch (and every document
333    /// written before this field existed) serializes byte for byte as before.
334    /// `crate::validate` reports a model-decision case with no agent here as a
335    /// node-precise error.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub agent_hash: Option<String>,
338    /// The cases, each a named condition. An expression condition is evaluated
339    /// against the routed value; a model-decision condition is resolved by the
340    /// node's `agent_hash` agent. The first matching case in
341    /// author order wins, and the engine records the choice as a
342    /// `crate::document`-external `BranchTaken` event.
343    pub cases: Vec<BranchCase>,
344}
345
346/// One case of a `BranchNode`: a name and the condition that selects it.
347///
348/// The realized routing (which downstream node a fired case flows to) is
349/// carried by an `Edge` whose `label` matches the case `name`, so topology
350/// stays entirely in the edge list and a branch has real outbound edges.
351#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
352#[serde(deny_unknown_fields)]
353pub struct BranchCase {
354    /// The case name. An edge labeled with this name realizes the route.
355    pub name: String,
356    /// The condition that selects this case. Data only, never evaluated here.
357    pub when: BranchCondition,
358}
359
360/// How a `BranchCase` is selected. Modeled as data; not evaluated in this
361/// crate.
362///
363/// Adjacently tagged (`{"kind": "...", "value": ...}`) so it stays additive:
364/// a future condition kind is a new variant, which does not change how an
365/// existing document encodes.
366#[derive(Clone, Debug, PartialEq, Serialize, JsonSchema)]
367#[serde(
368    tag = "kind",
369    content = "value",
370    rename_all = "snake_case",
371    deny_unknown_fields
372)]
373pub enum BranchCondition {
374    /// A constrained boolean expression over the routed value, recorded as an
375    /// opaque string. NOT parsed or evaluated in this crate.
376    Expression(String),
377    /// The case is chosen by a model decision at run time, recorded as an event.
378    /// Carries no author-time data.
379    ModelDecision,
380}
381
382/// The wire shape `BranchCondition` parses as: the exact same
383/// tag/content/deny_unknown_fields attributes as the type it mirrors, so
384/// what this accepts and rejects is unchanged. It exists only as a target for
385/// `BranchCondition`'s hand-written `Deserialize` impl below, so a
386/// malformed `when` can be reported in product language instead of serde's
387/// "adjacently tagged enum" internals.
388#[derive(Deserialize)]
389#[serde(
390    tag = "kind",
391    content = "value",
392    rename_all = "snake_case",
393    deny_unknown_fields
394)]
395enum BranchConditionShape {
396    Expression(String),
397    ModelDecision,
398}
399
400impl From<BranchConditionShape> for BranchCondition {
401    fn from(shape: BranchConditionShape) -> Self {
402        match shape {
403            BranchConditionShape::Expression(expr) => BranchCondition::Expression(expr),
404            BranchConditionShape::ModelDecision => BranchCondition::ModelDecision,
405        }
406    }
407}
408
409impl<'de> Deserialize<'de> for BranchCondition {
410    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
411    where
412        D: Deserializer<'de>,
413    {
414        // Buffer as generic JSON first, then parse that buffer through the
415        // identical adjacently tagged shape the derive would have used.
416        // Acceptance does not change: a value `BranchConditionShape`
417        // rejects was always rejected. Only the failure message changes, from
418        // serde's enum-internals wording to a product-language description of
419        // the two accepted shapes with the offending value echoed back.
420        let value = Value::deserialize(deserializer)?;
421        serde_json::from_value::<BranchConditionShape>(value.clone())
422            .map(Into::into)
423            .map_err(|_| D::Error::custom(describe_branch_condition_error(&value)))
424    }
425}
426
427/// Build the error text for a `when` that failed to parse as a
428/// `BranchCondition`: the two accepted shapes, then what was actually
429/// found, so a bare string (the obvious skim-and-adapt mistake, writing
430/// `"when": "value > 10000"` instead of the object form) reads as data
431/// against the expected shape rather than a serde internals error.
432fn describe_branch_condition_error(value: &Value) -> String {
433    format!(
434        "a branch condition must be an object shaped \
435         `{{\"kind\": \"expression\", \"value\": \"<expr>\"}}` or \
436         `{{\"kind\": \"model_decision\"}}`; got {}",
437        describe_json_value(value)
438    )
439}
440
441/// Describe a JSON value's shape and content for an error message: a bare
442/// string echoes as `a bare string "..."`, an object as `an object {...}`,
443/// and so on.
444fn describe_json_value(value: &Value) -> String {
445    let text = serde_json::to_string(value).unwrap_or_else(|_| "<unrepresentable>".to_string());
446    match value {
447        Value::Null => "null".to_string(),
448        Value::Bool(_) => format!("a bare boolean {text}"),
449        Value::Number(_) => format!("a bare number {text}"),
450        Value::String(_) => format!("a bare string {text}"),
451        Value::Array(_) => format!("a bare array {text}"),
452        Value::Object(_) => format!("an object {text}"),
453    }
454}
455
456/// A `map` node: fan-out a sub-run per element of a typed list, with a
457/// concurrency cap.
458#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct MapNode {
461    /// The node's stable id, unique within the document.
462    pub id: String,
463    /// Optional short display label for this node. See the module docs' "The
464    /// optional node display name" section for the bound and the deliberate
465    /// hash-inclusion contrast with the agent `name` field. Additive: absent
466    /// on the wire when unset.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub name: Option<String>,
469    /// Opaque reference to the typed list this node fans out over. Data only,
470    /// not resolved in this crate.
471    pub over: String,
472    /// The maximum number of sub-runs in flight at once. Must be at least 1;
473    /// `crate::validate` reports a non-positive cap by node id.
474    pub concurrency: u32,
475    /// What each element is mapped through: a node already in this document, or
476    /// an embedded sub-graph.
477    pub body: MapBody,
478    /// Optional JSON Schema for the joined list this node produces.
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub output_schema: Option<Value>,
481}
482
483/// The body a `MapNode` maps each element through.
484///
485/// Adjacently tagged, so adding a third form later is additive. A `node` body
486/// names an existing node by id (checked for existence during validation); a
487/// `subgraph` body embeds a whole `Graph`.
488#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
489#[serde(
490    tag = "kind",
491    content = "value",
492    rename_all = "snake_case",
493    deny_unknown_fields
494)]
495pub enum MapBody {
496    /// Map each element through an existing node in this document, by id.
497    Node(String),
498    /// Map each element through an embedded sub-graph. Boxed because a `Graph`
499    /// contains nodes, one of which may be a `map`, so the type is recursive.
500    Subgraph(Box<Graph>),
501}
502
503/// A `fold` node: bounded iteration that accumulates across passes.
504///
505/// Models an adversarial refine loop as one node: a `body` is run up to
506/// `max_iterations` times, each pass folding into an accumulated value, and the
507/// loop stops when `stop_when` holds over that value (or the bound is reached).
508/// The `join` rule then selects the value the node produces. Every field is
509/// author-time data; this crate never runs the loop.
510///
511/// Grounded in the AARG tailor loop the graph wiring models: bounded revisions
512/// (`max_iterations`), a stop predicate over the accumulated score
513/// (`stop_when`, an expression in the same language a branch case uses), and an
514/// argmax winner (`join` = `FoldJoin::BestBy` over the score). See the
515/// crate-level docs and the graph wiring plan.
516#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
517#[serde(deny_unknown_fields)]
518pub struct FoldNode {
519    /// The node's stable id, unique within the document.
520    pub id: String,
521    /// Optional short display label for this node. See the module docs' "The
522    /// optional node display name" section for the bound and the deliberate
523    /// hash-inclusion contrast with the agent `name` field. Additive: absent
524    /// on the wire when unset.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub name: Option<String>,
527    /// What each pass runs: a node already in this document, or an embedded
528    /// sub-graph. Not implemented exactly as `MapBody`'s subgraph form is not:
529    /// the shape is legal, but no engine runs it yet.
530    pub body: FoldBody,
531    /// The iteration bound: the most passes the loop may run. Must be at least
532    /// 1; `crate::validate` reports a zero bound by node id.
533    pub max_iterations: u32,
534    /// A boolean expression over the accumulated value that stops the loop when
535    /// it holds. Written in the `crate::expr` condition language, the same one
536    /// a `BranchCondition::Expression` uses, and validated at submit so a
537    /// malformed predicate is a node-precise error, never a run-time failure.
538    pub stop_when: String,
539    /// How the passes are folded into the value the node produces.
540    pub join: FoldJoin,
541    /// What a reached `max_iterations` bound means, when `stop_when` never
542    /// held. Absent is `OnBound::Join`: the bound joins the best pass, which is
543    /// what every fold written before this field existed does, so an absent
544    /// field is both the default and byte-identical to those documents on the
545    /// wire. Additive: absent on the wire when unset.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub on_bound: Option<OnBound>,
548    /// Optional JSON Schema for the accumulated value the loop carries and
549    /// produces. Data only, like an `AgentNode`'s `output_schema`: recorded
550    /// for authoring and tooling, never wired into the edge type-compatibility
551    /// check (a fold's produced-value semantics are not implemented with
552    /// its execution). Additive: absent on the wire when unset.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub accumulator_schema: Option<Value>,
555}
556
557/// The body a `FoldNode` runs each pass. Adjacently tagged, mirroring
558/// `MapBody`, so adding a third form later stays additive. A `node` body names
559/// an existing node by id (checked for existence during validation); a
560/// `subgraph` body embeds a whole `Graph` and is deferred exactly as the map's
561/// subgraph body is.
562#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
563#[serde(
564    tag = "kind",
565    content = "value",
566    rename_all = "snake_case",
567    deny_unknown_fields
568)]
569pub enum FoldBody {
570    /// Run each pass through an existing node in this document, by id.
571    Node(String),
572    /// Run each pass through an embedded sub-graph. Boxed because a `Graph`
573    /// contains nodes, one of which may itself be a `fold`, so the type is
574    /// recursive.
575    Subgraph(Box<Graph>),
576}
577
578/// How a `FoldNode` folds its passes into the single value it produces.
579///
580/// Adjacently tagged (`{"kind": "...", "value": ...}` for the variant that
581/// carries data, `{"kind": "..."}` for the unit variants) so a future join rule
582/// is a new variant that does not change how an existing document encodes,
583/// exactly like `BranchCondition`.
584///
585/// The variants are grounded in what the AARG loop actually needs. `best_by` is
586/// the argmax winner the loop's "best draft wins, never the last pass" rule
587/// requires; `last` and `all` are the two obvious simpler folds a different
588/// consumer might want.
589#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
590#[serde(
591    tag = "kind",
592    content = "value",
593    rename_all = "snake_case",
594    deny_unknown_fields
595)]
596pub enum FoldJoin {
597    /// Produce the pass whose value MAXIMIZES the given reference (a path into
598    /// the accumulated value, `score` or `review.overall_score`). This is the
599    /// argmax the AARG loop needs: the best draft wins, never the last. The
600    /// reference is parsed at submit like a `crate::expr` path, so a malformed
601    /// one is a node-precise error.
602    BestBy(String),
603    /// Produce the value of the last pass the loop ran.
604    Last,
605    /// Produce every pass's value as a list, in pass order.
606    All,
607}
608
609/// What a `FoldNode` reaching its iteration bound means, when `stop_when` never
610/// held.
611///
612/// A bare lowercase string on the wire (`"join"`, `"fail"`), not the adjacently
613/// tagged shape `FoldJoin` uses: the two variants carry no data and none is
614/// foreseen, so a tag object would be ceremony around a word. Adding a third
615/// variant later stays additive all the same, because an older document simply
616/// omits the field.
617///
618/// Absent means [`OnBound::Join`], which is what every fold written before this
619/// field existed does, so the default is the behavior already shipped rather
620/// than a new one chosen now. Authors reach for [`OnBound::Fail`] when the stop
621/// predicate is a REQUIREMENT rather than an early exit: a loop that must reach
622/// a score before its value is worth anything is better off saying so than
623/// handing a caller the best of several passes that all fell short. Data only;
624/// this crate never runs the loop, and the engine reads this field in a later
625/// slice.
626#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
627#[serde(rename_all = "snake_case")]
628pub enum OnBound {
629    /// Reaching the bound joins the passes exactly as `stop_when` holding
630    /// would: the `join` rule picks the value the node produces. Today's
631    /// behavior, and what an absent field means.
632    Join,
633    /// Reaching the bound without `stop_when` holding is an error: the loop
634    /// converged on nothing, and the node produces no value.
635    Fail,
636}
637
638/// A `delay` node: a durable wait that parks the run, then continues the walk.
639///
640/// # Why the wait is a DURATION and not an instant
641///
642/// A graph document is authored once, content-addressed, and then run any
643/// number of times: the hash IS the identity, and the same document backs
644/// `salvor graph run`, `POST /v1/graph-runs`, and every fork of every run that
645/// referenced it. An absolute wake instant baked into the document would make
646/// it a single-use artifact: correct on the first run and already in the past
647/// on the second, where every delay would fall through instantly and the
648/// document would silently mean something else than it did the day it was
649/// written.
650///
651/// A duration says the thing that stays true across runs ("hold this for an
652/// hour"), and it is what every other author-time number in this format
653/// already is: a `map`'s `concurrency`, a `fold`'s `max_iterations`. Nothing in
654/// a graph document is a value belonging to one particular run, and this field
655/// keeps it that way. The instant is resolved at EXECUTION, by
656/// `salvor_runtime::RunCtx::sleep_for`, which observes the clock into the log
657/// first and derives `wake_at` from that recorded reading, so the wake instant
658/// is recorded once and replays identically forever.
659///
660/// There is deliberately no second, absolute spelling. Two ways to say a wait
661/// would need a mutual-exclusion rule in the validator and would leave authors
662/// choosing between one form that composes and one that expires; a format with
663/// one answer is the smaller thing to keep true.
664#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
665#[serde(deny_unknown_fields)]
666pub struct DelayNode {
667    /// The node's stable id, unique within the document.
668    pub id: String,
669    /// Optional short display label for this node. See the module docs' "The
670    /// optional node display name" section for the bound and the deliberate
671    /// hash-inclusion contrast with the agent `name` field. Additive: absent
672    /// on the wire when unset.
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub name: Option<String>,
675    /// How long the run waits, in whole seconds, measured from the clock
676    /// reading the engine records on entering the node. Must be at least 1;
677    /// `crate::validate` reports a zero wait by node id.
678    pub seconds: u64,
679}
680
681/// A directed edge: a typed payload flows from one node to another.
682///
683/// Edges are the single source of graph topology. Referential integrity, the
684/// acyclic check, and the entry/terminal summary all read the edge list. No
685/// ports are modeled here; a `port` pair is a documented additive
686/// follow-up.
687#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
688#[serde(deny_unknown_fields)]
689pub struct Edge {
690    /// The source node id.
691    pub from: String,
692    /// The destination node id.
693    pub to: String,
694    /// Optional label. When the source is a `BranchNode`, this names the
695    /// `BranchCase` this edge realizes. Data only.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub label: Option<String>,
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use serde_json::json;
704
705    /// A small, valid document used across the round-trip tests.
706    fn sample() -> Graph {
707        Graph {
708            schema_version: SCHEMA_VERSION,
709            nodes: vec![
710                Node::Agent(AgentNode {
711                    id: "research".into(),
712                    agent_hash: format!("sha256:{}", "a".repeat(64)),
713                    name: None,
714                    input_schema: None,
715                    output_schema: Some(json!({"type": "object"})),
716                }),
717                Node::Gate(GateNode {
718                    id: "approve".into(),
719                    name: None,
720                    prompt: Some("Approve publication?".into()),
721                    approval_schema: json!({"type": "object"}),
722                }),
723            ],
724            edges: vec![Edge {
725                from: "research".into(),
726                to: "approve".into(),
727                label: None,
728            }],
729        }
730    }
731
732    /// Serializing then deserializing a document yields an equal value.
733    #[test]
734    fn round_trips_through_json() {
735        let original = sample();
736        let json = serde_json::to_string(&original).expect("serialize");
737        let restored: Graph = serde_json::from_str(&json).expect("deserialize");
738        assert_eq!(original, restored, "round trip changed the value: {json}");
739    }
740
741    /// A node serializes with the adjacent `kind`/`payload` shape, and the id
742    /// rides inside the payload. No `name` was set, so none appears on the
743    /// wire: this is the byte-stability guarantee the optional node name
744    /// must not disturb.
745    #[test]
746    fn node_uses_adjacent_kind_payload_shape() {
747        let node = Node::Tool(ToolNode {
748            id: "publish".into(),
749            tool: "http_post".into(),
750            name: None,
751            input: BTreeMap::new(),
752            input_schema: None,
753            output_schema: None,
754        });
755        let json = serde_json::to_string(&node).expect("serialize");
756        assert_eq!(
757            json,
758            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post"}}"#
759        );
760    }
761
762    /// Setting a node's `name` puts it on the wire; leaving it unset keeps the
763    /// payload byte-identical to a document written before the field existed.
764    #[test]
765    fn node_name_is_present_only_when_set() {
766        let named = Node::Tool(ToolNode {
767            id: "publish".into(),
768            tool: "http_post".into(),
769            name: Some("Publish the draft".into()),
770            input: BTreeMap::new(),
771            input_schema: None,
772            output_schema: None,
773        });
774        let json = serde_json::to_string(&named).expect("serialize");
775        assert_eq!(
776            json,
777            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post","name":"Publish the draft"}}"#
778        );
779
780        let unnamed = Node::Tool(ToolNode {
781            id: "publish".into(),
782            tool: "http_post".into(),
783            name: None,
784            input: BTreeMap::new(),
785            input_schema: None,
786            output_schema: None,
787        });
788        assert_eq!(
789            serde_json::to_string(&unnamed).expect("serialize"),
790            r#"{"kind":"tool","payload":{"id":"publish","tool":"http_post"}}"#,
791            "an unset name must not appear on the wire"
792        );
793    }
794
795    /// A fold node round-trips and serializes with the adjacent kind/payload
796    /// shape, its `join` as an adjacently tagged sub-object, and an unset
797    /// `accumulator_schema` staying off the wire.
798    #[test]
799    fn fold_node_serializes_with_join_and_body_shapes() {
800        let node = Node::Fold(FoldNode {
801            id: "refine".into(),
802            name: None,
803            body: FoldBody::Node("tailor".into()),
804            max_iterations: 3,
805            stop_when: "score >= 0.85".into(),
806            join: FoldJoin::BestBy("score".into()),
807            on_bound: None,
808            accumulator_schema: None,
809        });
810        let json = serde_json::to_string(&node).expect("serialize");
811        assert_eq!(
812            json,
813            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"}}}"#
814        );
815        let restored: Node = serde_json::from_str(&json).expect("deserialize");
816        assert_eq!(node, restored, "fold round trip changed the value: {json}");
817    }
818
819    /// `on_bound` is a bare word on the wire, and only there when set: an unset
820    /// one leaves a fold's bytes exactly as they were before the field existed,
821    /// which is what makes the field additive rather than a re-encoding of
822    /// every document already written.
823    #[test]
824    fn fold_on_bound_is_a_bare_word_present_only_when_set() {
825        let fold = |on_bound| {
826            Node::Fold(FoldNode {
827                id: "refine".into(),
828                name: None,
829                body: FoldBody::Node("tailor".into()),
830                max_iterations: 3,
831                stop_when: "score >= 0.85".into(),
832                join: FoldJoin::BestBy("score".into()),
833                on_bound,
834                accumulator_schema: None,
835            })
836        };
837
838        let joining = fold(Some(OnBound::Join));
839        let json = serde_json::to_string(&joining).expect("serialize");
840        assert_eq!(
841            json,
842            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"},"on_bound":"join"}}"#
843        );
844        assert_eq!(
845            serde_json::from_str::<Node>(&json).expect("deserialize"),
846            joining
847        );
848
849        let failing = fold(Some(OnBound::Fail));
850        let json = serde_json::to_string(&failing).expect("serialize");
851        assert_eq!(
852            json,
853            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"},"on_bound":"fail"}}"#
854        );
855        assert_eq!(
856            serde_json::from_str::<Node>(&json).expect("deserialize"),
857            failing
858        );
859
860        assert!(
861            !serde_json::to_string(&fold(None))
862                .expect("serialize")
863                .contains("on_bound"),
864            "an unset on_bound must not appear on the wire"
865        );
866    }
867
868    /// A word outside the two the field allows is refused at parse, exactly as
869    /// a stray field is: strict in, because a fold that silently forgot it was
870    /// told to fail is the failure this rejects.
871    #[test]
872    fn unknown_on_bound_word_is_rejected() {
873        let text = r#"{"kind":"fold","payload":{"id":"refine","body":{"kind":"node","value":"tailor"},"max_iterations":3,"stop_when":"done","join":{"kind":"last"},"on_bound":"retry"}}"#;
874        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
875        assert!(
876            error.to_string().contains("retry"),
877            "error should name the stray word: {error}"
878        );
879    }
880
881    /// The two unit `join` variants serialize with just their `kind` tag, no
882    /// `value`; this is the additive-tagged shape a future join rule extends.
883    #[test]
884    fn fold_join_unit_variants_carry_only_the_kind_tag() {
885        assert_eq!(
886            serde_json::to_string(&FoldJoin::Last).expect("serialize"),
887            r#"{"kind":"last"}"#
888        );
889        assert_eq!(
890            serde_json::to_string(&FoldJoin::All).expect("serialize"),
891            r#"{"kind":"all"}"#
892        );
893    }
894
895    /// A delay node serializes with the adjacent kind/payload shape, carries
896    /// its wait as a bare number of seconds, and round-trips. An unset `name`
897    /// stays off the wire.
898    #[test]
899    fn delay_node_serializes_with_its_wait_in_seconds() {
900        let node = Node::Delay(DelayNode {
901            id: "cooloff".into(),
902            name: None,
903            seconds: 3600,
904        });
905        let json = serde_json::to_string(&node).expect("serialize");
906        assert_eq!(
907            json,
908            r#"{"kind":"delay","payload":{"id":"cooloff","seconds":3600}}"#
909        );
910        let restored: Node = serde_json::from_str(&json).expect("deserialize");
911        assert_eq!(node, restored, "delay round trip changed the value: {json}");
912
913        let named = Node::Delay(DelayNode {
914            id: "cooloff".into(),
915            name: Some("Cool off before publishing".into()),
916            seconds: 3600,
917        });
918        assert_eq!(
919            serde_json::to_string(&named).expect("serialize"),
920            r#"{"kind":"delay","payload":{"id":"cooloff","name":"Cool off before publishing","seconds":3600}}"#
921        );
922    }
923
924    /// A `delay` payload is strict like every other: a stray key is refused,
925    /// and so is an absolute-instant spelling the format deliberately does not
926    /// have. An author who reaches for one gets told, not quietly ignored.
927    #[test]
928    fn delay_payload_rejects_a_key_it_does_not_have() {
929        let text =
930            r#"{"kind":"delay","payload":{"id":"cooloff","wake_at":"2026-08-14T09:00:00Z"}}"#;
931        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
932        assert!(
933            error.to_string().contains("wake_at"),
934            "error should name the stray field: {error}"
935        );
936    }
937
938    /// A document written before the `delay` kind existed serializes to exactly
939    /// the bytes it always did. Adding a node kind adds a variant nothing
940    /// already recorded uses, which is why it is additive rather than a bump of
941    /// `SCHEMA_VERSION`.
942    #[test]
943    fn an_existing_document_is_unchanged_by_the_delay_kind() {
944        let text = r#"{"schema_version":1,"nodes":[{"kind":"agent","payload":{"id":"research","agent_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_schema":{"type":"object"}}},{"kind":"gate","payload":{"id":"approve","prompt":"Approve publication?","approval_schema":{"type":"object"}}}],"edges":[{"from":"research","to":"approve"}]}"#;
945        let graph: Graph = serde_json::from_str(text).expect("deserialize");
946        assert_eq!(graph, sample(), "the pinned document parses to the sample");
947        assert_eq!(
948            serde_json::to_string(&graph).expect("serialize"),
949            text,
950            "an existing document must serialize byte for byte as before"
951        );
952    }
953
954    /// A stray top-level key on the document is rejected, not ignored: strict
955    /// in, because a graph is a control document.
956    #[test]
957    fn unknown_document_field_is_rejected() {
958        let text = r#"{"schema_version":1,"nodes":[],"edges":[],"surprise":true}"#;
959        let error = serde_json::from_str::<Graph>(text).expect_err("must reject");
960        assert!(
961            error.to_string().contains("surprise"),
962            "error should name the stray field: {error}"
963        );
964    }
965
966    /// A stray key inside a node payload is rejected too.
967    #[test]
968    fn unknown_payload_field_is_rejected() {
969        let text = r#"{"kind":"gate","payload":{"id":"g","approval_schema":{},"oops":1}}"#;
970        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
971        assert!(
972            error.to_string().contains("oops"),
973            "error should name the stray field: {error}"
974        );
975    }
976
977    /// A key other than `kind`/`payload` alongside a node is rejected.
978    #[test]
979    fn unknown_node_envelope_field_is_rejected() {
980        let text = r#"{"kind":"gate","payload":{"id":"g","approval_schema":{}},"extra":1}"#;
981        let error = serde_json::from_str::<Node>(text).expect_err("must reject");
982        assert!(
983            error.to_string().contains("extra"),
984            "error should name the stray key: {error}"
985        );
986    }
987
988    /// A `when` written as a bare string, the obvious skim-and-adapt mistake
989    /// (copying `"value > 10000"` straight in instead of wrapping it in the
990    /// `{"kind": "expression", "value": ...}` object), gets a product-language
991    /// error naming both accepted shapes and echoing the string back, not
992    /// serde's "adjacently tagged enum" internals.
993    #[test]
994    fn bare_string_branch_condition_names_accepted_shapes() {
995        let text = r#"{"name":"big","when":"value > 10000"}"#;
996        let error = serde_json::from_str::<BranchCase>(text).expect_err("must reject");
997        let message = error.to_string();
998        assert!(
999            !message.contains("adjacently tagged enum"),
1000            "error should not leak serde internals: {message}"
1001        );
1002        assert!(
1003            message.contains(r#"{"kind": "expression", "value": "<expr>"}"#)
1004                && message.contains(r#"{"kind": "model_decision"}"#),
1005            "error should name both accepted shapes: {message}"
1006        );
1007        assert!(
1008            message.contains(r#"a bare string "value > 10000""#),
1009            "error should echo the offending value: {message}"
1010        );
1011    }
1012
1013    /// A `when` written as some other wrong type (here, a bare number) also
1014    /// reads sensibly: same two accepted shapes, with the actual value
1015    /// described instead of a string-specific phrasing.
1016    #[test]
1017    fn non_string_branch_condition_also_names_accepted_shapes() {
1018        let text = r#"{"name":"big","when":10000}"#;
1019        let error = serde_json::from_str::<BranchCase>(text).expect_err("must reject");
1020        let message = error.to_string();
1021        assert!(
1022            !message.contains("adjacently tagged enum"),
1023            "error should not leak serde internals: {message}"
1024        );
1025        assert!(
1026            message.contains("a bare number 10000"),
1027            "error should describe the actual value: {message}"
1028        );
1029    }
1030
1031    /// A `when` object with an unrecognized `kind` (a wrong-shaped object,
1032    /// not a bare scalar) still reads as an error about the object found,
1033    /// not serde internals.
1034    #[test]
1035    fn wrong_kind_branch_condition_names_accepted_shapes() {
1036        let text = r#"{"name":"big","when":{"kind":"regex","value":"x"}}"#;
1037        let error = serde_json::from_str::<BranchCase>(text).expect_err("must reject");
1038        let message = error.to_string();
1039        assert!(
1040            !message.contains("adjacently tagged enum"),
1041            "error should not leak serde internals: {message}"
1042        );
1043        assert!(
1044            message.contains("an object"),
1045            "error should describe the value as an object: {message}"
1046        );
1047    }
1048
1049    /// The two valid `when` shapes still parse exactly as before: the custom
1050    /// `Deserialize` impl only changes the message on rejection, never
1051    /// acceptance.
1052    #[test]
1053    fn valid_branch_conditions_still_round_trip() {
1054        let expression = BranchCase {
1055            name: "big".into(),
1056            when: BranchCondition::Expression("value > 10000".into()),
1057        };
1058        let json = serde_json::to_string(&expression).expect("serialize");
1059        assert_eq!(
1060            json,
1061            r#"{"name":"big","when":{"kind":"expression","value":"value > 10000"}}"#
1062        );
1063        let restored: BranchCase = serde_json::from_str(&json).expect("deserialize");
1064        assert_eq!(expression, restored, "round trip changed the value: {json}");
1065
1066        let model_decision = BranchCase {
1067            name: "review".into(),
1068            when: BranchCondition::ModelDecision,
1069        };
1070        let json = serde_json::to_string(&model_decision).expect("serialize");
1071        assert_eq!(
1072            json,
1073            r#"{"name":"review","when":{"kind":"model_decision"}}"#
1074        );
1075        let restored: BranchCase = serde_json::from_str(&json).expect("deserialize");
1076        assert_eq!(
1077            model_decision, restored,
1078            "round trip changed the value: {json}"
1079        );
1080    }
1081
1082    /// A full document carrying a branch node serializes to the same bytes
1083    /// before and after this change, since only the `Deserialize` error path
1084    /// moved; `Serialize` is still the plain derive.
1085    #[test]
1086    fn branch_node_document_serializes_byte_identical() {
1087        let mut graph = sample();
1088        graph.nodes.push(Node::Branch(BranchNode {
1089            id: "route".into(),
1090            name: None,
1091            on: None,
1092            agent_hash: None,
1093            cases: vec![
1094                BranchCase {
1095                    name: "big".into(),
1096                    when: BranchCondition::Expression("value > 10000".into()),
1097                },
1098                BranchCase {
1099                    name: "small".into(),
1100                    when: BranchCondition::ModelDecision,
1101                },
1102            ],
1103        }));
1104        let json = serde_json::to_string(&graph).expect("serialize");
1105        let restored: Graph = serde_json::from_str(&json).expect("deserialize");
1106        assert_eq!(graph, restored, "round trip changed the value: {json}");
1107        let json_again = serde_json::to_string(&restored).expect("re-serialize");
1108        assert_eq!(
1109            json, json_again,
1110            "serialization is not byte-identical across a round trip"
1111        );
1112    }
1113}