Skip to main content

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