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
137 const MODE: &str = "macp.mode.test.v1";
138
139 struct TestMode;
143 impl Mode for TestMode {
144 fn on_session_start(&self, _s: &Session, _e: &Envelope) -> Result<ModeResponse, MacpError> {
145 Ok(ModeResponse::PersistState(vec![]))
146 }
147 fn on_message(&self, _s: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
148 if env.message_type == "Commitment" {
149 Ok(ModeResponse::PersistAndResolve {
150 state: vec![1],
151 resolution: vec![2],
152 })
153 } else {
154 Ok(ModeResponse::PersistState(vec![1]))
155 }
156 }
157 }
159
160 fn session() -> Session {
161 Session::builder("11111111-1111-4111-8111-111111111111", MODE, "agent://a")
162 .ttl_expiry(10_000)
163 .ttl_ms(10_000)
164 .participants(vec!["agent://a".into(), "agent://b".into()])
165 .mode_version("1.0.0")
166 .configuration_version("cfg-1")
167 .build()
168 }
169
170 fn env(sender: &str, message_type: &str, message_id: &str) -> Envelope {
171 Envelope {
172 macp_version: "1.0".into(),
173 mode: MODE.into(),
174 message_type: message_type.into(),
175 message_id: message_id.into(),
176 session_id: "11111111-1111-4111-8111-111111111111".into(),
177 sender: sender.into(),
178 timestamp_unix_ms: 0,
179 payload: vec![],
180 }
181 }
182
183 #[test]
184 fn duplicate_is_reported_and_changes_nothing() {
185 let mut s = session();
186 s.seen_message_ids.insert("m1".into());
187 let before = s.seen_message_ids.len();
188 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
189 assert_eq!(out, StepOutcome::Duplicate);
190 assert_eq!(s.seen_message_ids.len(), before);
191 assert_eq!(s.state, SessionState::Open);
192 }
193
194 #[test]
195 fn mode_binding_mismatch_rejected() {
196 let mut s = session();
197 let mut e = env("agent://a", "Msg", "m1");
198 e.mode = "macp.mode.other.v1".into();
199 assert!(matches!(
200 step(&mut s, &e, &TestMode, 1).unwrap_err(),
201 MacpError::InvalidEnvelope
202 ));
203 assert!(s.seen_message_ids.is_empty());
204 }
205
206 #[test]
207 fn ttl_strict_boundary_does_not_expire_but_past_does() {
208 let mut s = session();
210 let deadline = s.ttl_expiry;
211 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, deadline).unwrap();
212 assert_eq!(
213 out,
214 StepOutcome::Accepted {
215 state: SessionState::Open
216 }
217 );
218
219 let mut s2 = session();
221 let past = s2.ttl_expiry + 1;
222 let err = step(&mut s2, &env("agent://a", "Msg", "m2"), &TestMode, past).unwrap_err();
223 assert!(matches!(err, MacpError::TtlExpired));
224 assert_eq!(s2.state, SessionState::Expired);
225 assert!(s2.seen_message_ids.is_empty());
226 }
227
228 #[test]
229 fn ttl_does_not_re_expire_a_resolved_session() {
230 let mut s = session();
233 s.state = SessionState::Resolved;
234 let past = s.ttl_expiry + 5_000;
235 let err = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, past).unwrap_err();
236 assert!(matches!(err, MacpError::SessionNotOpen));
237 assert_eq!(s.state, SessionState::Resolved);
238 }
239
240 #[test]
241 fn non_open_session_rejected() {
242 for st in [SessionState::Resolved, SessionState::Expired] {
243 let mut s = session();
244 s.state = st.clone();
245 assert!(matches!(
246 step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap_err(),
247 MacpError::SessionNotOpen
248 ));
249 }
250 }
251
252 #[test]
253 fn accepted_consumes_dedup_records_activity_and_applies_state() {
254 let mut s = session();
255 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 42).unwrap();
256 assert_eq!(
257 out,
258 StepOutcome::Accepted {
259 state: SessionState::Open
260 }
261 );
262 assert!(s.seen_message_ids.contains("m1"));
263 assert_eq!(s.mode_state, vec![1]);
264 assert_eq!(s.participant_last_seen.get("agent://a"), Some(&42));
265 }
266
267 #[test]
268 fn commitment_resolves() {
269 let mut s = session();
270 let out = step(&mut s, &env("agent://a", "Commitment", "c1"), &TestMode, 1).unwrap();
271 assert_eq!(
272 out,
273 StepOutcome::Accepted {
274 state: SessionState::Resolved
275 }
276 );
277 assert_eq!(s.state, SessionState::Resolved);
278 assert_eq!(s.resolution, Some(vec![2]));
279 }
280
281 #[test]
282 fn rejected_validation_does_not_consume_dedup_slot() {
283 let mut s = session();
287 let err = step(&mut s, &env("agent://stranger", "Msg", "m1"), &TestMode, 1).unwrap_err();
288 assert!(matches!(err, MacpError::Forbidden));
289 assert!(!s.seen_message_ids.contains("m1"));
290 let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
292 assert_eq!(
293 out,
294 StepOutcome::Accepted {
295 state: SessionState::Open
296 }
297 );
298 assert!(s.seen_message_ids.contains("m1"));
299 }
300
301 #[test]
302 fn clock_is_injected_not_wall_clock() {
303 let mut s = session();
306 s.ttl_expiry = i64::MAX;
307 assert!(matches!(
308 check_preconditions(&s, &env("agent://a", "Msg", "m1"), i64::MAX - 1),
309 Ok(Precheck::Proceed)
310 ));
311 let mut s2 = session();
312 s2.ttl_expiry = 0;
313 assert!(matches!(
314 check_preconditions(&s2, &env("agent://a", "Msg", "m1"), 1),
315 Ok(Precheck::Expired)
316 ));
317 }
318
319 #[test]
320 fn phases_compose_like_step_for_durable_consumers() {
321 let mut s = session();
323 let e = env("agent://b", "Msg", "m1");
324 assert_eq!(check_preconditions(&s, &e, 5).unwrap(), Precheck::Proceed);
325 let resp = validate_message(&s, &e, &TestMode).unwrap();
326 assert!(s.seen_message_ids.is_empty());
328 let state = commit(&mut s, &e, resp, 5);
329 assert_eq!(state, SessionState::Open);
330 assert!(s.seen_message_ids.contains("m1"));
331 }
332}