Skip to main content

macp_core/
session.rs

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
10/// Hard cap on the cumulative time a session may spend `Suspended` before it is
11/// force-expired (RFC-MACP-0001 §7.5). Bounds indefinite human-in-the-loop holds.
12pub 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    /// Non-terminal pause of an `Open` session (RFC-MACP-0001 §7.5). TTL is
18    /// banked while suspended; only `Open`<->`Suspended` and `Suspended`->
19    /// `Expired`/`Cancelled` transitions are permitted.
20    Suspended,
21    Resolved,
22    Expired,
23    /// Terminal: ended by an accepted `CancelSession` (distinct from `Expired`).
24    Cancelled,
25}
26
27impl SessionState {
28    /// Terminal states accept no further transitions.
29    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    /// Wall-clock (session-timeline) ms at which the session was suspended, or
61    /// `None` when not suspended. Used to bank TTL across a suspension (§7.5).
62    pub suspended_at_ms: Option<i64>,
63    /// Cumulative ms the session has spent suspended across all suspend/resume
64    /// cycles. Drives the `MAX_SUSPEND_MS` cap.
65    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    /// Suspend an `Open` session (RFC-MACP-0001 §7.5). Records the suspend time
79    /// so TTL can be banked on resume. Pure: no clock, no I/O — the caller
80    /// injects `now_ms`.
81    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    /// Resume a `Suspended` session, banking the suspended duration into the
91    /// TTL deadline (`ttl_expiry += now - suspended_at`). If the cumulative
92    /// suspended time would exceed `MAX_SUSPEND_MS`, the session is force-expired
93    /// instead and `MacpError::TtlExpired` is returned.
94    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    /// Cancel an `Open` or `Suspended` session into the terminal `Cancelled`
112    /// state (RFC-MACP-0001 §7.3). Returns an error if already terminal.
113    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    /// Whether a currently-`Suspended` session has exceeded `MAX_SUSPEND_MS` as
123    /// of `now_ms` (cumulative banked plus the in-progress suspension).
124    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
164/// Parse a protobuf-encoded SessionStartPayload from raw bytes.
165pub 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
172/// Extract and validate TTL from a parsed SessionStartPayload.
173pub 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
180/// Validate the complete canonical SessionStart binding contract.
181pub 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    // Safety limit: prevent resource exhaustion from excessively large participant lists.
195    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
211/// Enforce the strict SessionStart binding contract for standards-track and qualifying extension modes.
212pub 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
223/// Validate that a session ID meets the acceptance policy.
224///
225/// Accepts:
226/// - UUID v4/v7 in hyphenated lowercase canonical form (36 chars)
227/// - base64url tokens of 22+ chars (`[A-Za-z0-9_-]`)
228///
229/// Rejects everything else (empty, short human-readable, uppercase UUID, etc.).
230pub 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    // Try UUID parse: must be valid UUID v4 or v7, canonical lowercase hyphenated form
236    if session_id.len() == 36 && session_id.contains('-') {
237        if let Ok(parsed) = uuid::Uuid::parse_str(session_id) {
238            // Verify it's the canonical lowercase hyphenated representation
239            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    // Try base64url: at least 22 chars, only [A-Za-z0-9_-]
252    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        // Resume 3_000ms later: banked 3_000 is added to the deadline.
378        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        // resume on an Open session is rejected
389        assert!(matches!(
390            s.resume(1).unwrap_err(),
391            MacpError::SessionNotOpen
392        ));
393        s.suspend(1).unwrap();
394        // double-suspend rejected
395        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        // Resume after more than MAX_SUSPEND_MS: force-expired.
406        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        // Already terminal: further cancel is rejected.
419        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        // 22-char base64url token
458        validate_session_id_for_acceptance("abcdefghijklmnopqrstuv").unwrap();
459        // longer base64url with underscore and hyphen
460        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        // Construct a v7 UUID by patching the version nibble of a v4 UUID
516        let v4 = uuid::Uuid::new_v4();
517        let mut bytes = *v4.as_bytes();
518        // Set version nibble (bits 48-51) to 0b0111 (v7)
519        bytes[6] = (bytes[6] & 0x0F) | 0x70;
520        // Keep variant bits valid (RFC 4122: 0b10xx)
521        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        // Construct a v1 UUID by patching the version nibble of a v4 UUID
529        let v4 = uuid::Uuid::new_v4();
530        let mut bytes = *v4.as_bytes();
531        // Set version nibble (bits 48-51) to 0b0001 (v1)
532        bytes[6] = (bytes[6] & 0x0F) | 0x10;
533        // Keep variant bits valid (RFC 4122: 0b10xx)
534        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}