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#[derive(Debug, Clone, PartialEq)]
25pub enum ContractError {
26 InvalidField { field: String, reason: String },
28 InvalidThresholds(String),
30 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RouteDecision {
88 AutoApprove,
90 HumanReview,
93 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
111pub struct RoutingThresholds {
112 pub auto_approve_above: f64,
114 pub human_review_band: (f64, f64),
116 pub suppress_below: f64,
119}
120
121impl Default for RoutingThresholds {
122 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct AiNodeContract {
174 pub contract_version: String,
175 pub thresholds: RoutingThresholds,
176 pub confidence_type: Option<ConfidenceType>,
178 pub max_inference_ms: Option<u64>,
180}
181
182impl AiNodeContract {
183 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#[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 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 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
343fn 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}