Skip to main content

core_api/
interaction_flow.rs

1//! Canonical Interaction Flow definition and Core-to-Node wire contract.
2//!
3//! Definitions describe presentation, typed inputs, transitions, and logical
4//! operations. They never select a Node instance or transport target. MeowCore
5//! binds an executor when it creates a session and owns all routing decisions.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::{HashMap, HashSet};
10
11pub const PROTOCOL_VERSION: u32 = 1;
12pub const NODE_PROTOCOL_SCHEMA: &str =
13    include_str!("../schema/interaction-flow-node.v1.schema.json");
14pub const MAX_STEPS: usize = 64;
15pub const MAX_RESOLVERS: usize = 128;
16pub const MAX_FIELDS_PER_STEP: usize = 64;
17pub const MAX_PRESENTATION_BYTES: usize = 64 * 1024;
18pub const MAX_VALUE_REF_PATH: usize = 32;
19pub const MAX_HANDLER_ARGS: usize = 128;
20pub const MAX_OPTIONS: usize = 1024;
21pub const MAX_TITLE_CHARS: usize = 1024;
22pub const MAX_DESCRIPTION_CHARS: usize = 16 * 1024;
23pub const MAX_CONTENT_CHARS: usize = 64 * 1024;
24pub const MAX_LABEL_CHARS: usize = 1024;
25pub const MAX_UNAVAILABLE_TEXT_CHARS: usize = 4096;
26pub const MAX_MIN_ROWS: u32 = 100;
27
28pub fn definition_target(node_type: &str) -> String {
29    format!("/{node_type}/interaction-flow/definition")
30}
31
32pub fn execute_target(node_type: &str) -> String {
33    format!("/{node_type}/interaction-flow/execute")
34}
35
36pub fn close_target(node_type: &str) -> String {
37    format!("/{node_type}/interaction-flow/close")
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct FlowValueRef {
43    pub root: String,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub path: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(
50    tag = "type",
51    rename_all = "camelCase",
52    rename_all_fields = "camelCase",
53    deny_unknown_fields
54)]
55pub enum FlowArgument {
56    Value { value: Value },
57    Ref { value_ref: FlowValueRef },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub enum FlowRenderer {
63    TypedForm,
64    /// Trusted MeowCore-only compatibility renderer used by the Custom
65    /// Connect adapter. It is deliberately not part of the serialized Node
66    /// protocol.
67    #[serde(skip)]
68    CustomConnectMdx,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct FlowPresentation {
74    pub renderer: FlowRenderer,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub title: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub description: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub content: Option<String>,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85pub struct FlowForm {
86    #[serde(default)]
87    pub fields: Vec<FlowFormField>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub enum FlowFieldValueType {
93    String,
94    Boolean,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub enum FlowFieldControl {
100    Text,
101    Password,
102    LongText,
103    Select,
104    Boolean,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase", deny_unknown_fields)]
109pub struct FlowFormField {
110    pub var: String,
111    pub value_type: FlowFieldValueType,
112    pub control: FlowFieldControl,
113    #[serde(default)]
114    pub required: bool,
115    #[serde(default)]
116    pub repeat: bool,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub label: Option<String>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub default_value: Option<Value>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub default_value_ref: Option<FlowValueRef>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub options: Option<Vec<Value>>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub options_ref: Option<FlowValueRef>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub option_unavailable_text: Option<String>,
129    #[serde(default)]
130    pub read_only: bool,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub min_rows: Option<u32>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(
137    tag = "type",
138    rename_all = "camelCase",
139    rename_all_fields = "camelCase",
140    deny_unknown_fields
141)]
142pub enum FlowTransition {
143    Step { step_id: String },
144    Dynamic { value_ref: FlowValueRef },
145    Finish,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct FlowHandler {
151    pub operation: String,
152    #[serde(default)]
153    pub args: Vec<FlowArgument>,
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158pub struct FlowValueResolver {
159    pub output_var: String,
160    pub handler: FlowHandler,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub enum ExternalAuthCompletionMode {
166    Callback,
167    Poll,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(
172    tag = "type",
173    rename_all = "camelCase",
174    rename_all_fields = "camelCase",
175    deny_unknown_fields
176)]
177pub enum FlowStepContent {
178    Form {
179        presentation: FlowPresentation,
180        form: FlowForm,
181        #[serde(default)]
182        render_refs: Vec<FlowValueRef>,
183    },
184    ExternalAuth {
185        presentation: FlowPresentation,
186        authorization_url_ref: FlowValueRef,
187        completion_mode: ExternalAuthCompletionMode,
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        poll_after_ms: Option<u64>,
190    },
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase", deny_unknown_fields)]
195pub struct InteractionFlowStep {
196    pub id: String,
197    pub content: FlowStepContent,
198    pub transition: FlowTransition,
199}
200
201#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct FlowLifecycleHandlers {
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub complete: Option<FlowHandler>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub cancel: Option<FlowHandler>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub expire: Option<FlowHandler>,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct InteractionFlowDefinition {
215    pub protocol_version: u32,
216    pub flow_id: String,
217    pub start_step_id: String,
218    pub steps: HashMap<String, InteractionFlowStep>,
219    #[serde(default)]
220    pub resolvers: HashMap<String, FlowValueResolver>,
221    #[serde(default)]
222    pub lifecycle: FlowLifecycleHandlers,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct FlowValidationError {
227    message: String,
228}
229
230impl FlowValidationError {
231    fn new(message: impl Into<String>) -> Self {
232        Self {
233            message: message.into(),
234        }
235    }
236}
237
238impl std::fmt::Display for FlowValidationError {
239    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        formatter.write_str(&self.message)
241    }
242}
243
244impl std::error::Error for FlowValidationError {}
245
246impl InteractionFlowDefinition {
247    pub fn validate(&self) -> Result<(), FlowValidationError> {
248        if self.protocol_version != PROTOCOL_VERSION {
249            return Err(FlowValidationError::new(
250                "unsupported interaction-flow protocol version",
251            ));
252        }
253        require_identifier("flow id", &self.flow_id)?;
254        require_identifier("start step id", &self.start_step_id)?;
255        if self.steps.is_empty() || self.steps.len() > MAX_STEPS {
256            return Err(FlowValidationError::new(
257                "interaction-flow step count is out of bounds",
258            ));
259        }
260        if self.resolvers.len() > MAX_RESOLVERS {
261            return Err(FlowValidationError::new(
262                "interaction-flow resolver count is out of bounds",
263            ));
264        }
265        if !self.steps.contains_key(&self.start_step_id) {
266            return Err(FlowValidationError::new(
267                "interaction-flow start step does not exist",
268            ));
269        }
270
271        let mut inputs = HashSet::new();
272        let mut input_shapes = HashMap::new();
273        for (step_key, step) in &self.steps {
274            require_identifier("step id", step_key)?;
275            if step.id != *step_key {
276                return Err(FlowValidationError::new(
277                    "interaction-flow step key/id mismatch",
278                ));
279            }
280            validate_presentation(step.presentation())?;
281            if let FlowStepContent::Form { form, .. } = &step.content {
282                if form.fields.len() > MAX_FIELDS_PER_STEP {
283                    return Err(FlowValidationError::new(
284                        "interaction-flow field count is out of bounds",
285                    ));
286                }
287                let mut step_inputs = HashSet::new();
288                for field in &form.fields {
289                    require_variable("input variable", &field.var)?;
290                    if is_builtin(&field.var) || !step_inputs.insert(field.var.as_str()) {
291                        return Err(FlowValidationError::new(format!(
292                            "duplicate or reserved interaction-flow input variable: {}",
293                            field.var
294                        )));
295                    }
296                    inputs.insert(field.var.as_str());
297                    validate_field_shape(field)?;
298                    if let Some((value_type, repeat)) = input_shapes.get(&field.var) {
299                        if *value_type != field.value_type || *repeat != field.repeat {
300                            return Err(FlowValidationError::new(format!(
301                                "interaction-flow input changes value type or cardinality across steps: {}",
302                                field.var
303                            )));
304                        }
305                    } else {
306                        input_shapes.insert(field.var.clone(), (field.value_type, field.repeat));
307                    }
308                }
309            }
310        }
311
312        for (name, resolver) in &self.resolvers {
313            require_variable("resolver output", name)?;
314            if resolver.output_var != *name {
315                return Err(FlowValidationError::new(
316                    "interaction-flow resolver key/output mismatch",
317                ));
318            }
319            if is_builtin(name) || inputs.contains(name.as_str()) {
320                return Err(FlowValidationError::new(format!(
321                    "duplicate or reserved interaction-flow resolver output: {name}"
322                )));
323            }
324        }
325
326        let known = |name: &str| {
327            is_builtin(name) || inputs.contains(name) || self.resolvers.contains_key(name)
328        };
329        for step in self.steps.values() {
330            match &step.transition {
331                FlowTransition::Step { step_id } if !self.steps.contains_key(step_id) => {
332                    return Err(FlowValidationError::new(format!(
333                        "unknown interaction-flow step: {step_id}"
334                    )));
335                }
336                FlowTransition::Dynamic { value_ref } => {
337                    validate_ref(value_ref, &known)?;
338                    if !self.resolvers.contains_key(&value_ref.root) {
339                        return Err(FlowValidationError::new(
340                            "dynamic interaction-flow transition must be resolver-backed",
341                        ));
342                    }
343                }
344                _ => {}
345            }
346            match &step.content {
347                FlowStepContent::Form {
348                    form, render_refs, ..
349                } => {
350                    for value_ref in render_refs {
351                        validate_ref(value_ref, &known)?;
352                    }
353                    for field in &form.fields {
354                        for value_ref in
355                            [field.default_value_ref.as_ref(), field.options_ref.as_ref()]
356                                .into_iter()
357                                .flatten()
358                        {
359                            validate_ref(value_ref, &known)?;
360                        }
361                    }
362                }
363                FlowStepContent::ExternalAuth {
364                    authorization_url_ref,
365                    poll_after_ms,
366                    completion_mode,
367                    ..
368                } => {
369                    validate_ref(authorization_url_ref, &known)?;
370                    if matches!(completion_mode, ExternalAuthCompletionMode::Poll)
371                        && poll_after_ms.is_none_or(|value| !(250..=60_000).contains(&value))
372                    {
373                        return Err(FlowValidationError::new(
374                            "polling external-auth steps require pollAfterMs between 250 and 60000",
375                        ));
376                    }
377                }
378            }
379        }
380
381        for resolver in self.resolvers.values() {
382            validate_handler(&resolver.handler, &known)?;
383        }
384        for handler in [
385            self.lifecycle.complete.as_ref(),
386            self.lifecycle.cancel.as_ref(),
387            self.lifecycle.expire.as_ref(),
388        ]
389        .into_iter()
390        .flatten()
391        {
392            validate_handler(handler, &known)?;
393        }
394        validate_resolver_graph(&self.resolvers)
395    }
396}
397
398impl InteractionFlowStep {
399    pub fn presentation(&self) -> &FlowPresentation {
400        match &self.content {
401            FlowStepContent::Form { presentation, .. }
402            | FlowStepContent::ExternalAuth { presentation, .. } => presentation,
403        }
404    }
405}
406
407fn validate_presentation(presentation: &FlowPresentation) -> Result<(), FlowValidationError> {
408    if presentation
409        .title
410        .as_ref()
411        .is_some_and(|value| value.chars().count() > MAX_TITLE_CHARS)
412        || presentation
413            .description
414            .as_ref()
415            .is_some_and(|value| value.chars().count() > MAX_DESCRIPTION_CHARS)
416        || presentation
417            .content
418            .as_ref()
419            .is_some_and(|value| value.chars().count() > MAX_CONTENT_CHARS)
420    {
421        return Err(FlowValidationError::new(
422            "interaction-flow presentation field is too large",
423        ));
424    }
425    let total = presentation.title.as_deref().unwrap_or_default().len()
426        + presentation
427            .description
428            .as_deref()
429            .unwrap_or_default()
430            .len()
431        + presentation.content.as_deref().unwrap_or_default().len();
432    if total > MAX_PRESENTATION_BYTES {
433        return Err(FlowValidationError::new(
434            "interaction-flow presentation is too large",
435        ));
436    }
437    if matches!(presentation.renderer, FlowRenderer::CustomConnectMdx)
438        && presentation.content.as_deref().is_none_or(str::is_empty)
439    {
440        return Err(FlowValidationError::new(
441            "custom-connect presentation requires content",
442        ));
443    }
444    Ok(())
445}
446
447fn validate_field_shape(field: &FlowFormField) -> Result<(), FlowValidationError> {
448    if field
449        .label
450        .as_ref()
451        .is_some_and(|value| value.chars().count() > MAX_LABEL_CHARS)
452        || field
453            .option_unavailable_text
454            .as_ref()
455            .is_some_and(|value| value.chars().count() > MAX_UNAVAILABLE_TEXT_CHARS)
456        || field
457            .min_rows
458            .is_some_and(|value| value == 0 || value > MAX_MIN_ROWS)
459        || field
460            .options
461            .as_ref()
462            .is_some_and(|options| options.len() > MAX_OPTIONS)
463    {
464        return Err(FlowValidationError::new(format!(
465            "interaction-flow field metadata is out of bounds: {}",
466            field.var
467        )));
468    }
469    let compatible = matches!(
470        (field.value_type, field.control),
471        (FlowFieldValueType::String, FlowFieldControl::Text)
472            | (FlowFieldValueType::String, FlowFieldControl::Password)
473            | (FlowFieldValueType::String, FlowFieldControl::LongText)
474            | (FlowFieldValueType::String, FlowFieldControl::Select)
475            | (FlowFieldValueType::Boolean, FlowFieldControl::Boolean)
476    );
477    if !compatible {
478        return Err(FlowValidationError::new(format!(
479            "interaction-flow field has incompatible value type and control: {}",
480            field.var
481        )));
482    }
483    if field.repeat
484        && !matches!(
485            field.control,
486            FlowFieldControl::Text | FlowFieldControl::Password | FlowFieldControl::LongText
487        )
488    {
489        return Err(FlowValidationError::new(format!(
490            "interaction-flow field control cannot repeat: {}",
491            field.var
492        )));
493    }
494    if field.default_value.is_some() && field.default_value_ref.is_some() {
495        return Err(FlowValidationError::new(format!(
496            "interaction-flow field has two default sources: {}",
497            field.var
498        )));
499    }
500    if matches!(field.control, FlowFieldControl::Password)
501        && (field.default_value.is_some() || field.default_value_ref.is_some() || field.read_only)
502    {
503        return Err(FlowValidationError::new(format!(
504            "interaction-flow password fields cannot have defaults or be read-only: {}",
505            field.var
506        )));
507    }
508    if field.options.is_some() && field.options_ref.is_some() {
509        return Err(FlowValidationError::new(format!(
510            "interaction-flow field has two option sources: {}",
511            field.var
512        )));
513    }
514    if matches!(field.control, FlowFieldControl::Select)
515        && field.options.is_none()
516        && field.options_ref.is_none()
517    {
518        return Err(FlowValidationError::new(format!(
519            "interaction-flow select field requires options: {}",
520            field.var
521        )));
522    }
523    if !matches!(field.control, FlowFieldControl::Select)
524        && (field.options.is_some()
525            || field.options_ref.is_some()
526            || field.option_unavailable_text.is_some())
527    {
528        return Err(FlowValidationError::new(format!(
529            "interaction-flow options require a select control: {}",
530            field.var
531        )));
532    }
533    if field.min_rows.is_some() && !matches!(field.control, FlowFieldControl::LongText) {
534        return Err(FlowValidationError::new(format!(
535            "interaction-flow minRows requires a long-text control: {}",
536            field.var
537        )));
538    }
539    if let Some(default) = &field.default_value {
540        validate_static_default(field, default)?;
541        if let Some(options) = &field.options {
542            let selected = if field.repeat {
543                default.as_array().map(Vec::as_slice).unwrap_or_default()
544            } else {
545                std::slice::from_ref(default)
546            };
547            if selected.iter().any(|selected| {
548                !options
549                    .iter()
550                    .any(|option| option_value(option) == Some(selected))
551            }) {
552                return Err(FlowValidationError::new(format!(
553                    "interaction-flow default is not in static options: {}",
554                    field.var
555                )));
556            }
557        }
558    }
559    if let Some(options) = &field.options {
560        for option in options {
561            let value = option_value(option).ok_or_else(|| {
562                FlowValidationError::new(format!(
563                    "interaction-flow option has an invalid shape: {}",
564                    field.var
565                ))
566            })?;
567            validate_static_scalar(field, value, "option")?;
568        }
569    }
570    Ok(())
571}
572
573fn validate_static_default(
574    field: &FlowFormField,
575    value: &Value,
576) -> Result<(), FlowValidationError> {
577    if field.repeat {
578        let values = value.as_array().ok_or_else(|| {
579            FlowValidationError::new(format!(
580                "interaction-flow repeat default must be an array: {}",
581                field.var
582            ))
583        })?;
584        for value in values {
585            validate_static_scalar(field, value, "default")?;
586        }
587    } else {
588        validate_static_scalar(field, value, "default")?;
589    }
590    Ok(())
591}
592
593fn validate_static_scalar(
594    field: &FlowFormField,
595    value: &Value,
596    kind: &str,
597) -> Result<(), FlowValidationError> {
598    let valid = match field.value_type {
599        FlowFieldValueType::String => value.is_string(),
600        FlowFieldValueType::Boolean => value.is_boolean(),
601    };
602    if !valid {
603        return Err(FlowValidationError::new(format!(
604            "interaction-flow {kind} has the wrong type: {}",
605            field.var
606        )));
607    }
608    Ok(())
609}
610
611fn option_value(option: &Value) -> Option<&Value> {
612    let Value::Object(object) = option else {
613        return Some(option);
614    };
615    if object.get("label").is_some_and(|label| !label.is_string()) {
616        return None;
617    }
618    object.get("value")
619}
620
621fn validate_handler(
622    handler: &FlowHandler,
623    known: &impl Fn(&str) -> bool,
624) -> Result<(), FlowValidationError> {
625    require_identifier("handler operation", &handler.operation)?;
626    if handler.args.len() > MAX_HANDLER_ARGS {
627        return Err(FlowValidationError::new(
628            "interaction-flow handler argument count is out of bounds",
629        ));
630    }
631    for argument in &handler.args {
632        if let FlowArgument::Ref { value_ref } = argument {
633            validate_ref(value_ref, known)?;
634        }
635    }
636    Ok(())
637}
638
639fn validate_ref(
640    value_ref: &FlowValueRef,
641    known: &impl Fn(&str) -> bool,
642) -> Result<(), FlowValidationError> {
643    require_variable("value reference", &value_ref.root)?;
644    if !known(&value_ref.root)
645        || value_ref.path.len() > MAX_VALUE_REF_PATH
646        || value_ref.path.iter().any(|part| !is_identifier(part))
647    {
648        return Err(FlowValidationError::new(format!(
649            "unknown or invalid interaction-flow value reference: {}",
650            value_ref.root
651        )));
652    }
653    Ok(())
654}
655
656fn validate_resolver_graph(
657    resolvers: &HashMap<String, FlowValueResolver>,
658) -> Result<(), FlowValidationError> {
659    fn visit<'a>(
660        name: &'a str,
661        resolvers: &'a HashMap<String, FlowValueResolver>,
662        visiting: &mut HashSet<&'a str>,
663        visited: &mut HashSet<&'a str>,
664    ) -> Result<(), FlowValidationError> {
665        if visited.contains(name) {
666            return Ok(());
667        }
668        if !visiting.insert(name) {
669            return Err(FlowValidationError::new(
670                "interaction-flow resolver dependency cycle",
671            ));
672        }
673        let resolver = resolvers
674            .get(name)
675            .ok_or_else(|| FlowValidationError::new("interaction-flow resolver is missing"))?;
676        for dependency in resolver
677            .handler
678            .args
679            .iter()
680            .filter_map(|argument| match argument {
681                FlowArgument::Ref { value_ref } if resolvers.contains_key(&value_ref.root) => {
682                    Some(value_ref.root.as_str())
683                }
684                _ => None,
685            })
686        {
687            visit(dependency, resolvers, visiting, visited)?;
688        }
689        visiting.remove(name);
690        visited.insert(name);
691        Ok(())
692    }
693
694    let mut visiting = HashSet::new();
695    let mut visited = HashSet::new();
696    for name in resolvers.keys() {
697        visit(name, resolvers, &mut visiting, &mut visited)?;
698    }
699    Ok(())
700}
701
702fn require_identifier(kind: &str, value: &str) -> Result<(), FlowValidationError> {
703    if value.is_empty()
704        || value.len() > 128
705        || !value.chars().all(|character| {
706            character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '/')
707        })
708    {
709        return Err(FlowValidationError::new(format!(
710            "invalid interaction-flow {kind}: {value}"
711        )));
712    }
713    Ok(())
714}
715
716fn require_variable(kind: &str, value: &str) -> Result<(), FlowValidationError> {
717    let Some(name) = value.strip_prefix('$') else {
718        return Err(FlowValidationError::new(format!(
719            "invalid interaction-flow {kind}: {value}"
720        )));
721    };
722    if name.is_empty()
723        || name.len() > 127
724        || !name
725            .chars()
726            .next()
727            .is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
728        || !name
729            .chars()
730            .all(|character| character.is_ascii_alphanumeric() || character == '_')
731    {
732        return Err(FlowValidationError::new(format!(
733            "invalid interaction-flow {kind}: {value}"
734        )));
735    }
736    Ok(())
737}
738
739fn is_identifier(value: &str) -> bool {
740    !value.is_empty()
741        && value.len() <= 128
742        && value
743            .chars()
744            .next()
745            .is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
746        && value
747            .chars()
748            .all(|character| character.is_ascii_alphanumeric() || character == '_')
749}
750
751fn is_builtin(value: &str) -> bool {
752    matches!(value, "$env" | "$context")
753}
754
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756#[serde(rename_all = "camelCase", deny_unknown_fields)]
757pub struct FlowDefinitionRequest {
758    pub flow_id: String,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub subject_id: Option<String>,
761}
762
763#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
764#[serde(rename_all = "camelCase", deny_unknown_fields)]
765pub struct FlowDefinitionResponse {
766    pub definition_revision: String,
767    pub source_session_id: String,
768    pub definition: InteractionFlowDefinition,
769}
770
771#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
772#[serde(rename_all = "camelCase", deny_unknown_fields)]
773pub struct FlowExecuteRequest {
774    pub flow_id: String,
775    pub source_session_id: String,
776    pub definition_revision: String,
777    pub operation: String,
778    pub operation_id: String,
779    #[serde(default)]
780    pub args: Vec<Value>,
781}
782
783#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
784#[serde(rename_all = "camelCase")]
785pub struct FlowExecuteResponse {
786    pub data: Value,
787}
788
789#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
790#[serde(rename_all = "camelCase")]
791pub enum FlowCloseReason {
792    Completed,
793    Cancelled,
794    Expired,
795    StartFailed,
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(rename_all = "camelCase", deny_unknown_fields)]
800pub struct FlowCloseRequest {
801    pub flow_id: String,
802    pub source_session_id: String,
803    pub operation_id: String,
804    pub reason: FlowCloseReason,
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(rename_all = "camelCase")]
809pub struct FlowCloseResponse {
810    pub closed: bool,
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use serde_json::json;
817
818    fn definition() -> InteractionFlowDefinition {
819        InteractionFlowDefinition {
820            protocol_version: PROTOCOL_VERSION,
821            flow_id: "provider.connect".into(),
822            start_step_id: "credentials".into(),
823            steps: HashMap::from([(
824                "credentials".into(),
825                InteractionFlowStep {
826                    id: "credentials".into(),
827                    content: FlowStepContent::Form {
828                        presentation: FlowPresentation {
829                            renderer: FlowRenderer::TypedForm,
830                            title: Some("Connect".into()),
831                            description: None,
832                            content: None,
833                        },
834                        form: FlowForm {
835                            fields: vec![FlowFormField {
836                                var: "$host".into(),
837                                value_type: FlowFieldValueType::String,
838                                control: FlowFieldControl::Text,
839                                required: true,
840                                repeat: false,
841                                label: Some("Host".into()),
842                                default_value: None,
843                                default_value_ref: None,
844                                options: None,
845                                options_ref: None,
846                                option_unavailable_text: None,
847                                read_only: false,
848                                min_rows: None,
849                            }],
850                        },
851                        render_refs: Vec::new(),
852                    },
853                    transition: FlowTransition::Finish,
854                },
855            )]),
856            resolvers: HashMap::new(),
857            lifecycle: FlowLifecycleHandlers {
858                complete: Some(FlowHandler {
859                    operation: "provider.save".into(),
860                    args: vec![FlowArgument::Ref {
861                        value_ref: FlowValueRef {
862                            root: "$host".into(),
863                            path: Vec::new(),
864                        },
865                    }],
866                }),
867                ..FlowLifecycleHandlers::default()
868            },
869        }
870    }
871
872    #[test]
873    fn canonical_definition_round_trips_and_validates() {
874        let definition = definition();
875        definition.validate().unwrap();
876        let value = serde_json::to_value(&definition).unwrap();
877        assert_eq!(value["lifecycle"]["complete"]["operation"], "provider.save");
878        assert!(value.to_string().contains("\"type\":\"ref\""));
879        assert_eq!(
880            serde_json::from_value::<InteractionFlowDefinition>(value).unwrap(),
881            definition
882        );
883    }
884
885    #[test]
886    fn dependencies_are_inferred_from_typed_arguments_and_cycles_are_rejected() {
887        let mut definition = definition();
888        definition.resolvers.insert(
889            "$a".into(),
890            FlowValueResolver {
891                output_var: "$a".into(),
892                handler: FlowHandler {
893                    operation: "resolve.a".into(),
894                    args: vec![FlowArgument::Ref {
895                        value_ref: FlowValueRef {
896                            root: "$b".into(),
897                            path: Vec::new(),
898                        },
899                    }],
900                },
901            },
902        );
903        definition.resolvers.insert(
904            "$b".into(),
905            FlowValueResolver {
906                output_var: "$b".into(),
907                handler: FlowHandler {
908                    operation: "resolve.b".into(),
909                    args: vec![FlowArgument::Ref {
910                        value_ref: FlowValueRef {
911                            root: "$a".into(),
912                            path: Vec::new(),
913                        },
914                    }],
915                },
916            },
917        );
918        assert!(definition
919            .validate()
920            .unwrap_err()
921            .to_string()
922            .contains("cycle"));
923    }
924
925    #[test]
926    fn node_routes_and_requests_do_not_accept_routing_identity() {
927        assert_eq!(
928            definition_target("camera"),
929            "/camera/interaction-flow/definition"
930        );
931        assert_eq!(execute_target("camera"), "/camera/interaction-flow/execute");
932        assert_eq!(close_target("camera"), "/camera/interaction-flow/close");
933        let request = serde_json::from_value::<FlowExecuteRequest>(json!({
934            "flowId": "provider.connect",
935            "sourceSessionId": "source-1",
936            "definitionRevision": "sha256:abc",
937            "operation": "provider.discover",
938            "operationId": "operation-1",
939            "args": [],
940            "nodeId": "untrusted"
941        }));
942        assert!(request.is_err());
943    }
944
945    #[test]
946    fn node_renderer_is_typed_form_only_on_the_wire() {
947        let mut definition = definition();
948        let step = definition.steps.get_mut("credentials").unwrap();
949        let FlowStepContent::Form { presentation, .. } = &mut step.content else {
950            unreachable!();
951        };
952        presentation.renderer = FlowRenderer::CustomConnectMdx;
953        assert!(serde_json::to_value(definition).is_err());
954    }
955
956    #[test]
957    fn input_shape_is_stable_across_steps_and_passwords_are_user_owned() {
958        let mut invalid_definition = definition();
959        invalid_definition.steps.insert(
960            "second".into(),
961            InteractionFlowStep {
962                id: "second".into(),
963                content: FlowStepContent::Form {
964                    presentation: FlowPresentation {
965                        renderer: FlowRenderer::TypedForm,
966                        title: None,
967                        description: None,
968                        content: None,
969                    },
970                    form: FlowForm {
971                        fields: vec![FlowFormField {
972                            var: "$host".into(),
973                            value_type: FlowFieldValueType::Boolean,
974                            control: FlowFieldControl::Boolean,
975                            required: true,
976                            repeat: false,
977                            label: None,
978                            default_value: None,
979                            default_value_ref: None,
980                            options: None,
981                            options_ref: None,
982                            option_unavailable_text: None,
983                            read_only: false,
984                            min_rows: None,
985                        }],
986                    },
987                    render_refs: Vec::new(),
988                },
989                transition: FlowTransition::Finish,
990            },
991        );
992        assert!(invalid_definition.validate().is_err());
993
994        let mut definition = definition();
995        let step = definition.steps.get_mut("credentials").unwrap();
996        let FlowStepContent::Form { form, .. } = &mut step.content else {
997            unreachable!();
998        };
999        let field = form.fields.first_mut().unwrap();
1000        field.control = FlowFieldControl::Password;
1001        field.default_value = Some(json!("not-allowed"));
1002        assert!(definition.validate().is_err());
1003    }
1004
1005    #[test]
1006    fn conformance_fixture_matches_the_published_node_schema() {
1007        let schema: Value = serde_json::from_str(NODE_PROTOCOL_SCHEMA).unwrap();
1008        let fixture: Value = serde_json::from_str(include_str!(
1009            "../fixtures/interaction-flow-node.conformance.json"
1010        ))
1011        .unwrap();
1012        let validator = jsonschema::validator_for(&schema).unwrap();
1013        let errors = validator
1014            .iter_errors(&fixture)
1015            .map(|error| error.to_string())
1016            .collect::<Vec<_>>();
1017        assert!(errors.is_empty(), "fixture failed: {errors:?}");
1018    }
1019}