Skip to main content

meerkat_runtime/
durability.rs

1//! §10 Durability validation — enforce durability rules on inputs.
2//!
3//! Rules:
4//! - Derived is FORBIDDEN for: PromptInput, PeerInput(Message/Request/ResponseTerminal), FlowStepInput
5//! - External ingress cannot submit Derived durability
6
7use crate::input::{Input, InputDurability, PeerConvention};
8
9/// Errors from durability validation.
10#[derive(Debug, Clone, thiserror::Error)]
11#[non_exhaustive]
12pub enum DurabilityError {
13    /// Derived durability is not allowed for this input type.
14    #[error("Derived durability forbidden for {kind}")]
15    DerivedForbidden { kind: String },
16
17    /// External source cannot submit derived inputs.
18    #[error("External ingress cannot submit derived inputs")]
19    ExternalDerivedForbidden,
20}
21
22/// Validate the durability of an input.
23pub fn validate_durability(input: &Input) -> Result<(), DurabilityError> {
24    let durability = input.header().durability;
25
26    // Check external ingress cannot submit Derived
27    if durability == InputDurability::Derived {
28        match &input.header().source {
29            crate::input::InputOrigin::Operator
30            | crate::input::InputOrigin::Peer { .. }
31            | crate::input::InputOrigin::External { .. } => {
32                return Err(DurabilityError::ExternalDerivedForbidden);
33            }
34            // System and Flow sources CAN submit Derived
35            crate::input::InputOrigin::System | crate::input::InputOrigin::Flow { .. } => {}
36        }
37    }
38
39    // Check Derived forbidden for specific input types
40    if durability == InputDurability::Derived {
41        match input {
42            Input::Prompt(_) => {
43                return Err(DurabilityError::DerivedForbidden {
44                    kind: "prompt".into(),
45                });
46            }
47            Input::Peer(p) => {
48                match &p.convention {
49                    Some(
50                        PeerConvention::Message
51                        | PeerConvention::Request { .. }
52                        | PeerConvention::ResponseTerminal { .. },
53                    ) => {
54                        return Err(DurabilityError::DerivedForbidden {
55                            kind: format!("peer_{}", input.kind_id().0),
56                        });
57                    }
58                    // ResponseProgress CAN be Derived
59                    Some(PeerConvention::ResponseProgress { .. }) | None => {}
60                }
61            }
62            Input::FlowStep(_) => {
63                return Err(DurabilityError::DerivedForbidden {
64                    kind: "flow_step".into(),
65                });
66            }
67            // External events, explicit continuations, and explicit operation
68            // lifecycle inputs may be reconstructed or derived.
69            Input::ExternalEvent(_) | Input::Continuation(_) | Input::Operation(_) => {}
70        }
71    }
72
73    Ok(())
74}
75
76#[cfg(test)]
77#[allow(clippy::unwrap_used)]
78mod tests {
79    use super::*;
80    use crate::input::*;
81    use chrono::Utc;
82    use meerkat_core::lifecycle::InputId;
83    use meerkat_core::types::HandlingMode;
84
85    fn make_header(durability: InputDurability, source: InputOrigin) -> InputHeader {
86        InputHeader {
87            id: InputId::new(),
88            timestamp: Utc::now(),
89            source,
90            durability,
91            visibility: InputVisibility::default(),
92            idempotency_key: None,
93            supersession_key: None,
94            correlation_id: None,
95        }
96    }
97
98    #[test]
99    fn prompt_derived_rejected() {
100        let input = Input::Prompt(PromptInput {
101            header: make_header(InputDurability::Derived, InputOrigin::System),
102            text: "hi".into(),
103            blocks: None,
104            turn_metadata: None,
105        });
106        assert!(validate_durability(&input).is_err());
107    }
108
109    #[test]
110    fn prompt_durable_accepted() {
111        let input = Input::Prompt(PromptInput {
112            header: make_header(InputDurability::Durable, InputOrigin::Operator),
113            text: "hi".into(),
114            blocks: None,
115            turn_metadata: None,
116        });
117        assert!(validate_durability(&input).is_ok());
118    }
119
120    #[test]
121    fn prompt_ephemeral_accepted() {
122        let input = Input::Prompt(PromptInput {
123            header: make_header(InputDurability::Ephemeral, InputOrigin::Operator),
124            text: "hi".into(),
125            blocks: None,
126            turn_metadata: None,
127        });
128        assert!(validate_durability(&input).is_ok());
129    }
130
131    #[test]
132    fn peer_message_derived_rejected() {
133        let input = Input::Peer(PeerInput {
134            header: make_header(InputDurability::Derived, InputOrigin::System),
135            convention: Some(PeerConvention::Message),
136            body: "hi".into(),
137            blocks: None,
138        });
139        assert!(validate_durability(&input).is_err());
140    }
141
142    #[test]
143    fn peer_request_derived_rejected() {
144        let input = Input::Peer(PeerInput {
145            header: make_header(InputDurability::Derived, InputOrigin::System),
146            convention: Some(PeerConvention::Request {
147                request_id: "r".into(),
148                intent: "i".into(),
149            }),
150            body: "hi".into(),
151            blocks: None,
152        });
153        assert!(validate_durability(&input).is_err());
154    }
155
156    #[test]
157    fn peer_response_terminal_derived_rejected() {
158        let input = Input::Peer(PeerInput {
159            header: make_header(InputDurability::Derived, InputOrigin::System),
160            convention: Some(PeerConvention::ResponseTerminal {
161                request_id: "r".into(),
162                status: ResponseTerminalStatus::Completed,
163            }),
164            body: "done".into(),
165            blocks: None,
166        });
167        assert!(validate_durability(&input).is_err());
168    }
169
170    #[test]
171    fn peer_response_progress_derived_accepted() {
172        let input = Input::Peer(PeerInput {
173            header: make_header(InputDurability::Derived, InputOrigin::System),
174            convention: Some(PeerConvention::ResponseProgress {
175                request_id: "r".into(),
176                phase: ResponseProgressPhase::InProgress,
177            }),
178            body: "working".into(),
179            blocks: None,
180        });
181        assert!(validate_durability(&input).is_ok());
182    }
183
184    #[test]
185    fn flow_step_derived_rejected() {
186        let input = Input::FlowStep(FlowStepInput {
187            header: make_header(InputDurability::Derived, InputOrigin::System),
188            step_id: "s1".into(),
189            instructions: "do it".into(),
190            blocks: None,
191            turn_metadata: None,
192        });
193        assert!(validate_durability(&input).is_err());
194    }
195
196    #[test]
197    fn external_event_derived_from_system_accepted() {
198        let input = Input::ExternalEvent(ExternalEventInput {
199            header: make_header(InputDurability::Derived, InputOrigin::System),
200            event_type: "test".into(),
201            payload: serde_json::json!({}),
202            blocks: None,
203            handling_mode: HandlingMode::Queue,
204            render_metadata: None,
205        });
206        assert!(validate_durability(&input).is_ok());
207    }
208
209    #[test]
210    fn external_ingress_derived_rejected() {
211        let input = Input::ExternalEvent(ExternalEventInput {
212            header: make_header(
213                InputDurability::Derived,
214                InputOrigin::External {
215                    source_name: "webhook".into(),
216                },
217            ),
218            event_type: "test".into(),
219            payload: serde_json::json!({}),
220            blocks: None,
221            handling_mode: HandlingMode::Queue,
222            render_metadata: None,
223        });
224        assert!(validate_durability(&input).is_err());
225    }
226
227    #[test]
228    fn operator_derived_rejected() {
229        let input = Input::Continuation(ContinuationInput {
230            header: make_header(InputDurability::Derived, InputOrigin::Operator),
231            reason: "test".into(),
232            handling_mode: meerkat_core::types::HandlingMode::Steer,
233            request_id: None,
234        });
235        assert!(validate_durability(&input).is_err());
236    }
237
238    #[test]
239    fn operation_derived_from_system_accepted() {
240        let input = Input::Operation(OperationInput {
241            header: make_header(InputDurability::Derived, InputOrigin::System),
242            operation_id: meerkat_core::ops::OperationId::new(),
243            event: meerkat_core::ops::OpEvent::Cancelled {
244                id: meerkat_core::ops::OperationId::new(),
245            },
246        });
247        assert!(validate_durability(&input).is_ok());
248    }
249}