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