1use crate::mode::{Mode, ModeResponse};
20use macp_core::error::MacpError;
21use macp_core::session::{Session, SessionState};
22use macp_pb::pb::Envelope;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Precheck {
27 Duplicate,
29 Expired,
31 Proceed,
33}
34
35pub fn check_preconditions(
44 session: &Session,
45 env: &Envelope,
46 now_ms: i64,
47) -> Result<Precheck, MacpError> {
48 if session.seen_message_ids.contains(&env.message_id) {
49 return Ok(Precheck::Duplicate);
50 }
51 if env.mode != session.mode {
52 return Err(MacpError::InvalidEnvelope);
53 }
54 if session.state == SessionState::Open && now_ms > session.ttl_expiry {
55 return Ok(Precheck::Expired);
56 }
57 if session.state != SessionState::Open {
58 return Err(MacpError::SessionNotOpen);
59 }
60 Ok(Precheck::Proceed)
61}
62
63pub fn validate_message(
67 session: &Session,
68 env: &Envelope,
69 mode: &dyn Mode,
70) -> Result<ModeResponse, MacpError> {
71 mode.authorize_sender(session, env)?;
72 mode.on_message(session, env)
73}
74
75pub fn commit(
84 session: &mut Session,
85 env: &Envelope,
86 response: ModeResponse,
87 now_ms: i64,
88) -> SessionState {
89 session.seen_message_ids.insert(env.message_id.clone());
90 session.record_participant_activity(&env.sender, now_ms);
91 session.apply_mode_response(response);
92 session.state.clone()
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub enum StepOutcome {
98 Duplicate,
100 Accepted { state: SessionState },
102}
103
104pub fn step(
114 session: &mut Session,
115 env: &Envelope,
116 mode: &dyn Mode,
117 now_ms: i64,
118) -> Result<StepOutcome, MacpError> {
119 match check_preconditions(session, env, now_ms)? {
120 Precheck::Duplicate => Ok(StepOutcome::Duplicate),
121 Precheck::Expired => {
122 session.state = SessionState::Expired;
123 Err(MacpError::TtlExpired)
124 }
125 Precheck::Proceed => {
126 let response = validate_message(session, env, mode)?;
127 let state = commit(session, env, response, now_ms);
128 Ok(StepOutcome::Accepted { state })
129 }
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use std::collections::{HashMap, HashSet};
137
138 const MODE: &str = "macp.mode.test.v1";
139
140 struct TestMode;
144 impl Mode for TestMode {
145 fn on_session_start(&self, _s: &Session, _e: &Envelope) -> Result<ModeResponse, MacpError> {
146 Ok(ModeResponse::PersistState(vec![]))
147 }
148 fn on_message(&self, _s: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
149 if env.message_type == "Commitment" {
150 Ok(ModeResponse::PersistAndResolve {
151 state: vec![1],
152 resolution: vec![2],
153 })
154 } else {
155 Ok(ModeResponse::PersistState(vec![1]))
156 }
157 }
158 }
160
161 fn session() -> Session {
162 Session {
163 session_id: "11111111-1111-4111-8111-111111111111".into(),
164 state: SessionState::Open,
165 ttl_expiry: 10_000,
166 ttl_ms: 10_000,
167 started_at_unix_ms: 0,
168 resolution: None,
169 mode: MODE.into(),
170 mode_state: vec![],
171 participants: vec!["agent://a".into(), "agent://b".into()],
172 seen_message_ids: HashSet::new(),
173 intent: String::new(),
174 mode_version: "1.0.0".into(),
175 configuration_version: "cfg-1".into(),
176 policy_version: String::new(),
177 context_id: String::new(),
178 extensions: HashMap::new(),
179 roots: vec![],
180 initiator_sender: "agent://a".into(),
181 participant_message_counts: HashMap::new(),
182 participant_last_seen: HashMap::new(),
183 policy_definition: None,
184 suspended_at_ms: None,
185 accumulated_suspended_ms: 0,
186 }
187 }
188
189 fn env(sender: &str, message_type: &str, message_id: &str) -> Envelope {
190 Envelope {
191 macp_version: "1.0".into(),
192 mode: MODE.into(),
193 message_type: message_type.into(),
194 message_id: message_id.into(),
195 session_id: "11111111-1111-4111-8111-111111111111".into(),
196 sender: sender.into(),
197 timestamp_unix_ms: 0,
198 payload: vec![],
199 }
200 }
201
202 #[test]
203 fn duplicate_is_reported_and_changes_nothing() {
204 let mut s = session();
205 s.seen_message_ids.insert("m1".into());
206 let before = s.seen_message_ids.len();
207 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
208 assert_eq!(out, StepOutcome::Duplicate);
209 assert_eq!(s.seen_message_ids.len(), before);
210 assert_eq!(s.state, SessionState::Open);
211 }
212
213 #[test]
214 fn mode_binding_mismatch_rejected() {
215 let mut s = session();
216 let mut e = env("agent://a", "Msg", "m1");
217 e.mode = "macp.mode.other.v1".into();
218 assert!(matches!(
219 step(&mut s, &e, &TestMode, 1).unwrap_err(),
220 MacpError::InvalidEnvelope
221 ));
222 assert!(s.seen_message_ids.is_empty());
223 }
224
225 #[test]
226 fn ttl_strict_boundary_does_not_expire_but_past_does() {
227 let mut s = session();
229 let deadline = s.ttl_expiry;
230 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, deadline).unwrap();
231 assert_eq!(
232 out,
233 StepOutcome::Accepted {
234 state: SessionState::Open
235 }
236 );
237
238 let mut s2 = session();
240 let past = s2.ttl_expiry + 1;
241 let err = step(&mut s2, &env("agent://a", "Msg", "m2"), &TestMode, past).unwrap_err();
242 assert!(matches!(err, MacpError::TtlExpired));
243 assert_eq!(s2.state, SessionState::Expired);
244 assert!(s2.seen_message_ids.is_empty());
245 }
246
247 #[test]
248 fn ttl_does_not_re_expire_a_resolved_session() {
249 let mut s = session();
252 s.state = SessionState::Resolved;
253 let past = s.ttl_expiry + 5_000;
254 let err = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, past).unwrap_err();
255 assert!(matches!(err, MacpError::SessionNotOpen));
256 assert_eq!(s.state, SessionState::Resolved);
257 }
258
259 #[test]
260 fn non_open_session_rejected() {
261 for st in [SessionState::Resolved, SessionState::Expired] {
262 let mut s = session();
263 s.state = st.clone();
264 assert!(matches!(
265 step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap_err(),
266 MacpError::SessionNotOpen
267 ));
268 }
269 }
270
271 #[test]
272 fn accepted_consumes_dedup_records_activity_and_applies_state() {
273 let mut s = session();
274 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 42).unwrap();
275 assert_eq!(
276 out,
277 StepOutcome::Accepted {
278 state: SessionState::Open
279 }
280 );
281 assert!(s.seen_message_ids.contains("m1"));
282 assert_eq!(s.mode_state, vec![1]);
283 assert_eq!(s.participant_last_seen.get("agent://a"), Some(&42));
284 }
285
286 #[test]
287 fn commitment_resolves() {
288 let mut s = session();
289 let out = step(&mut s, &env("agent://a", "Commitment", "c1"), &TestMode, 1).unwrap();
290 assert_eq!(
291 out,
292 StepOutcome::Accepted {
293 state: SessionState::Resolved
294 }
295 );
296 assert_eq!(s.state, SessionState::Resolved);
297 assert_eq!(s.resolution, Some(vec![2]));
298 }
299
300 #[test]
301 fn rejected_validation_does_not_consume_dedup_slot() {
302 let mut s = session();
306 let err = step(&mut s, &env("agent://stranger", "Msg", "m1"), &TestMode, 1).unwrap_err();
307 assert!(matches!(err, MacpError::Forbidden));
308 assert!(!s.seen_message_ids.contains("m1"));
309 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
311 assert_eq!(
312 out,
313 StepOutcome::Accepted {
314 state: SessionState::Open
315 }
316 );
317 assert!(s.seen_message_ids.contains("m1"));
318 }
319
320 #[test]
321 fn clock_is_injected_not_wall_clock() {
322 let mut s = session();
325 s.ttl_expiry = i64::MAX;
326 assert!(matches!(
327 check_preconditions(&s, &env("agent://a", "Msg", "m1"), i64::MAX - 1),
328 Ok(Precheck::Proceed)
329 ));
330 let mut s2 = session();
331 s2.ttl_expiry = 0;
332 assert!(matches!(
333 check_preconditions(&s2, &env("agent://a", "Msg", "m1"), 1),
334 Ok(Precheck::Expired)
335 ));
336 }
337
338 #[test]
339 fn phases_compose_like_step_for_durable_consumers() {
340 let mut s = session();
342 let e = env("agent://b", "Msg", "m1");
343 assert_eq!(check_preconditions(&s, &e, 5).unwrap(), Precheck::Proceed);
344 let resp = validate_message(&s, &e, &TestMode).unwrap();
345 assert!(s.seen_message_ids.is_empty());
347 let state = commit(&mut s, &e, resp, 5);
348 assert_eq!(state, SessionState::Open);
349 assert!(s.seen_message_ids.contains("m1"));
350 }
351}