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
8/// Default `SessionStart.mode_version` used by this repo's example clients.
9/// `#[doc(hidden)]` — a conventional default, not a value any kernel check
10/// compares against (unlike [`crate::MACP_VERSION`]); promoted here purely so
11/// `tests/parity_contract.rs` and `src/bin/support/common.rs` share one
12/// source instead of two literals that merely happen to agree.
13#[doc(hidden)]
14pub const DEFAULT_MODE_VERSION: &str = "1.0.0";
15
16/// Default `SessionStart.configuration_version` used by this repo's example
17/// clients. Same `#[doc(hidden)]` rationale as [`DEFAULT_MODE_VERSION`].
18#[doc(hidden)]
19pub const DEFAULT_CONFIGURATION_VERSION: &str = "config.default";
20
21pub const MAX_TTL_MS: i64 = 24 * 60 * 60 * 1000;
22
23/// Default cap on the cumulative time a session may spend `Suspended` before
24/// it is force-expired (RFC-MACP-0001 §7.5). Bounds indefinite
25/// human-in-the-loop holds. Sessions may bind their own cap via
26/// `SessionStartPayload.max_suspend_ms` (0 selects this default); the
27/// resolved cap is recorded at SessionStart and used by replay
28/// (RFC-MACP-0003 §2) — see [`Session::effective_max_suspend_ms`].
29pub const MAX_SUSPEND_MS: i64 = 7 * 24 * 60 * 60 * 1000;
30
31/// Cap on the number of completed suspend/resume *cycles* a session may
32/// record in [`Session::suspension_intervals`]. Enforced in
33/// [`Session::resume`] at **every** revision, but with revision-dependent
34/// behavior (see below), so legacy histories replay bit-identically.
35///
36/// Why a *count* cap is needed even though [`MAX_SUSPEND_MS`] exists:
37/// `MAX_SUSPEND_MS` bounds accumulated suspended *duration* (7 days by
38/// default), not the number of cycles — N one-millisecond suspend/resume
39/// cycles accrue ~0 against that budget, and there is no other cycle counter
40/// anywhere in the session model. `SuspendSession`/`ResumeSession` are also
41/// un-rate-limited RPCs (unlike `Send`), so the cycle count is attacker- (or
42/// bug-) controlled by the session initiator alone.
43///
44/// What the cap closes: each cycle writes two full `PersistedSession`
45/// snapshots, and `suspension_intervals` is part of that snapshot — so an
46/// unbounded vec turns two constant-size writes per cycle into O(N) writes,
47/// i.e. O(N²) total snapshot bytes over a session's life. Bounding the vec
48/// bounds the amplification — and the vec must be bounded at *every*
49/// revision, because a session replayed from a legacy log loads at rev 0/1
50/// and can still be suspended and resumed through the un-rate-limited
51/// `SuspendSession`/`ResumeSession` RPCs.
52///
53/// Two behaviors, split on the revision:
54///
55/// - **`semantics_rev >= 2`** — over the cap, `resume` takes the posture it
56///   already takes for `MAX_SUSPEND_MS`: force-expire the session and return
57///   [`MacpError::TtlExpired`].
58/// - **`semantics_rev <= 1`** — `resume` keeps succeeding but simply **stops
59///   recording** once the vec reaches the cap. Nothing reads
60///   `suspension_intervals` below rev 2 ([`Session::unsuspended_deadline`] is
61///   a rev >= 2 path), so dropping the overflow keeps legacy replay
62///   bit-identical while still bounding memory and snapshot size. A legacy
63///   session is never force-expired by a rule that did not exist when it was
64///   accepted.
65pub const MAX_SUSPENSION_CYCLES: usize = 1024;
66
67/// Current session-semantics revision. Recorded at SessionStart (on the
68/// session and its log entry) and consulted wherever acceptance-time behavior
69/// changed across releases, so legacy histories replay under the semantics
70/// they were accepted with (RFC-MACP-0003 §1).
71///
72/// Revisions:
73/// - 0 — legacy: Handoff implicit-accept timed against the client-supplied
74///   envelope timestamp.
75/// - 1 — Handoff implicit-accept times against the runtime acceptance clock
76///   (`MessageContext::accepted_at_ms`).
77/// - 2 — the Handoff implicit accept becomes a **recorded event** rather than
78///   an inference, and its deadline excludes suspended time (RFC-MACP-0010
79///   §5.1). Two changes, one revision:
80///   - *The synthetic entry.* Once an outstanding offer's
81///     `implicit_accept_timeout_ms` has elapsed, the runtime appends a
82///     synthetic `HandoffAccept` envelope to accepted history — sender = the
83///     offer's target, `implicit = true`, deterministic `message_id`
84///     `implicit-accept:<handoff_id>`, and both clocks
85///     (`timestamp_unix_ms` / the entry's `received_at_ms`) fixed at the
86///     computed deadline `D`, never at observation time — *before* evaluating
87///     any subsequent message against the offer's acceptance state (§5.1(2)).
88///     Clients may not submit that shape: the reserved id namespace and
89///     `implicit = true` are rejected at the client boundary (§5.1(3)).
90///     Revisions 0 and 1 record no such entry and instead infer the accept
91///     inside `Commitment` handling, so their histories replay to the outcome
92///     they were accepted with; at rev >= 2 a `Commitment` on a history
93///     lacking the synthetic entry fails loudly instead.
94///   - *The suspension-corrected deadline* (§5.1(1)): time the session spends
95///     `Suspended` no longer counts toward `implicit_accept_timeout_ms`. The
96///     offer record snapshots `accumulated_suspended_ms` at offer time and the
97///     timeout arithmetic subtracts the suspension accrued since the offer.
98///     Revisions 0 and 1 keep counting suspended time.
99pub const CURRENT_SEMANTICS_REV: u32 = 2;
100
101#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
102pub enum SessionState {
103    Open,
104    /// Non-terminal pause of an `Open` session (RFC-MACP-0001 §7.5). TTL is
105    /// banked while suspended; only `Open`<->`Suspended` and `Suspended`->
106    /// `Expired`/`Cancelled` transitions are permitted.
107    Suspended,
108    Resolved,
109    Expired,
110    /// Terminal: ended by an accepted `CancelSession` (distinct from `Expired`).
111    Cancelled,
112}
113
114impl SessionState {
115    /// Terminal states accept no further transitions.
116    pub fn is_terminal(&self) -> bool {
117        matches!(
118            self,
119            SessionState::Resolved | SessionState::Expired | SessionState::Cancelled
120        )
121    }
122}
123
124/// Session model. Fields are public for reads, but the struct is
125/// `#[non_exhaustive]`: construct via [`Session::builder`]. This lets the model
126/// gain fields without breaking every constructor in downstream crates
127/// (pre-1.0 freeze requirement).
128#[non_exhaustive]
129#[derive(Clone, Debug)]
130pub struct Session {
131    pub session_id: String,
132    pub state: SessionState,
133    pub ttl_expiry: i64,
134    pub ttl_ms: i64,
135    pub started_at_unix_ms: i64,
136    pub resolution: Option<Vec<u8>>,
137    pub mode: String,
138    pub mode_state: Vec<u8>,
139    pub participants: Vec<String>,
140    pub seen_message_ids: HashSet<String>,
141    pub intent: String,
142    pub mode_version: String,
143    pub configuration_version: String,
144    pub policy_version: String,
145    pub context_id: String,
146    pub extensions: HashMap<String, Vec<u8>>,
147    pub roots: Vec<macp_pb::pb::Root>,
148    pub initiator_sender: String,
149    pub participant_message_counts: HashMap<String, u32>,
150    pub participant_last_seen: HashMap<String, i64>,
151    pub policy_definition: Option<PolicyDefinition>,
152    /// Wall-clock (session-timeline) ms at which the session was suspended, or
153    /// `None` when not suspended. Used to bank TTL across a suspension (§7.5).
154    pub suspended_at_ms: Option<i64>,
155    /// Cumulative ms the session has spent suspended across all suspend/resume
156    /// cycles. Drives the `MAX_SUSPEND_MS` cap.
157    pub accumulated_suspended_ms: i64,
158    /// Completed `(suspended_at, resumed_at)` pairs on the session timeline
159    /// (ms), in the order they completed. An *in-progress* suspension is
160    /// deliberately **not** here — the pair is pushed by [`Session::resume`],
161    /// so the vec always describes finished pauses only.
162    ///
163    /// Recorded at **every** `semantics_rev` (so a session that later matters
164    /// has the history), but read only at rev >= 2, by
165    /// [`Session::unsuspended_deadline`] (RFC-MACP-0010 §5.1). Legacy
166    /// snapshots and checkpoints deserialize this as empty; see
167    /// `unsuspended_deadline`'s under-count invariant for why that is safe.
168    ///
169    /// Bounded at every revision by [`MAX_SUSPENSION_CYCLES`] — rev >= 2
170    /// force-expires the session over the cap, rev <= 1 stops recording.
171    pub suspension_intervals: Vec<(i64, i64)>,
172    /// Session-semantics revision this session was accepted under. See
173    /// [`CURRENT_SEMANTICS_REV`]. Legacy persisted sessions load as `0`.
174    pub semantics_rev: u32,
175    /// Maximum-suspension cap bound at SessionStart (RFC-MACP-0001 §7.5,
176    /// RFC-MACP-0003 §2). `0` = unbound (legacy sessions and library
177    /// defaults) — the [`MAX_SUSPEND_MS`] default applies; see
178    /// [`Session::effective_max_suspend_ms`]. The kernel records the
179    /// *resolved* value here for new sessions.
180    pub max_suspend_ms: i64,
181}
182
183impl Session {
184    /// Start building a `Session`. The three arguments are the fields with no
185    /// meaningful default; everything else starts from documented defaults
186    /// (see [`SessionBuilder`]) and is set with the builder's methods.
187    pub fn builder(
188        session_id: impl Into<String>,
189        mode: impl Into<String>,
190        initiator_sender: impl Into<String>,
191    ) -> SessionBuilder {
192        SessionBuilder {
193            inner: Session {
194                session_id: session_id.into(),
195                state: SessionState::Open,
196                // Never-expires by default: every kernel path overrides this
197                // from the validated SessionStart payload; library/test
198                // consumers get a session that behaves until told otherwise.
199                ttl_expiry: i64::MAX,
200                ttl_ms: 0,
201                started_at_unix_ms: 0,
202                resolution: None,
203                mode: mode.into(),
204                mode_state: vec![],
205                participants: vec![],
206                seen_message_ids: HashSet::new(),
207                intent: String::new(),
208                mode_version: String::new(),
209                configuration_version: String::new(),
210                policy_version: String::new(),
211                context_id: String::new(),
212                extensions: HashMap::new(),
213                roots: vec![],
214                initiator_sender: initiator_sender.into(),
215                participant_message_counts: HashMap::new(),
216                participant_last_seen: HashMap::new(),
217                policy_definition: None,
218                suspended_at_ms: None,
219                accumulated_suspended_ms: 0,
220                suspension_intervals: vec![],
221                semantics_rev: CURRENT_SEMANTICS_REV,
222                max_suspend_ms: 0,
223            },
224        }
225    }
226
227    pub fn record_participant_activity(&mut self, sender: &str, timestamp_ms: i64) {
228        *self
229            .participant_message_counts
230            .entry(sender.to_string())
231            .or_insert(0) += 1;
232        self.participant_last_seen
233            .insert(sender.to_string(), timestamp_ms);
234    }
235
236    /// Suspend an `Open` session (RFC-MACP-0001 §7.5). Records the suspend time
237    /// so TTL can be banked on resume. Pure: no clock, no I/O — the caller
238    /// injects `now_ms`.
239    pub fn suspend(&mut self, now_ms: i64) -> Result<(), MacpError> {
240        if self.state != SessionState::Open {
241            return Err(MacpError::SessionNotOpen);
242        }
243        self.state = SessionState::Suspended;
244        self.suspended_at_ms = Some(now_ms);
245        Ok(())
246    }
247
248    /// The suspension cap governing this session: the value bound at
249    /// SessionStart, or the [`MAX_SUSPEND_MS`] default when unbound (0).
250    pub fn effective_max_suspend_ms(&self) -> i64 {
251        if self.max_suspend_ms > 0 {
252            self.max_suspend_ms
253        } else {
254            MAX_SUSPEND_MS
255        }
256    }
257
258    /// Resume a `Suspended` session, banking the suspended duration into the
259    /// TTL deadline (`ttl_expiry += now - suspended_at`) and recording the
260    /// completed pause in [`Session::suspension_intervals`].
261    ///
262    /// Force-expires the session (state `Expired`, `Err(TtlExpired)`) when
263    /// either suspension cap is exceeded: cumulative duration past
264    /// [`MAX_SUSPEND_MS`] (every revision), or completed cycle count past
265    /// [`MAX_SUSPENSION_CYCLES`] (`semantics_rev >= 2` only — at rev <= 1 the
266    /// cap instead stops the recording, see [`MAX_SUSPENSION_CYCLES`]). The
267    /// pair is pushed before either check, so history is recorded even on the
268    /// expiring call.
269    ///
270    /// Pure: no clock, no I/O — the caller injects `now_ms`.
271    pub fn resume(&mut self, now_ms: i64) -> Result<(), MacpError> {
272        if self.state != SessionState::Suspended {
273            return Err(MacpError::SessionNotOpen);
274        }
275        let suspended_at = self.suspended_at_ms.unwrap_or(now_ms);
276        let banked = (now_ms - suspended_at).max(0);
277        self.accumulated_suspended_ms = self.accumulated_suspended_ms.saturating_add(banked);
278        self.suspended_at_ms = None;
279        // Record the completed pair at EVERY revision and BEFORE either cap
280        // check can return: the vec is history, not a decision input, and a
281        // pause that force-expires the session is still a pause that happened.
282        // (Phase-9 precedent: record everywhere, read only under rev >= 2.)
283        //
284        // The one exception is the rev <= 1 overflow below: a legacy session
285        // is reachable through the un-rate-limited Suspend/Resume RPCs just
286        // like a current one, so its vec has to be bounded too — but it must
287        // not be force-expired by a rule postdating its acceptance. Since
288        // nothing reads the vec below rev 2, dropping the overflow bounds
289        // memory and snapshot size while leaving legacy replay bit-identical.
290        if self.semantics_rev >= 2 || self.suspension_intervals.len() < MAX_SUSPENSION_CYCLES {
291            self.suspension_intervals.push((suspended_at, now_ms));
292        }
293        // Cycle-count cap, rev >= 2 only — see `MAX_SUSPENSION_CYCLES`. Gated
294        // on the revision so rev <= 1 histories replay bit-identically.
295        if self.semantics_rev >= 2 && self.suspension_intervals.len() > MAX_SUSPENSION_CYCLES {
296            self.state = SessionState::Expired;
297            return Err(MacpError::TtlExpired);
298        }
299        if self.accumulated_suspended_ms > self.effective_max_suspend_ms() {
300            self.state = SessionState::Expired;
301            return Err(MacpError::TtlExpired);
302        }
303        self.ttl_expiry = self.ttl_expiry.saturating_add(banked);
304        self.state = SessionState::Open;
305        Ok(())
306    }
307
308    /// Cancel an `Open` or `Suspended` session into the terminal `Cancelled`
309    /// state (RFC-MACP-0001 §7.3). Returns an error if already terminal.
310    pub fn cancel(&mut self) -> Result<(), MacpError> {
311        if self.state.is_terminal() {
312            return Err(MacpError::SessionNotOpen);
313        }
314        self.state = SessionState::Cancelled;
315        self.suspended_at_ms = None;
316        Ok(())
317    }
318
319    /// Whether a currently-`Suspended` session has exceeded `MAX_SUSPEND_MS` as
320    /// of `now_ms` (cumulative banked plus the in-progress suspension).
321    pub fn suspend_cap_exceeded(&self, now_ms: i64) -> bool {
322        match self.suspended_at_ms {
323            Some(at) => {
324                self.accumulated_suspended_ms
325                    .saturating_add((now_ms - at).max(0))
326                    > self.effective_max_suspend_ms()
327            }
328            None => self.accumulated_suspended_ms > self.effective_max_suspend_ms(),
329        }
330    }
331
332    /// The session-timeline instant at which `duration_ms` of **unsuspended**
333    /// time has elapsed since `from_ms` — i.e. the earliest `T` with
334    /// `(T - from_ms) - suspended_in[from_ms, T] >= duration_ms`.
335    ///
336    /// This is RFC-MACP-0010 §5.1(3)'s own formula for the synthetic implicit
337    /// accept's timestamp ("offer acceptance time + timeout + suspended time
338    /// **within the window**"), evaluated on the recorded timeline required by
339    /// §5.1(1). It is deliberately not the naive
340    /// `from_ms + duration_ms + banked_since(from_ms)`: that counts pauses
341    /// that begin *after* the true deadline, so it is wrong whenever a
342    /// suspend/resume pair lands between the deadline and the observation —
343    /// fully reachable, since `SuspendSession`/`ResumeSession` are RPCs that
344    /// need no session-scoped message. It would also make the timestamp
345    /// depend on *when* it was computed, which is unacceptable for a value
346    /// baked into permanent history.
347    ///
348    /// The walk: start at `from_ms` with the full `duration_ms` remaining;
349    /// for each completed pause `(s, e)` starting at or after `from_ms`, if
350    /// the unsuspended run up to `s` already covers what remains, stop inside
351    /// that run; otherwise consume it and jump to `e`. A pause starting
352    /// exactly at the returned deadline does not extend it — the offer's
353    /// unsuspended time had already hit the timeout at that instant.
354    ///
355    /// Pure and saturating: no clock read, no I/O, no panics on overflow.
356    ///
357    /// **Under-count invariant.** The walk's contribution satisfies
358    /// `walk_sum <= accumulated_suspended_ms - offer.suspended_ms_at_offer`:
359    /// [`Session::suspension_intervals`] may *under*-report completed pauses
360    /// but can never over-report them. Three distinct sources produce a short
361    /// or empty vec, each permanent for the life of that log:
362    ///
363    /// 1. A snapshot or checkpoint written *before the field existed*
364    ///    deserializes it as empty (`#[serde(default)]`) while
365    ///    `accumulated_suspended_ms` is already positive.
366    /// 2. Any replay — including a post-11b one — that resumes from a
367    ///    *pre-11b mid-session checkpoint*: the fast path replays only
368    ///    `&log_entries[idx + 1..]`, so every pause that completed before that
369    ///    checkpoint is gone and can never be recovered, no matter how many
370    ///    times the log is replayed afterwards.
371    /// 3. A `semantics_rev <= 1` session that exceeded
372    ///    [`MAX_SUSPENSION_CYCLES`]: recording stops rather than
373    ///    force-expiring, so pairs past the cap are dropped. This one is
374    ///    outside this function's read domain by construction — the walk is
375    ///    only consulted at `semantics_rev >= 2`, where the cap force-expires
376    ///    instead of dropping — so the enumeration above is exhaustive for
377    ///    every vec this function can actually be asked to walk.
378    ///
379    /// An under-count only moves the returned deadline *earlier*, never later
380    /// — the safe direction, so callers may rely on `deadline <= now_ms` once
381    /// the scalar arithmetic has already decided the timeout elapsed.
382    ///
383    /// **Why a short vec cannot break replay determinism.** This describes the
384    /// design this function exists to serve; the synthetic entry itself
385    /// arrives in a later phase. Replay is never to *recompute* a synthetic
386    /// implicit-accept timestamp — it replays the recorded synthetic entry as
387    /// data. Only the live emitter computes a deadline, exactly once, at
388    /// *emission* time, from the offer's recorded `offered_at_ms`. So a short
389    /// vec can make a
390    /// *newly* emitted implicit accept land earlier than a fully-recorded one
391    /// would have, but it can never make a *replayed* one disagree with the
392    /// live value already baked into history — byte-identical replay is not
393    /// foreclosed.
394    pub fn unsuspended_deadline(&self, from_ms: i64, duration_ms: i64) -> i64 {
395        let mut cur = from_ms;
396        let mut remaining = duration_ms;
397        for &(s, e) in self
398            .suspension_intervals
399            .iter()
400            .filter(|(s, _)| *s >= from_ms)
401        {
402            // Clamp both the run and the jump. `Utc::now()` is not monotonic
403            // (an NTP step back between suspend and resume yields `e < s`),
404            // pairs can overlap or nest, and the public
405            // `SessionBuilder::suspension_intervals` setter lets a library
406            // consumer supply anything at all. Without the clamps a negative
407            // `run` would *grow* `remaining` and a backwards `e` would rewind
408            // `cur`, i.e. the walk would OVER-report and violate the
409            // under-count invariant above.
410            let run = s.saturating_sub(cur).max(0);
411            if run >= remaining {
412                return cur.saturating_add(remaining);
413            }
414            remaining = remaining.saturating_sub(run);
415            // `s` is in the max as well as `e`: the run up to `s` was just
416            // consumed, so `cur` must end at or after `s` even when the pair
417            // is backwards. Jumping only to `e` there would spend the run
418            // without advancing the cursor, returning a deadline *before*
419            // `from_ms + duration_ms` — safe against over-reporting, but a
420            // timeout that fires before it nominally elapsed is its own bug.
421            // A degenerate pair is therefore treated as a zero-width pause.
422            cur = e.max(s).max(cur);
423        }
424        cur.saturating_add(remaining)
425    }
426
427    pub fn apply_mode_response(&mut self, response: ModeResponse) {
428        match response {
429            ModeResponse::NoOp => {}
430            ModeResponse::PersistState(state) => self.mode_state = state,
431            ModeResponse::Resolve(resolution) => {
432                self.state = SessionState::Resolved;
433                self.resolution = Some(resolution);
434            }
435            ModeResponse::PersistAndResolve { state, resolution } => {
436                self.mode_state = state;
437                self.state = SessionState::Resolved;
438                self.resolution = Some(resolution);
439            }
440        }
441    }
442}
443
444/// Builder for [`Session`] — the only construction path outside `macp-core`
445/// (the struct is `#[non_exhaustive]`).
446///
447/// Defaults: `state: Open`, `ttl_expiry: i64::MAX` (never expires until set),
448/// numeric fields `0`, everything else empty/`None`.
449#[derive(Clone, Debug)]
450pub struct SessionBuilder {
451    inner: Session,
452}
453
454macro_rules! builder_setters {
455    ($($(#[$doc:meta])* $name:ident: $ty:ty),* $(,)?) => {
456        $(
457            $(#[$doc])*
458            pub fn $name(mut self, value: $ty) -> Self {
459                self.inner.$name = value;
460                self
461            }
462        )*
463    };
464}
465
466impl SessionBuilder {
467    builder_setters! {
468        state: SessionState,
469        ttl_expiry: i64,
470        ttl_ms: i64,
471        started_at_unix_ms: i64,
472        resolution: Option<Vec<u8>>,
473        mode_state: Vec<u8>,
474        participants: Vec<String>,
475        seen_message_ids: HashSet<String>,
476        extensions: HashMap<String, Vec<u8>>,
477        roots: Vec<macp_pb::pb::Root>,
478        participant_message_counts: HashMap<String, u32>,
479        participant_last_seen: HashMap<String, i64>,
480        policy_definition: Option<crate::policy::PolicyDefinition>,
481        suspended_at_ms: Option<i64>,
482        accumulated_suspended_ms: i64,
483        /// Completed suspend/resume pairs (see
484        /// [`Session::suspension_intervals`]). Needed so persistence layers
485        /// can restore the field — `Session` is `#[non_exhaustive]`, so they
486        /// cannot use a struct literal.
487        suspension_intervals: Vec<(i64, i64)>,
488        semantics_rev: u32,
489        /// Suspension cap bound at SessionStart; 0 = use the
490        /// [`MAX_SUSPEND_MS`] default (legacy sessions, library consumers).
491        max_suspend_ms: i64,
492    }
493
494    pub fn intent(mut self, value: impl Into<String>) -> Self {
495        self.inner.intent = value.into();
496        self
497    }
498
499    pub fn mode_version(mut self, value: impl Into<String>) -> Self {
500        self.inner.mode_version = value.into();
501        self
502    }
503
504    pub fn configuration_version(mut self, value: impl Into<String>) -> Self {
505        self.inner.configuration_version = value.into();
506        self
507    }
508
509    pub fn policy_version(mut self, value: impl Into<String>) -> Self {
510        self.inner.policy_version = value.into();
511        self
512    }
513
514    pub fn context_id(mut self, value: impl Into<String>) -> Self {
515        self.inner.context_id = value.into();
516        self
517    }
518
519    pub fn build(self) -> Session {
520        self.inner
521    }
522}
523
524pub fn requires_strict_session_start(mode: &str) -> bool {
525    matches!(
526        mode,
527        "macp.mode.decision.v1"
528            | "macp.mode.proposal.v1"
529            | "macp.mode.task.v1"
530            | "macp.mode.handoff.v1"
531            | "macp.mode.quorum.v1"
532            | "ext.multi_round.v1"
533    )
534}
535
536/// Parse a protobuf-encoded SessionStartPayload from raw bytes.
537pub fn parse_session_start_payload(payload: &[u8]) -> Result<SessionStartPayload, MacpError> {
538    if payload.is_empty() {
539        return Err(MacpError::InvalidPayload);
540    }
541    SessionStartPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
542}
543
544/// Extract and validate TTL from a parsed SessionStartPayload.
545pub fn extract_ttl_ms(payload: &SessionStartPayload) -> Result<i64, MacpError> {
546    if !(1..=MAX_TTL_MS).contains(&payload.ttl_ms) {
547        return Err(MacpError::InvalidTtl);
548    }
549    Ok(payload.ttl_ms)
550}
551
552/// Modes whose canonical `SessionStart` may bind an **empty** `participants`
553/// list.
554///
555/// Decision alone. RFC-MACP-0001 §7.1 requires `participants` only "when
556/// required by the Mode", and RFC-MACP-0007 makes the initiator's authority
557/// role-based rather than membership-based, so a Decision session with no
558/// declared participants is well-defined: nobody — the initiator included — can
559/// emit a `Proposal`, `Evaluation`, `Objection` or `Vote`, because
560/// `DecisionMode::authorize_sender` routes all four through
561/// `is_declared_participant`, which is `false` over an empty list. Such a
562/// session can therefore only expire or be cancelled. Spec #99 removed
563/// `minItems: 1` from the conformance fixture schema on exactly that reasoning
564/// and added `decision_zero_participants.json` to pin it.
565///
566/// **Deliberately a positive allowlist of one, checked in one place.** The
567/// other four standards-track modes each re-reject an insufficient roster in
568/// their own `on_session_start`, but those are five independent
569/// implementations: if the rule lived only there, deleting any one guard would
570/// silently remove the guarantee with nothing at the core level left to notice.
571/// Keeping the rule here means the exception is named once and every other
572/// mode — including a promoted extension mode the list below has never heard
573/// of — keeps the full canonical contract by default.
574fn allows_empty_participants(mode: &str) -> bool {
575    mode == "macp.mode.decision.v1"
576}
577
578/// Validate the complete canonical SessionStart binding contract.
579///
580/// Mode-independent, and therefore holds the roster non-emptiness rule for
581/// **every** mode. Prefer
582/// [`validate_canonical_session_start_payload_for_mode`] on any path that knows
583/// the mode name; this entry point is retained with its original signature and
584/// its original behaviour.
585pub fn validate_canonical_session_start_payload(
586    payload: &SessionStartPayload,
587) -> Result<(), MacpError> {
588    validate_canonical_start(payload, false)
589}
590
591/// The canonical SessionStart binding contract, with the roster rule scoped to
592/// the mode.
593///
594/// Identical to [`validate_canonical_session_start_payload`] in every respect
595/// except one: an empty `participants` list is accepted for the modes
596/// `allows_empty_participants` names (Decision, and only Decision) and
597/// rejected for all others, promoted extension modes included.
598///
599/// This is additive rather than a new parameter on
600/// [`validate_canonical_session_start_payload`] on purpose — changing that
601/// function's signature would be a `macp-core` API break, and every crate in
602/// this workspace shares one version.
603pub fn validate_canonical_session_start_payload_for_mode(
604    mode: &str,
605    payload: &SessionStartPayload,
606) -> Result<(), MacpError> {
607    validate_canonical_start(payload, allows_empty_participants(mode))
608}
609
610fn validate_canonical_start(
611    payload: &SessionStartPayload,
612    allow_empty_participants: bool,
613) -> Result<(), MacpError> {
614    extract_ttl_ms(payload)?;
615
616    if payload.mode_version.trim().is_empty() || payload.configuration_version.trim().is_empty() {
617        return Err(MacpError::InvalidPayload);
618    }
619
620    if payload.participants.is_empty() && !allow_empty_participants {
621        return Err(MacpError::InvalidPayload);
622    }
623
624    // Safety limit: prevent resource exhaustion from excessively large participant lists.
625    const MAX_PARTICIPANTS: usize = 1000;
626    if payload.participants.len() > MAX_PARTICIPANTS {
627        return Err(MacpError::InvalidPayload);
628    }
629
630    let mut seen = HashSet::new();
631    for participant in &payload.participants {
632        let participant = participant.trim();
633        if participant.is_empty() || !seen.insert(participant.to_string()) {
634            return Err(MacpError::InvalidPayload);
635        }
636    }
637
638    // max_suspend_ms: 0 selects the runtime default; a positive value binds a
639    // session-specific cap (RFC-MACP-0001 §7.1). Negative is meaningless.
640    if payload.max_suspend_ms < 0 {
641        return Err(MacpError::InvalidPayload);
642    }
643
644    Ok(())
645}
646
647/// Enforce the strict SessionStart binding contract for standards-track and qualifying extension modes.
648pub fn validate_strict_session_start_payload(
649    mode: &str,
650    payload: &SessionStartPayload,
651) -> Result<(), MacpError> {
652    if !requires_strict_session_start(mode) {
653        return Ok(());
654    }
655
656    validate_canonical_session_start_payload_for_mode(mode, payload)
657}
658
659/// Validate that a session ID meets the acceptance policy.
660///
661/// Accepts:
662/// - UUID v4/v7 in hyphenated lowercase canonical form (36 chars)
663/// - base64url tokens of 22+ chars (`[A-Za-z0-9_-]`)
664///
665/// Rejects everything else (empty, short human-readable, uppercase UUID, etc.).
666pub fn validate_session_id_for_acceptance(session_id: &str) -> Result<(), MacpError> {
667    if session_id.is_empty() {
668        return Err(MacpError::InvalidSessionId);
669    }
670
671    // UUID-shaped ids (36 chars, parseable) are held to strict UUID rules with no
672    // fall-through: canonical lowercase hyphenated form, version v4 or v7. This
673    // keeps non-canonical forms (e.g. uppercase) of the same UUID from being
674    // admitted as distinct base64url tokens. Only strings that do not parse as a
675    // UUID at all fall through to the base64url rule.
676    if session_id.len() == 36 && session_id.contains('-') {
677        if let Ok(parsed) = uuid::Uuid::parse_str(session_id) {
678            if parsed.as_hyphenated().to_string() == session_id {
679                match parsed.get_version() {
680                    Some(uuid::Version::Random) | Some(uuid::Version::SortRand) => {
681                        return Ok(());
682                    }
683                    _ => {}
684                }
685            }
686            return Err(MacpError::InvalidSessionId);
687        }
688    }
689
690    // Try base64url: at least 22 chars, only [A-Za-z0-9_-]
691    if session_id.len() >= 22
692        && session_id
693            .chars()
694            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
695    {
696        return Ok(());
697    }
698
699    Err(MacpError::InvalidSessionId)
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use prost::Message;
706
707    #[test]
708    fn default_mode_and_configuration_version_values() {
709        assert_eq!(DEFAULT_MODE_VERSION, "1.0.0");
710        assert_eq!(DEFAULT_CONFIGURATION_VERSION, "config.default");
711    }
712
713    fn encode_payload(ttl_ms: i64, participants: Vec<String>) -> Vec<u8> {
714        let payload = SessionStartPayload {
715            intent: String::new(),
716            participants,
717            mode_version: "1.0.0".into(),
718            configuration_version: "cfg-1".into(),
719            policy_version: String::new(),
720            ttl_ms,
721            context_id: String::new(),
722            extensions: std::collections::HashMap::new(),
723            roots: vec![],
724            max_suspend_ms: 0,
725        };
726        payload.encode_to_vec()
727    }
728
729    #[test]
730    fn parse_empty_payload_is_invalid() {
731        let err = parse_session_start_payload(b"").unwrap_err();
732        assert_eq!(err.to_string(), "InvalidPayload");
733    }
734
735    #[test]
736    fn parse_valid_protobuf_payload() {
737        let bytes = encode_payload(5000, vec!["alice".into(), "bob".into()]);
738        let result = parse_session_start_payload(&bytes).unwrap();
739        assert_eq!(result.ttl_ms, 5000);
740        assert_eq!(result.participants, vec!["alice", "bob"]);
741    }
742
743    #[test]
744    fn extract_ttl_requires_explicit_positive_value() {
745        let payload = SessionStartPayload::default();
746        assert_eq!(
747            extract_ttl_ms(&payload).unwrap_err().to_string(),
748            "InvalidTtl"
749        );
750
751        let payload = SessionStartPayload {
752            ttl_ms: 5000,
753            ..Default::default()
754        };
755        assert_eq!(extract_ttl_ms(&payload).unwrap(), 5000);
756    }
757
758    #[test]
759    fn standard_mode_requires_explicit_versions() {
760        // Renamed from `standard_mode_requires_explicit_versions_and_participants`
761        // and narrowed: the empty-`participants` half moved out, because
762        // Decision now accepts an empty roster. The version and TTL halves of
763        // the strict contract are kept verbatim so the rest stays pinned; the
764        // roster rule is pinned for every other mode by
765        // `every_standard_mode_except_decision_rejects_an_empty_roster` below.
766        let payload = SessionStartPayload {
767            participants: vec!["alice".into()],
768            mode_version: String::new(),
769            configuration_version: "cfg-1".into(),
770            ttl_ms: 1000,
771            ..Default::default()
772        };
773        assert_eq!(
774            validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
775                .unwrap_err()
776                .to_string(),
777            "InvalidPayload"
778        );
779
780        let payload = SessionStartPayload {
781            participants: vec!["alice".into()],
782            mode_version: "1.0.0".into(),
783            configuration_version: String::new(),
784            ttl_ms: 1000,
785            ..Default::default()
786        };
787        assert_eq!(
788            validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
789                .unwrap_err()
790                .to_string(),
791            "InvalidPayload"
792        );
793
794        let payload = SessionStartPayload {
795            participants: vec!["alice".into()],
796            mode_version: "1.0.0".into(),
797            configuration_version: "cfg-1".into(),
798            ttl_ms: 0,
799            ..Default::default()
800        };
801        assert_eq!(
802            validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
803                .unwrap_err()
804                .to_string(),
805            "InvalidTtl"
806        );
807    }
808
809    /// Build an otherwise-valid strict payload with an empty roster.
810    fn empty_roster_payload() -> SessionStartPayload {
811        SessionStartPayload {
812            participants: vec![],
813            mode_version: "1.0.0".into(),
814            configuration_version: "cfg-1".into(),
815            ttl_ms: 1000,
816            ..Default::default()
817        }
818    }
819
820    #[test]
821    fn decision_accepts_an_empty_participant_list() {
822        assert!(
823            validate_strict_session_start_payload("macp.mode.decision.v1", &empty_roster_payload())
824                .is_ok(),
825            "RFC-MACP-0001 §7.1 requires participants only when the mode does, and \
826             RFC-MACP-0007 makes Decision authority role-based; spec #99's \
827             decision_zero_participants.json pins the accepted SessionStart"
828        );
829        // The relaxation must be the *only* thing that moved: the same payload
830        // with everything else intact still fails on a missing version.
831        let mut broken = empty_roster_payload();
832        broken.mode_version = String::new();
833        assert!(
834            validate_strict_session_start_payload("macp.mode.decision.v1", &broken).is_err(),
835            "an empty roster must not waive the rest of the strict contract"
836        );
837    }
838
839    #[test]
840    fn every_standard_mode_except_decision_rejects_an_empty_roster() {
841        // The structural guard, and the reason the rule lives here rather than
842        // in five `on_session_start` implementations. Iterating the strict-mode
843        // list means a mode *added* to it inherits the roster requirement, and
844        // a future change that widened the carve-out would have to edit this
845        // table to stay green — neither is true of a per-mode guard, which a
846        // PR can delete alongside its own test.
847        for mode in [
848            "macp.mode.proposal.v1",
849            "macp.mode.task.v1",
850            "macp.mode.handoff.v1",
851            "macp.mode.quorum.v1",
852            "ext.multi_round.v1",
853        ] {
854            assert!(
855                requires_strict_session_start(mode),
856                "{mode} must be strict for this table to mean anything"
857            );
858            assert_eq!(
859                validate_strict_session_start_payload(mode, &empty_roster_payload())
860                    .unwrap_err()
861                    .to_string(),
862                "InvalidPayload",
863                "{mode} must still reject an empty participant list at the core layer"
864            );
865        }
866
867        // And a *promoted* extension mode — a name the static carve-out list
868        // has never heard of, which `ModeRegistry::promote_mode` can mark
869        // strict at runtime — keeps the full canonical contract. This is the
870        // trap the two call sites had to avoid: swapping the strictness source
871        // for the core's static list would have dropped canonical validation
872        // for exactly these names.
873        assert_eq!(
874            validate_canonical_session_start_payload_for_mode(
875                "ext.promoted.v1",
876                &empty_roster_payload()
877            )
878            .unwrap_err()
879            .to_string(),
880            "InvalidPayload",
881            "an unrecognised (e.g. promoted) mode must default to the strict roster rule"
882        );
883
884        // The mode-independent entry point keeps its original behaviour, so a
885        // caller that cannot supply a mode name is never silently relaxed.
886        assert_eq!(
887            validate_canonical_session_start_payload(&empty_roster_payload())
888                .unwrap_err()
889                .to_string(),
890            "InvalidPayload"
891        );
892    }
893
894    fn open_session(ttl_expiry: i64) -> Session {
895        Session {
896            session_id: "s1".into(),
897            state: SessionState::Open,
898            ttl_expiry,
899            ttl_ms: 60_000,
900            started_at_unix_ms: 0,
901            resolution: None,
902            mode: "macp.mode.decision.v1".into(),
903            mode_state: vec![],
904            participants: vec![],
905            seen_message_ids: HashSet::new(),
906            intent: String::new(),
907            mode_version: "1.0.0".into(),
908            configuration_version: "cfg-1".into(),
909            policy_version: String::new(),
910            context_id: String::new(),
911            extensions: HashMap::new(),
912            roots: vec![],
913            initiator_sender: "agent://a".into(),
914            participant_message_counts: HashMap::new(),
915            participant_last_seen: HashMap::new(),
916            policy_definition: None,
917            suspended_at_ms: None,
918            accumulated_suspended_ms: 0,
919            suspension_intervals: vec![],
920            semantics_rev: CURRENT_SEMANTICS_REV,
921            max_suspend_ms: 0,
922        }
923    }
924
925    #[test]
926    fn suspend_then_resume_banks_ttl() {
927        let mut s = open_session(10_000);
928        s.suspend(2_000).unwrap();
929        assert_eq!(s.state, SessionState::Suspended);
930        assert_eq!(s.suspended_at_ms, Some(2_000));
931        // Resume 3_000ms later: banked 3_000 is added to the deadline.
932        s.resume(5_000).unwrap();
933        assert_eq!(s.state, SessionState::Open);
934        assert_eq!(s.ttl_expiry, 13_000);
935        assert_eq!(s.accumulated_suspended_ms, 3_000);
936        assert_eq!(s.suspended_at_ms, None);
937    }
938
939    #[test]
940    fn suspend_requires_open_and_resume_requires_suspended() {
941        let mut s = open_session(10_000);
942        // resume on an Open session is rejected
943        assert!(matches!(
944            s.resume(1).unwrap_err(),
945            MacpError::SessionNotOpen
946        ));
947        s.suspend(1).unwrap();
948        // double-suspend rejected
949        assert!(matches!(
950            s.suspend(2).unwrap_err(),
951            MacpError::SessionNotOpen
952        ));
953    }
954
955    #[test]
956    fn resume_exceeding_max_suspend_expires() {
957        let mut s = open_session(10_000);
958        s.suspend(0).unwrap();
959        // Resume after more than MAX_SUSPEND_MS: force-expired.
960        let err = s.resume(MAX_SUSPEND_MS + 1).unwrap_err();
961        assert!(matches!(err, MacpError::TtlExpired));
962        assert_eq!(s.state, SessionState::Expired);
963    }
964
965    /// A session-bound cap (SessionStartPayload.max_suspend_ms) overrides the
966    /// default: resuming past the BOUND cap force-expires even though the
967    /// default cap is nowhere near exceeded.
968    #[test]
969    fn bound_cap_overrides_default_on_resume() {
970        let mut s = open_session(10_000);
971        s.max_suspend_ms = 500;
972        s.suspend(0).unwrap();
973        let err = s.resume(501).unwrap_err();
974        assert!(matches!(err, MacpError::TtlExpired));
975        assert_eq!(s.state, SessionState::Expired);
976    }
977
978    #[test]
979    fn bound_cap_within_limit_resumes_and_banks_ttl() {
980        let mut s = open_session(10_000);
981        s.max_suspend_ms = 500;
982        s.suspend(0).unwrap();
983        s.resume(400).unwrap();
984        assert_eq!(s.state, SessionState::Open);
985        assert_eq!(s.ttl_expiry, 10_400);
986    }
987
988    /// The cap is cumulative across pauses, not per-pause: two 300ms
989    /// suspensions each fit under a 500ms bound cap on their own, but the
990    /// second resume sees the 600ms total and force-expires. Pins that
991    /// `resume` accumulates `accumulated_suspended_ms` rather than
992    /// overwriting it with the latest pause — the invariant the rev-2 handoff
993    /// deadline reads (`HandoffMode::rev2_elapsed_ms`).
994    #[test]
995    fn bound_cap_counts_suspension_cumulatively_across_pauses() {
996        let mut s = open_session(10_000);
997        s.max_suspend_ms = 500;
998        s.suspend(0).unwrap();
999        s.resume(300).unwrap();
1000        assert_eq!(s.accumulated_suspended_ms, 300);
1001        assert_eq!(s.ttl_expiry, 10_300);
1002        s.suspend(400).unwrap();
1003        // 300 + 300 = 600 > the 500ms cap, though neither pause alone is.
1004        let err = s.resume(700).unwrap_err();
1005        assert!(matches!(err, MacpError::TtlExpired));
1006        assert_eq!(s.state, SessionState::Expired);
1007        assert_eq!(s.accumulated_suspended_ms, 600);
1008    }
1009
1010    #[test]
1011    fn suspend_cap_exceeded_uses_bound_cap() {
1012        let mut s = open_session(10_000);
1013        s.max_suspend_ms = 500;
1014        s.suspend(0).unwrap();
1015        assert!(!s.suspend_cap_exceeded(400));
1016        assert!(s.suspend_cap_exceeded(501));
1017    }
1018
1019    #[test]
1020    fn unbound_session_uses_default_cap() {
1021        let s = open_session(10_000);
1022        assert_eq!(s.max_suspend_ms, 0);
1023        assert_eq!(s.effective_max_suspend_ms(), MAX_SUSPEND_MS);
1024    }
1025
1026    #[test]
1027    fn negative_max_suspend_ms_rejected_in_canonical_payload() {
1028        let payload = SessionStartPayload {
1029            participants: vec!["a".into()],
1030            mode_version: "1.0.0".into(),
1031            configuration_version: "cfg-1".into(),
1032            ttl_ms: 60_000,
1033            max_suspend_ms: -1,
1034            ..Default::default()
1035        };
1036        assert_eq!(
1037            validate_canonical_session_start_payload(&payload)
1038                .unwrap_err()
1039                .to_string(),
1040            "InvalidPayload"
1041        );
1042        // 0 (runtime default) and positive values are both valid.
1043        let ok0 = SessionStartPayload {
1044            max_suspend_ms: 0,
1045            ..payload.clone()
1046        };
1047        validate_canonical_session_start_payload(&ok0).unwrap();
1048        let ok_pos = SessionStartPayload {
1049            max_suspend_ms: 60_000,
1050            ..payload
1051        };
1052        validate_canonical_session_start_payload(&ok_pos).unwrap();
1053    }
1054
1055    #[test]
1056    fn cancel_from_open_or_suspended_then_terminal_is_rejected() {
1057        let mut s = open_session(10_000);
1058        s.suspend(1).unwrap();
1059        s.cancel().unwrap();
1060        assert_eq!(s.state, SessionState::Cancelled);
1061        assert_eq!(s.suspended_at_ms, None);
1062        // Already terminal: further cancel is rejected.
1063        assert!(matches!(s.cancel().unwrap_err(), MacpError::SessionNotOpen));
1064
1065        let mut open = open_session(10_000);
1066        open.cancel().unwrap();
1067        assert_eq!(open.state, SessionState::Cancelled);
1068    }
1069
1070    #[test]
1071    fn standard_mode_rejects_duplicate_participants() {
1072        let payload = SessionStartPayload {
1073            participants: vec!["alice".into(), "alice".into()],
1074            mode_version: "1.0.0".into(),
1075            configuration_version: "cfg-1".into(),
1076            ttl_ms: 1000,
1077            ..Default::default()
1078        };
1079        assert_eq!(
1080            validate_strict_session_start_payload("macp.mode.proposal.v1", &payload)
1081                .unwrap_err()
1082                .to_string(),
1083            "InvalidPayload"
1084        );
1085    }
1086
1087    #[test]
1088    fn multi_round_requires_strict_session_start() {
1089        let payload = SessionStartPayload::default();
1090        assert!(validate_strict_session_start_payload("ext.multi_round.v1", &payload).is_err());
1091    }
1092
1093    #[test]
1094    fn valid_uuid_v4_accepted() {
1095        let id = uuid::Uuid::new_v4().as_hyphenated().to_string();
1096        validate_session_id_for_acceptance(&id).unwrap();
1097    }
1098
1099    #[test]
1100    fn valid_base64url_accepted() {
1101        // 22-char base64url token
1102        validate_session_id_for_acceptance("abcdefghijklmnopqrstuv").unwrap();
1103        // longer base64url with underscore and hyphen
1104        validate_session_id_for_acceptance("abc-def_ghi-jkl_mno-pqr").unwrap();
1105    }
1106
1107    #[test]
1108    fn empty_id_rejected() {
1109        assert_eq!(
1110            validate_session_id_for_acceptance("")
1111                .unwrap_err()
1112                .to_string(),
1113            "InvalidSessionId"
1114        );
1115    }
1116
1117    #[test]
1118    fn short_weak_id_rejected() {
1119        assert_eq!(
1120            validate_session_id_for_acceptance("s1")
1121                .unwrap_err()
1122                .to_string(),
1123            "InvalidSessionId"
1124        );
1125        assert_eq!(
1126            validate_session_id_for_acceptance("decision-demo-1")
1127                .unwrap_err()
1128                .to_string(),
1129            "InvalidSessionId"
1130        );
1131    }
1132
1133    #[test]
1134    fn uppercase_uuid_rejected() {
1135        let id = uuid::Uuid::new_v4()
1136            .as_hyphenated()
1137            .to_string()
1138            .to_uppercase();
1139        assert_eq!(
1140            validate_session_id_for_acceptance(&id)
1141                .unwrap_err()
1142                .to_string(),
1143            "InvalidSessionId"
1144        );
1145    }
1146
1147    #[test]
1148    fn base64url_36_chars_with_hyphen_accepted() {
1149        // 36-char base64url token containing '-' that is NOT UUID-shaped: must be
1150        // accepted via the base64url rule, not rejected by the UUID branch.
1151        // (Regression test for the hard-routing bug: len==36 && contains('-')
1152        // previously returned Err without trying the base64url rule.)
1153        let id = "Zx-abcdefghijklmnopqrstuvwxyz_ABCDE-";
1154        assert_eq!(id.len(), 36);
1155        assert!(uuid::Uuid::parse_str(id).is_err());
1156        validate_session_id_for_acceptance(id).unwrap();
1157    }
1158
1159    #[test]
1160    fn uuid_shaped_but_wrong_version_does_not_fall_through() {
1161        // A canonical v1 UUID is also 36 chars of valid base64url charset; it must
1162        // still be rejected (UUID rules apply, no fall-through to base64url).
1163        let v4 = uuid::Uuid::new_v4();
1164        let mut bytes = *v4.as_bytes();
1165        bytes[6] = (bytes[6] & 0x0F) | 0x10;
1166        bytes[8] = (bytes[8] & 0x3F) | 0x80;
1167        let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
1168        assert!(validate_session_id_for_acceptance(&v1_id).is_err());
1169    }
1170
1171    #[test]
1172    fn base64url_too_short_rejected() {
1173        assert_eq!(
1174            validate_session_id_for_acceptance("abcdefghij")
1175                .unwrap_err()
1176                .to_string(),
1177            "InvalidSessionId"
1178        );
1179    }
1180
1181    #[test]
1182    fn valid_uuid_v7_accepted() {
1183        // Construct a v7 UUID by patching the version nibble of a v4 UUID
1184        let v4 = uuid::Uuid::new_v4();
1185        let mut bytes = *v4.as_bytes();
1186        // Set version nibble (bits 48-51) to 0b0111 (v7)
1187        bytes[6] = (bytes[6] & 0x0F) | 0x70;
1188        // Keep variant bits valid (RFC 4122: 0b10xx)
1189        bytes[8] = (bytes[8] & 0x3F) | 0x80;
1190        let v7_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
1191        assert!(validate_session_id_for_acceptance(&v7_id).is_ok());
1192    }
1193
1194    #[test]
1195    fn uuid_v1_rejected() {
1196        // Construct a v1 UUID by patching the version nibble of a v4 UUID
1197        let v4 = uuid::Uuid::new_v4();
1198        let mut bytes = *v4.as_bytes();
1199        // Set version nibble (bits 48-51) to 0b0001 (v1)
1200        bytes[6] = (bytes[6] & 0x0F) | 0x10;
1201        // Keep variant bits valid (RFC 4122: 0b10xx)
1202        bytes[8] = (bytes[8] & 0x3F) | 0x80;
1203        let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
1204        assert_eq!(
1205            validate_session_id_for_acceptance(&v1_id)
1206                .unwrap_err()
1207                .to_string(),
1208            "InvalidSessionId"
1209        );
1210    }
1211
1212    #[test]
1213    fn too_many_participants_rejected() {
1214        let participants: Vec<String> = (0..1001).map(|i| format!("agent://p{i}")).collect();
1215        let bytes = encode_payload(5000, participants);
1216        let payload = parse_session_start_payload(&bytes).unwrap();
1217        assert_eq!(
1218            validate_canonical_session_start_payload(&payload)
1219                .unwrap_err()
1220                .to_string(),
1221            "InvalidPayload"
1222        );
1223    }
1224
1225    #[test]
1226    fn max_participants_accepted() {
1227        let participants: Vec<String> = (0..1000).map(|i| format!("agent://p{i}")).collect();
1228        let bytes = encode_payload(5000, participants);
1229        let payload = parse_session_start_payload(&bytes).unwrap();
1230        validate_canonical_session_start_payload(&payload).unwrap();
1231    }
1232
1233    // ---- Phase 11b: suspension intervals + the unsuspended deadline ----
1234
1235    /// `resume` records the completed pause, in order, at every revision.
1236    #[test]
1237    fn resume_records_completed_suspension_intervals() {
1238        let mut s = open_session(100_000);
1239        assert!(s.suspension_intervals.is_empty());
1240        s.suspend(2_000).unwrap();
1241        // An in-progress suspension is NOT in the vec — completed pairs only.
1242        assert!(s.suspension_intervals.is_empty());
1243        s.resume(5_000).unwrap();
1244        s.suspend(6_000).unwrap();
1245        s.resume(6_500).unwrap();
1246        assert_eq!(s.suspension_intervals, vec![(2_000, 5_000), (6_000, 6_500)]);
1247
1248        // Recorded at legacy revisions too (read only at rev >= 2).
1249        let mut legacy = open_session(100_000);
1250        legacy.semantics_rev = 0;
1251        legacy.suspend(1_000).unwrap();
1252        legacy.resume(1_400).unwrap();
1253        assert_eq!(legacy.suspension_intervals, vec![(1_000, 1_400)]);
1254    }
1255
1256    /// The pause is recorded even when the resume force-expires the session:
1257    /// the push happens before either cap check can return.
1258    #[test]
1259    fn resume_records_the_pause_even_when_it_force_expires() {
1260        let mut s = open_session(10_000);
1261        s.suspend(0).unwrap();
1262        assert!(s.resume(MAX_SUSPEND_MS + 1).is_err());
1263        assert_eq!(s.state, SessionState::Expired);
1264        assert_eq!(s.suspension_intervals, vec![(0, MAX_SUSPEND_MS + 1)]);
1265    }
1266
1267    /// Acceptance criterion 1 — the `unsuspended_deadline` matrix
1268    /// (RFC-MACP-0010 §5.1(3): "offer acceptance time + timeout + suspended
1269    /// time *within the window*").
1270    #[test]
1271    fn unsuspended_deadline_walks_the_suspension_intervals() {
1272        let base = open_session(1_000_000);
1273        let with = |pairs: Vec<(i64, i64)>| {
1274            let mut s = base.clone();
1275            s.suspension_intervals = pairs;
1276            s
1277        };
1278
1279        // No pauses: the raw deadline.
1280        assert_eq!(with(vec![]).unsuspended_deadline(1_000, 100), 1_100);
1281
1282        // One pause that starts inside the window: extends by its full width.
1283        assert_eq!(
1284            with(vec![(1_050, 1_200)]).unsuspended_deadline(1_000, 100),
1285            1_250
1286        );
1287
1288        // One pause starting after the raw deadline: does NOT extend it.
1289        assert_eq!(
1290            with(vec![(1_500, 1_600)]).unsuspended_deadline(1_000, 100),
1291            1_100
1292        );
1293
1294        // Boundary: a pause starting exactly at the returned deadline does not
1295        // extend it — the timeout had already elapsed at that instant.
1296        assert_eq!(
1297            with(vec![(1_100, 1_300)]).unsuspended_deadline(1_000, 100),
1298            1_100
1299        );
1300
1301        // Two pauses, both inside the window: both are added.
1302        assert_eq!(
1303            with(vec![(1_050, 1_200), (1_230, 1_300)]).unsuspended_deadline(1_000, 100),
1304            1_320
1305        );
1306
1307        // A pause predating `from_ms` is ignored entirely.
1308        assert_eq!(
1309            with(vec![(500, 700)]).unsuspended_deadline(1_000, 100),
1310            1_100
1311        );
1312        assert_eq!(
1313            with(vec![(500, 700), (1_050, 1_200)]).unsuspended_deadline(1_000, 100),
1314            1_250
1315        );
1316    }
1317
1318    /// The walk must never OVER-report, whatever shape the vec is in. The
1319    /// pairs are not guaranteed sorted, disjoint, or even monotone: `Utc::now`
1320    /// is not monotonic (an NTP step back between suspend and resume yields
1321    /// `e < s`, which `resume` records as-is — it clamps only `banked`), and
1322    /// the public `SessionBuilder::suspension_intervals` setter lets a library
1323    /// consumer supply arbitrary pairs. Each case below drove `remaining`
1324    /// upward or `cur` backwards before the clamps in `unsuspended_deadline`.
1325    #[test]
1326    fn unsuspended_deadline_never_over_reports_on_adversarial_pairs() {
1327        let base = open_session(1_000_000);
1328        let with = |pairs: Vec<(i64, i64)>| {
1329            let mut s = base.clone();
1330            s.suspension_intervals = pairs;
1331            s
1332        };
1333
1334        // Overlapping pairs, second starting inside the first. The 100 ms of
1335        // unsuspended time is exhausted at 1_050 + the union of the pauses
1336        // (1_050..1_400) + the remaining 50 ms => 1_450. Before the clamp the
1337        // negative run (1_100 - 1_400) GREW `remaining` to 350 and returned
1338        // 1_550 — a deadline 100 ms LATER than the truth.
1339        assert_eq!(
1340            with(vec![(1_050, 1_400), (1_100, 1_200)]).unsuspended_deadline(1_000, 100),
1341            1_450
1342        );
1343
1344        // A fully-nested pair: the inner pause contributes nothing.
1345        assert_eq!(
1346            with(vec![(1_050, 1_400), (1_200, 1_300)]).unsuspended_deadline(1_000, 100),
1347            1_450
1348        );
1349
1350        // A backwards pair (e < s), as an NTP step back would record it. It
1351        // must neither rewind `cur` nor inflate `remaining`; the 50 ms of run
1352        // before it is consumed and nothing is added.
1353        assert_eq!(
1354            with(vec![(1_050, 1_000)]).unsuspended_deadline(1_000, 100),
1355            1_100
1356        );
1357        // ... and the same pair ahead of a real one extends by the real
1358        // pause's width only (60 ms of run, then the 1_060..1_200 pause, then
1359        // the remaining 40 ms), the degenerate pair counting as zero-width.
1360        assert_eq!(
1361            with(vec![(1_050, 1_000), (1_060, 1_200)]).unsuspended_deadline(1_000, 100),
1362            1_240
1363        );
1364
1365        // Unsorted pairs: the later pause listed first. The walk's answer must
1366        // still not exceed the sorted-order answer.
1367        let sorted = with(vec![(1_050, 1_200), (1_230, 1_300)]).unsuspended_deadline(1_000, 100);
1368        let unsorted = with(vec![(1_230, 1_300), (1_050, 1_200)]).unsuspended_deadline(1_000, 100);
1369        assert_eq!(sorted, 1_320);
1370        assert!(
1371            unsorted <= sorted,
1372            "an unsorted vec must under-report, never over-report \
1373             ({unsorted} vs {sorted})"
1374        );
1375
1376        // The invariant stated in the rustdoc, checked directly: the walk's
1377        // own contribution never exceeds the sum of the normalized pair widths (an upper bound on their union).
1378        for pairs in [
1379            vec![(1_050, 1_400), (1_100, 1_200)],
1380            vec![(1_050, 1_400), (1_200, 1_300)],
1381            vec![(1_050, 1_000)],
1382            vec![(1_230, 1_300), (1_050, 1_200)],
1383        ] {
1384            let union: i64 = pairs.iter().map(|(s, e)| (e - s).max(0)).sum();
1385            let walk = with(pairs.clone()).unsuspended_deadline(1_000, 100) - 1_100;
1386            assert!(
1387                (0..=union).contains(&walk),
1388                "walk contribution {walk} outside [0, {union}] for {pairs:?}"
1389            );
1390        }
1391    }
1392
1393    /// Acceptance criterion 5 — the cycle cap fires on *count*, at rev 2 only.
1394    ///
1395    /// Every pause here is 0 ms wide, so `accumulated_suspended_ms` stays at 0
1396    /// and `MAX_SUSPEND_MS` is nowhere near exhausted: only the count cap can
1397    /// be what expires the session.
1398    #[test]
1399    fn suspension_cycle_cap_force_expires_at_rev2() {
1400        let mut s = open_session(1_000_000_000);
1401        assert_eq!(s.semantics_rev, CURRENT_SEMANTICS_REV);
1402        #[allow(clippy::assertions_on_constants)]
1403        {
1404            assert!(CURRENT_SEMANTICS_REV >= 2);
1405        }
1406        for i in 0..MAX_SUSPENSION_CYCLES as i64 {
1407            s.suspend(i).unwrap();
1408            s.resume(i).unwrap();
1409        }
1410        assert_eq!(s.suspension_intervals.len(), MAX_SUSPENSION_CYCLES);
1411        assert_eq!(s.accumulated_suspended_ms, 0);
1412        assert_eq!(s.state, SessionState::Open);
1413
1414        // One cycle past the cap force-expires.
1415        s.suspend(MAX_SUSPENSION_CYCLES as i64).unwrap();
1416        let err = s.resume(MAX_SUSPENSION_CYCLES as i64).unwrap_err();
1417        assert!(matches!(err, MacpError::TtlExpired));
1418        assert_eq!(s.state, SessionState::Expired);
1419        assert_eq!(
1420            s.accumulated_suspended_ms, 0,
1421            "the duration cap must be nowhere near exhausted, else the count \
1422             cap is not what fired"
1423        );
1424
1425        // The rev gate: the identical sequence on a rev-1 session keeps
1426        // succeeding, so legacy histories replay bit-identically.
1427        let mut legacy = open_session(1_000_000_000);
1428        legacy.semantics_rev = 1;
1429        for i in 0..(MAX_SUSPENSION_CYCLES as i64 + 10) {
1430            legacy.suspend(i).unwrap();
1431            legacy.resume(i).unwrap();
1432        }
1433        assert_eq!(legacy.state, SessionState::Open);
1434    }
1435
1436    /// The other half of the cap: a legacy (rev <= 1) session is reachable
1437    /// through the same un-rate-limited `SuspendSession`/`ResumeSession` RPCs
1438    /// as a current one, so its `suspension_intervals` must be bounded too —
1439    /// but it must NOT be force-expired by a rule postdating its acceptance.
1440    /// So the cap stops the *recording* instead: the session keeps cycling and
1441    /// the vec stays pinned at [`MAX_SUSPENSION_CYCLES`].
1442    #[test]
1443    fn suspension_cycle_cap_stops_recording_at_rev1_without_expiring() {
1444        for rev in [0u32, 1] {
1445            let mut s = open_session(1_000_000_000);
1446            s.semantics_rev = rev;
1447            for i in 0..(MAX_SUSPENSION_CYCLES as i64 * 2) {
1448                s.suspend(i).unwrap();
1449                assert_eq!(s.state, SessionState::Suspended, "rev {rev}, cycle {i}");
1450                s.resume(i)
1451                    .unwrap_or_else(|e| panic!("rev {rev}, cycle {i} must resume, got {e:?}"));
1452                assert_eq!(s.state, SessionState::Open, "rev {rev}, cycle {i}");
1453            }
1454            assert_eq!(
1455                s.suspension_intervals.len(),
1456                MAX_SUSPENSION_CYCLES,
1457                "rev {rev}: the vec must stay pinned at the cap, not grow \
1458                 unbounded — an unbounded vec is the O(N²) snapshot \
1459                 amplification MAX_SUSPENSION_CYCLES exists to close"
1460            );
1461            // The recorded prefix is the FIRST `MAX_SUSPENSION_CYCLES` pauses:
1462            // overflow is dropped, not rotated, so nothing already written
1463            // ever changes.
1464            assert_eq!(s.suspension_intervals[0], (0, 0));
1465            assert_eq!(
1466                s.suspension_intervals[MAX_SUSPENSION_CYCLES - 1],
1467                (
1468                    MAX_SUSPENSION_CYCLES as i64 - 1,
1469                    MAX_SUSPENSION_CYCLES as i64 - 1
1470                )
1471            );
1472        }
1473    }
1474
1475    /// Acceptance criterion 6 — a rev-2 session carrying the pre-11b artifact
1476    /// shape (positive `accumulated_suspended_ms`, empty
1477    /// `suspension_intervals`, e.g. a snapshot or mid-session checkpoint
1478    /// written before the field existed) walks to a deadline at or *earlier*
1479    /// than the fully-recorded one. Under-counting is the safe direction.
1480    #[test]
1481    fn legacy_rev2_snapshot_without_intervals_walks_early_not_late() {
1482        let mut recorded = open_session(1_000_000);
1483        recorded.semantics_rev = 2;
1484        recorded.accumulated_suspended_ms = 150 + 70;
1485        recorded.suspension_intervals = vec![(1_050, 1_200), (1_230, 1_300)];
1486
1487        let mut legacy = recorded.clone();
1488        legacy.suspension_intervals.clear();
1489
1490        let recorded_deadline = recorded.unsuspended_deadline(1_000, 100);
1491        let legacy_deadline = legacy.unsuspended_deadline(1_000, 100);
1492        assert_eq!(recorded_deadline, 1_320);
1493        assert_eq!(legacy_deadline, 1_100);
1494        assert!(
1495            legacy_deadline <= recorded_deadline,
1496            "an under-reported interval vec must move the deadline EARLIER \
1497             ({legacy_deadline} vs {recorded_deadline}), never later"
1498        );
1499
1500        // The invariant the walk relies on: the walk's own contribution never
1501        // exceeds the scalar it is refining.
1502        let walk_sum = recorded_deadline - (1_000 + 100);
1503        assert!(walk_sum <= recorded.accumulated_suspended_ms);
1504        let legacy_walk_sum = legacy_deadline - (1_000 + 100);
1505        assert!(legacy_walk_sum <= legacy.accumulated_suspended_ms);
1506    }
1507}