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::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}