Skip to main content

protocol_droid/domain/session/
state.rs

1//! The session lifecycle: five states, six events, and the table between them.
2//!
3//! # Why the table is explicit
4//!
5//! Every edge is written out. A conditional inside a match arm would be shorter
6//! and would let the legal set drift away from the documented one, which is
7//! exactly the failure a lifecycle has to be able to rule out. Written out, the
8//! set of states is the set of match arms and the set of edges is the set of
9//! arms, so a reader can check one against the other by eye.
10//!
11//! # Why `Closed` is terminal
12//!
13//! A session that could re-open would leak every in-flight request it was
14//! holding, and the specification has no transition back. [`Session::apply`]
15//! answers [`LifecycleError::Terminal`] from `Closed` for *every* event rather
16//! than silently treating the session as freshly uninitialised.
17
18use core::fmt;
19use std::time::Duration;
20
21use thiserror::Error;
22
23/// A session's identity.
24///
25/// A `u64` behind a newtype rather than a string: it is compared constantly,
26/// and `Copy` is what lets it sit inside every event without a borrow.
27///
28/// Where the value comes from is [`IdSource`](crate::application::session::IdSource)'s
29/// business, not this type's — a counter is fine for one process, and
30/// `adapters::secondary::id_source::RandomIds` exists for a deployment that
31/// needs them unguessable. Deliberately not an intra-doc link: that module is
32/// behind the `random-ids` feature, and a link to it breaks every lean build's
33/// documentation for a type that is compiled in all of them.
34///
35/// # Redaction
36///
37/// [`Debug`](fmt::Debug) prints `SessionId(…)` and never the value. `Debug` is
38/// what a panic, an `assert_eq!` and a `dbg!` expand to, so leaving it derived
39/// made "we never log session ids" a convention rather than a property of the
40/// type. [`RequestId`](crate::domain::request_id::RequestId) keeps a
41/// four-character prefix so two ids in one line can be told apart; this cannot,
42/// because there is nothing here to keep — the whole value is a counter, so
43/// every digit of it is either the value itself or a leading zero.
44///
45/// [`McpSessionId`](crate::domain::mcp::McpSessionId) derives its
46/// `Debug` from this one, so it inherits the redaction rather than needing an
47/// impl of its own.
48///
49/// # Why `Display` is not redacted
50///
51/// This is load-bearing, not an oversight.
52/// [`IdempotencyKey::for_request`](crate::domain::idempotency::IdempotencyKey::for_request)
53/// composes `"{session}/{id}"`, so a redacted `Display` would collapse every
54/// session onto one key and let a result recorded for one session answer
55/// another's request. `Display` is the identity and serialisation API here, not
56/// a logging one, and the redaction belongs at `Debug` because that is the
57/// path a value escapes through by accident.
58#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub struct SessionId(u64);
60
61impl fmt::Debug for SessionId {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter.write_str("SessionId(…)")
64    }
65}
66
67impl SessionId {
68    /// The identity a source minted.
69    #[must_use]
70    pub const fn new(value: u64) -> Self {
71        Self(value)
72    }
73
74    /// The underlying value.
75    #[must_use]
76    pub const fn get(self) -> u64 {
77        self.0
78    }
79}
80
81impl fmt::Display for SessionId {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(formatter, "session#{}", self.0)
84    }
85}
86
87/// A monotonic reading from an injected clock.
88///
89/// # Why a duration and not a wall clock
90///
91/// Two sessions started in the same millisecond are indistinguishable, and two
92/// readings of a wall clock can go backwards when NTP steps. A duration from
93/// whichever origin the adapter chose has neither problem, and it makes every
94/// comparison in the engine a subtraction.
95///
96/// The origin is the adapter's business. Nothing here reads a clock, so
97/// nothing here can disagree with one.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
99pub struct Instant(Duration);
100
101impl Instant {
102    /// A reading `since` after the clock's origin.
103    #[must_use]
104    pub const fn from_duration(since: Duration) -> Self {
105        Self(since)
106    }
107
108    /// The reading, as a duration from the clock's origin.
109    #[must_use]
110    pub const fn as_duration(self) -> Duration {
111        self.0
112    }
113
114    /// How much time passed between `earlier` and `self`.
115    ///
116    /// Saturating rather than wrapping: a clock whose second reading somehow
117    /// precedes the first should report no elapsed time, not a duration near
118    /// `u64::MAX` that would expire every in-flight request at once.
119    #[must_use]
120    pub const fn since(self, earlier: Self) -> Duration {
121        self.0.saturating_sub(earlier.0)
122    }
123
124    /// This reading, `by` later.
125    #[must_use]
126    pub const fn advanced(self, by: Duration) -> Self {
127        Self(self.0.saturating_add(by))
128    }
129}
130
131impl fmt::Display for Instant {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(formatter, "{}ms", self.0.as_millis())
134    }
135}
136
137/// Where a session is in its lifecycle.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub enum SessionState {
140    /// A connection exists; no handshake has been offered.
141    Uninitialised,
142    /// A handshake was accepted and answered; the peer has not confirmed it.
143    Initialising,
144    /// The handshake completed. Ordinary work is admitted.
145    Operating,
146    /// A close was asked for. In-flight work is being drained; nothing new is
147    /// admitted.
148    Closing,
149    /// Finished. Terminal.
150    Closed,
151    /// No handshake at all: every message negotiates for itself and the
152    /// session retains nothing between them.
153    Stateless,
154}
155
156impl SessionState {
157    /// Whether ordinary work — anything that is not part of the handshake — may
158    /// be dispatched in this state.
159    ///
160    /// The gate a lifecycle is asked to refine, not replace: a protocol
161    /// narrows this further for its own methods, but the engine refuses
162    /// everything in a closing or closed session regardless of what any
163    /// lifecycle says.
164    #[must_use]
165    pub const fn admits_ordinary_work(self) -> bool {
166        matches!(self, Self::Operating | Self::Stateless)
167    }
168
169    /// Whether the session retains nothing between messages.
170    #[must_use]
171    pub const fn is_stateless(self) -> bool {
172        matches!(self, Self::Stateless)
173    }
174
175    /// Whether no further transition is possible.
176    #[must_use]
177    pub const fn is_terminal(self) -> bool {
178        matches!(self, Self::Closed)
179    }
180
181    /// The state name, for messages and event logs.
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::Uninitialised => "uninitialised",
186            Self::Initialising => "initialising",
187            Self::Operating => "operating",
188            Self::Closing => "closing",
189            Self::Closed => "closed",
190            Self::Stateless => "stateless",
191        }
192    }
193}
194
195impl fmt::Display for SessionState {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        formatter.write_str(self.as_str())
198    }
199}
200
201/// Something that happened to a session.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum LifecycleEvent {
204    /// The peer offered its handshake.
205    HandshakeStarted,
206    /// The peer confirmed the handshake result landed.
207    HandshakeConfirmed,
208    /// A message negotiated its own profile, so there is no handshake to wait
209    /// for.
210    WentStateless,
211    /// A close was asked for.
212    CloseRequested,
213    /// The session finished.
214    Finished,
215}
216
217impl LifecycleEvent {
218    /// The event name, for messages and event logs.
219    #[must_use]
220    pub const fn as_str(self) -> &'static str {
221        match self {
222            Self::HandshakeStarted => "handshake-started",
223            Self::HandshakeConfirmed => "handshake-confirmed",
224            Self::WentStateless => "went-stateless",
225            Self::CloseRequested => "close-requested",
226            Self::Finished => "finished",
227        }
228    }
229}
230
231impl fmt::Display for LifecycleEvent {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        formatter.write_str(self.as_str())
234    }
235}
236
237/// Why a lifecycle transition was refused.
238///
239/// Every variant is a case where guessing an edge would invent a state the
240/// protocol never described — there is deliberately no "force" arm.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
242pub enum LifecycleError {
243    /// The session has finished and admits nothing.
244    #[error("{event} is not legal on a closed session")]
245    Terminal {
246        /// The event that was refused.
247        event: LifecycleEvent,
248    },
249    /// The event does not apply to the state the session is in.
250    #[error("{event} is not legal while the session is {state}")]
251    IllegalTransition {
252        /// The state the session is in.
253        state: SessionState,
254        /// The event that was refused.
255        event: LifecycleEvent,
256    },
257}
258
259impl LifecycleError {
260    /// Whether the refusal is final rather than a "not yet".
261    ///
262    /// A caller that batches transitions — draining a transport, say — needs
263    /// to tell "you are early" from "stop asking". Only a closed session is
264    /// final.
265    #[must_use]
266    pub const fn is_final(self) -> bool {
267        matches!(self, Self::Terminal { .. })
268    }
269}
270
271/// A session and the phase it is in.
272///
273/// `apply` takes `&self` and returns a new value rather than mutating: the
274/// transition table is then the only thing that can produce a state, and a
275/// caller that gets an `Err` still holds the session it had. The value is four
276/// machine words, so returning one costs less than the clone-and-mutate the
277/// alternative would need at the call site.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct Session {
280    id: SessionId,
281    state: SessionState,
282    opened_at: Instant,
283    transitions: u32,
284}
285
286impl Session {
287    /// A new session, waiting for a handshake.
288    #[must_use]
289    pub const fn new(id: SessionId, opened_at: Instant) -> Self {
290        Self {
291            id,
292            state: SessionState::Uninitialised,
293            opened_at,
294            transitions: 0,
295        }
296    }
297
298    /// A session that negotiates per message and retains nothing.
299    #[must_use]
300    pub const fn stateless(id: SessionId, opened_at: Instant) -> Self {
301        Self {
302            id,
303            state: SessionState::Stateless,
304            opened_at,
305            transitions: 0,
306        }
307    }
308
309    /// Which session this is.
310    #[must_use]
311    pub const fn id(&self) -> SessionId {
312        self.id
313    }
314
315    /// Where it is in its lifecycle.
316    #[must_use]
317    pub const fn state(&self) -> SessionState {
318        self.state
319    }
320
321    /// When it opened, as read from the injected clock.
322    #[must_use]
323    pub const fn opened_at(&self) -> Instant {
324        self.opened_at
325    }
326
327    /// How many transitions have been applied.
328    ///
329    /// A cheap way for the event log to order two sessions' histories, and the
330    /// number a test asserts on to show that a refused event changed nothing.
331    #[must_use]
332    pub const fn transitions(&self) -> u32 {
333        self.transitions
334    }
335
336    /// Whether ordinary work is admitted right now.
337    #[must_use]
338    pub const fn admits_ordinary_work(&self) -> bool {
339        self.state.admits_ordinary_work()
340    }
341
342    /// Applies one event.
343    ///
344    /// # Errors
345    ///
346    /// [`LifecycleError::Terminal`] from [`SessionState::Closed`], and
347    /// [`LifecycleError::IllegalTransition`] for any event the table does not
348    /// list for the current state.
349    ///
350    /// # Examples
351    ///
352    /// ```
353    /// use protocol_droid::domain::session::{LifecycleEvent, Session, SessionId};
354    ///
355    /// let open = Session::new(SessionId::new(1), Default::default());
356    /// let starting = open.apply(LifecycleEvent::HandshakeStarted)?;
357    /// assert_eq!(starting.state().as_str(), "initialising");
358    ///
359    /// // A second handshake offer is not a thing the specification has, so it
360    /// // is refused rather than treated as a restart.
361    /// assert!(starting.apply(LifecycleEvent::HandshakeStarted).is_err());
362    /// # Ok::<(), protocol_droid::domain::session::LifecycleError>(())
363    /// ```
364    pub const fn apply(&self, event: LifecycleEvent) -> Result<Self, LifecycleError> {
365        if self.state.is_terminal() {
366            return Err(LifecycleError::Terminal { event });
367        }
368        let state = match (self.state, event) {
369            (SessionState::Uninitialised, LifecycleEvent::HandshakeStarted) => {
370                SessionState::Initialising
371            }
372            (SessionState::Uninitialised, LifecycleEvent::WentStateless) => SessionState::Stateless,
373            (SessionState::Initialising, LifecycleEvent::HandshakeConfirmed) => {
374                SessionState::Operating
375            }
376            // Closing is reachable from every live state and from nowhere else,
377            // so a session can never be closed twice by two different paths.
378            (
379                SessionState::Uninitialised
380                | SessionState::Initialising
381                | SessionState::Operating
382                | SessionState::Stateless,
383                LifecycleEvent::CloseRequested,
384            ) => SessionState::Closing,
385            (SessionState::Closing, LifecycleEvent::Finished) => SessionState::Closed,
386            (state, event) => return Err(LifecycleError::IllegalTransition { state, event }),
387        };
388        Ok(Self {
389            id: self.id,
390            state,
391            opened_at: self.opened_at,
392            transitions: self.transitions.saturating_add(1),
393        })
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    // Test code unwraps deliberately: the strict profile exists to keep
400    use proptest::prelude::*;
401
402    use super::{Instant, LifecycleError, LifecycleEvent, Session, SessionId, SessionState};
403
404    fn open() -> Session {
405        Session::new(
406            SessionId::new(7),
407            Instant::from_duration(std::time::Duration::ZERO),
408        )
409    }
410
411    /// Every edge the lifecycle is documented to have, checked one at a time.
412    ///
413    /// Written out rather than generated because the list *is* the
414    /// specification: a generated list would only prove the implementation
415    /// agrees with itself, which is the mistake the property test below is
416    /// careful to avoid.
417    #[test]
418    fn every_documented_edge_is_reachable() {
419        let cases: &[(SessionState, LifecycleEvent, SessionState)] = &[
420            (
421                SessionState::Uninitialised,
422                LifecycleEvent::HandshakeStarted,
423                SessionState::Initialising,
424            ),
425            (
426                SessionState::Uninitialised,
427                LifecycleEvent::WentStateless,
428                SessionState::Stateless,
429            ),
430            (
431                SessionState::Uninitialised,
432                LifecycleEvent::CloseRequested,
433                SessionState::Closing,
434            ),
435            (
436                SessionState::Initialising,
437                LifecycleEvent::HandshakeConfirmed,
438                SessionState::Operating,
439            ),
440            (
441                SessionState::Initialising,
442                LifecycleEvent::CloseRequested,
443                SessionState::Closing,
444            ),
445            (
446                SessionState::Operating,
447                LifecycleEvent::CloseRequested,
448                SessionState::Closing,
449            ),
450            (
451                SessionState::Stateless,
452                LifecycleEvent::CloseRequested,
453                SessionState::Closing,
454            ),
455            (
456                SessionState::Closing,
457                LifecycleEvent::Finished,
458                SessionState::Closed,
459            ),
460        ];
461        for (from, event, to) in cases {
462            let session = Session {
463                state: *from,
464                ..open()
465            };
466            assert_eq!(
467                session.apply(*event).map(|next| next.state()),
468                Ok(*to),
469                "{event} from {from} should be legal"
470            );
471        }
472    }
473
474    #[test]
475    fn closing_is_reachable_from_every_live_state_and_nothing_reaches_operating_from_closed()
476    -> Result<(), LifecycleError> {
477        for state in [
478            SessionState::Uninitialised,
479            SessionState::Initialising,
480            SessionState::Operating,
481            SessionState::Stateless,
482        ] {
483            let session = Session { state, ..open() };
484            let closing = session.apply(LifecycleEvent::CloseRequested)?;
485            assert_eq!(closing.state(), SessionState::Closing, "from {state}");
486            let closed = closing.apply(LifecycleEvent::Finished)?;
487            assert_eq!(closed.state(), SessionState::Closed, "from {state}");
488        }
489        Ok(())
490    }
491
492    /// A session walked through `events`.
493    ///
494    /// `apply` already answers `LifecycleError`, so a `Result`-returning test
495    /// can `?` its way to the state it means to assert on. A refused step then
496    /// fails the test with the table's own explanation attached, rather than
497    /// panicking at an unwrap that names neither the event nor the state.
498    fn walked(events: &[LifecycleEvent]) -> Result<Session, LifecycleError> {
499        events
500            .iter()
501            .try_fold(open(), |session, event| session.apply(*event))
502    }
503
504    #[test]
505    fn a_refused_event_leaves_the_session_exactly_as_it_was() -> Result<(), LifecycleError> {
506        let operating = walked(&[
507            LifecycleEvent::HandshakeStarted,
508            LifecycleEvent::HandshakeConfirmed,
509        ])?;
510        let before = operating.transitions();
511        let refused = operating.apply(LifecycleEvent::HandshakeConfirmed);
512        assert_matches::assert_matches!(
513            refused,
514            Err(LifecycleError::IllegalTransition {
515                state: SessionState::Operating,
516                event: LifecycleEvent::HandshakeConfirmed
517            })
518        );
519        assert_eq!(
520            operating.transitions(),
521            before,
522            "a refused event was counted"
523        );
524        assert!(operating.admits_ordinary_work());
525        Ok(())
526    }
527
528    #[test]
529    fn closed_is_terminal_for_every_event() -> Result<(), LifecycleError> {
530        let closed = walked(&[LifecycleEvent::CloseRequested, LifecycleEvent::Finished])?;
531        for event in [
532            LifecycleEvent::HandshakeStarted,
533            LifecycleEvent::HandshakeConfirmed,
534            LifecycleEvent::WentStateless,
535            LifecycleEvent::CloseRequested,
536            LifecycleEvent::Finished,
537        ] {
538            let refused = closed.apply(event);
539            assert!(
540                matches!(refused, Err(LifecycleError::Terminal { .. })),
541                "{event} revived a closed session"
542            );
543        }
544        Ok(())
545    }
546
547    /// A caller that batches transitions has to tell "you are early" from
548    /// "stop asking", and that is the whole of [`LifecycleError::is_final`].
549    #[test]
550    fn only_a_closed_session_gives_a_final_refusal() -> Result<(), LifecycleError> {
551        let closed = walked(&[LifecycleEvent::CloseRequested, LifecycleEvent::Finished])?;
552        let terminal = closed.apply(LifecycleEvent::HandshakeStarted);
553        assert!(
554            matches!(terminal, Err(refusal) if refusal.is_final()),
555            "a closed session will never accept anything again: {terminal:?}"
556        );
557
558        // Still initialising, so a handshake out of turn is a "not yet" and a
559        // batched drain must keep going rather than abandon the queue.
560        let early = open().apply(LifecycleEvent::HandshakeConfirmed);
561        assert!(
562            matches!(early, Err(refusal) if !refusal.is_final()),
563            "an out-of-turn event is recoverable, not a dead end: {early:?}"
564        );
565        Ok(())
566    }
567
568    /// The property the task asks for: no sequence of events reaches a state
569    /// that is not one of the five.
570    ///
571    /// It is weaker than it looks, because the state is an enum and a caller
572    /// cannot construct an invalid one. The falsifiable half is `closed_is_absorbing`
573    /// below, which a "reset to uninitialised when an event does not apply"
574    /// implementation would fail.
575    fn any_event() -> impl Strategy<Value = LifecycleEvent> {
576        prop_oneof![
577            Just(LifecycleEvent::HandshakeStarted),
578            Just(LifecycleEvent::HandshakeConfirmed),
579            Just(LifecycleEvent::WentStateless),
580            Just(LifecycleEvent::CloseRequested),
581            Just(LifecycleEvent::Finished),
582        ]
583    }
584
585    proptest! {
586        #[test]
587        fn no_event_sequence_reaches_an_unmodelled_state(events in prop::collection::vec(any_event(), 0..24)) {
588            let mut session = open();
589            for event in &events {
590                if let Ok(next) = session.apply(*event) {
591                    session = next;
592                }
593                prop_assert!(matches!(
594                    session.state(),
595                    SessionState::Uninitialised
596                        | SessionState::Initialising
597                        | SessionState::Operating
598                        | SessionState::Closing
599                        | SessionState::Closed
600                        | SessionState::Stateless
601                ));
602                // The count only ever goes up by one per accepted event, and
603                // never past the number of events offered.
604                prop_assert!(session.transitions() as usize <= events.len());
605            }
606        }
607
608        /// The strong half. Once closed, no suffix of events can reopen the
609        /// session, and a session that has closed admits no ordinary work.
610        #[test]
611        fn closed_is_absorbing(
612            prefix in prop::collection::vec(any_event(), 0..12),
613            suffix in prop::collection::vec(any_event(), 0..12),
614        ) {
615            let mut session = open();
616            for event in &prefix {
617                if let Ok(next) = session.apply(*event) {
618                    session = next;
619                }
620            }
621            for event in &suffix {
622                if let Ok(next) = session.apply(*event) {
623                    session = next;
624                }
625            }
626            if session.state().is_terminal() {
627                prop_assert!(!session.admits_ordinary_work());
628                // A second sweep cannot escape either: the guard is a property
629                // of the state, not of the first event that produced it.
630                for event in &suffix {
631                    prop_assert!(session.apply(*event).is_err());
632                }
633            }
634        }
635
636        /// Closing is one-way. A session that has been asked to close never
637        /// reaches `operating` again, however many events follow.
638        #[test]
639        fn closing_does_not_reverse(
640            prefix in prop::collection::vec(any_event(), 0..12),
641            suffix in prop::collection::vec(any_event(), 0..12),
642        ) {
643            let mut session = open();
644            for event in prefix {
645                if let Ok(next) = session.apply(event) {
646                    session = next;
647                }
648            }
649            if !matches!(session.state(), SessionState::Closing) {
650                return Ok(());
651            }
652            for event in suffix {
653                if let Ok(next) = session.apply(event) {
654                    session = next;
655                }
656            }
657            prop_assert!(matches!(
658                session.state(),
659                SessionState::Closing | SessionState::Closed
660            ));
661        }
662    }
663
664    #[test]
665    fn a_backwards_clock_reports_no_elapsed_time_rather_than_wrapping() {
666        let later = Instant::from_duration(std::time::Duration::from_secs(10));
667        let earlier = Instant::from_duration(std::time::Duration::from_secs(1));
668        assert_eq!(later.since(earlier).as_secs(), 9);
669        assert_eq!(earlier.since(later), std::time::Duration::ZERO);
670    }
671
672    #[test]
673    fn advancing_saturates_instead_of_overflowing() {
674        let base = Instant::from_duration(std::time::Duration::MAX);
675        let advanced = base.advanced(std::time::Duration::from_secs(1));
676        assert_eq!(advanced, base);
677    }
678
679    #[test]
680    fn ordinary_work_is_admitted_only_where_the_specification_says() {
681        assert!(!SessionState::Uninitialised.admits_ordinary_work());
682        assert!(!SessionState::Initialising.admits_ordinary_work());
683        assert!(SessionState::Operating.admits_ordinary_work());
684        assert!(!SessionState::Closing.admits_ordinary_work());
685        assert!(!SessionState::Closed.admits_ordinary_work());
686        assert!(SessionState::Stateless.admits_ordinary_work());
687    }
688
689    /// The redaction is the property the type documents, so it is asserted
690    /// against values that would be unmistakable if they leaked: a small one, a
691    /// large one, and the extremes.
692    #[test]
693    fn debug_never_renders_the_value() {
694        for value in [0_u64, 7, 42, u64::from(u32::MAX), u64::MAX] {
695            let rendered = format!("{:?}", SessionId::new(value));
696            assert_eq!(
697                rendered, "SessionId(…)",
698                "value {value} must not reach Debug"
699            );
700            assert!(
701                !rendered.contains(&value.to_string()),
702                "Debug leaked the value {value}: {rendered}"
703            );
704        }
705    }
706
707    /// `Debug` is what a failing `assert_eq!` and a panic expand to, so that is
708    /// the path a session id actually escapes through by accident. Rendered
709    /// explicitly, because the interesting case never appears in a passing suite.
710    #[test]
711    fn two_ids_are_indistinguishable_in_debug() {
712        let rendered = format!("{:?} {:?}", SessionId::new(11), SessionId::new(12));
713        assert_eq!(rendered, "SessionId(…) SessionId(…)");
714        assert!(
715            !rendered.contains('1'),
716            "a digit escaped into Debug: {rendered}"
717        );
718    }
719
720    /// `Display` stays full, and this is the reason the exemption is allowed:
721    /// idempotency keys are composed from it, so a redacted form would make
722    /// every session share one key.
723    #[test]
724    fn display_is_still_the_full_identity() {
725        assert_eq!(SessionId::new(42).to_string(), "session#42");
726    }
727
728    /// `McpSessionId` derives `Debug` from `SessionId` and so inherits the
729    /// redaction. Asserted rather than assumed: the property rests on that
730    /// derive, and deleting it would silently re-expose the internal counter.
731    #[test]
732    fn the_mcp_session_id_inherits_the_redaction() {
733        let mcp = crate::domain::McpSessionId::new(SessionId::new(0xDEAD_BEEF));
734        let rendered = format!("{mcp:?}");
735        assert!(
736            !rendered.contains("deadbeef") && !rendered.contains("3735928559"),
737            "the MCP session id leaked its value through Debug: {rendered}"
738        );
739    }
740}