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}