Skip to main content

flow_domain/
contract.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct NodeConstraints {
5    pub max_duration_ms: Option<u64>,
6    pub no_credential_access: bool,
7    pub no_execution_authority: bool,
8    pub no_network_access: bool,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct NodeContract {
13    pub kind: String,
14    pub type_id: String,
15    pub title: String,
16    pub description: Option<String>,
17    pub input_schema: serde_json::Value,
18    pub output_schema: serde_json::Value,
19    pub constraints: NodeConstraints,
20}
21
22/// Error raised while reading a RAO contract off a node or validating a model
23/// output against it (RAO Spec v1.0, docs/rao-spec).
24#[derive(Debug, Clone, PartialEq)]
25pub enum ContractError {
26    /// A contract field on the node is present but not usable.
27    InvalidField { field: String, reason: String },
28    /// The threshold ordering invariant does not hold.
29    InvalidThresholds(String),
30    /// The model output does not conform to the expected envelope schema.
31    SchemaViolation(String),
32}
33
34impl std::fmt::Display for ContractError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            ContractError::InvalidField { field, reason } => {
38                write!(f, "invalid contract field `{field}`: {reason}")
39            }
40            ContractError::InvalidThresholds(reason) => {
41                write!(f, "invalid contract thresholds: {reason}")
42            }
43            ContractError::SchemaViolation(reason) => {
44                write!(f, "output violates contract schema: {reason}")
45            }
46        }
47    }
48}
49
50impl std::error::Error for ContractError {}
51
52/// How an AI node's reported confidence was produced (RAO node-contract
53/// `confidence_type`). Self-reported in-text confidence is `verbalized`;
54/// `token_level` derives from token logprobs; `calibrated` is post-processed
55/// against a calibration set.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ConfidenceType {
59    Verbalized,
60    TokenLevel,
61    Calibrated,
62}
63
64impl ConfidenceType {
65    pub fn parse(s: &str) -> Option<Self> {
66        match s {
67            "verbalized" => Some(Self::Verbalized),
68            "token_level" => Some(Self::TokenLevel),
69            "calibrated" => Some(Self::Calibrated),
70            _ => None,
71        }
72    }
73
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Self::Verbalized => "verbalized",
77            Self::TokenLevel => "token_level",
78            Self::Calibrated => "calibrated",
79        }
80    }
81}
82
83/// Where the orchestration engine routes a contract-bound AI output. Decided
84/// by [`RoutingThresholds::route`] - never by the model (RAO constraint 5).
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RouteDecision {
88    /// Confidence clears `auto_approve_above`: output flows to the next step.
89    AutoApprove,
90    /// Confidence sits in the human review band, or the model set `escalate`:
91    /// the run pauses at a human approval gate.
92    HumanReview,
93    /// Confidence is below `suppress_below`: the output is suppressed and the
94    /// node fails so the flow's fallback (`.fail`) path runs.
95    Suppress,
96}
97
98impl RouteDecision {
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            Self::AutoApprove => "auto_approve",
102            Self::HumanReview => "human_review",
103            Self::Suppress => "suppress",
104        }
105    }
106}
107
108/// RAO routing thresholds. Applied by the orchestration engine to the model's
109/// reported confidence; defined on the node contract, never in the prompt.
110#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
111pub struct RoutingThresholds {
112    /// Confidence strictly above this value routes to the next step.
113    pub auto_approve_above: f64,
114    /// Inclusive `[low, high]` band routed to a human approval gate.
115    pub human_review_band: (f64, f64),
116    /// Confidence strictly below this value suppresses the output and fails
117    /// the node onto its fallback path.
118    pub suppress_below: f64,
119}
120
121impl Default for RoutingThresholds {
122    /// Defaults mirror the RAO spec's reference contract:
123    /// suppress < 0.50, review [0.50, 0.85], auto-approve > 0.85.
124    fn default() -> Self {
125        Self {
126            auto_approve_above: 0.85,
127            human_review_band: (0.50, 0.85),
128            suppress_below: 0.50,
129        }
130    }
131}
132
133impl RoutingThresholds {
134    /// Enforce `0 <= suppress_below <= band.low <= band.high <= auto_approve_above <= 1`.
135    pub fn validate(&self) -> Result<(), ContractError> {
136        let (lo, hi) = self.human_review_band;
137        let ordered = 0.0 <= self.suppress_below
138            && self.suppress_below <= lo
139            && lo <= hi
140            && hi <= self.auto_approve_above
141            && self.auto_approve_above <= 1.0;
142        if !ordered {
143            return Err(ContractError::InvalidThresholds(format!(
144                "expected 0 <= suppressBelow ({}) <= reviewLow ({lo}) <= reviewHigh ({hi}) \
145                 <= autoApproveAbove ({}) <= 1",
146                self.suppress_below, self.auto_approve_above
147            )));
148        }
149        Ok(())
150    }
151
152    /// Route a reported confidence. `escalate` can only force a human gate -
153    /// it can never raise an output past one (RAO checklist 5.4).
154    pub fn route(&self, confidence: f64, escalate: bool) -> RouteDecision {
155        if confidence < self.suppress_below {
156            return RouteDecision::Suppress;
157        }
158        if escalate {
159            return RouteDecision::HumanReview;
160        }
161        if confidence > self.auto_approve_above {
162            return RouteDecision::AutoApprove;
163        }
164        RouteDecision::HumanReview
165    }
166}
167
168/// RAO node contract carried by an `ai` node (scalar fields on the node body,
169/// so it round-trips through the DSL). Presence of `contract: true` opts the
170/// node into contract-bound output: the model must return an
171/// [`AiOutputEnvelope`], and the engine validates and routes it.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct AiNodeContract {
174    pub contract_version: String,
175    pub thresholds: RoutingThresholds,
176    /// When set, the envelope's `confidence_type` must match.
177    pub confidence_type: Option<ConfidenceType>,
178    /// Inference wall-clock budget; exceeding it fails the node.
179    pub max_inference_ms: Option<u64>,
180}
181
182impl AiNodeContract {
183    /// Read the contract off an AI node's `data`. Returns `Ok(None)` when the
184    /// node does not opt in (`contract` absent or false). Field names follow
185    /// the node-data camelCase convention.
186    pub fn from_node_data(data: &serde_json::Value) -> Result<Option<Self>, ContractError> {
187        let enabled = match data.get("contract") {
188            None => false,
189            Some(serde_json::Value::Bool(b)) => *b,
190            Some(serde_json::Value::String(s)) => s == "true",
191            Some(other) => {
192                return Err(ContractError::InvalidField {
193                    field: "contract".into(),
194                    reason: format!("expected a boolean, got {other}"),
195                })
196            }
197        };
198        if !enabled {
199            return Ok(None);
200        }
201
202        let defaults = RoutingThresholds::default();
203        let thresholds = RoutingThresholds {
204            auto_approve_above: read_unit_f64(data, "autoApproveAbove")?
205                .unwrap_or(defaults.auto_approve_above),
206            human_review_band: (
207                read_unit_f64(data, "reviewLow")?.unwrap_or(defaults.human_review_band.0),
208                read_unit_f64(data, "reviewHigh")?.unwrap_or(defaults.human_review_band.1),
209            ),
210            suppress_below: read_unit_f64(data, "suppressBelow")?
211                .unwrap_or(defaults.suppress_below),
212        };
213        thresholds.validate()?;
214
215        let confidence_type = match data.get("confidenceType").and_then(|v| v.as_str()) {
216            None => None,
217            Some(s) => Some(ConfidenceType::parse(s).ok_or_else(|| {
218                ContractError::InvalidField {
219                    field: "confidenceType".into(),
220                    reason: format!(
221                        "expected one of verbalized | token_level | calibrated, got `{s}`"
222                    ),
223                }
224            })?),
225        };
226
227        let max_inference_ms = match data.get("maxInferenceMs") {
228            None => None,
229            Some(v) => Some(v.as_u64().ok_or_else(|| ContractError::InvalidField {
230                field: "maxInferenceMs".into(),
231                reason: format!("expected a positive integer, got {v}"),
232            })?),
233        };
234
235        let contract_version = data
236            .get("contractVersion")
237            .and_then(|v| v.as_str())
238            .unwrap_or("1.0.0")
239            .to_string();
240
241        Ok(Some(Self {
242            contract_version,
243            thresholds,
244            confidence_type,
245            max_inference_ms,
246        }))
247    }
248}
249
250/// Structured output a contract-bound AI node must return (RAO node-contract
251/// `expected_output`). Anything outside this schema is a node failure.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct AiOutputEnvelope {
254    pub primary_output: String,
255    pub confidence: f64,
256    pub confidence_type: ConfidenceType,
257    pub escalate: bool,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub reasoning: Option<String>,
260}
261
262impl AiOutputEnvelope {
263    /// Strictly validate a parsed model output against the envelope schema.
264    /// Missing fields, wrong types, or an out-of-range confidence are
265    /// [`ContractError::SchemaViolation`]s - failed nodes, not degraded trust.
266    pub fn from_value(value: &serde_json::Value) -> Result<Self, ContractError> {
267        let obj = value
268            .as_object()
269            .ok_or_else(|| ContractError::SchemaViolation("output is not a JSON object".into()))?;
270
271        let primary_output = obj
272            .get("primary_output")
273            .and_then(|v| v.as_str())
274            .ok_or_else(|| {
275                ContractError::SchemaViolation("missing string field `primary_output`".into())
276            })?
277            .to_string();
278
279        let confidence = obj.get("confidence").and_then(|v| v.as_f64()).ok_or_else(|| {
280            ContractError::SchemaViolation("missing numeric field `confidence`".into())
281        })?;
282        if !(0.0..=1.0).contains(&confidence) {
283            return Err(ContractError::SchemaViolation(format!(
284                "confidence {confidence} is outside [0.0, 1.0]"
285            )));
286        }
287
288        let confidence_type = match obj.get("confidence_type").and_then(|v| v.as_str()) {
289            Some(s) => ConfidenceType::parse(s).ok_or_else(|| {
290                ContractError::SchemaViolation(format!("unknown confidence_type `{s}`"))
291            })?,
292            None => {
293                return Err(ContractError::SchemaViolation(
294                    "missing string field `confidence_type`".into(),
295                ))
296            }
297        };
298
299        let escalate = obj.get("escalate").and_then(|v| v.as_bool()).ok_or_else(|| {
300            ContractError::SchemaViolation("missing boolean field `escalate`".into())
301        })?;
302
303        let reasoning = match obj.get("reasoning") {
304            None | Some(serde_json::Value::Null) => None,
305            Some(serde_json::Value::String(s)) => Some(s.clone()),
306            Some(other) => {
307                return Err(ContractError::SchemaViolation(format!(
308                    "`reasoning` must be a string when present, got {other}"
309                )))
310            }
311        };
312
313        Ok(Self {
314            primary_output,
315            confidence,
316            confidence_type,
317            escalate,
318            reasoning,
319        })
320    }
321
322    /// JSON Schema for the envelope, used both as a hard `response_schema`
323    /// where the provider supports it and inside the system instruction.
324    pub fn json_schema() -> serde_json::Value {
325        serde_json::json!({
326            "type": "object",
327            "properties": {
328                "primary_output": { "type": "string" },
329                "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
330                "confidence_type": {
331                    "type": "string",
332                    "enum": ["verbalized", "token_level", "calibrated"]
333                },
334                "escalate": { "type": "boolean" },
335                "reasoning": { "type": "string" }
336            },
337            "required": ["primary_output", "confidence", "confidence_type", "escalate"],
338            "additionalProperties": false
339        })
340    }
341}
342
343/// Read an optional node-data number constrained to `[0.0, 1.0]`.
344fn read_unit_f64(data: &serde_json::Value, field: &str) -> Result<Option<f64>, ContractError> {
345    match data.get(field) {
346        None => Ok(None),
347        Some(v) => {
348            let n = v.as_f64().ok_or_else(|| ContractError::InvalidField {
349                field: field.into(),
350                reason: format!("expected a number, got {v}"),
351            })?;
352            if !(0.0..=1.0).contains(&n) {
353                return Err(ContractError::InvalidField {
354                    field: field.into(),
355                    reason: format!("{n} is outside [0.0, 1.0]"),
356                });
357            }
358            Ok(Some(n))
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367
368    #[test]
369    fn contract_absent_when_not_opted_in() {
370        assert_eq!(AiNodeContract::from_node_data(&json!({})).unwrap(), None);
371        assert_eq!(
372            AiNodeContract::from_node_data(&json!({ "contract": false })).unwrap(),
373            None
374        );
375    }
376
377    #[test]
378    fn contract_defaults_mirror_spec_reference() {
379        let c = AiNodeContract::from_node_data(&json!({ "contract": true }))
380            .unwrap()
381            .unwrap();
382        assert_eq!(c.contract_version, "1.0.0");
383        assert_eq!(c.thresholds, RoutingThresholds::default());
384        assert_eq!(c.confidence_type, None);
385    }
386
387    #[test]
388    fn contract_reads_custom_thresholds() {
389        let c = AiNodeContract::from_node_data(&json!({
390            "contract": true,
391            "autoApproveAbove": 0.9,
392            "reviewLow": 0.6,
393            "reviewHigh": 0.9,
394            "suppressBelow": 0.4,
395            "confidenceType": "verbalized",
396            "maxInferenceMs": 8000,
397            "contractVersion": "1.1.0"
398        }))
399        .unwrap()
400        .unwrap();
401        assert_eq!(c.thresholds.auto_approve_above, 0.9);
402        assert_eq!(c.thresholds.human_review_band, (0.6, 0.9));
403        assert_eq!(c.thresholds.suppress_below, 0.4);
404        assert_eq!(c.confidence_type, Some(ConfidenceType::Verbalized));
405        assert_eq!(c.max_inference_ms, Some(8000));
406        assert_eq!(c.contract_version, "1.1.0");
407    }
408
409    #[test]
410    fn contract_rejects_misordered_thresholds() {
411        let err = AiNodeContract::from_node_data(&json!({
412            "contract": true,
413            "suppressBelow": 0.9,
414            "reviewLow": 0.5
415        }))
416        .unwrap_err();
417        assert!(matches!(err, ContractError::InvalidThresholds(_)));
418    }
419
420    #[test]
421    fn contract_rejects_out_of_range_threshold() {
422        let err = AiNodeContract::from_node_data(&json!({
423            "contract": true,
424            "autoApproveAbove": 1.5
425        }))
426        .unwrap_err();
427        assert!(matches!(err, ContractError::InvalidField { .. }));
428    }
429
430    #[test]
431    fn routing_follows_thresholds() {
432        let t = RoutingThresholds::default();
433        assert_eq!(t.route(0.95, false), RouteDecision::AutoApprove);
434        assert_eq!(t.route(0.85, false), RouteDecision::HumanReview);
435        assert_eq!(t.route(0.50, false), RouteDecision::HumanReview);
436        assert_eq!(t.route(0.49, false), RouteDecision::Suppress);
437    }
438
439    #[test]
440    fn escalate_forces_review_but_never_bypasses_suppression() {
441        let t = RoutingThresholds::default();
442        assert_eq!(t.route(0.95, true), RouteDecision::HumanReview);
443        assert_eq!(t.route(0.10, true), RouteDecision::Suppress);
444    }
445
446    #[test]
447    fn envelope_parses_valid_output() {
448        let env = AiOutputEnvelope::from_value(&json!({
449            "primary_output": "JCL step 3 failed: dataset not found",
450            "confidence": 0.72,
451            "confidence_type": "verbalized",
452            "escalate": false,
453            "reasoning": "IEF212I in the log names the missing DD"
454        }))
455        .unwrap();
456        assert_eq!(env.confidence, 0.72);
457        assert_eq!(env.confidence_type, ConfidenceType::Verbalized);
458        assert!(!env.escalate);
459        assert!(env.reasoning.is_some());
460    }
461
462    #[test]
463    fn envelope_rejects_missing_or_invalid_fields() {
464        for bad in [
465            json!("just text"),
466            json!({ "confidence": 0.9, "confidence_type": "verbalized", "escalate": false }),
467            json!({ "primary_output": "x", "confidence_type": "verbalized", "escalate": false }),
468            json!({ "primary_output": "x", "confidence": 1.2, "confidence_type": "verbalized", "escalate": false }),
469            json!({ "primary_output": "x", "confidence": 0.9, "confidence_type": "vibes", "escalate": false }),
470            json!({ "primary_output": "x", "confidence": 0.9, "confidence_type": "verbalized" }),
471        ] {
472            assert!(
473                AiOutputEnvelope::from_value(&bad).is_err(),
474                "expected schema violation for {bad}"
475            );
476        }
477    }
478}