klieo-workflow 3.8.1

Declarative no-code workflow substrate for the klieo agent framework.
Documentation
//! Declarative workflow document. This is exactly what a visual builder
//! emits; nothing here executes.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};

/// Author bound into every run record. Fixed by the runtime rather than
/// caller-supplied, so a workflow author cannot forge attribution.
const RUN_RECORD_AUTHOR: &str = "system";

/// A complete workflow definition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorkflowDef {
    /// Stable workflow identifier.
    pub id: String,
    /// Monotonic version; a run pins the version it used.
    #[serde(default)]
    pub version: u32,
    /// Id of the node the run starts at.
    pub entry: String,
    /// All nodes, keyed by their `id`.
    pub nodes: Vec<NodeDef>,
    /// Routing edges. At most one edge per source node (validated at compile).
    #[serde(default)]
    pub edges: Vec<EdgeDef>,
}

impl WorkflowDef {
    /// Stable content hash: hex SHA-256 over the definition serialised with
    /// recursively sorted object keys, so two documents that differ only in
    /// source key order hash identically. Derived from the canonical serde
    /// form, never from `Debug`.
    pub fn canonical_hash(&self) -> String {
        let value = serde_json::to_value(self).expect("WorkflowDef serialises");
        hex::encode(Sha256::digest(canonical_json_bytes(&value)))
    }
}

/// Serialise `value` to canonical JSON bytes: object keys are recursively
/// sorted, so logically-equal values produce byte-identical output
/// regardless of key insertion order (and regardless of whether serde_json's
/// `preserve_order` feature is enabled anywhere in the build).
///
/// Callers that need a stable content hash or idempotency key over arbitrary
/// JSON — not just a [`WorkflowDef`] — hash these bytes.
pub fn canonical_json_bytes(value: &Value) -> Vec<u8> {
    let canonical = canonicalise(value);
    serde_json::to_vec(&canonical).expect("canonical JSON serialises")
}

/// Recursively re-key every object by sorted key order so logically-equal
/// documents produce byte-identical JSON regardless of insertion order.
fn canonicalise(value: &Value) -> Value {
    match value {
        Value::Object(map) => {
            let sorted: std::collections::BTreeMap<String, Value> = map
                .iter()
                .map(|(k, v)| (k.clone(), canonicalise(v)))
                .collect();
            Value::Object(sorted.into_iter().collect())
        }
        Value::Array(items) => Value::Array(items.iter().map(canonicalise).collect()),
        other => other.clone(),
    }
}

/// Immutable provenance binding minted at run-start: which workflow (id +
/// version) ran, the content hash it pinned, and the author that launched
/// it. Emit into a provenance chain so a run is attributable to an exact
/// definition.
///
/// Every field is private and read-only through an accessor. [`run_record`]
/// is the only constructor, so the binding cannot be mutated after minting
/// (no field reassignment) and `author` is always mint-stamped, never
/// caller-supplied — the future REST layer will stamp it from the
/// authenticated identity. Serialisable for emission into a chain; not
/// `Deserialize`, so an attacker-supplied payload cannot reconstruct a
/// record with a forged author or hash.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct WorkflowRunRecord {
    workflow_id: String,
    version: u32,
    content_hash: String,
    author: String,
}

impl WorkflowRunRecord {
    /// The workflow's stable id.
    pub fn workflow_id(&self) -> &str {
        &self.workflow_id
    }

    /// The version the run pinned.
    pub fn version(&self) -> u32 {
        self.version
    }

    /// Hex SHA-256 of the canonical definition (see [`WorkflowDef::canonical_hash`]).
    pub fn content_hash(&self) -> &str {
        &self.content_hash
    }

    /// The mint-stamped author. Always `"system"` in this version.
    pub fn author(&self) -> &str {
        &self.author
    }
}

/// Mint the run-start provenance binding for `def`. The author is fixed to a
/// system label — not caller-supplied — so attribution cannot be forged
/// through this call.
pub fn run_record(def: &WorkflowDef) -> WorkflowRunRecord {
    WorkflowRunRecord {
        workflow_id: def.id.clone(),
        version: def.version,
        content_hash: def.canonical_hash(),
        author: RUN_RECORD_AUTHOR.to_string(),
    }
}

/// One node in the workflow graph.
///
/// Does not `deny_unknown_fields`: serde's `flatten` (needed to tag
/// `kind` inline with the node's other fields) is documented as
/// incompatible with `deny_unknown_fields` on the same struct — combining
/// them rejects every legitimate `kind`-tagged payload, not just typos.
/// Unknown top-level keys on a node are silently ignored as a result;
/// [`WorkflowDef`], [`AgentConfig`], and [`EdgeDef`] (no `flatten` field)
/// keep the strict check.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NodeDef {
    /// Unique node id, referenced by `entry` and edges.
    pub id: String,
    /// What this node does.
    #[serde(flatten)]
    pub kind: NodeKind,
    /// Envelope field this node reads as its input. Unused by `subflow`.
    #[serde(default)]
    pub input_from: Option<String>,
    /// Envelope field this node writes its output to. Unused by `subflow`.
    #[serde(default)]
    pub output_to: Option<String>,
}

/// Node behaviour. Tagged by `kind`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum NodeKind {
    /// A data-configured `SimpleAgent`.
    Agent {
        /// Agent configuration.
        agent: AgentConfig,
    },
    /// A registered tool invoked by name.
    Tool {
        /// Registered tool id.
        tool: String,
    },
    /// A registered prebuilt sub-flow invoked by name.
    Subflow {
        /// Registered sub-flow id. Serialised as `ref`.
        #[serde(rename = "ref")]
        target: String,
    },
}

