Skip to main content

laser_sdk/types/
ids.rs

1use serde::{Deserialize, Serialize};
2use std::fmt::{self, Display, Formatter};
3use std::str::FromStr;
4use ulid::Ulid;
5
6const MAX_ID_LEN: usize = 255;
7
8// Versioned FNV-1a so derived ids stay stable across compiler/std versions.
9// Bumping DERIVE_VERSION deliberately remaps every derived conversation id.
10const DERIVE_VERSION: u8 = 1;
11const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
12const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
13
14/// Why parsing or validating an id failed (`ConversationId`, `AgentId`, `MessageId`).
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
16pub enum IdError {
17    #[error("identifier must not be empty")]
18    Empty,
19    #[error("identifier length {got}B exceeds max {max}B")]
20    TooLong { got: usize, max: usize },
21    #[error("identifier contains invalid character `{0}`")]
22    InvalidChar(char),
23    #[error("invalid ULID `{0}`")]
24    InvalidUlid(String),
25    #[error("invalid message id `{0}`, expected `<partition_id>:<offset>`")]
26    InvalidMessageId(String),
27}
28
29/// A conversation: the unit of ordering and causality. One conversation maps to
30/// one Iggy partition, so all its messages share a total order. Created fresh with
31/// [`new`](Self::new) or derived deterministically from a seed with
32/// [`derive`](Self::derive).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(try_from = "String", into = "String")]
35pub struct ConversationId(Ulid);
36
37impl ConversationId {
38    /// A fresh, random conversation id (a time-ordered ULID).
39    pub fn new() -> Self {
40        Self(Ulid::generate())
41    }
42
43    /// Derives a stable conversation id from a seed (e.g. a user identity).
44    /// The same seed always yields the same id, giving per-seed ordering and
45    /// isolation without coordination. Used by `SessionPolicy::PerUser`.
46    pub fn derive(seed: &str) -> Self {
47        let high = hash_with(0x1d, seed);
48        let low = hash_with(0x9e, seed);
49        Self(Ulid((u128::from(high) << 64) | u128::from(low)))
50    }
51}
52
53fn hash_with(salt: u8, seed: &str) -> u64 {
54    let mut hash = FNV_OFFSET;
55    for byte in [DERIVE_VERSION, salt].into_iter().chain(seed.bytes()) {
56        hash ^= u64::from(byte);
57        hash = hash.wrapping_mul(FNV_PRIME);
58    }
59    hash
60}
61
62impl Default for ConversationId {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Display for ConversationId {
69    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
70        Display::fmt(&self.0, f)
71    }
72}
73
74impl FromStr for ConversationId {
75    type Err = IdError;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        Ulid::from_string(s)
79            .map(Self)
80            .map_err(|_| IdError::InvalidUlid(s.to_owned()))
81    }
82}
83
84impl TryFrom<String> for ConversationId {
85    type Error = IdError;
86
87    fn try_from(s: String) -> Result<Self, Self::Error> {
88        s.parse()
89    }
90}
91
92impl From<ConversationId> for String {
93    fn from(id: ConversationId) -> Self {
94        id.to_string()
95    }
96}
97
98/// A durable intent's id (a fresh, time-ordered ULID), naming one proposed
99/// effect across its `Intent`/`Vote`/`Decision` records (see
100/// `crate::intent`).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(try_from = "String", into = "String")]
103pub struct IntentId(Ulid);
104
105impl IntentId {
106    /// A fresh, random intent id (a time-ordered ULID).
107    pub fn new() -> Self {
108        Self(Ulid::generate())
109    }
110}
111
112impl Default for IntentId {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl Display for IntentId {
119    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
120        Display::fmt(&self.0, f)
121    }
122}
123
124impl FromStr for IntentId {
125    type Err = IdError;
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        Ulid::from_string(s)
129            .map(Self)
130            .map_err(|_| IdError::InvalidUlid(s.to_owned()))
131    }
132}
133
134impl TryFrom<String> for IntentId {
135    type Error = IdError;
136
137    fn try_from(s: String) -> Result<Self, Self::Error> {
138        s.parse()
139    }
140}
141
142impl From<IntentId> for String {
143    fn from(id: IntentId) -> Self {
144        id.to_string()
145    }
146}
147
148/// An agent's stable logical name. Almost any string: the only rules are that it
149/// is non-empty, at most 255 bytes, and free of ASCII control characters because
150/// it rides as a message header value. So plain labels (`planner`), email-like
151/// federated identities (`planner@acme.example`), URLs, and namespaced names
152/// (`team/planner`) are all valid. Construct with [`new`](Self::new), or by
153/// parsing (`"planner".parse()?` / `TryFrom`).
154#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(try_from = "String", into = "String")]
156pub struct AgentId(String);
157
158impl AgentId {
159    /// Build an agent id from any string that satisfies the rules (non-empty,
160    /// ≤ 255 bytes, no ASCII control characters), or an [`IdError`] saying which
161    /// rule it broke.
162    pub fn new(name: impl Into<String>) -> Result<Self, IdError> {
163        let name = name.into();
164        validate_id(&name)?;
165        Ok(Self(name))
166    }
167
168    /// The id as a string slice.
169    pub fn as_str(&self) -> &str {
170        &self.0
171    }
172}
173
174impl Display for AgentId {
175    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
176        f.write_str(&self.0)
177    }
178}
179
180impl FromStr for AgentId {
181    type Err = IdError;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        Self::new(s)
185    }
186}
187
188impl TryFrom<&str> for AgentId {
189    type Error = IdError;
190
191    fn try_from(s: &str) -> Result<Self, Self::Error> {
192        Self::new(s)
193    }
194}
195
196impl TryFrom<String> for AgentId {
197    type Error = IdError;
198
199    fn try_from(s: String) -> Result<Self, Self::Error> {
200        Self::new(s)
201    }
202}
203
204impl From<AgentId> for String {
205    fn from(id: AgentId) -> Self {
206        id.0
207    }
208}
209
210/// An Apache Iggy consumer-group name. This is deployment topology, distinct
211/// from the logical [`AgentId`]: replicas of one agent commonly share a group,
212/// while non-agent workers also use groups.
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214pub struct ConsumerGroupName(String);
215
216impl ConsumerGroupName {
217    /// Validate a non-empty Iggy group name that fits the 255-byte name limit.
218    pub fn new(name: impl Into<String>) -> Result<Self, IdError> {
219        let name = name.into();
220        validate_id(&name)?;
221        Ok(Self(name))
222    }
223
224    /// Use the logical agent spelling as its default deployment group.
225    /// Infallible by a deliberate invariant: a group name today has exactly an
226    /// agent id's rules (`validate_id`), so every valid id is a valid group.
227    /// If group syntax ever diverges, this must become fallible in the same
228    /// change.
229    pub fn for_agent(agent: &AgentId) -> Self {
230        debug_assert!(validate_id(agent.as_str()).is_ok());
231        Self(agent.as_str().to_owned())
232    }
233
234    pub fn as_str(&self) -> &str {
235        &self.0
236    }
237}
238
239impl Display for ConsumerGroupName {
240    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
241        f.write_str(&self.0)
242    }
243}
244
245impl FromStr for ConsumerGroupName {
246    type Err = IdError;
247
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        Self::new(s)
250    }
251}
252
253impl From<&AgentId> for ConsumerGroupName {
254    fn from(agent: &AgentId) -> Self {
255        Self::for_agent(agent)
256    }
257}
258
259/// The numeric identity of an authenticated Apache Iggy principal. A semantic
260/// tag, not a proof: anyone can construct one client-side, and the trust
261/// anchor is always the server-stamped user id a connection authenticated as.
262/// The type exists so principal-scoped APIs (presence, bindings, RBAC) cannot
263/// silently accept an arbitrary integer that was never meant as a principal.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
265pub struct PrincipalId(u32);
266
267impl PrincipalId {
268    pub const fn new(value: u32) -> Self {
269        Self(value)
270    }
271
272    pub const fn get(self) -> u32 {
273        self.0
274    }
275}
276
277impl Display for PrincipalId {
278    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
279        Display::fmt(&self.0, f)
280    }
281}
282
283impl From<u32> for PrincipalId {
284    fn from(value: u32) -> Self {
285        Self::new(value)
286    }
287}
288
289impl From<PrincipalId> for u32 {
290    fn from(value: PrincipalId) -> Self {
291        value.get()
292    }
293}
294
295/// A message's position on the log: its partition and offset. Stamped on a reply
296/// as the `causal_parent` so a flow's causality is walkable. Displays as
297/// `<partition_id>:<offset>`.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
299pub struct MessageId {
300    /// The Iggy partition the message lives on.
301    pub partition_id: u32,
302    /// The message's offset within that partition.
303    pub offset: u64,
304}
305
306impl MessageId {
307    /// A message id at `(partition_id, offset)`.
308    pub fn new(partition_id: u32, offset: u64) -> Self {
309        Self {
310            partition_id,
311            offset,
312        }
313    }
314}
315
316impl Display for MessageId {
317    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
318        write!(f, "{}:{}", self.partition_id, self.offset)
319    }
320}
321
322impl FromStr for MessageId {
323    type Err = IdError;
324
325    fn from_str(s: &str) -> Result<Self, Self::Err> {
326        let (partition, offset) = s
327            .split_once(':')
328            .ok_or_else(|| IdError::InvalidMessageId(s.to_owned()))?;
329        // Reject signs, whitespace, leading zeros, anything that survives
330        // FromStr<u32>/<u64> but breaks the round-trip with `Display`. We
331        // do the check at byte level (no allocation) so a high-traffic
332        // request/reply loop does not pay a `String` per parse.
333        if !is_canonical_digits(partition) || !is_canonical_digits(offset) {
334            return Err(IdError::InvalidMessageId(s.to_owned()));
335        }
336        Ok(Self {
337            partition_id: partition
338                .parse()
339                .map_err(|_| IdError::InvalidMessageId(s.to_owned()))?,
340            offset: offset
341                .parse()
342                .map_err(|_| IdError::InvalidMessageId(s.to_owned()))?,
343        })
344    }
345}
346
347// Canonical: non-empty, all ASCII digits, no leading zero except for "0"
348// itself. This is exactly what `format!("{}", u32_or_u64)` produces, so the
349// invariant equals `display(parse(s)) == s` without allocating to check.
350fn is_canonical_digits(s: &str) -> bool {
351    let payload = s.as_bytes();
352    if payload.is_empty() {
353        return false;
354    }
355    if payload.len() > 1 && payload[0] == b'0' {
356        return false;
357    }
358    payload.iter().all(u8::is_ascii_digit)
359}
360
361fn validate_id(s: &str) -> Result<(), IdError> {
362    if s.is_empty() {
363        return Err(IdError::Empty);
364    }
365    if s.len() > MAX_ID_LEN {
366        return Err(IdError::TooLong {
367            got: s.len(),
368            max: MAX_ID_LEN,
369        });
370    }
371    // Both agent ids and consumer-group names reject control characters. Agent
372    // ids ride in headers, and keeping group names printable makes logs and
373    // operational tooling unambiguous.
374    if let Some(c) = s.chars().find(|c| c.is_control()) {
375        return Err(IdError::InvalidChar(c));
376    }
377    Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn given_a_conversation_id_when_round_tripped_through_a_string_then_should_be_equal() {
386        let id = ConversationId::new();
387        let parsed = id
388            .to_string()
389            .parse::<ConversationId>()
390            .expect("a formatted conversation id should parse");
391        assert_eq!(parsed, id);
392    }
393
394    #[test]
395    fn given_seeds_when_deriving_conversation_ids_then_should_be_stable_and_distinct() {
396        assert_eq!(
397            ConversationId::derive("user-1"),
398            ConversationId::derive("user-1")
399        );
400        assert_ne!(
401            ConversationId::derive("user-1"),
402            ConversationId::derive("user-2")
403        );
404    }
405
406    // Pins the derivation to a golden value. A change here means existing
407    // `SessionPolicy::PerUser` conversations would be remapped: bump
408    // `DERIVE_VERSION` deliberately rather than silently editing this constant.
409    #[test]
410    fn given_a_known_seed_when_derived_then_should_match_the_pinned_id() {
411        assert_eq!(
412            ConversationId::derive("user-1").to_string(),
413            "6X4VM88293CP9BFK3H58TFMMS7"
414        );
415    }
416
417    #[test]
418    fn given_an_invalid_string_when_parsing_a_conversation_id_then_should_error() {
419        assert!(matches!(
420            "not-a-ulid".parse::<ConversationId>(),
421            Err(IdError::InvalidUlid(_))
422        ));
423    }
424
425    #[test]
426    fn given_agent_id_strings_when_parsing_then_should_accept_valid_and_reject_invalid() {
427        let valid = "executor-v1"
428            .parse::<AgentId>()
429            .expect("a valid agent id should parse");
430        assert_eq!(valid.as_str(), "executor-v1");
431        assert_eq!("".parse::<AgentId>(), Err(IdError::Empty));
432        // Almost any printable string is a valid agent id: spaces, `:`, `@`, `+`,
433        // `/`, email-like federated identities, and namespaced names all parse.
434        for s in [
435            "bad id",
436            "planner+eu@acme.example",
437            "team/planner",
438            "a:b",
439            "https://acme.example/agents/planner",
440        ] {
441            assert_eq!(
442                s.parse::<AgentId>()
443                    .expect("a printable agent id is valid")
444                    .as_str(),
445                s
446            );
447        }
448        // Only ASCII control characters are rejected.
449        assert_eq!(
450            "bad\nid".parse::<AgentId>(),
451            Err(IdError::InvalidChar('\n'))
452        );
453    }
454
455    #[test]
456    fn given_a_message_id_when_round_tripped_then_should_be_equal() {
457        let id = MessageId::new(3, 42);
458        assert_eq!(id.to_string(), "3:42");
459        assert_eq!(
460            "3:42"
461                .parse::<MessageId>()
462                .expect("a formatted message id should parse"),
463            id
464        );
465        assert!(matches!(
466            "nope".parse::<MessageId>(),
467            Err(IdError::InvalidMessageId(_))
468        ));
469    }
470
471    #[test]
472    fn given_an_agent_when_deriving_its_default_group_then_should_keep_distinct_types() {
473        let agent = AgentId::new("planner").expect("planner is a valid agent id");
474        let group = ConsumerGroupName::for_agent(&agent);
475        assert_eq!(group.as_str(), agent.as_str());
476        assert_eq!(group.to_string(), "planner");
477    }
478
479    #[test]
480    fn given_a_principal_when_converted_then_should_preserve_the_server_id() {
481        let principal = PrincipalId::new(42);
482        assert_eq!(principal.get(), 42);
483        assert_eq!(u32::from(principal), 42);
484        assert_eq!(principal.to_string(), "42");
485    }
486
487    #[test]
488    fn given_non_canonical_message_id_strings_when_parsed_then_should_reject() {
489        for bad in [
490            "+3:42", " 3:42", "3:42 ", "03:42", "3:042", "3:", ":42", "::", "3", "",
491        ] {
492            assert!(
493                bad.parse::<MessageId>().is_err(),
494                "expected `{bad}` to be rejected by MessageId::from_str",
495            );
496        }
497    }
498
499    #[test]
500    fn given_a_message_id_shaped_string_when_used_as_agent_id_then_should_accept() {
501        let id = AgentId::new("3:42").expect("`3:42` is a valid agent id");
502        assert_eq!(id.as_str(), "3:42");
503    }
504
505    #[test]
506    fn given_a_control_character_when_constructing_agent_id_then_should_reject() {
507        assert_eq!(AgentId::new("a\tb"), Err(IdError::InvalidChar('\t')));
508        assert_eq!(AgentId::new(""), Err(IdError::Empty));
509    }
510}
511
512// The Agent Data Exchange Protocol (AGDX) id bridge. The wire crate's agent ids are plain
513// u128 newtypes with no clock or entropy (it must stay runtime-free and
514// wasm-portable). GENERATION lives here, where the ulid crate already is.
515
516/// Mint a fresh ULID-valued wire id (time-ordered, human-readable). The
517/// trait exists because the wire crate deliberately cannot generate ids: it
518/// has no clock and no randomness.
519pub trait MintUlid: Sized + From<u128> {
520    /// A fresh ULID as this id type.
521    fn mint() -> Self {
522        Self::from(Ulid::generate().0)
523    }
524}
525
526impl MintUlid for laser_wire::agent::RecordId {}
527impl MintUlid for laser_wire::agent::ConversationId {}
528impl MintUlid for laser_wire::agent::CorrelationId {}
529impl MintUlid for laser_wire::agent::ChannelId {}
530impl MintUlid for laser_wire::query::QueryExecutionId {}
531impl MintUlid for laser_wire::destination::DestinationId {}
532impl MintUlid for laser_wire::destination::DestinationOperationId {}
533impl MintUlid for laser_wire::destination::BackendResourceId {}
534impl MintUlid for laser_wire::destination::QueryRouteId {}
535impl MintUlid for laser_wire::checkpoint::CheckpointOwnerId {}
536impl MintUlid for laser_wire::checkpoint::PreparedAttemptId {}
537impl MintUlid for laser_wire::checkpoint::CheckpointRequestId {}
538
539impl ConversationId {
540    /// The raw 128-bit ULID value.
541    pub fn as_u128(&self) -> u128 {
542        self.0.0
543    }
544}
545
546impl From<ConversationId> for laser_wire::agent::ConversationId {
547    fn from(id: ConversationId) -> Self {
548        Self::from_u128(id.as_u128())
549    }
550}
551
552impl From<laser_wire::agent::ConversationId> for ConversationId {
553    fn from(id: laser_wire::agent::ConversationId) -> Self {
554        Self(Ulid(id.as_u128()))
555    }
556}
557
558impl AgentId {
559    /// This agent's identity on the wire: the same name string. Wire and SDK
560    /// agent ids are both the name now, so this only re-checks the wire cap
561    /// (the SDK cap is the tighter of the two, so a valid SDK id always fits).
562    pub fn wire_id(&self) -> laser_wire::agent::AgentId {
563        laser_wire::agent::AgentId::from_str(&self.0)
564            .expect("a valid SDK agent id is a valid wire agent id")
565    }
566}
567
568// The SDK and wire agent ids share one rule set, so an SDK id passes wherever
569// the public API takes the wire id.
570impl From<AgentId> for laser_wire::agent::AgentId {
571    fn from(id: AgentId) -> Self {
572        id.wire_id()
573    }
574}
575
576impl From<&AgentId> for laser_wire::agent::AgentId {
577    fn from(id: &AgentId) -> Self {
578        id.wire_id()
579    }
580}
581
582#[cfg(test)]
583mod agdx_bridge_tests {
584    use super::*;
585
586    #[test]
587    fn given_a_conversation_id_when_bridged_to_the_wire_id_then_should_round_trip() {
588        let id = ConversationId::new();
589        let wire: laser_wire::agent::ConversationId = id.into();
590        let back: ConversationId = wire.into();
591        assert_eq!(back, id);
592        // Same canonical Crockford rendering on both sides.
593        assert_eq!(wire.to_string(), id.to_string());
594    }
595
596    #[test]
597    fn given_an_agent_name_when_derived_then_wire_id_should_be_stable_and_distinct() {
598        let planner: AgentId = "planner".parse().expect("valid agent id");
599        let executor: AgentId = "executor".parse().expect("valid agent id");
600        assert_eq!(planner.wire_id(), planner.wire_id());
601        assert_ne!(planner.wire_id(), executor.wire_id());
602    }
603
604    // The wire id is the name verbatim now (no derivation), so it round-trips
605    // as the readable string rather than an opaque code.
606    #[test]
607    fn given_an_agent_name_when_taken_to_the_wire_then_should_be_the_same_string() {
608        let planner: AgentId = "planner".parse().expect("valid agent id");
609        assert_eq!(planner.wire_id().to_string(), "planner");
610    }
611
612    #[test]
613    fn given_minted_wire_ids_when_compared_then_should_be_distinct() {
614        use laser_wire::agent::RecordId;
615        assert_ne!(RecordId::mint(), RecordId::mint());
616    }
617}