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/// Default cap on the cumulative time a session may spend `Suspended` before
11/// it is force-expired (RFC-MACP-0001 §7.5). Bounds indefinite
12/// human-in-the-loop holds. Sessions may bind their own cap via
13/// `SessionStartPayload.max_suspend_ms` (0 selects this default); the
14/// resolved cap is recorded at SessionStart and used by replay
15/// (RFC-MACP-0003 §2) — see [`Session::effective_max_suspend_ms`].
16pub const MAX_SUSPEND_MS: i64 = 7 * 24 * 60 * 60 * 1000;
17
18/// Current session-semantics revision. Recorded at SessionStart (on the
19/// session and its log entry) and consulted wherever acceptance-time behavior
20/// changed across releases, so legacy histories replay under the semantics
21/// they were accepted with (RFC-MACP-0003 §1).
22///
23/// Revisions:
24/// - 0 — legacy: Handoff implicit-accept timed against the client-supplied
25///   envelope timestamp.
26/// - 1 — Handoff implicit-accept times against the runtime acceptance clock
27///   (`MessageContext::accepted_at_ms`).
28pub const CURRENT_SEMANTICS_REV: u32 = 1;
29
30#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
31pub enum SessionState {
32    Open,
33    /// Non-terminal pause of an `Open` session (RFC-MACP-0001 §7.5). TTL is
34    /// banked while suspended; only `Open`<->`Suspended` and `Suspended`->
35    /// `Expired`/`Cancelled` transitions are permitted.
36    Suspended,
37    Resolved,
38    Expired,
39    /// Terminal: ended by an accepted `CancelSession` (distinct from `Expired`).
40    Cancelled,
41}
42
43impl SessionState {
44    /// Terminal states accept no further transitions.
45    pub fn is_terminal(&self) -> bool {
46        matches!(
47            self,
48            SessionState::Resolved | SessionState::Expired | SessionState::Cancelled
49        )
50    }
51}
52
53/// Session model. Fields are public for reads, but the struct is
54/// `#[non_exhaustive]`: construct via [`Session::builder`]. This lets the model
55/// gain fields without breaking every constructor in downstream crates
56/// (pre-1.0 freeze requirement).
57#[non_exhaustive]
58#[derive(Clone, Debug)]
59pub struct Session {
60    pub session_id: String,
61    pub state: SessionState,
62    pub ttl_expiry: i64,
63    pub ttl_ms: i64,
64    pub started_at_unix_ms: i64,
65    pub resolution: Option<Vec<u8>>,
66    pub mode: String,
67    pub mode_state: Vec<u8>,
68    pub participants: Vec<String>,
69    pub seen_message_ids: HashSet<String>,
70    pub intent: String,
71    pub mode_version: String,
72    pub configuration_version: String,
73    pub policy_version: String,
74    pub context_id: String,
75    pub extensions: HashMap<String, Vec<u8>>,
76    pub roots: Vec<macp_pb::pb::Root>,
77    pub initiator_sender: String,
78    pub participant_message_counts: HashMap<String, u32>,
79    pub participant_last_seen: HashMap<String, i64>,
80    pub policy_definition: Option<PolicyDefinition>,
81    /// Wall-clock (session-timeline) ms at which the session was suspended, or
82    /// `None` when not suspended. Used to bank TTL across a suspension (§7.5).
83    pub suspended_at_ms: Option<i64>,
84    /// Cumulative ms the session has spent suspended across all suspend/resume
85    /// cycles. Drives the `MAX_SUSPEND_MS` cap.
86    pub accumulated_suspended_ms: i64,
87    /// Session-semantics revision this session was accepted under. See
88    /// [`CURRENT_SEMANTICS_REV`]. Legacy persisted sessions load as `0`.
89    pub semantics_rev: u32,
90    /// Maximum-suspension cap bound at SessionStart (RFC-MACP-0001 §7.5,
91    /// RFC-MACP-0003 §2). `0` = unbound (legacy sessions and library
92    /// defaults) — the [`MAX_SUSPEND_MS`] default applies; see
93    /// [`Session::effective_max_suspend_ms`]. The kernel records the
94    /// *resolved* value here for new sessions.
95    pub max_suspend_ms: i64,
96}
97
98impl Session {
99    /// Start building a `Session`. The three arguments are the fields with no
100    /// meaningful default; everything else starts from documented defaults
101    /// (see [`SessionBuilder`]) and is set with the builder's methods.
102    pub fn builder(
103        session_id: impl Into<String>,
104        mode: impl Into<String>,
105        initiator_sender: impl Into<String>,
106    ) -> SessionBuilder {
107        SessionBuilder {
108            inner: Session {
109                session_id: session_id.into(),
110                state: SessionState::Open,
111                // Never-expires by default: every kernel path overrides this
112                // from the validated SessionStart payload; library/test
113                // consumers get a session that behaves until told otherwise.
114                ttl_expiry: i64::MAX,
115                ttl_ms: 0,
116                started_at_unix_ms: 0,
117                resolution: None,
118                mode: mode.into(),
119                mode_state: vec![],
120                participants: vec![],
121                seen_message_ids: HashSet::new(),
122                intent: String::new(),
123                mode_version: String::new(),
124                configuration_version: String::new(),
125                policy_version: String::new(),
126                context_id: String::new(),
127                extensions: HashMap::new(),
128                roots: vec![],
129                initiator_sender: initiator_sender.into(),
130                participant_message_counts: HashMap::new(),
131                participant_last_seen: HashMap::new(),
132                policy_definition: None,
133                suspended_at_ms: None,
134                accumulated_suspended_ms: 0,
135                semantics_rev: CURRENT_SEMANTICS_REV,
136                max_suspend_ms: 0,
137            },
138        }
139    }
140
141    pub fn record_participant_activity(&mut self, sender: &str, timestamp_ms: i64) {
142        *self
143            .participant_message_counts
144            .entry(sender.to_string())
145            .or_insert(0) += 1;
146        self.participant_last_seen
147            .insert(sender.to_string(), timestamp_ms);
148    }
149
150    /// Suspend an `Open` session (RFC-MACP-0001 §7.5). Records the suspend time
151    /// so TTL can be banked on resume. Pure: no clock, no I/O — the caller
152    /// injects `now_ms`.
153    pub fn suspend(&mut self, now_ms: i64) -> Result<(), MacpError> {
154        if self.state != SessionState::Open {
155            return Err(MacpError::SessionNotOpen);
156        }
157        self.state = SessionState::Suspended;
158        self.suspended_at_ms = Some(now_ms);
159        Ok(())
160    }
161
162    /// Resume a `Suspended` session, banking the suspended duration into the
163    /// TTL deadline (`ttl_expiry += now - suspended_at`). If the cumulative
164    /// suspended time would exceed `MAX_SUSPEND_MS`, the session is force-expired
165    /// instead and `MacpError::TtlExpired` is returned.
166    /// The suspension cap governing this session: the value bound at
167    /// SessionStart, or the [`MAX_SUSPEND_MS`] default when unbound (0).
168    pub fn effective_max_suspend_ms(&self) -> i64 {
169        if self.max_suspend_ms > 0 {
170            self.max_suspend_ms
171        } else {
172            MAX_SUSPEND_MS
173        }
174    }
175
176    pub fn resume(&mut self, now_ms: i64) -> Result<(), MacpError> {
177        if self.state != SessionState::Suspended {
178            return Err(MacpError::SessionNotOpen);
179        }
180        let suspended_at = self.suspended_at_ms.unwrap_or(now_ms);
181        let banked = (now_ms - suspended_at).max(0);
182        self.accumulated_suspended_ms = self.accumulated_suspended_ms.saturating_add(banked);
183        self.suspended_at_ms = None;
184        if self.accumulated_suspended_ms > self.effective_max_suspend_ms() {
185            self.state = SessionState::Expired;
186            return Err(MacpError::TtlExpired);
187        }
188        self.ttl_expiry = self.ttl_expiry.saturating_add(banked);
189        self.state = SessionState::Open;
190        Ok(())
191    }
192
193    /// Cancel an `Open` or `Suspended` session into the terminal `Cancelled`
194    /// state (RFC-MACP-0001 §7.3). Returns an error if already terminal.
195    pub fn cancel(&mut self) -> Result<(), MacpError> {
196        if self.state.is_terminal() {
197            return Err(MacpError::SessionNotOpen);
198        }
199        self.state = SessionState::Cancelled;
200        self.suspended_at_ms = None;
201        Ok(())
202    }
203
204    /// Whether a currently-`Suspended` session has exceeded `MAX_SUSPEND_MS` as
205    /// of `now_ms` (cumulative banked plus the in-progress suspension).
206    pub fn suspend_cap_exceeded(&self, now_ms: i64) -> bool {
207        match self.suspended_at_ms {
208            Some(at) => {
209                self.accumulated_suspended_ms
210                    .saturating_add((now_ms - at).max(0))
211                    > self.effective_max_suspend_ms()
212            }
213            None => self.accumulated_suspended_ms > self.effective_max_suspend_ms(),
214        }
215    }
216
217    pub fn apply_mode_response(&mut self, response: ModeResponse) {
218        match response {
219            ModeResponse::NoOp => {}
220            ModeResponse::PersistState(state) => self.mode_state = state,
221            ModeResponse::Resolve(resolution) => {
222                self.state = SessionState::Resolved;
223                self.resolution = Some(resolution);
224            }
225            ModeResponse::PersistAndResolve { state, resolution } => {
226                self.mode_state = state;
227                self.state = SessionState::Resolved;
228                self.resolution = Some(resolution);
229            }
230        }
231    }
232}
233
234/// Builder for [`Session`] — the only construction path outside `macp-core`
235/// (the struct is `#[non_exhaustive]`).
236///
237/// Defaults: `state: Open`, `ttl_expiry: i64::MAX` (never expires until set),
238/// numeric fields `0`, everything else empty/`None`.
239#[derive(Clone, Debug)]
240pub struct SessionBuilder {
241    inner: Session,
242}
243
244macro_rules! builder_setters {
245    ($($(#[$doc:meta])* $name:ident: $ty:ty),* $(,)?) => {
246        $(
247            $(#[$doc])*
248            pub fn $name(mut self, value: $ty) -> Self {
249                self.inner.$name = value;
250                self
251            }
252        )*
253    };
254}
255
256impl SessionBuilder {
257    builder_setters! {
258        state: SessionState,
259        ttl_expiry: i64,
260        ttl_ms: i64,
261        started_at_unix_ms: i64,
262        resolution: Option<Vec<u8>>,
263        mode_state: Vec<u8>,
264        participants: Vec<String>,
265        seen_message_ids: HashSet<String>,
266        extensions: HashMap<String, Vec<u8>>,
267        roots: Vec<macp_pb::pb::Root>,
268        participant_message_counts: HashMap<String, u32>,
269        participant_last_seen: HashMap<String, i64>,
270        policy_definition: Option<crate::policy::PolicyDefinition>,
271        suspended_at_ms: Option<i64>,
272        accumulated_suspended_ms: i64,
273        semantics_rev: u32,
274        /// Suspension cap bound at SessionStart; 0 = use the
275        /// [`MAX_SUSPEND_MS`] default (legacy sessions, library consumers).
276        max_suspend_ms: i64,
277    }
278
279    pub fn intent(mut self, value: impl Into<String>) -> Self {
280        self.inner.intent = value.into();
281        self
282    }
283
284    pub fn mode_version(mut self, value: impl Into<String>) -> Self {
285        self.inner.mode_version = value.into();
286        self
287    }
288
289    pub fn configuration_version(mut self, value: impl Into<String>) -> Self {
290        self.inner.configuration_version = value.into();
291        self
292    }
293
294    pub fn policy_version(mut self, value: impl Into<String>) -> Self {
295        self.inner.policy_version = value.into();
296        self
297    }
298
299    pub fn context_id(mut self, value: impl Into<String>) -> Self {
300        self.inner.context_id = value.into();
301        self
302    }
303
304    pub fn build(self) -> Session {
305        self.inner
306    }
307}
308
309pub fn requires_strict_session_start(mode: &str) -> bool {
310    matches!(
311        mode,
312        "macp.mode.decision.v1"
313            | "macp.mode.proposal.v1"
314            | "macp.mode.task.v1"
315            | "macp.mode.handoff.v1"
316            | "macp.mode.quorum.v1"
317            | "ext.multi_round.v1"
318    )
319}
320
321/// Parse a protobuf-encoded SessionStartPayload from raw bytes.
322pub fn parse_session_start_payload(payload: &[u8]) -> Result<SessionStartPayload, MacpError> {
323    if payload.is_empty() {
324        return Err(MacpError::InvalidPayload);
325    }
326    SessionStartPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
327}
328
329/// Extract and validate TTL from a parsed SessionStartPayload.
330pub fn extract_ttl_ms(payload: &SessionStartPayload) -> Result<i64, MacpError> {
331    if !(1..=MAX_TTL_MS).contains(&payload.ttl_ms) {
332        return Err(MacpError::InvalidTtl);
333    }
334    Ok(payload.ttl_ms)
335}
336
337/// Modes whose canonical `SessionStart` may bind an **empty** `participants`
338/// list.
339///
340/// Decision alone. RFC-MACP-0001 §7.1 requires `participants` only "when
341/// required by the Mode", and RFC-MACP-0007 makes the initiator's authority
342/// role-based rather than membership-based, so a Decision session with no
343/// declared participants is well-defined: nobody — the initiator included — can
344/// emit a `Proposal`, `Evaluation`, `Objection` or `Vote`, because
345/// `DecisionMode::authorize_sender` routes all four through
346/// `is_declared_participant`, which is `false` over an empty list. Such a
347/// session can therefore only expire or be cancelled. Spec #99 removed
348/// `minItems: 1` from the conformance fixture schema on exactly that reasoning
349/// and added `decision_zero_participants.json` to pin it.
350///
351/// **Deliberately a positive allowlist of one, checked in one place.** The
352/// other four standards-track modes each re-reject an insufficient roster in
353/// their own `on_session_start`, but those are five independent
354/// implementations: if the rule lived only there, deleting any one guard would
355/// silently remove the guarantee with nothing at the core level left to notice.
356/// Keeping the rule here means the exception is named once and every other
357/// mode — including a promoted extension mode the list below has never heard
358/// of — keeps the full canonical contract by default.
359fn allows_empty_participants(mode: &str) -> bool {
360    mode == "macp.mode.decision.v1"
361}
362
363/// Validate the complete canonical SessionStart binding contract.
364///
365/// Mode-independent, and therefore holds the roster non-emptiness rule for
366/// **every** mode. Prefer
367/// [`validate_canonical_session_start_payload_for_mode`] on any path that knows
368/// the mode name; this entry point is retained with its original signature and
369/// its original behaviour.
370pub fn validate_canonical_session_start_payload(
371    payload: &SessionStartPayload,
372) -> Result<(), MacpError> {
373    validate_canonical_start(payload, false)
374}
375
376/// The canonical SessionStart binding contract, with the roster rule scoped to
377/// the mode.
378///
379/// Identical to [`validate_canonical_session_start_payload`] in every respect
380/// except one: an empty `participants` list is accepted for the modes
381/// `allows_empty_participants` names (Decision, and only Decision) and
382/// rejected for all others, promoted extension modes included.
383///
384/// This is additive rather than a new parameter on
385/// [`validate_canonical_session_start_payload`] on purpose — changing that
386/// function's signature would be a `macp-core` API break, and every crate in
387/// this workspace shares one version.
388pub fn validate_canonical_session_start_payload_for_mode(
389    mode: &str,
390    payload: &SessionStartPayload,
391) -> Result<(), MacpError> {
392    validate_canonical_start(payload, allows_empty_participants(mode))
393}
394
395fn validate_canonical_start(
396    payload: &SessionStartPayload,
397    allow_empty_participants: bool,
398) -> Result<(), MacpError> {
399    extract_ttl_ms(payload)?;
400
401    if payload.mode_version.trim().is_empty() || payload.configuration_version.trim().is_empty() {
402        return Err(MacpError::InvalidPayload);
403    }
404
405    if payload.participants.is_empty() && !allow_empty_participants {
406        return Err(MacpError::InvalidPayload);
407    }
408
409    // Safety limit: prevent resource exhaustion from excessively large participant lists.
410    const MAX_PARTICIPANTS: usize = 1000;
411    if payload.participants.len() > MAX_PARTICIPANTS {
412        return Err(MacpError::InvalidPayload);
413    }
414
415    let mut seen = HashSet::new();
416    for participant in &payload.participants {
417        let participant = participant.trim();
418        if participant.is_empty() || !seen.insert(participant.to_string()) {
419            return Err(MacpError::InvalidPayload);
420        }
421    }
422
423    // max_suspend_ms: 0 selects the runtime default; a positive value binds a
424    // session-specific cap (RFC-MACP-0001 §7.1). Negative is meaningless.
425    if payload.max_suspend_ms < 0 {
426        return Err(MacpError::InvalidPayload);
427    }
428
429    Ok(())
430}
431
432/// Enforce the strict SessionStart binding contract for standards-track and qualifying extension modes.
433pub fn validate_strict_session_start_payload(
434    mode: &str,
435    payload: &SessionStartPayload,
436) -> Result<(), MacpError> {
437    if !requires_strict_session_start(mode) {
438        return Ok(());
439    }
440
441    validate_canonical_session_start_payload_for_mode(mode, payload)
442}
443
444/// Validate that a session ID meets the acceptance policy.
445///
446/// Accepts:
447/// - UUID v4/v7 in hyphenated lowercase canonical form (36 chars)
448/// - base64url tokens of 22+ chars (`[A-Za-z0-9_-]`)
449///
450/// Rejects everything else (empty, short human-readable, uppercase UUID, etc.).
451pub fn validate_session_id_for_acceptance(session_id: &str) -> Result<(), MacpError> {
452    if session_id.is_empty() {
453        return Err(MacpError::InvalidSessionId);
454    }
455
456    // UUID-shaped ids (36 chars, parseable) are held to strict UUID rules with no
457    // fall-through: canonical lowercase hyphenated form, version v4 or v7. This
458    // keeps non-canonical forms (e.g. uppercase) of the same UUID from being
459    // admitted as distinct base64url tokens. Only strings that do not parse as a
460    // UUID at all fall through to the base64url rule.
461    if session_id.len() == 36 && session_id.contains('-') {
462        if let Ok(parsed) = uuid::Uuid::parse_str(session_id) {
463            if parsed.as_hyphenated().to_string() == session_id {
464                match parsed.get_version() {
465                    Some(uuid::Version::Random) | Some(uuid::Version::SortRand) => {
466                        return Ok(());
467                    }
468                    _ => {}
469                }
470            }
471            return Err(MacpError::InvalidSessionId);
472        }
473    }
474
475    // Try base64url: at least 22 chars, only [A-Za-z0-9_-]
476    if session_id.len() >= 22
477        && session_id
478            .chars()
479            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
480    {
481        return Ok(());
482    }
483
484    Err(MacpError::InvalidSessionId)
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use prost::Message;
491
492    fn encode_payload(ttl_ms: i64, participants: Vec<String>) -> Vec<u8> {
493        let payload = SessionStartPayload {
494            intent: String::new(),
495            participants,
496            mode_version: "1.0.0".into(),
497            configuration_version: "cfg-1".into(),
498            policy_version: String::new(),
499            ttl_ms,
500            context_id: String::new(),
501            extensions: std::collections::HashMap::new(),
502            roots: vec![],
503            max_suspend_ms: 0,
504        };
505        payload.encode_to_vec()
506    }
507
508    #[test]
509    fn parse_empty_payload_is_invalid() {
510        let err = parse_session_start_payload(b"").unwrap_err();
511        assert_eq!(err.to_string(), "InvalidPayload");
512    }
513
514    #[test]
515    fn parse_valid_protobuf_payload() {
516        let bytes = encode_payload(5000, vec!["alice".into(), "bob".into()]);
517        let result = parse_session_start_payload(&bytes).unwrap();
518        assert_eq!(result.ttl_ms, 5000);
519        assert_eq!(result.participants, vec!["alice", "bob"]);
520    }
521
522    #[test]
523    fn extract_ttl_requires_explicit_positive_value() {
524        let payload = SessionStartPayload::default();
525        assert_eq!(
526            extract_ttl_ms(&payload).unwrap_err().to_string(),
527            "InvalidTtl"
528        );
529
530        let payload = SessionStartPayload {
531            ttl_ms: 5000,
532            ..Default::default()
533        };
534        assert_eq!(extract_ttl_ms(&payload).unwrap(), 5000);
535    }
536
537    #[test]
538    fn standard_mode_requires_explicit_versions() {
539        // Renamed from `standard_mode_requires_explicit_versions_and_participants`
540        // and narrowed: the empty-`participants` half moved out, because
541        // Decision now accepts an empty roster. The version and TTL halves of
542        // the strict contract are kept verbatim so the rest stays pinned; the
543        // roster rule is pinned for every other mode by
544        // `every_standard_mode_except_decision_rejects_an_empty_roster` below.
545        let payload = SessionStartPayload {
546            participants: vec!["alice".into()],
547            mode_version: String::new(),
548            configuration_version: "cfg-1".into(),
549            ttl_ms: 1000,
550            ..Default::default()
551        };
552        assert_eq!(
553            validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
554                .unwrap_err()
555                .to_string(),
556            "InvalidPayload"
557        );
558
559        let payload = SessionStartPayload {
560            participants: vec!["alice".into()],
561            mode_version: "1.0.0".into(),
562            configuration_version: String::new(),
563            ttl_ms: 1000,
564            ..Default::default()
565        };
566        assert_eq!(
567            validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
568                .unwrap_err()
569                .to_string(),
570            "InvalidPayload"
571        );
572
573        let payload = SessionStartPayload {
574            participants: vec!["alice".into()],
575            mode_version: "1.0.0".into(),
576            configuration_version: "cfg-1".into(),
577            ttl_ms: 0,
578            ..Default::default()
579        };
580        assert_eq!(
581            validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
582                .unwrap_err()
583                .to_string(),
584            "InvalidTtl"
585        );
586    }
587
588    /// Build an otherwise-valid strict payload with an empty roster.
589    fn empty_roster_payload() -> SessionStartPayload {
590        SessionStartPayload {
591            participants: vec![],
592            mode_version: "1.0.0".into(),
593            configuration_version: "cfg-1".into(),
594            ttl_ms: 1000,
595            ..Default::default()
596        }
597    }
598
599    #[test]
600    fn decision_accepts_an_empty_participant_list() {
601        assert!(
602            validate_strict_session_start_payload("macp.mode.decision.v1", &empty_roster_payload())
603                .is_ok(),
604            "RFC-MACP-0001 §7.1 requires participants only when the mode does, and \
605             RFC-MACP-0007 makes Decision authority role-based; spec #99's \
606             decision_zero_participants.json pins the accepted SessionStart"
607        );
608        // The relaxation must be the *only* thing that moved: the same payload
609        // with everything else intact still fails on a missing version.
610        let mut broken = empty_roster_payload();
611        broken.mode_version = String::new();
612        assert!(
613            validate_strict_session_start_payload("macp.mode.decision.v1", &broken).is_err(),
614            "an empty roster must not waive the rest of the strict contract"
615        );
616    }
617
618    #[test]
619    fn every_standard_mode_except_decision_rejects_an_empty_roster() {
620        // The structural guard, and the reason the rule lives here rather than
621        // in five `on_session_start` implementations. Iterating the strict-mode
622        // list means a mode *added* to it inherits the roster requirement, and
623        // a future change that widened the carve-out would have to edit this
624        // table to stay green — neither is true of a per-mode guard, which a
625        // PR can delete alongside its own test.
626        for mode in [
627            "macp.mode.proposal.v1",
628            "macp.mode.task.v1",
629            "macp.mode.handoff.v1",
630            "macp.mode.quorum.v1",
631            "ext.multi_round.v1",
632        ] {
633            assert!(
634                requires_strict_session_start(mode),
635                "{mode} must be strict for this table to mean anything"
636            );
637            assert_eq!(
638                validate_strict_session_start_payload(mode, &empty_roster_payload())
639                    .unwrap_err()
640                    .to_string(),
641                "InvalidPayload",
642                "{mode} must still reject an empty participant list at the core layer"
643            );
644        }
645
646        // And a *promoted* extension mode — a name the static carve-out list
647        // has never heard of, which `ModeRegistry::promote_mode` can mark
648        // strict at runtime — keeps the full canonical contract. This is the
649        // trap the two call sites had to avoid: swapping the strictness source
650        // for the core's static list would have dropped canonical validation
651        // for exactly these names.
652        assert_eq!(
653            validate_canonical_session_start_payload_for_mode(
654                "ext.promoted.v1",
655                &empty_roster_payload()
656            )
657            .unwrap_err()
658            .to_string(),
659            "InvalidPayload",
660            "an unrecognised (e.g. promoted) mode must default to the strict roster rule"
661        );
662
663        // The mode-independent entry point keeps its original behaviour, so a
664        // caller that cannot supply a mode name is never silently relaxed.
665        assert_eq!(
666            validate_canonical_session_start_payload(&empty_roster_payload())
667                .unwrap_err()
668                .to_string(),
669            "InvalidPayload"
670        );
671    }
672
673    fn open_session(ttl_expiry: i64) -> Session {
674        Session {
675            session_id: "s1".into(),
676            state: SessionState::Open,
677            ttl_expiry,
678            ttl_ms: 60_000,
679            started_at_unix_ms: 0,
680            resolution: None,
681            mode: "macp.mode.decision.v1".into(),
682            mode_state: vec![],
683            participants: vec![],
684            seen_message_ids: HashSet::new(),
685            intent: String::new(),
686            mode_version: "1.0.0".into(),
687            configuration_version: "cfg-1".into(),
688            policy_version: String::new(),
689            context_id: String::new(),
690            extensions: HashMap::new(),
691            roots: vec![],
692            initiator_sender: "agent://a".into(),
693            participant_message_counts: HashMap::new(),
694            participant_last_seen: HashMap::new(),
695            policy_definition: None,
696            suspended_at_ms: None,
697            accumulated_suspended_ms: 0,
698            semantics_rev: CURRENT_SEMANTICS_REV,
699            max_suspend_ms: 0,
700        }
701    }
702
703    #[test]
704    fn suspend_then_resume_banks_ttl() {
705        let mut s = open_session(10_000);
706        s.suspend(2_000).unwrap();
707        assert_eq!(s.state, SessionState::Suspended);
708        assert_eq!(s.suspended_at_ms, Some(2_000));
709        // Resume 3_000ms later: banked 3_000 is added to the deadline.
710        s.resume(5_000).unwrap();
711        assert_eq!(s.state, SessionState::Open);
712        assert_eq!(s.ttl_expiry, 13_000);
713        assert_eq!(s.accumulated_suspended_ms, 3_000);
714        assert_eq!(s.suspended_at_ms, None);
715    }
716
717    #[test]
718    fn suspend_requires_open_and_resume_requires_suspended() {
719        let mut s = open_session(10_000);
720        // resume on an Open session is rejected
721        assert!(matches!(
722            s.resume(1).unwrap_err(),
723            MacpError::SessionNotOpen
724        ));
725        s.suspend(1).unwrap();
726        // double-suspend rejected
727        assert!(matches!(
728            s.suspend(2).unwrap_err(),
729            MacpError::SessionNotOpen
730        ));
731    }
732
733    #[test]
734    fn resume_exceeding_max_suspend_expires() {
735        let mut s = open_session(10_000);
736        s.suspend(0).unwrap();
737        // Resume after more than MAX_SUSPEND_MS: force-expired.
738        let err = s.resume(MAX_SUSPEND_MS + 1).unwrap_err();
739        assert!(matches!(err, MacpError::TtlExpired));
740        assert_eq!(s.state, SessionState::Expired);
741    }
742
743    /// A session-bound cap (SessionStartPayload.max_suspend_ms) overrides the
744    /// default: resuming past the BOUND cap force-expires even though the
745    /// default cap is nowhere near exceeded.
746    #[test]
747    fn bound_cap_overrides_default_on_resume() {
748        let mut s = open_session(10_000);
749        s.max_suspend_ms = 500;
750        s.suspend(0).unwrap();
751        let err = s.resume(501).unwrap_err();
752        assert!(matches!(err, MacpError::TtlExpired));
753        assert_eq!(s.state, SessionState::Expired);
754    }
755
756    #[test]
757    fn bound_cap_within_limit_resumes_and_banks_ttl() {
758        let mut s = open_session(10_000);
759        s.max_suspend_ms = 500;
760        s.suspend(0).unwrap();
761        s.resume(400).unwrap();
762        assert_eq!(s.state, SessionState::Open);
763        assert_eq!(s.ttl_expiry, 10_400);
764    }
765
766    #[test]
767    fn suspend_cap_exceeded_uses_bound_cap() {
768        let mut s = open_session(10_000);
769        s.max_suspend_ms = 500;
770        s.suspend(0).unwrap();
771        assert!(!s.suspend_cap_exceeded(400));
772        assert!(s.suspend_cap_exceeded(501));
773    }
774
775    #[test]
776    fn unbound_session_uses_default_cap() {
777        let s = open_session(10_000);
778        assert_eq!(s.max_suspend_ms, 0);
779        assert_eq!(s.effective_max_suspend_ms(), MAX_SUSPEND_MS);
780    }
781
782    #[test]
783    fn negative_max_suspend_ms_rejected_in_canonical_payload() {
784        let payload = SessionStartPayload {
785            participants: vec!["a".into()],
786            mode_version: "1.0.0".into(),
787            configuration_version: "cfg-1".into(),
788            ttl_ms: 60_000,
789            max_suspend_ms: -1,
790            ..Default::default()
791        };
792        assert_eq!(
793            validate_canonical_session_start_payload(&payload)
794                .unwrap_err()
795                .to_string(),
796            "InvalidPayload"
797        );
798        // 0 (runtime default) and positive values are both valid.
799        let ok0 = SessionStartPayload {
800            max_suspend_ms: 0,
801            ..payload.clone()
802        };
803        validate_canonical_session_start_payload(&ok0).unwrap();
804        let ok_pos = SessionStartPayload {
805            max_suspend_ms: 60_000,
806            ..payload
807        };
808        validate_canonical_session_start_payload(&ok_pos).unwrap();
809    }
810
811    #[test]
812    fn cancel_from_open_or_suspended_then_terminal_is_rejected() {
813        let mut s = open_session(10_000);
814        s.suspend(1).unwrap();
815        s.cancel().unwrap();
816        assert_eq!(s.state, SessionState::Cancelled);
817        assert_eq!(s.suspended_at_ms, None);
818        // Already terminal: further cancel is rejected.
819        assert!(matches!(s.cancel().unwrap_err(), MacpError::SessionNotOpen));
820
821        let mut open = open_session(10_000);
822        open.cancel().unwrap();
823        assert_eq!(open.state, SessionState::Cancelled);
824    }
825
826    #[test]
827    fn standard_mode_rejects_duplicate_participants() {
828        let payload = SessionStartPayload {
829            participants: vec!["alice".into(), "alice".into()],
830            mode_version: "1.0.0".into(),
831            configuration_version: "cfg-1".into(),
832            ttl_ms: 1000,
833            ..Default::default()
834        };
835        assert_eq!(
836            validate_strict_session_start_payload("macp.mode.proposal.v1", &payload)
837                .unwrap_err()
838                .to_string(),
839            "InvalidPayload"
840        );
841    }
842
843    #[test]
844    fn multi_round_requires_strict_session_start() {
845        let payload = SessionStartPayload::default();
846        assert!(validate_strict_session_start_payload("ext.multi_round.v1", &payload).is_err());
847    }
848
849    #[test]
850    fn valid_uuid_v4_accepted() {
851        let id = uuid::Uuid::new_v4().as_hyphenated().to_string();
852        validate_session_id_for_acceptance(&id).unwrap();
853    }
854
855    #[test]
856    fn valid_base64url_accepted() {
857        // 22-char base64url token
858        validate_session_id_for_acceptance("abcdefghijklmnopqrstuv").unwrap();
859        // longer base64url with underscore and hyphen
860        validate_session_id_for_acceptance("abc-def_ghi-jkl_mno-pqr").unwrap();
861    }
862
863    #[test]
864    fn empty_id_rejected() {
865        assert_eq!(
866            validate_session_id_for_acceptance("")
867                .unwrap_err()
868                .to_string(),
869            "InvalidSessionId"
870        );
871    }
872
873    #[test]
874    fn short_weak_id_rejected() {
875        assert_eq!(
876            validate_session_id_for_acceptance("s1")
877                .unwrap_err()
878                .to_string(),
879            "InvalidSessionId"
880        );
881        assert_eq!(
882            validate_session_id_for_acceptance("decision-demo-1")
883                .unwrap_err()
884                .to_string(),
885            "InvalidSessionId"
886        );
887    }
888
889    #[test]
890    fn uppercase_uuid_rejected() {
891        let id = uuid::Uuid::new_v4()
892            .as_hyphenated()
893            .to_string()
894            .to_uppercase();
895        assert_eq!(
896            validate_session_id_for_acceptance(&id)
897                .unwrap_err()
898                .to_string(),
899            "InvalidSessionId"
900        );
901    }
902
903    #[test]
904    fn base64url_36_chars_with_hyphen_accepted() {
905        // 36-char base64url token containing '-' that is NOT UUID-shaped: must be
906        // accepted via the base64url rule, not rejected by the UUID branch.
907        // (Regression test for the hard-routing bug: len==36 && contains('-')
908        // previously returned Err without trying the base64url rule.)
909        let id = "Zx-abcdefghijklmnopqrstuvwxyz_ABCDE-";
910        assert_eq!(id.len(), 36);
911        assert!(uuid::Uuid::parse_str(id).is_err());
912        validate_session_id_for_acceptance(id).unwrap();
913    }
914
915    #[test]
916    fn uuid_shaped_but_wrong_version_does_not_fall_through() {
917        // A canonical v1 UUID is also 36 chars of valid base64url charset; it must
918        // still be rejected (UUID rules apply, no fall-through to base64url).
919        let v4 = uuid::Uuid::new_v4();
920        let mut bytes = *v4.as_bytes();
921        bytes[6] = (bytes[6] & 0x0F) | 0x10;
922        bytes[8] = (bytes[8] & 0x3F) | 0x80;
923        let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
924        assert!(validate_session_id_for_acceptance(&v1_id).is_err());
925    }
926
927    #[test]
928    fn base64url_too_short_rejected() {
929        assert_eq!(
930            validate_session_id_for_acceptance("abcdefghij")
931                .unwrap_err()
932                .to_string(),
933            "InvalidSessionId"
934        );
935    }
936
937    #[test]
938    fn valid_uuid_v7_accepted() {
939        // Construct a v7 UUID by patching the version nibble of a v4 UUID
940        let v4 = uuid::Uuid::new_v4();
941        let mut bytes = *v4.as_bytes();
942        // Set version nibble (bits 48-51) to 0b0111 (v7)
943        bytes[6] = (bytes[6] & 0x0F) | 0x70;
944        // Keep variant bits valid (RFC 4122: 0b10xx)
945        bytes[8] = (bytes[8] & 0x3F) | 0x80;
946        let v7_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
947        assert!(validate_session_id_for_acceptance(&v7_id).is_ok());
948    }
949
950    #[test]
951    fn uuid_v1_rejected() {
952        // Construct a v1 UUID by patching the version nibble of a v4 UUID
953        let v4 = uuid::Uuid::new_v4();
954        let mut bytes = *v4.as_bytes();
955        // Set version nibble (bits 48-51) to 0b0001 (v1)
956        bytes[6] = (bytes[6] & 0x0F) | 0x10;
957        // Keep variant bits valid (RFC 4122: 0b10xx)
958        bytes[8] = (bytes[8] & 0x3F) | 0x80;
959        let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
960        assert_eq!(
961            validate_session_id_for_acceptance(&v1_id)
962                .unwrap_err()
963                .to_string(),
964            "InvalidSessionId"
965        );
966    }
967
968    #[test]
969    fn too_many_participants_rejected() {
970        let participants: Vec<String> = (0..1001).map(|i| format!("agent://p{i}")).collect();
971        let bytes = encode_payload(5000, participants);
972        let payload = parse_session_start_payload(&bytes).unwrap();
973        assert_eq!(
974            validate_canonical_session_start_payload(&payload)
975                .unwrap_err()
976                .to_string(),
977            "InvalidPayload"
978        );
979    }
980
981    #[test]
982    fn max_participants_accepted() {
983        let participants: Vec<String> = (0..1000).map(|i| format!("agent://p{i}")).collect();
984        let bytes = encode_payload(5000, participants);
985        let payload = parse_session_start_payload(&bytes).unwrap();
986        validate_canonical_session_start_payload(&payload).unwrap();
987    }
988}