salvor_graph/lib.rs
1//! The Salvor graph document format, strict versioned validation, and JSON
2//! Schema emission.
3//!
4//! A graph is a declarative CONTROL document: authored once, submitted, hashed
5//! into a run, and then frozen. It coordinates nodes the runtime already knows
6//! how to execute (a full `agent` loop, a single `tool` call, a human `gate`, a
7//! `branch`, a `map` fan-out, a `fold` bounded-iteration loop, and a `delay`
8//! durable wait) and the typed
9//! edges between them. This crate owns four things and no more:
10//!
11//! - the [`document`] model: [`Graph`], [`Node`], the payloads, and [`Edge`],
12//! parsed strictly (unknown fields rejected) and versioned additively;
13//! - the [`validate`] pass: a set of independent checks that collect every
14//! error and name the offending node or edge;
15//! - the [`expr`] language: the total, non-Turing-complete condition language a
16//! `branch` case's expression string is written in, parsed at the submit
17//! boundary so a malformed condition is a node-precise error, never a runtime
18//! failure;
19//! - [`graph_schema`]: the graph document's JSON Schema, the single source of
20//! truth for editors and the future per-language builders.
21//!
22//! # What this crate is NOT
23//!
24//! There is no run-time execution here. `salvor-engine` is the crate that
25//! drives a graph, fans a `map` out, and backs the `POST /v1/graphs` and
26//! `POST /v1/graph-runs` endpoints in `salvor-server`; none of that lives in
27//! this one. Validation PARSES a branch condition (so a bad one fails at
28//! submit) but never EVALUATES one against a routed value; the evaluator
29//! [`expr::Expr::eval`] exists and is total, but it is `salvor-engine` that
30//! calls it, not this crate. Keeping this crate to format-plus-validation is
31//! what keeps it a pure, IO-free leaf: it depends only on `serde`,
32//! `serde_json`, `schemars`, and `thiserror`, drags in no runtime, and so
33//! stays usable from a future wasm dashboard projection.
34//!
35//! # Strict in, additive out
36//!
37//! Parsing rejects a stray field loudly, because a silently dropped field could
38//! drop a gate or an unenforced budget. Validation is likewise strict and fails
39//! at the submit boundary, not at run time. The one forward-compatibility
40//! concession is the additive `schema_version` discipline (see
41//! [`document::SCHEMA_VERSION`]): a graph recorded under an older build still
42//! parses and validates under a newer one.
43
44#![warn(missing_docs)]
45
46pub mod builder;
47pub mod document;
48pub mod expr;
49pub mod validate;
50
51pub use builder::{
52 AgentSpec, BranchSpec, DelaySpec, FoldSpec, GateSpec, GraphBuilder, MapSpec, ToolSpec,
53};
54pub use document::{
55 AgentNode, BranchCase, BranchCondition, BranchNode, DelayNode, Edge, FoldBody, FoldJoin,
56 FoldNode, GateNode, Graph, MapBody, MapNode, Node, OnBound, SCHEMA_VERSION, ToolNode,
57};
58pub use expr::{Expr, ExprError, MAX_EXPRESSION_LEN, Segment, parse as parse_expression};
59pub use validate::{GraphError, GraphSummary, MAX_NODE_NAME_LEN, validate};
60
61/// Returns the graph document's JSON Schema as a [`serde_json::Value`].
62///
63/// This is the single source of truth for the document format: editors read it
64/// for autocomplete and inline validation, and the future per-language builders
65/// generate from it so a Rust, Python, or TypeScript author reduces to the same
66/// canonical JSON. It is derived from the [`Graph`] types by `schemars`, so it
67/// can never drift from what this crate actually parses.
68#[must_use]
69pub fn graph_schema() -> serde_json::Value {
70 serde_json::to_value(schemars::schema_for!(Graph))
71 .expect("a schemars-generated schema is always valid JSON")
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 /// The emitted schema is a JSON object that describes the `Graph` type: it
79 /// declares the document's own fields (`schema_version`, `nodes`, `edges`)
80 /// and, through `$defs`, the node payloads.
81 #[test]
82 fn graph_schema_describes_the_document() {
83 let schema = graph_schema();
84 assert!(schema.is_object(), "schema is a JSON object");
85 let text = serde_json::to_string(&schema).expect("serialize");
86 for expected in [
87 "schema_version",
88 "nodes",
89 "edges",
90 "agent_hash",
91 "approval_schema",
92 ] {
93 assert!(
94 text.contains(expected),
95 "schema mentions {expected}: {text}"
96 );
97 }
98 }
99}