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            // ExternalEvent, SystemGenerated, Projected CAN be Derived
68            Input::ExternalEvent(_) | Input::SystemGenerated(_) | Input::Projected(_) => {}
69        }
70    }
71
72    Ok(())
73}
74
75#[cfg(test)]
76#[allow(clippy::unwrap_used)]
77mod tests {
78    use super::*;
79    use crate::input::*;
80    use chrono::Utc;
81    use meerkat_core::lifecycle::InputId;
82
83    fn make_header(durability: InputDurability, source: InputOrigin) -> InputHeader {
84        InputHeader {
85            id: InputId::new(),
86            timestamp: Utc::now(),
87            source,
88            durability,
89            visibility: InputVisibility::default(),
90            idempotency_key: None,
91            supersession_key: None,
92            correlation_id: None,
93        }
94    }
95
96    #[test]
97    fn prompt_derived_rejected() {
98        let input = Input::Prompt(PromptInput {
99            header: make_header(InputDurability::Derived, InputOrigin::System),
100            text: "hi".into(),
101            blocks: None,
102            turn_metadata: None,
103        });
104        assert!(validate_durability(&input).is_err());
105    }
106
107    #[test]
108    fn prompt_durable_accepted() {
109        let input = Input::Prompt(PromptInput {
110            header: make_header(InputDurability::Durable, InputOrigin::Operator),
111            text: "hi".into(),
112            blocks: None,
113            turn_metadata: None,
114        });
115        assert!(validate_durability(&input).is_ok());
116    }
117
118    #[test]
119    fn prompt_ephemeral_accepted() {
120        let input = Input::Prompt(PromptInput {
121            header: make_header(InputDurability::Ephemeral, InputOrigin::Operator),
122            text: "hi".into(),
123            blocks: None,
124            turn_metadata: None,
125        });
126        assert!(validate_durability(&input).is_ok());
127    }
128
129    #[test]
130    fn peer_message_derived_rejected() {
131        let input = Input::Peer(PeerInput {
132            header: make_header(InputDurability::Derived, InputOrigin::System),
133            convention: Some(PeerConvention::Message),
134            body: "hi".into(),
135            blocks: None,
136        });
137        assert!(validate_durability(&input).is_err());
138    }
139
140    #[test]
141    fn peer_request_derived_rejected() {
142        let input = Input::Peer(PeerInput {
143            header: make_header(InputDurability::Derived, InputOrigin::System),
144            convention: Some(PeerConvention::Request {
145                request_id: "r".into(),
146                intent: "i".into(),
147            }),
148            body: "hi".into(),
149            blocks: None,
150        });
151        assert!(validate_durability(&input).is_err());
152    }
153
154    #[test]
155    fn peer_response_terminal_derived_rejected() {
156        let input = Input::Peer(PeerInput {
157            header: make_header(InputDurability::Derived, InputOrigin::System),
158            convention: Some(PeerConvention::ResponseTerminal {
159                request_id: "r".into(),
160                status: ResponseTerminalStatus::Completed,
161            }),
162            body: "done".into(),
163            blocks: None,
164        });
165        assert!(validate_durability(&input).is_err());
166    }
167
168    #[test]
169    fn peer_response_progress_derived_accepted() {
170        let input = Input::Peer(PeerInput {
171            header: make_header(InputDurability::Derived, InputOrigin::System),
172            convention: Some(PeerConvention::ResponseProgress {
173                request_id: "r".into(),
174                phase: ResponseProgressPhase::InProgress,
175            }),
176            body: "working".into(),
177            blocks: None,
178        });
179        assert!(validate_durability(&input).is_ok());
180    }
181
182    #[test]
183    fn flow_step_derived_rejected() {
184        let input = Input::FlowStep(FlowStepInput {
185            header: make_header(InputDurability::Derived, InputOrigin::System),
186            step_id: "s1".into(),
187            instructions: "do it".into(),
188            turn_metadata: None,
189        });
190        assert!(validate_durability(&input).is_err());
191    }
192
193    #[test]
194    fn external_event_derived_from_system_accepted() {
195        let input = Input::ExternalEvent(ExternalEventInput {
196            header: make_header(InputDurability::Derived, InputOrigin::System),
197            event_type: "test".into(),
198            payload: serde_json::json!({}),
199        });
200        assert!(validate_durability(&input).is_ok());
201    }
202
203    #[test]
204    fn external_ingress_derived_rejected() {
205        let input = Input::ExternalEvent(ExternalEventInput {
206            header: make_header(
207                InputDurability::Derived,
208                InputOrigin::External {
209                    source_name: "webhook".into(),
210                },
211            ),
212            event_type: "test".into(),
213            payload: serde_json::json!({}),
214        });
215        assert!(validate_durability(&input).is_err());
216    }
217
218    #[test]
219    fn operator_derived_rejected() {
220        let input = Input::SystemGenerated(SystemGeneratedInput {
221            header: make_header(InputDurability::Derived, InputOrigin::Operator),
222            generator: "test".into(),
223            content: "content".into(),
224        });
225        assert!(validate_durability(&input).is_err());
226    }
227
228    #[test]
229    fn projected_derived_from_system_accepted() {
230        let input = Input::Projected(ProjectedInput {
231            header: make_header(InputDurability::Derived, InputOrigin::System),
232            rule_id: "rule-1".into(),
233            source_event_id: "evt-1".into(),
234            content: "projected".into(),
235        });
236        assert!(validate_durability(&input).is_ok());
237    }
238}