Skip to main content

meerkat_runtime/
peer_handling_mode.rs

1//! Peer handling-mode validation — reject handling_mode on response progress conventions.
2//!
3//! Rules:
4//! - handling_mode is FORBIDDEN for: PeerInput(ResponseProgress)
5//! - handling_mode is ALLOWED for: PeerInput(Message), PeerInput(Request), PeerInput(ResponseTerminal), PeerInput(no convention)
6//! - Steer handling_mode is FORBIDDEN on peer inputs carrying injected
7//!   context: steer realization stages live system-context appends only, so
8//!   the injected-context transcript appends would be silently dropped
9//! - Non-peer inputs are always accepted (validation is a no-op)
10
11use crate::input::{Input, PeerConvention};
12use meerkat_core::types::HandlingMode;
13
14/// Errors from peer handling-mode validation.
15#[derive(Debug, Clone, thiserror::Error)]
16#[non_exhaustive]
17pub enum PeerHandlingModeError {
18    /// handling_mode is not allowed on ResponseProgress peer inputs.
19    #[error("handling_mode is forbidden on ResponseProgress peer inputs")]
20    ForbiddenForResponseProgress,
21    /// Steer deliveries realize as live system-context appends, which carry
22    /// no transcript boundary for injected context to precede. Fail closed
23    /// rather than silently dropping host-attached context.
24    #[error("steer handling_mode cannot carry injected context on peer inputs")]
25    SteerCannotCarryInjectedContext,
26}
27
28/// Validate that a peer input does not carry handling_mode on response progress.
29pub fn validate_peer_handling_mode(input: &Input) -> Result<(), PeerHandlingModeError> {
30    let Input::Peer(peer) = input else {
31        return Ok(());
32    };
33    if peer.handling_mode == Some(HandlingMode::Steer) && !peer.injected_context.is_empty() {
34        return Err(PeerHandlingModeError::SteerCannotCarryInjectedContext);
35    }
36    if peer.handling_mode.is_none() {
37        return Ok(());
38    }
39    match &peer.convention {
40        Some(PeerConvention::ResponseProgress { .. }) => {
41            Err(PeerHandlingModeError::ForbiddenForResponseProgress)
42        }
43        _ => Ok(()),
44    }
45}
46
47#[cfg(test)]
48#[allow(clippy::unwrap_used)]
49mod tests {
50    use super::*;
51    use crate::input::*;
52    use chrono::Utc;
53    use meerkat_core::lifecycle::InputId;
54    use meerkat_core::types::HandlingMode;
55
56    fn make_header() -> InputHeader {
57        InputHeader {
58            id: InputId::new(),
59            timestamp: Utc::now(),
60            source: InputOrigin::Peer {
61                peer_id: "peer-1".into(),
62                display_identity: None,
63                runtime_id: None,
64            },
65            durability: InputDurability::Durable,
66            visibility: InputVisibility::default(),
67            idempotency_key: None,
68            supersession_key: None,
69            correlation_id: None,
70        }
71    }
72
73    #[test]
74    fn response_progress_with_handling_mode_rejected() {
75        let input = Input::Peer(PeerInput {
76            injected_context: Vec::new(),
77            sender_taint: None,
78            header: make_header(),
79            convention: Some(PeerConvention::ResponseProgress {
80                request_id: "r".into(),
81                phase: ResponseProgressPhase::InProgress,
82            }),
83            content: "working".into(),
84            payload: Some(serde_json::json!({"progress": "working"})),
85            handling_mode: Some(HandlingMode::Queue),
86        });
87        let err = validate_peer_handling_mode(&input).unwrap_err();
88        assert!(matches!(
89            err,
90            PeerHandlingModeError::ForbiddenForResponseProgress
91        ));
92    }
93
94    #[test]
95    fn response_terminal_with_handling_mode_accepted() {
96        let input = Input::Peer(PeerInput {
97            injected_context: Vec::new(),
98            sender_taint: None,
99            header: make_header(),
100            convention: Some(PeerConvention::ResponseTerminal {
101                request_id: "r".into(),
102                status: ResponseTerminalStatus::Completed,
103            }),
104            content: "done".into(),
105            payload: Some(serde_json::json!({"ok": true})),
106            handling_mode: Some(HandlingMode::Steer),
107        });
108        assert!(validate_peer_handling_mode(&input).is_ok());
109    }
110
111    #[test]
112    fn response_terminal_with_queue_handling_mode_accepted() {
113        let input = Input::Peer(PeerInput {
114            injected_context: Vec::new(),
115            sender_taint: None,
116            header: make_header(),
117            convention: Some(PeerConvention::ResponseTerminal {
118                request_id: "r".into(),
119                status: ResponseTerminalStatus::Completed,
120            }),
121            content: "done".into(),
122            payload: Some(serde_json::json!({"ok": true})),
123            handling_mode: Some(HandlingMode::Queue),
124        });
125        assert!(validate_peer_handling_mode(&input).is_ok());
126    }
127
128    #[test]
129    fn message_with_handling_mode_accepted() {
130        let input = Input::Peer(PeerInput {
131            injected_context: Vec::new(),
132            sender_taint: None,
133            header: make_header(),
134            convention: Some(PeerConvention::Message),
135            content: "hi".into(),
136            payload: None,
137            handling_mode: Some(HandlingMode::Queue),
138        });
139        assert!(validate_peer_handling_mode(&input).is_ok());
140    }
141
142    #[test]
143    fn request_with_handling_mode_accepted() {
144        let input = Input::Peer(PeerInput {
145            injected_context: Vec::new(),
146            sender_taint: None,
147            header: make_header(),
148            convention: Some(PeerConvention::Request {
149                request_id: "r".into(),
150                intent: "i".into(),
151            }),
152            content: "do it".into(),
153            payload: Some(serde_json::json!({"subject": "x"})),
154            handling_mode: Some(HandlingMode::Steer),
155        });
156        assert!(validate_peer_handling_mode(&input).is_ok());
157    }
158
159    #[test]
160    fn no_convention_with_handling_mode_accepted() {
161        let input = Input::Peer(PeerInput {
162            injected_context: Vec::new(),
163            sender_taint: None,
164            header: make_header(),
165            convention: None,
166            content: "hi".into(),
167            payload: None,
168            handling_mode: Some(HandlingMode::Queue),
169        });
170        assert!(validate_peer_handling_mode(&input).is_ok());
171    }
172
173    /// Steer realization stages live system-context appends only — injected
174    /// context would silently vanish. The accept boundary fails closed.
175    #[test]
176    fn steer_with_injected_context_rejected() {
177        let input = Input::Peer(PeerInput {
178            injected_context: vec![meerkat_core::types::ContentInput::Text(
179                "ambient".to_string(),
180            )],
181            sender_taint: None,
182            header: make_header(),
183            convention: Some(PeerConvention::Message),
184            content: "urgent".into(),
185            payload: None,
186            handling_mode: Some(HandlingMode::Steer),
187        });
188        let err = validate_peer_handling_mode(&input).unwrap_err();
189        assert!(matches!(
190            err,
191            PeerHandlingModeError::SteerCannotCarryInjectedContext
192        ));
193    }
194
195    /// Queue-mode deliveries stage at a run boundary, which carries the
196    /// transcript appends — injected context is deliverable there.
197    #[test]
198    fn queue_with_injected_context_accepted() {
199        let input = Input::Peer(PeerInput {
200            injected_context: vec![meerkat_core::types::ContentInput::Text(
201                "ambient".to_string(),
202            )],
203            sender_taint: None,
204            header: make_header(),
205            convention: Some(PeerConvention::Message),
206            content: "work".into(),
207            payload: None,
208            handling_mode: Some(HandlingMode::Queue),
209        });
210        assert!(validate_peer_handling_mode(&input).is_ok());
211    }
212
213    #[test]
214    fn peer_without_handling_mode_always_accepted() {
215        for convention in [
216            Some(PeerConvention::Message),
217            Some(PeerConvention::Request {
218                request_id: "r".into(),
219                intent: "i".into(),
220            }),
221            Some(PeerConvention::ResponseProgress {
222                request_id: "r".into(),
223                phase: ResponseProgressPhase::InProgress,
224            }),
225            Some(PeerConvention::ResponseTerminal {
226                request_id: "r".into(),
227                status: ResponseTerminalStatus::Completed,
228            }),
229            None,
230        ] {
231            let input = Input::Peer(PeerInput {
232                injected_context: Vec::new(),
233                sender_taint: None,
234                header: make_header(),
235                convention,
236                content: "hi".into(),
237                payload: None,
238                handling_mode: None,
239            });
240            assert!(
241                validate_peer_handling_mode(&input).is_ok(),
242                "should accept peer without handling_mode"
243            );
244        }
245    }
246
247    #[test]
248    fn non_peer_input_always_accepted() {
249        let input = Input::Prompt(PromptInput {
250            injected_context: Vec::new(),
251            header: InputHeader {
252                id: InputId::new(),
253                timestamp: Utc::now(),
254                source: InputOrigin::Operator,
255                durability: InputDurability::Durable,
256                visibility: InputVisibility::default(),
257                idempotency_key: None,
258                supersession_key: None,
259                correlation_id: None,
260            },
261            content: "hi".into(),
262            typed_turn_appends: Vec::new(),
263            turn_metadata: None,
264        });
265        assert!(validate_peer_handling_mode(&input).is_ok());
266    }
267}