Skip to main content

soma_ops/
identity.rs

1use std::{fmt, str::FromStr, time::SystemTime};
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6const MAX_REFERENCE_CHARS: usize = 256;
7const MAX_TRACEPARENT_CHARS: usize = 512;
8const MAX_TRACESTATE_CHARS: usize = 1_024;
9
10macro_rules! uuid_identity {
11    ($name:ident, $kind:literal, $doc:literal) => {
12        #[doc = $doc]
13        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14        #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15        #[serde(transparent)]
16        pub struct $name(String);
17
18        impl $name {
19            /// Generates a time-ordered UUIDv7 identity.
20            #[must_use]
21            pub fn new() -> Self {
22                Self(Uuid::now_v7().to_string())
23            }
24
25            /// Parses and normalizes an existing UUID identity.
26            pub fn parse(value: impl AsRef<str>) -> Result<Self, IdentityError> {
27                let value = value.as_ref();
28                let parsed = Uuid::parse_str(value).map_err(|_| IdentityError::InvalidUuid {
29                    kind: $kind,
30                    value: value.to_owned(),
31                })?;
32                Ok(Self(parsed.hyphenated().to_string()))
33            }
34
35            /// Returns the canonical hyphenated UUID string.
36            #[must_use]
37            pub fn as_str(&self) -> &str {
38                &self.0
39            }
40
41            /// Consumes the identity and returns its canonical string.
42            #[must_use]
43            pub fn into_string(self) -> String {
44                self.0
45            }
46        }
47
48        impl Default for $name {
49            fn default() -> Self {
50                Self::new()
51            }
52        }
53
54        impl fmt::Display for $name {
55            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56                formatter.write_str(&self.0)
57            }
58        }
59
60        impl FromStr for $name {
61            type Err = IdentityError;
62
63            fn from_str(value: &str) -> Result<Self, Self::Err> {
64                Self::parse(value)
65            }
66        }
67    };
68}
69
70uuid_identity!(
71    OperationId,
72    "operation",
73    "Unique identity for one operation execution."
74);
75uuid_identity!(
76    EventId,
77    "event",
78    "Unique identity for one operation lifecycle event."
79);
80uuid_identity!(
81    CorrelationId,
82    "correlation",
83    "Identity shared by operations participating in one workflow or incident."
84);
85uuid_identity!(
86    AuthorizationId,
87    "authorization",
88    "Opaque identity for authorization evidence issued by a product policy layer."
89);
90
91/// Milliseconds from the Unix epoch used in cross-process operation contracts.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[serde(transparent)]
95pub struct Timestamp(i64);
96
97impl Timestamp {
98    /// Creates a timestamp from Unix milliseconds.
99    #[must_use]
100    pub const fn from_unix_millis(value: i64) -> Self {
101        Self(value)
102    }
103
104    /// Returns the current system time as Unix milliseconds.
105    #[must_use]
106    pub fn now() -> Self {
107        match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
108            Ok(duration) => Self(clamp_millis(duration.as_millis() as i128)),
109            Err(error) => Self(clamp_millis(-(error.duration().as_millis() as i128))),
110        }
111    }
112
113    /// Returns Unix milliseconds.
114    #[must_use]
115    pub const fn unix_millis(self) -> i64 {
116        self.0
117    }
118}
119
120fn clamp_millis(value: i128) -> i64 {
121    value.clamp(i64::MIN as i128, i64::MAX as i128) as i64
122}
123
124/// Stable reference to the product or agent that requested an operation.
125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct ActorRef {
128    namespace: String,
129    id: String,
130}
131
132impl ActorRef {
133    /// Creates a validated actor reference.
134    pub fn new(namespace: impl Into<String>, id: impl Into<String>) -> Result<Self, IdentityError> {
135        let namespace = namespace.into();
136        let id = id.into();
137        validate_reference("actor namespace", &namespace)?;
138        validate_reference("actor id", &id)?;
139        Ok(Self { namespace, id })
140    }
141
142    /// Returns the namespace that interprets this actor identity.
143    #[must_use]
144    pub fn namespace(&self) -> &str {
145        &self.namespace
146    }
147
148    /// Returns the identity within the actor namespace.
149    #[must_use]
150    pub fn id(&self) -> &str {
151        &self.id
152    }
153}
154
155/// Identity and version of the component that emitted an operation event.
156#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158pub struct ProducerRef {
159    name: String,
160    version: String,
161}
162
163impl ProducerRef {
164    /// Creates a validated producer reference.
165    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Result<Self, IdentityError> {
166        let name = name.into();
167        let version = version.into();
168        validate_reference("producer name", &name)?;
169        validate_reference("producer version", &version)?;
170        Ok(Self { name, version })
171    }
172
173    /// Returns the producer name.
174    #[must_use]
175    pub fn name(&self) -> &str {
176        &self.name
177    }
178
179    /// Returns the producer version.
180    #[must_use]
181    pub fn version(&self) -> &str {
182        &self.version
183    }
184}
185
186/// W3C trace context safe to propagate across operation boundaries.
187#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
189pub struct TraceContext {
190    traceparent: Option<String>,
191    tracestate: Option<String>,
192}
193
194impl TraceContext {
195    /// Creates a validated trace context.
196    pub fn new(
197        traceparent: Option<impl Into<String>>,
198        tracestate: Option<impl Into<String>>,
199    ) -> Result<Self, IdentityError> {
200        let traceparent = traceparent.map(Into::into);
201        let tracestate = tracestate.map(Into::into);
202        validate_optional_trace("traceparent", traceparent.as_deref(), MAX_TRACEPARENT_CHARS)?;
203        validate_optional_trace("tracestate", tracestate.as_deref(), MAX_TRACESTATE_CHARS)?;
204        Ok(Self {
205            traceparent,
206            tracestate,
207        })
208    }
209
210    /// Returns the W3C traceparent header value when present.
211    #[must_use]
212    pub fn traceparent(&self) -> Option<&str> {
213        self.traceparent.as_deref()
214    }
215
216    /// Returns the W3C tracestate header value when present.
217    #[must_use]
218    pub fn tracestate(&self) -> Option<&str> {
219        self.tracestate.as_deref()
220    }
221}
222
223/// Validation failures for opaque identities and cross-product references.
224#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
225#[non_exhaustive]
226pub enum IdentityError {
227    /// A supplied identity was not a UUID.
228    #[error("invalid {kind} UUID: {value}")]
229    InvalidUuid {
230        /// Identity kind being parsed.
231        kind: &'static str,
232        /// Invalid value.
233        value: String,
234    },
235    /// A reference was empty, too long, or contained control characters.
236    #[error("invalid {field}: expected 1..={max_chars} non-control characters")]
237    InvalidReference {
238        /// Reference field.
239        field: &'static str,
240        /// Maximum accepted character count.
241        max_chars: usize,
242    },
243}
244
245fn validate_reference(field: &'static str, value: &str) -> Result<(), IdentityError> {
246    let chars = value.chars().count();
247    if chars == 0 || chars > MAX_REFERENCE_CHARS || value.chars().any(char::is_control) {
248        return Err(IdentityError::InvalidReference {
249            field,
250            max_chars: MAX_REFERENCE_CHARS,
251        });
252    }
253    Ok(())
254}
255
256fn validate_optional_trace(
257    field: &'static str,
258    value: Option<&str>,
259    max_chars: usize,
260) -> Result<(), IdentityError> {
261    let Some(value) = value else {
262        return Ok(());
263    };
264    let chars = value.chars().count();
265    if chars == 0 || chars > max_chars || value.chars().any(char::is_control) {
266        return Err(IdentityError::InvalidReference { field, max_chars });
267    }
268    Ok(())
269}
270
271#[cfg(test)]
272#[path = "identity_tests.rs"]
273mod tests;