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}