/// Data-driven agent configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AgentConfig {
    /// Registered model id.
    pub model: String,
    /// System prompt.
    pub system_prompt: String,
    /// Registered tool ids exposed to this agent.
    #[serde(default)]
    pub tools: Vec<String>,
}

/// A routing edge from one node to another.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EdgeDef {
    /// Source node id.
    pub from: String,
    /// Unconditional target node id. Mutually exclusive with `when`; an
    /// edge with neither `to` nor `when` is rejected at compile.
    #[serde(default)]
    pub to: Option<String>,
    /// Branch condition. When present, `then` and `otherwise` are required
    /// and `to` must be absent (enforced at compile).
    #[serde(default)]
    pub when: Option<crate::condition::Condition>,
    /// Target when `when` evaluates true.
    #[serde(default)]
    pub then: Option<String>,
    /// Target when `when` evaluates false. Serialised as `else`.
    #[serde(default, rename = "else")]
    pub otherwise: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_a_minimal_linear_workflow() {
        let json = serde_json::json!({
            "id": "greet", "entry": "hello",
            "nodes": [
                { "id": "hello", "kind": "agent", "input_from": "q", "output_to": "a",
                  "agent": { "model": "default", "system_prompt": "Be brief." } }
            ],
            "edges": []
        });
        let def: WorkflowDef = serde_json::from_value(json).unwrap();
        assert_eq!(def.id, "greet");
        assert_eq!(def.entry, "hello");
        assert_eq!(def.nodes.len(), 1);
        assert!(matches!(def.nodes[0].kind, NodeKind::Agent { .. }));
    }

    #[test]
    fn unknown_field_is_rejected() {
        let json = serde_json::json!({
            "id": "x", "entry": "n", "nodes": [], "bogus": true
        });
        let err = serde_json::from_value::<WorkflowDef>(json).unwrap_err();
        assert!(err.to_string().contains("bogus"), "got: {err}");
    }

    #[test]
    fn tool_and_subflow_kinds_parse() {
        let t: NodeDef = serde_json::from_value(serde_json::json!({
            "id": "t", "kind": "tool", "tool": "lookup"
        }))
        .unwrap();
        assert!(matches!(t.kind, NodeKind::Tool { .. }));
        let s: NodeDef = serde_json::from_value(serde_json::json!({
            "id": "s", "kind": "subflow", "ref": "fraud"
        }))
        .unwrap();
        assert!(matches!(s.kind, NodeKind::Subflow { .. }));
    }

    #[test]
    fn node_def_currently_accepts_unknown_field() {
        // Locks the serde tradeoff: `NodeDef` cannot combine `flatten` (for
        // the inline `kind` tag) with `deny_unknown_fields`, so an extraneous
        // top-level key is silently ignored today. If a future change
        // reintroduces the strict check, this test flips — catching the
        // regression where every legitimate tagged payload gets rejected.
        let n: NodeDef = serde_json::from_value(serde_json::json!({
            "id": "t", "kind": "tool", "tool": "lookup", "bogus": true
        }))
        .unwrap();
        assert!(matches!(n.kind, NodeKind::Tool { .. }));
    }

    #[test]
    fn canonical_hash_is_stable_across_key_order() {
        let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
        let b: WorkflowDef = serde_json::from_str(r#"{"entry":"n","id":"x","nodes":[]}"#).unwrap();
        assert_eq!(a.canonical_hash(), b.canonical_hash());
    }

    #[test]
    fn canonical_hash_stable_across_nested_object_key_order() {
        // Populated `nodes` array with a nested `agent` object; the two docs
        // differ only in key order inside the node and agent objects. Drives
        // `canonicalise`'s array-recursion + nested-object-sort path.
        let a: WorkflowDef = serde_json::from_str(
            r#"{"id":"w","entry":"n","nodes":[
                {"id":"n","kind":"agent","input_from":"q","output_to":"a",
                 "agent":{"model":"m","system_prompt":"p"}}]}"#,
        )
        .unwrap();
        let b: WorkflowDef = serde_json::from_str(
            r#"{"nodes":[
                {"agent":{"system_prompt":"p","model":"m"},
                 "output_to":"a","input_from":"q","kind":"agent","id":"n"}],
              "entry":"n","id":"w"}"#,
        )
        .unwrap();
        assert_eq!(a.canonical_hash(), b.canonical_hash());
    }

    #[test]
    fn canonical_hash_changes_with_content() {
        let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
        let b: WorkflowDef = serde_json::from_str(r#"{"id":"y","entry":"n","nodes":[]}"#).unwrap();
        assert_ne!(a.canonical_hash(), b.canonical_hash());
    }

    #[test]
    fn canonical_json_bytes_stable_across_key_order() {
        let a: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 3}});
        let b: Value = serde_json::json!({"b": {"y": 3, "x": 2}, "a": 1});
        assert_eq!(canonical_json_bytes(&a), canonical_json_bytes(&b));
        let different: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 4}});
        assert_ne!(canonical_json_bytes(&a), canonical_json_bytes(&different));
    }

    #[test]
    fn run_record_binds_id_version_hash_and_system_author() {
        let def: WorkflowDef =
            serde_json::from_str(r#"{"id":"x","version":3,"entry":"n","nodes":[]}"#).unwrap();
        let record = run_record(&def);
        assert_eq!(record.workflow_id(), "x");
        assert_eq!(record.version(), 3);
        assert_eq!(record.content_hash(), def.canonical_hash());
        assert_eq!(record.author(), "system");
    }
}