Skip to main content

protocol_droid/application/session/
lifecycle.rs

1//! The protocol's half of the session engine: what a message means, and what
2//! it was agreed on.
3//!
4//! # Why the trait has two methods
5//!
6//! [`classify`](Lifecycle::classify) and [`resolve`](Lifecycle::resolve) are the
7//! only two questions the engine ever asks a protocol, and they are asked
8//! together for a reason: a protocol that answered "this method is permitted"
9//! with one rule and "this method opens the handshake" with another could
10//! disagree, and the disagreement would show up as a session whose phase is
11//! wrong while every individual answer looks reasonable. One method that
12//! classifies, and one that reads a declaration, cannot be split apart without
13//! the engine having to reconcile two sources of truth.
14//!
15//! # The two associated types, and why the engine cannot look inside them
16//!
17//! `Profile` is MCP's `FeatureProfile`, a u128 bitset, and would be A2A's
18//! `Major.Minor` pair. `Version` is a date for MCP and something else
19//! entirely for A2A. The engine stores both opaquely and never inspects
20//! either: it passes a `Profile` back to the lifecycle whenever it needs a
21//! decision, and renders a `Version` into a string for the event log. That is
22//! what keeps this module free of MCP types, and it is the same property T09's
23//! `MethodTable` has — a protocol drops in as an implementation rather than as
24//! a fork.
25//!
26//! # Implementing it for a protocol with no handshake
27//!
28//! A2A has none: every request carries an `A2A-Version` header and there is
29//! nothing to initialise. The sketch below is the whole of it, and it is a
30//! doc-test rather than a stub in the tree so that nobody has to keep a
31//! half-implemented protocol compiling. Note what it does *not* need: a
32//! version list, a capability set, a transport, or any of the machinery MCP
33//! requires, because none of those concepts are referenced here.
34
35use serde_json::Value;
36
37use crate::domain::session::SessionState;
38
39/// What a message naming a method means for a session in a given state.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Classification {
42    /// Ordinary work. Admitted only if [`SessionState::admits_ordinary_work`]
43    /// holds and the protocol does not narrow it further.
44    Ordinary,
45    /// The peer is opening a handshake.
46    HandshakeStart,
47    /// The peer is confirming that the handshake landed.
48    HandshakeConfirm,
49    /// Not permitted here, whatever the method is.
50    Refused,
51}
52
53/// What a peer declared about itself, in this crate's vocabulary.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Declaration<'a> {
56    /// A handshake request: the version it asked for, and whatever the
57    /// protocol also puts in one.
58    Handshake {
59        /// The requested version, as the peer spelled it. Parsed here rather
60        /// than by the engine, because a version this protocol cannot parse is
61        /// a statement about *its* vocabulary.
62        version: &'a str,
63        /// The capabilities the peer advertised, if this protocol has any.
64        capabilities: Option<&'a Value>,
65    },
66    /// A message that negotiates for itself: the reserved keys its metadata
67    /// carried, and nothing else.
68    Stateless {
69        /// The metadata the request carried.
70        meta: MetaView<'a>,
71    },
72}
73
74/// The reserved metadata a request carried.
75///
76/// A borrowed view over an already-parsed object, because the transport
77/// boundary is where JSON is parsed and nothing downstream of it should meet a
78/// raw string. A protocol that reserves no keys is handed an empty view and
79/// answers `None`; one that reserves several picks the ones it defines and
80/// ignores the rest, because a peer sending a key this protocol has not heard
81/// of is a peer running a newer draft, not a peer that sent nonsense.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
83pub struct MetaView<'a> {
84    object: Option<&'a serde_json::Map<String, Value>>,
85}
86
87impl<'a> MetaView<'a> {
88    /// A view over a parsed metadata object.
89    #[must_use]
90    pub const fn new(object: Option<&'a serde_json::Map<String, Value>>) -> Self {
91        Self { object }
92    }
93
94    /// The value at `key`, if the request carried one.
95    #[must_use]
96    pub fn get(&self, key: &str) -> Option<&'a Value> {
97        self.object.and_then(|object| object.get(key))
98    }
99
100    /// Whether the request carried no metadata at all.
101    #[must_use]
102    pub const fn is_empty(&self) -> bool {
103        self.object.is_none()
104    }
105}
106
107/// What a protocol answered to a declaration.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Resolution<P, V> {
110    /// What this session may now do, in the protocol's own type.
111    pub profile: P,
112    /// The version the session speaks, in the protocol's own type.
113    pub version: V,
114    /// Whether the peer asked for this version and got it, or was answered with
115    /// a different one.
116    pub echoed: bool,
117}
118
119/// Why a session could not be established.
120///
121/// Deliberately smaller than the engine's own error: these are the cases where
122/// the *protocol's* answer is "I cannot", and the engine adds its own state
123/// errors around them. The distinction matters because only one of the two is
124/// something a peer can fix by asking for something different.
125#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
126pub enum DeclarationError {
127    /// The peer named a version this side cannot parse.
128    #[error("{declared:?} is not a version this protocol speaks")]
129    Unparseable {
130        /// What the peer sent, verbatim. It is a peer's own token, and the
131        /// message is what lets an operator see the spelling that failed.
132        declared: String,
133    },
134    /// A message declared no version, and there is no session to fall back to.
135    ///
136    /// The distinction from a missing header on a *stateful* session is the
137    /// whole point of the stateless lifecycle: with nothing established, there
138    /// is nothing to assume from, so an absent version is an error rather than
139    /// a default. Answering with a guess here is how a stateless server ends
140    /// up serving a modern peer a 2024 profile.
141    #[error("the request declared no protocol version and no session is established")]
142    NoVersion,
143}
144
145/// The vocabulary one protocol brings to the session engine.
146///
147/// Declared by the engine, which is its only consumer, and implemented by
148/// whichever protocol module is in play. See the module docs for why it is two
149/// methods and two associated types rather than a wider interface.
150///
151/// # Examples
152///
153/// A protocol with no handshake, which is what A2A's per-request version header
154/// amounts to. Nothing in this module changes for it.
155///
156/// ```
157/// use protocol_droid::application::session::{
158///     Classification, Declaration, DeclarationError, Lifecycle, MetaView, Resolution,
159/// };
160/// use protocol_droid::domain::session::SessionState;
161///
162/// /// A2A speaks `Major.Minor`; MCP speaks dates. Nothing here assumes either.
163/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
164/// struct MajorMinor(u8, u8);
165///
166/// impl std::fmt::Display for MajorMinor {
167///     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168///         write!(formatter, "{}.{}", self.0, self.1)
169///     }
170/// }
171///
172/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
173/// struct A2AProfile {
174///     version: MajorMinor,
175/// }
176///
177/// struct A2ALifecycle {
178///     supported: Vec<MajorMinor>,
179/// }
180///
181/// impl Lifecycle for A2ALifecycle {
182///     type Profile = A2AProfile;
183///     type Version = MajorMinor;
184///
185///     fn classify(
186///         &self,
187///         state: SessionState,
188///         _profile: Option<Self::Profile>,
189///         _method: &str,
190///     ) -> Classification {
191///         // No handshake, so nothing is ever a handshake — and the engine
192///         // does not need to be told what "initialize" means.
193///         let _ = state;
194///         Classification::Ordinary
195///     }
196///
197///     fn resolve(&self, declaration: Declaration<'_>) -> Result<Resolution<Self::Profile, Self::Version>, DeclarationError> {
198///         // A2A negotiates out of its own metadata rather than a version
199///         // string, so both arms are read but neither is the version. That
200///         // difference is the point: the engine never supplies it.
201///         match declaration {
202///             Declaration::Stateless { .. } | Declaration::Handshake { .. } => {}
203///         }
204///         let version = self.supported.first().copied().ok_or(DeclarationError::NoVersion)?;
205///         Ok(Resolution {
206///             profile: A2AProfile { version },
207///             version,
208///             echoed: true,
209///         })
210///     }
211/// }
212///
213/// let lifecycle = A2ALifecycle { supported: vec![MajorMinor(0, 3)] };
214/// let resolution = lifecycle
215///     .resolve(Declaration::Stateless { meta: MetaView::default() })
216///     .expect("a supported version is answered");
217/// assert_eq!(resolution.version, MajorMinor(0, 3));
218/// assert_eq!(
219///     lifecycle.classify(SessionState::Stateless, Some(resolution.profile), "message/send"),
220///     Classification::Ordinary,
221/// );
222/// ```
223pub trait Lifecycle {
224    /// What this protocol calls the set of things a session may do.
225    ///
226    /// Opaque to the engine. `FeatureProfile` for MCP, which the engine also
227    /// never inspects — it is handed back to [`classify`](Self::classify).
228    type Profile: Copy + core::fmt::Debug + PartialEq;
229
230    /// How this protocol spells a version.
231    type Version: Copy + core::fmt::Display + PartialEq;
232
233    /// What a message naming `method` means for a session in `state`.
234    ///
235    /// `profile` is `None` before anything has been agreed. A protocol that
236    /// gates a method on a feature has to say so here rather than by
237    /// inspecting a version, because the version is not this engine's to
238    /// interpret.
239    ///
240    /// The engine refuses a message in a closing or closed session whatever
241    /// this answers, so a protocol does not have to repeat that check.
242    fn classify(
243        &self,
244        state: SessionState,
245        profile: Option<Self::Profile>,
246        method: &str,
247    ) -> Classification;
248
249    /// Reads what a peer declared and answers with what this side agrees to.
250    ///
251    /// # Errors
252    ///
253    /// [`DeclarationError`] when the declaration is not one this protocol can
254    /// turn into a profile. Never for an *unsupported but well-formed* version:
255    /// answering with something else is the specified behaviour, and an error
256    /// there would stop a server working the day a revision is published.
257    fn resolve(
258        &self,
259        declaration: Declaration<'_>,
260    ) -> Result<Resolution<Self::Profile, Self::Version>, DeclarationError>;
261}