1use crate::input::{Input, InputDurability, PeerConvention};
8
9#[derive(Debug, Clone, thiserror::Error)]
11#[non_exhaustive]
12pub enum DurabilityError {
13 #[error("Derived durability forbidden for {kind}")]
15 DerivedForbidden { kind: String },
16
17 #[error("External ingress cannot submit derived inputs")]
19 ExternalDerivedForbidden,
20}
21
22pub fn validate_durability(input: &Input) -> Result<(), DurabilityError> {
24 let durability = input.header().durability;
25
26 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 crate::input::InputOrigin::System | crate::input::InputOrigin::Flow { .. } => {}
36 }
37 }
38
39 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 Some(PeerConvention::ResponseProgress { .. }) | None => {}
60 }
61 }
62 Input::FlowStep(_) => {
63 return Err(DurabilityError::DerivedForbidden {
64 kind: "flow_step".into(),
65 });
66 }
67 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}