1use crate::error::MacpError;
2use crate::mode::ModeResponse;
3use crate::policy::PolicyDefinition;
4use macp_pb::pb::SessionStartPayload;
5use prost::Message;
6use std::collections::{HashMap, HashSet};
7
8pub const MAX_TTL_MS: i64 = 24 * 60 * 60 * 1000;
9
10pub const MAX_SUSPEND_MS: i64 = 7 * 24 * 60 * 60 * 1000;
13
14#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
15pub enum SessionState {
16 Open,
17 Suspended,
21 Resolved,
22 Expired,
23 Cancelled,
25}
26
27impl SessionState {
28 pub fn is_terminal(&self) -> bool {
30 matches!(
31 self,
32 SessionState::Resolved | SessionState::Expired | SessionState::Cancelled
33 )
34 }
35}
36
37#[derive(Clone, Debug)]
38pub struct Session {
39 pub session_id: String,
40 pub state: SessionState,
41 pub ttl_expiry: i64,
42 pub ttl_ms: i64,
43 pub started_at_unix_ms: i64,
44 pub resolution: Option<Vec<u8>>,
45 pub mode: String,
46 pub mode_state: Vec<u8>,
47 pub participants: Vec<String>,
48 pub seen_message_ids: HashSet<String>,
49 pub intent: String,
50 pub mode_version: String,
51 pub configuration_version: String,
52 pub policy_version: String,
53 pub context_id: String,
54 pub extensions: HashMap<String, Vec<u8>>,
55 pub roots: Vec<macp_pb::pb::Root>,
56 pub initiator_sender: String,
57 pub participant_message_counts: HashMap<String, u32>,
58 pub participant_last_seen: HashMap<String, i64>,
59 pub policy_definition: Option<PolicyDefinition>,
60 pub suspended_at_ms: Option<i64>,
63 pub accumulated_suspended_ms: i64,
66}
67
68impl Session {
69 pub fn record_participant_activity(&mut self, sender: &str, timestamp_ms: i64) {
70 *self
71 .participant_message_counts
72 .entry(sender.to_string())
73 .or_insert(0) += 1;
74 self.participant_last_seen
75 .insert(sender.to_string(), timestamp_ms);
76 }
77
78 pub fn suspend(&mut self, now_ms: i64) -> Result<(), MacpError> {
82 if self.state != SessionState::Open {
83 return Err(MacpError::SessionNotOpen);
84 }
85 self.state = SessionState::Suspended;
86 self.suspended_at_ms = Some(now_ms);
87 Ok(())
88 }
89
90 pub fn resume(&mut self, now_ms: i64) -> Result<(), MacpError> {
95 if self.state != SessionState::Suspended {
96 return Err(MacpError::SessionNotOpen);
97 }
98 let suspended_at = self.suspended_at_ms.unwrap_or(now_ms);
99 let banked = (now_ms - suspended_at).max(0);
100 self.accumulated_suspended_ms = self.accumulated_suspended_ms.saturating_add(banked);
101 self.suspended_at_ms = None;
102 if self.accumulated_suspended_ms > MAX_SUSPEND_MS {
103 self.state = SessionState::Expired;
104 return Err(MacpError::TtlExpired);
105 }
106 self.ttl_expiry = self.ttl_expiry.saturating_add(banked);
107 self.state = SessionState::Open;
108 Ok(())
109 }
110
111 pub fn cancel(&mut self) -> Result<(), MacpError> {
114 if self.state.is_terminal() {
115 return Err(MacpError::SessionNotOpen);
116 }
117 self.state = SessionState::Cancelled;
118 self.suspended_at_ms = None;
119 Ok(())
120 }
121
122 pub fn suspend_cap_exceeded(&self, now_ms: i64) -> bool {
125 match self.suspended_at_ms {
126 Some(at) => {
127 self.accumulated_suspended_ms
128 .saturating_add((now_ms - at).max(0))
129 > MAX_SUSPEND_MS
130 }
131 None => self.accumulated_suspended_ms > MAX_SUSPEND_MS,
132 }
133 }
134
135 pub fn apply_mode_response(&mut self, response: ModeResponse) {
136 match response {
137 ModeResponse::NoOp => {}
138 ModeResponse::PersistState(state) => self.mode_state = state,
139 ModeResponse::Resolve(resolution) => {
140 self.state = SessionState::Resolved;
141 self.resolution = Some(resolution);
142 }
143 ModeResponse::PersistAndResolve { state, resolution } => {
144 self.mode_state = state;
145 self.state = SessionState::Resolved;
146 self.resolution = Some(resolution);
147 }
148 }
149 }
150}
151
152pub fn requires_strict_session_start(mode: &str) -> bool {
153 matches!(
154 mode,
155 "macp.mode.decision.v1"
156 | "macp.mode.proposal.v1"
157 | "macp.mode.task.v1"
158 | "macp.mode.handoff.v1"
159 | "macp.mode.quorum.v1"
160 | "ext.multi_round.v1"
161 )
162}
163
164pub fn parse_session_start_payload(payload: &[u8]) -> Result<SessionStartPayload, MacpError> {
166 if payload.is_empty() {
167 return Err(MacpError::InvalidPayload);
168 }
169 SessionStartPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
170}
171
172pub fn extract_ttl_ms(payload: &SessionStartPayload) -> Result<i64, MacpError> {
174 if !(1..=MAX_TTL_MS).contains(&payload.ttl_ms) {
175 return Err(MacpError::InvalidTtl);
176 }
177 Ok(payload.ttl_ms)
178}
179
180pub fn validate_canonical_session_start_payload(
182 payload: &SessionStartPayload,
183) -> Result<(), MacpError> {
184 extract_ttl_ms(payload)?;
185
186 if payload.mode_version.trim().is_empty() || payload.configuration_version.trim().is_empty() {
187 return Err(MacpError::InvalidPayload);
188 }
189
190 if payload.participants.is_empty() {
191 return Err(MacpError::InvalidPayload);
192 }
193
194 const MAX_PARTICIPANTS: usize = 1000;
196 if payload.participants.len() > MAX_PARTICIPANTS {
197 return Err(MacpError::InvalidPayload);
198 }
199
200 let mut seen = HashSet::new();
201 for participant in &payload.participants {
202 let participant = participant.trim();
203 if participant.is_empty() || !seen.insert(participant.to_string()) {
204 return Err(MacpError::InvalidPayload);
205 }
206 }
207
208 Ok(())
209}
210
211pub fn validate_strict_session_start_payload(
213 mode: &str,
214 payload: &SessionStartPayload,
215) -> Result<(), MacpError> {
216 if !requires_strict_session_start(mode) {
217 return Ok(());
218 }
219
220 validate_canonical_session_start_payload(payload)
221}
222
223pub fn validate_session_id_for_acceptance(session_id: &str) -> Result<(), MacpError> {
231 if session_id.is_empty() {
232 return Err(MacpError::InvalidSessionId);
233 }
234
235 if session_id.len() == 36 && session_id.contains('-') {
237 if let Ok(parsed) = uuid::Uuid::parse_str(session_id) {
238 if parsed.as_hyphenated().to_string() == session_id {
240 match parsed.get_version() {
241 Some(uuid::Version::Random) | Some(uuid::Version::SortRand) => {
242 return Ok(());
243 }
244 _ => {}
245 }
246 }
247 }
248 return Err(MacpError::InvalidSessionId);
249 }
250
251 if session_id.len() >= 22
253 && session_id
254 .chars()
255 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
256 {
257 return Ok(());
258 }
259
260 Err(MacpError::InvalidSessionId)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use prost::Message;
267
268 fn encode_payload(ttl_ms: i64, participants: Vec<String>) -> Vec<u8> {
269 let payload = SessionStartPayload {
270 intent: String::new(),
271 participants,
272 mode_version: "1.0.0".into(),
273 configuration_version: "cfg-1".into(),
274 policy_version: String::new(),
275 ttl_ms,
276 context_id: String::new(),
277 extensions: std::collections::HashMap::new(),
278 roots: vec![],
279 };
280 payload.encode_to_vec()
281 }
282
283 #[test]
284 fn parse_empty_payload_is_invalid() {
285 let err = parse_session_start_payload(b"").unwrap_err();
286 assert_eq!(err.to_string(), "InvalidPayload");
287 }
288
289 #[test]
290 fn parse_valid_protobuf_payload() {
291 let bytes = encode_payload(5000, vec!["alice".into(), "bob".into()]);
292 let result = parse_session_start_payload(&bytes).unwrap();
293 assert_eq!(result.ttl_ms, 5000);
294 assert_eq!(result.participants, vec!["alice", "bob"]);
295 }
296
297 #[test]
298 fn extract_ttl_requires_explicit_positive_value() {
299 let payload = SessionStartPayload::default();
300 assert_eq!(
301 extract_ttl_ms(&payload).unwrap_err().to_string(),
302 "InvalidTtl"
303 );
304
305 let payload = SessionStartPayload {
306 ttl_ms: 5000,
307 ..Default::default()
308 };
309 assert_eq!(extract_ttl_ms(&payload).unwrap(), 5000);
310 }
311
312 #[test]
313 fn standard_mode_requires_explicit_versions_and_participants() {
314 let payload = SessionStartPayload {
315 participants: vec!["alice".into()],
316 mode_version: String::new(),
317 configuration_version: "cfg-1".into(),
318 ttl_ms: 1000,
319 ..Default::default()
320 };
321 assert_eq!(
322 validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
323 .unwrap_err()
324 .to_string(),
325 "InvalidPayload"
326 );
327
328 let payload = SessionStartPayload {
329 participants: vec![],
330 mode_version: "1.0.0".into(),
331 configuration_version: "cfg-1".into(),
332 ttl_ms: 1000,
333 ..Default::default()
334 };
335 assert_eq!(
336 validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
337 .unwrap_err()
338 .to_string(),
339 "InvalidPayload"
340 );
341 }
342
343 fn open_session(ttl_expiry: i64) -> Session {
344 Session {
345 session_id: "s1".into(),
346 state: SessionState::Open,
347 ttl_expiry,
348 ttl_ms: 60_000,
349 started_at_unix_ms: 0,
350 resolution: None,
351 mode: "macp.mode.decision.v1".into(),
352 mode_state: vec![],
353 participants: vec![],
354 seen_message_ids: HashSet::new(),
355 intent: String::new(),
356 mode_version: "1.0.0".into(),
357 configuration_version: "cfg-1".into(),
358 policy_version: String::new(),
359 context_id: String::new(),
360 extensions: HashMap::new(),
361 roots: vec![],
362 initiator_sender: "agent://a".into(),
363 participant_message_counts: HashMap::new(),
364 participant_last_seen: HashMap::new(),
365 policy_definition: None,
366 suspended_at_ms: None,
367 accumulated_suspended_ms: 0,
368 }
369 }
370
371 #[test]
372 fn suspend_then_resume_banks_ttl() {
373 let mut s = open_session(10_000);
374 s.suspend(2_000).unwrap();
375 assert_eq!(s.state, SessionState::Suspended);
376 assert_eq!(s.suspended_at_ms, Some(2_000));
377 s.resume(5_000).unwrap();
379 assert_eq!(s.state, SessionState::Open);
380 assert_eq!(s.ttl_expiry, 13_000);
381 assert_eq!(s.accumulated_suspended_ms, 3_000);
382 assert_eq!(s.suspended_at_ms, None);
383 }
384
385 #[test]
386 fn suspend_requires_open_and_resume_requires_suspended() {
387 let mut s = open_session(10_000);
388 assert!(matches!(
390 s.resume(1).unwrap_err(),
391 MacpError::SessionNotOpen
392 ));
393 s.suspend(1).unwrap();
394 assert!(matches!(
396 s.suspend(2).unwrap_err(),
397 MacpError::SessionNotOpen
398 ));
399 }
400
401 #[test]
402 fn resume_exceeding_max_suspend_expires() {
403 let mut s = open_session(10_000);
404 s.suspend(0).unwrap();
405 let err = s.resume(MAX_SUSPEND_MS + 1).unwrap_err();
407 assert!(matches!(err, MacpError::TtlExpired));
408 assert_eq!(s.state, SessionState::Expired);
409 }
410
411 #[test]
412 fn cancel_from_open_or_suspended_then_terminal_is_rejected() {
413 let mut s = open_session(10_000);
414 s.suspend(1).unwrap();
415 s.cancel().unwrap();
416 assert_eq!(s.state, SessionState::Cancelled);
417 assert_eq!(s.suspended_at_ms, None);
418 assert!(matches!(s.cancel().unwrap_err(), MacpError::SessionNotOpen));
420
421 let mut open = open_session(10_000);
422 open.cancel().unwrap();
423 assert_eq!(open.state, SessionState::Cancelled);
424 }
425
426 #[test]
427 fn standard_mode_rejects_duplicate_participants() {
428 let payload = SessionStartPayload {
429 participants: vec!["alice".into(), "alice".into()],
430 mode_version: "1.0.0".into(),
431 configuration_version: "cfg-1".into(),
432 ttl_ms: 1000,
433 ..Default::default()
434 };
435 assert_eq!(
436 validate_strict_session_start_payload("macp.mode.proposal.v1", &payload)
437 .unwrap_err()
438 .to_string(),
439 "InvalidPayload"
440 );
441 }
442
443 #[test]
444 fn multi_round_requires_strict_session_start() {
445 let payload = SessionStartPayload::default();
446 assert!(validate_strict_session_start_payload("ext.multi_round.v1", &payload).is_err());
447 }
448
449 #[test]
450 fn valid_uuid_v4_accepted() {
451 let id = uuid::Uuid::new_v4().as_hyphenated().to_string();
452 validate_session_id_for_acceptance(&id).unwrap();
453 }
454
455 #[test]
456 fn valid_base64url_accepted() {
457 validate_session_id_for_acceptance("abcdefghijklmnopqrstuv").unwrap();
459 validate_session_id_for_acceptance("abc-def_ghi-jkl_mno-pqr").unwrap();
461 }
462
463 #[test]
464 fn empty_id_rejected() {
465 assert_eq!(
466 validate_session_id_for_acceptance("")
467 .unwrap_err()
468 .to_string(),
469 "InvalidSessionId"
470 );
471 }
472
473 #[test]
474 fn short_weak_id_rejected() {
475 assert_eq!(
476 validate_session_id_for_acceptance("s1")
477 .unwrap_err()
478 .to_string(),
479 "InvalidSessionId"
480 );
481 assert_eq!(
482 validate_session_id_for_acceptance("decision-demo-1")
483 .unwrap_err()
484 .to_string(),
485 "InvalidSessionId"
486 );
487 }
488
489 #[test]
490 fn uppercase_uuid_rejected() {
491 let id = uuid::Uuid::new_v4()
492 .as_hyphenated()
493 .to_string()
494 .to_uppercase();
495 assert_eq!(
496 validate_session_id_for_acceptance(&id)
497 .unwrap_err()
498 .to_string(),
499 "InvalidSessionId"
500 );
501 }
502
503 #[test]
504 fn base64url_too_short_rejected() {
505 assert_eq!(
506 validate_session_id_for_acceptance("abcdefghij")
507 .unwrap_err()
508 .to_string(),
509 "InvalidSessionId"
510 );
511 }
512
513 #[test]
514 fn valid_uuid_v7_accepted() {
515 let v4 = uuid::Uuid::new_v4();
517 let mut bytes = *v4.as_bytes();
518 bytes[6] = (bytes[6] & 0x0F) | 0x70;
520 bytes[8] = (bytes[8] & 0x3F) | 0x80;
522 let v7_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
523 assert!(validate_session_id_for_acceptance(&v7_id).is_ok());
524 }
525
526 #[test]
527 fn uuid_v1_rejected() {
528 let v4 = uuid::Uuid::new_v4();
530 let mut bytes = *v4.as_bytes();
531 bytes[6] = (bytes[6] & 0x0F) | 0x10;
533 bytes[8] = (bytes[8] & 0x3F) | 0x80;
535 let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
536 assert_eq!(
537 validate_session_id_for_acceptance(&v1_id)
538 .unwrap_err()
539 .to_string(),
540 "InvalidSessionId"
541 );
542 }
543
544 #[test]
545 fn too_many_participants_rejected() {
546 let participants: Vec<String> = (0..1001).map(|i| format!("agent://p{i}")).collect();
547 let bytes = encode_payload(5000, participants);
548 let payload = parse_session_start_payload(&bytes).unwrap();
549 assert_eq!(
550 validate_canonical_session_start_payload(&payload)
551 .unwrap_err()
552 .to_string(),
553 "InvalidPayload"
554 );
555 }
556
557 #[test]
558 fn max_participants_accepted() {
559 let participants: Vec<String> = (0..1000).map(|i| format!("agent://p{i}")).collect();
560 let bytes = encode_payload(5000, participants);
561 let payload = parse_session_start_payload(&bytes).unwrap();
562 validate_canonical_session_start_payload(&payload).unwrap();
563 }
564}