Skip to main content

klieo_workflow/
def.rs

1//! Declarative workflow document. This is exactly what a visual builder
2//! emits; nothing here executes.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7
8/// Author bound into every run record. Fixed by the runtime rather than
9/// caller-supplied, so a workflow author cannot forge attribution.
10const RUN_RECORD_AUTHOR: &str = "system";
11
12/// A complete workflow definition.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(deny_unknown_fields)]
15pub struct WorkflowDef {
16    /// Stable workflow identifier.
17    pub id: String,
18    /// Monotonic version; a run pins the version it used.
19    #[serde(default)]
20    pub version: u32,
21    /// Id of the node the run starts at.
22    pub entry: String,
23    /// All nodes, keyed by their `id`.
24    pub nodes: Vec<NodeDef>,
25    /// Routing edges. At most one edge per source node (validated at compile).
26    #[serde(default)]
27    pub edges: Vec<EdgeDef>,
28}
29
30impl WorkflowDef {
31    /// Stable content hash: hex SHA-256 over the definition serialised with
32    /// recursively sorted object keys, so two documents that differ only in
33    /// source key order hash identically. Derived from the canonical serde
34    /// form, never from `Debug`.
35    pub fn canonical_hash(&self) -> String {
36        let value = serde_json::to_value(self).expect("WorkflowDef serialises");
37        hex::encode(Sha256::digest(canonical_json_bytes(&value)))
38    }
39}
40
41/// Serialise `value` to canonical JSON bytes: object keys are recursively
42/// sorted, so logically-equal values produce byte-identical output
43/// regardless of key insertion order (and regardless of whether serde_json's
44/// `preserve_order` feature is enabled anywhere in the build).
45///
46/// Callers that need a stable content hash or idempotency key over arbitrary
47/// JSON — not just a [`WorkflowDef`] — hash these bytes.
48pub fn canonical_json_bytes(value: &Value) -> Vec<u8> {
49    let canonical = canonicalise(value);
50    serde_json::to_vec(&canonical).expect("canonical JSON serialises")
51}
52
53/// Recursively re-key every object by sorted key order so logically-equal
54/// documents produce byte-identical JSON regardless of insertion order.
55fn canonicalise(value: &Value) -> Value {
56    match value {
57        Value::Object(map) => {
58            let sorted: std::collections::BTreeMap<String, Value> = map
59                .iter()
60                .map(|(k, v)| (k.clone(), canonicalise(v)))
61                .collect();
62            Value::Object(sorted.into_iter().collect())
63        }
64        Value::Array(items) => Value::Array(items.iter().map(canonicalise).collect()),
65        other => other.clone(),
66    }
67}
68
69/// Immutable provenance binding minted at run-start: which workflow (id +
70/// version) ran, the content hash it pinned, and the author that launched
71/// it. Emit into a provenance chain so a run is attributable to an exact
72/// definition.
73///
74/// Every field is private and read-only through an accessor. [`run_record`]
75/// is the only constructor, so the binding cannot be mutated after minting
76/// (no field reassignment) and `author` is always mint-stamped, never
77/// caller-supplied — the future REST layer will stamp it from the
78/// authenticated identity. Serialisable for emission into a chain; not
79/// `Deserialize`, so an attacker-supplied payload cannot reconstruct a
80/// record with a forged author or hash.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[non_exhaustive]
83pub struct WorkflowRunRecord {
84    workflow_id: String,
85    version: u32,
86    content_hash: String,
87    author: String,
88}
89
90impl WorkflowRunRecord {
91    /// The workflow's stable id.
92    pub fn workflow_id(&self) -> &str {
93        &self.workflow_id
94    }
95
96    /// The version the run pinned.
97    pub fn version(&self) -> u32 {
98        self.version
99    }
100
101    /// Hex SHA-256 of the canonical definition (see [`WorkflowDef::canonical_hash`]).
102    pub fn content_hash(&self) -> &str {
103        &self.content_hash
104    }
105
106    /// The mint-stamped author. Always `"system"` in this version.
107    pub fn author(&self) -> &str {
108        &self.author
109    }
110}
111
112/// Mint the run-start provenance binding for `def`. The author is fixed to a
113/// system label — not caller-supplied — so attribution cannot be forged
114/// through this call.
115pub fn run_record(def: &WorkflowDef) -> WorkflowRunRecord {
116    WorkflowRunRecord {
117        workflow_id: def.id.clone(),
118        version: def.version,
119        content_hash: def.canonical_hash(),
120        author: RUN_RECORD_AUTHOR.to_string(),
121    }
122}
123
124/// One node in the workflow graph.
125///
126/// Does not `deny_unknown_fields`: serde's `flatten` (needed to tag
127/// `kind` inline with the node's other fields) is documented as
128/// incompatible with `deny_unknown_fields` on the same struct — combining
129/// them rejects every legitimate `kind`-tagged payload, not just typos.
130/// Unknown top-level keys on a node are silently ignored as a result;
131/// [`WorkflowDef`], [`AgentConfig`], and [`EdgeDef`] (no `flatten` field)
132/// keep the strict check.
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct NodeDef {
135    /// Unique node id, referenced by `entry` and edges.
136    pub id: String,
137    /// What this node does.
138    #[serde(flatten)]
139    pub kind: NodeKind,
140    /// Envelope field this node reads as its input. Unused by `subflow`.
141    #[serde(default)]
142    pub input_from: Option<String>,
143    /// Envelope field this node writes its output to. Unused by `subflow`.
144    #[serde(default)]
145    pub output_to: Option<String>,
146}
147
148/// Node behaviour. Tagged by `kind`.
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150#[serde(tag = "kind", rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum NodeKind {
153    /// A data-configured `SimpleAgent`.
154    Agent {
155        /// Agent configuration.
156        agent: AgentConfig,
157    },
158    /// A registered tool invoked by name.
159    Tool {
160        /// Registered tool id.
161        tool: String,
162    },
163    /// A registered prebuilt sub-flow invoked by name.
164    Subflow {
165        /// Registered sub-flow id. Serialised as `ref`.
166        #[serde(rename = "ref")]
167        target: String,
168    },
169}
170
171/// Data-driven agent configuration.
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173#[serde(deny_unknown_fields)]
174pub struct AgentConfig {
175    /// Registered model id.
176    pub model: String,
177    /// System prompt.
178    pub system_prompt: String,
179    /// Registered tool ids exposed to this agent.
180    #[serde(default)]
181    pub tools: Vec<String>,
182}
183
184/// A routing edge from one node to another.
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186#[serde(deny_unknown_fields)]
187pub struct EdgeDef {
188    /// Source node id.
189    pub from: String,
190    /// Unconditional target node id. Mutually exclusive with `when`; an
191    /// edge with neither `to` nor `when` is rejected at compile.
192    #[serde(default)]
193    pub to: Option<String>,
194    /// Branch condition. When present, `then` and `otherwise` are required
195    /// and `to` must be absent (enforced at compile).
196    #[serde(default)]
197    pub when: Option<crate::condition::Condition>,
198    /// Target when `when` evaluates true.
199    #[serde(default)]
200    pub then: Option<String>,
201    /// Target when `when` evaluates false. Serialised as `else`.
202    #[serde(default, rename = "else")]
203    pub otherwise: Option<String>,
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn parses_a_minimal_linear_workflow() {
212        let json = serde_json::json!({
213            "id": "greet", "entry": "hello",
214            "nodes": [
215                { "id": "hello", "kind": "agent", "input_from": "q", "output_to": "a",
216                  "agent": { "model": "default", "system_prompt": "Be brief." } }
217            ],
218            "edges": []
219        });
220        let def: WorkflowDef = serde_json::from_value(json).unwrap();
221        assert_eq!(def.id, "greet");
222        assert_eq!(def.entry, "hello");
223        assert_eq!(def.nodes.len(), 1);
224        assert!(matches!(def.nodes[0].kind, NodeKind::Agent { .. }));
225    }
226
227    #[test]
228    fn unknown_field_is_rejected() {
229        let json = serde_json::json!({
230            "id": "x", "entry": "n", "nodes": [], "bogus": true
231        });
232        let err = serde_json::from_value::<WorkflowDef>(json).unwrap_err();
233        assert!(err.to_string().contains("bogus"), "got: {err}");
234    }
235
236    #[test]
237    fn tool_and_subflow_kinds_parse() {
238        let t: NodeDef = serde_json::from_value(serde_json::json!({
239            "id": "t", "kind": "tool", "tool": "lookup"
240        }))
241        .unwrap();
242        assert!(matches!(t.kind, NodeKind::Tool { .. }));
243        let s: NodeDef = serde_json::from_value(serde_json::json!({
244            "id": "s", "kind": "subflow", "ref": "fraud"
245        }))
246        .unwrap();
247        assert!(matches!(s.kind, NodeKind::Subflow { .. }));
248    }
249
250    #[test]
251    fn node_def_currently_accepts_unknown_field() {
252        // Locks the serde tradeoff: `NodeDef` cannot combine `flatten` (for
253        // the inline `kind` tag) with `deny_unknown_fields`, so an extraneous
254        // top-level key is silently ignored today. If a future change
255        // reintroduces the strict check, this test flips — catching the
256        // regression where every legitimate tagged payload gets rejected.
257        let n: NodeDef = serde_json::from_value(serde_json::json!({
258            "id": "t", "kind": "tool", "tool": "lookup", "bogus": true
259        }))
260        .unwrap();
261        assert!(matches!(n.kind, NodeKind::Tool { .. }));
262    }
263
264    #[test]
265    fn canonical_hash_is_stable_across_key_order() {
266        let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
267        let b: WorkflowDef = serde_json::from_str(r#"{"entry":"n","id":"x","nodes":[]}"#).unwrap();
268        assert_eq!(a.canonical_hash(), b.canonical_hash());
269    }
270
271    #[test]
272    fn canonical_hash_stable_across_nested_object_key_order() {
273        // Populated `nodes` array with a nested `agent` object; the two docs
274        // differ only in key order inside the node and agent objects. Drives
275        // `canonicalise`'s array-recursion + nested-object-sort path.
276        let a: WorkflowDef = serde_json::from_str(
277            r#"{"id":"w","entry":"n","nodes":[
278                {"id":"n","kind":"agent","input_from":"q","output_to":"a",
279                 "agent":{"model":"m","system_prompt":"p"}}]}"#,
280        )
281        .unwrap();
282        let b: WorkflowDef = serde_json::from_str(
283            r#"{"nodes":[
284                {"agent":{"system_prompt":"p","model":"m"},
285                 "output_to":"a","input_from":"q","kind":"agent","id":"n"}],
286              "entry":"n","id":"w"}"#,
287        )
288        .unwrap();
289        assert_eq!(a.canonical_hash(), b.canonical_hash());
290    }
291
292    #[test]
293    fn canonical_hash_changes_with_content() {
294        let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
295        let b: WorkflowDef = serde_json::from_str(r#"{"id":"y","entry":"n","nodes":[]}"#).unwrap();
296        assert_ne!(a.canonical_hash(), b.canonical_hash());
297    }
298
299    #[test]
300    fn canonical_json_bytes_stable_across_key_order() {
301        let a: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 3}});
302        let b: Value = serde_json::json!({"b": {"y": 3, "x": 2}, "a": 1});
303        assert_eq!(canonical_json_bytes(&a), canonical_json_bytes(&b));
304        let different: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 4}});
305        assert_ne!(canonical_json_bytes(&a), canonical_json_bytes(&different));
306    }
307
308    #[test]
309    fn run_record_binds_id_version_hash_and_system_author() {
310        let def: WorkflowDef =
311            serde_json::from_str(r#"{"id":"x","version":3,"entry":"n","nodes":[]}"#).unwrap();
312        let record = run_record(&def);
313        assert_eq!(record.workflow_id(), "x");
314        assert_eq!(record.version(), 3);
315        assert_eq!(record.content_hash(), def.canonical_hash());
316        assert_eq!(record.author(), "system");
317    }
318}