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