Skip to main content

monoloop_contracts/
id.rs

1//! Correlation identities. Opaque wrappers — no ambient current identity.
2//!
3//! Public string newtypes reject empty, oversized, and control-character values.
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use thiserror::Error;
8use uuid::Uuid;
9
10/// Maximum bytes for opaque string identities and tool names.
11pub const MAX_IDENTITY_BYTES: usize = 256;
12
13/// Identity construction failure (safe, closed).
14#[derive(Clone, Debug, Error, PartialEq, Eq)]
15pub enum IdentityError {
16    /// Empty string rejected.
17    #[error("identity must be non-empty")]
18    Empty,
19    /// Exceeds [`MAX_IDENTITY_BYTES`].
20    #[error("identity exceeds maximum length of {MAX_IDENTITY_BYTES} bytes")]
21    TooLong,
22    /// Contains a Unicode control character.
23    #[error("identity must not contain control characters")]
24    ControlCharacter,
25}
26
27/// Validate a bounded opaque identity string.
28pub fn validate_identity_string(value: &str) -> Result<(), IdentityError> {
29    if value.is_empty() {
30        return Err(IdentityError::Empty);
31    }
32    if value.len() > MAX_IDENTITY_BYTES {
33        return Err(IdentityError::TooLong);
34    }
35    if value.chars().any(|c| c.is_control()) {
36        return Err(IdentityError::ControlCharacter);
37    }
38    Ok(())
39}
40
41fn validated_string(value: impl Into<String>) -> Result<String, IdentityError> {
42    let s = value.into();
43    validate_identity_string(&s)?;
44    Ok(s)
45}
46
47/// Local logical transport attachment identity for one connection scope.
48#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct ConnectionId(String);
50
51impl ConnectionId {
52    /// Create a connection id from an explicit caller-supplied string.
53    ///
54    /// # Panics
55    ///
56    /// Panics if `value` fails identity validation. Prefer [`Self::try_new`].
57    pub fn new(value: impl Into<String>) -> Self {
58        Self::try_new(value).expect("ConnectionId::new requires a valid identity string")
59    }
60
61    /// Fallible constructor.
62    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
63        Ok(Self(validated_string(value)?))
64    }
65
66    /// Allocate a random connection id (for tests and callers without an injector).
67    pub fn generate() -> Self {
68        Self(Uuid::new_v4().to_string())
69    }
70
71    /// Borrow the underlying string.
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77impl fmt::Display for ConnectionId {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str(&self.0)
80    }
81}
82
83/// Monoloop run correlation identity.
84#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub struct MonoloopRunId(String);
86
87impl MonoloopRunId {
88    /// Create from an explicit value.
89    ///
90    /// # Panics
91    ///
92    /// Panics if `value` fails identity validation. Prefer [`Self::try_new`].
93    pub fn new(value: impl Into<String>) -> Self {
94        Self::try_new(value).expect("MonoloopRunId::new requires a valid identity string")
95    }
96
97    /// Fallible constructor.
98    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
99        Ok(Self(validated_string(value)?))
100    }
101
102    /// Allocate a random run id.
103    pub fn generate() -> Self {
104        Self(Uuid::new_v4().to_string())
105    }
106
107    /// One-to-one derivation from a transaction id (internal component correlation).
108    pub fn from_transaction(id: &TransactionId) -> Self {
109        Self(format!("txn:{}", id.as_uuid()))
110    }
111
112    /// Borrow the underlying string.
113    pub fn as_str(&self) -> &str {
114        &self.0
115    }
116}
117
118impl Default for MonoloopRunId {
119    fn default() -> Self {
120        Self::generate()
121    }
122}
123
124impl fmt::Display for MonoloopRunId {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130/// Opaque externally owned session identity (e.g. Grok `sessionId`).
131///
132/// Monoloop compares and routes this value; it does not invent a competing ID
133/// or derive authority from its contents.
134#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub struct ExternalSessionId(String);
136
137impl ExternalSessionId {
138    /// Wrap an external system's authoritative session id.
139    ///
140    /// # Panics
141    ///
142    /// Panics if `value` fails identity validation. Prefer [`Self::try_new`].
143    pub fn new(value: impl Into<String>) -> Self {
144        Self::try_new(value).expect("ExternalSessionId::new requires a valid identity string")
145    }
146
147    /// Fallible constructor.
148    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
149        Ok(Self(validated_string(value)?))
150    }
151
152    /// Borrow the opaque value.
153    pub fn as_str(&self) -> &str {
154        &self.0
155    }
156}
157
158impl fmt::Display for ExternalSessionId {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        // Display redacts by default; Display is for tests/safe logs only.
161        f.write_str("<external-session>")
162    }
163}
164
165/// Grok Build's authoritative `sessionId` — the sole session correlation identity
166/// for the Grok connector profile.
167#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
168pub struct GrokSessionId(ExternalSessionId);
169
170impl GrokSessionId {
171    /// Wrap a Grok-returned session id.
172    ///
173    /// # Panics
174    ///
175    /// Panics if `value` fails identity validation. Prefer [`Self::try_new`].
176    pub fn new(value: impl Into<String>) -> Self {
177        Self::try_new(value).expect("GrokSessionId::new requires a valid identity string")
178    }
179
180    /// Fallible constructor.
181    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
182        Ok(Self(ExternalSessionId::try_new(value)?))
183    }
184
185    /// Borrow the opaque session id string (for protocol routing only).
186    pub fn as_str(&self) -> &str {
187        self.0.as_str()
188    }
189
190    /// View as a generic external session id.
191    pub fn as_external(&self) -> &ExternalSessionId {
192        &self.0
193    }
194
195    /// Convert into a generic external session id.
196    pub fn into_external(self) -> ExternalSessionId {
197        self.0
198    }
199}
200
201impl From<GrokSessionId> for ExternalSessionId {
202    fn from(value: GrokSessionId) -> Self {
203        value.0
204    }
205}
206
207/// Caller/request correlation identity (opaque, no authority).
208#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
209pub struct RequestId(String);
210
211impl RequestId {
212    /// Create from an explicit value.
213    ///
214    /// # Panics
215    ///
216    /// Panics if `value` fails identity validation. Prefer [`Self::try_new`].
217    pub fn new(value: impl Into<String>) -> Self {
218        Self::try_new(value).expect("RequestId::new requires a valid identity string")
219    }
220
221    /// Fallible constructor.
222    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
223        Ok(Self(validated_string(value)?))
224    }
225
226    /// Allocate a random request id.
227    pub fn generate() -> Self {
228        Self(Uuid::new_v4().to_string())
229    }
230
231    /// Borrow the underlying string.
232    pub fn as_str(&self) -> &str {
233        &self.0
234    }
235}
236
237/// Admitted transaction identity (Monoloop-generated, never reused).
238#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
239pub struct TransactionId(Uuid);
240
241impl TransactionId {
242    /// Allocate a fresh transaction id.
243    pub fn generate() -> Self {
244        Self(Uuid::new_v4())
245    }
246
247    /// Wrap an existing UUID (admission / tests).
248    pub fn from_uuid(id: Uuid) -> Self {
249        Self(id)
250    }
251
252    /// Borrow the UUID.
253    pub fn as_uuid(&self) -> Uuid {
254        self.0
255    }
256
257    /// Stable string form (not a secret).
258    pub fn as_str(&self) -> String {
259        self.0.to_string()
260    }
261}
262
263impl fmt::Display for TransactionId {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        write!(f, "{}", self.0)
266    }
267}
268
269/// One provider request/response exchange inside a transaction.
270#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
271pub struct ExchangeId(Uuid);
272
273impl ExchangeId {
274    /// Allocate a fresh exchange id.
275    pub fn generate() -> Self {
276        Self(Uuid::new_v4())
277    }
278
279    /// Wrap an existing UUID.
280    pub fn from_uuid(id: Uuid) -> Self {
281        Self(id)
282    }
283
284    /// Borrow the UUID.
285    pub fn as_uuid(&self) -> Uuid {
286        self.0
287    }
288}
289
290impl fmt::Display for ExchangeId {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        write!(f, "{}", self.0)
293    }
294}
295
296/// Caller-visible session / correlation identity for transaction routing.
297///
298/// For external-agent Channels this is the validated external session string.
299/// For direct-LLM Channels it is ephemeral routing only (no provider history).
300#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
301pub struct SessionId(String);
302
303impl SessionId {
304    /// Fallible constructor.
305    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
306        Ok(Self(validated_string(value)?))
307    }
308
309    /// Allocate a random direct-LLM session id.
310    pub fn generate() -> Self {
311        Self(Uuid::new_v4().to_string())
312    }
313
314    /// Borrow the underlying string.
315    pub fn as_str(&self) -> &str {
316        &self.0
317    }
318
319    /// View as an external session id with identical bytes.
320    pub fn as_external(&self) -> ExternalSessionId {
321        ExternalSessionId(self.0.clone())
322    }
323
324    /// Convert into an external session id with identical bytes.
325    pub fn into_external(self) -> ExternalSessionId {
326        ExternalSessionId(self.0)
327    }
328
329    /// Build from an external session id with identical bytes.
330    pub fn from_external(id: &ExternalSessionId) -> Self {
331        Self(id.0.clone())
332    }
333}
334
335impl fmt::Display for SessionId {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        f.write_str("<session>")
338    }
339}
340
341/// Channel identity (caller-selected; never ambient).
342#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
343pub struct ChannelId(String);
344
345impl ChannelId {
346    /// Fallible constructor.
347    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
348        Ok(Self(validated_string(value)?))
349    }
350
351    /// Borrow the underlying string.
352    pub fn as_str(&self) -> &str {
353        &self.0
354    }
355}
356
357impl fmt::Display for ChannelId {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        f.write_str(&self.0)
360    }
361}
362
363/// Session exclusion and session-directed control key.
364///
365/// Equal session strings on different Channels are distinct keys.
366#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
367pub struct SessionKey {
368    /// Selected Channel.
369    pub channel_id: ChannelId,
370    /// Session correlation identity on that Channel.
371    pub session_id: SessionId,
372}
373
374impl SessionKey {
375    /// Construct a session key from validated components.
376    pub fn new(channel_id: ChannelId, session_id: SessionId) -> Self {
377        Self {
378            channel_id,
379            session_id,
380        }
381    }
382}
383
384/// Stable host-registry tool identity (selection key on requests).
385#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
386pub struct ToolId(String);
387
388impl ToolId {
389    /// Fallible constructor.
390    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
391        Ok(Self(validated_string(value)?))
392    }
393
394    /// Borrow the underlying string.
395    pub fn as_str(&self) -> &str {
396        &self.0
397    }
398}
399
400impl fmt::Display for ToolId {
401    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402        f.write_str(&self.0)
403    }
404}
405
406/// Tool name as exposed to models / MCP (distinct from [`ToolId`]).
407#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
408pub struct ToolName(String);
409
410impl ToolName {
411    /// Fallible constructor.
412    pub fn try_new(value: impl Into<String>) -> Result<Self, IdentityError> {
413        Ok(Self(validated_string(value)?))
414    }
415
416    /// Borrow the underlying string.
417    pub fn as_str(&self) -> &str {
418        &self.0
419    }
420}
421
422impl fmt::Display for ToolName {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        f.write_str(&self.0)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn rejects_empty_and_control() {
434        assert_eq!(SessionId::try_new(""), Err(IdentityError::Empty));
435        assert_eq!(
436            ChannelId::try_new("a\nb"),
437            Err(IdentityError::ControlCharacter)
438        );
439        assert!(ToolId::try_new("x".repeat(MAX_IDENTITY_BYTES + 1)).is_err());
440    }
441
442    #[test]
443    fn session_key_isolates_channels() {
444        let a = SessionKey::new(
445            ChannelId::try_new("ch-a").unwrap(),
446            SessionId::try_new("same-sess").unwrap(),
447        );
448        let b = SessionKey::new(
449            ChannelId::try_new("ch-b").unwrap(),
450            SessionId::try_new("same-sess").unwrap(),
451        );
452        assert_ne!(a, b);
453        assert_eq!(a.session_id.as_str(), b.session_id.as_str());
454    }
455
456    #[test]
457    fn session_external_round_trip_bytes() {
458        let ext = ExternalSessionId::try_new("provider-abc").unwrap();
459        let sid = SessionId::from_external(&ext);
460        assert_eq!(sid.as_str(), ext.as_str());
461        assert_eq!(sid.into_external().as_str(), "provider-abc");
462    }
463
464    #[test]
465    fn transaction_id_serializes() {
466        let id = TransactionId::generate();
467        let json = serde_json::to_string(&id).unwrap();
468        let back: TransactionId = serde_json::from_str(&json).unwrap();
469        assert_eq!(id, back);
470    }
471}