Skip to main content

cortex_core/
ids.rs

1//! Strongly-typed identifiers for Cortex primitives.
2//!
3//! Every ID is a **prefix + ULID** newtype (see BUILD_SPEC ยง9). The textual form is
4//! `<prefix>_<26-char Crockford ULID>` (e.g. `evt_01ARZ3NDEKTSV4RRFFQ69G5FAV`). The
5//! prefix is part of the wire format and is verified on parse: this prevents an
6//! `EventId` from accidentally being constructed from, say, a `TraceId` string.
7//!
8//! IDs implement `Display` (writes the prefixed form), `FromStr` (parses and
9//! validates the prefix + ULID), and `serde::{Serialize, Deserialize}` as a
10//! transparent string. `JsonSchema` is implemented manually as a `string` so
11//! generated schemas stay stable across `schemars` versions.
12//!
13//! Generation is via `<Id>::new()` (random ULID) or `<Id>::from_ulid(u)` (caller
14//! supplies the ULID, useful for time-ordered IDs in tests / fixtures).
15
16use std::fmt;
17use std::str::FromStr;
18
19use schemars::gen::SchemaGenerator;
20use schemars::schema::{InstanceType, Schema, SchemaObject};
21use schemars::JsonSchema;
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use ulid::Ulid;
24
25use crate::error::CoreError;
26
27/// Defines a prefix-ULID newtype with `Display` / `FromStr` / `Serialize` /
28/// `Deserialize` / `JsonSchema` impls.
29///
30/// The textual form is `<prefix>_<26-char ULID>`; parse rejects any other shape.
31macro_rules! prefix_ulid_newtype {
32    ($name:ident, $prefix:literal, $doc:literal) => {
33        #[doc = $doc]
34        ///
35        /// Wire format: `
36        #[doc = $prefix]
37        /// _<26-char Crockford ULID>`.
38        #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
39        pub struct $name(pub Ulid);
40
41        impl $name {
42            /// The textual prefix (without the trailing underscore).
43            pub const PREFIX: &'static str = $prefix;
44
45            /// Generate a new random ULID-backed ID.
46            #[must_use]
47            pub fn new() -> Self {
48                Self(Ulid::new())
49            }
50
51            /// Wrap an existing ULID without parsing.
52            #[must_use]
53            pub const fn from_ulid(u: Ulid) -> Self {
54                Self(u)
55            }
56
57            /// Borrow the underlying ULID.
58            #[must_use]
59            pub const fn as_ulid(&self) -> &Ulid {
60                &self.0
61            }
62        }
63
64        impl Default for $name {
65            fn default() -> Self {
66                Self::new()
67            }
68        }
69
70        impl fmt::Display for $name {
71            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72                // ULID `Display` writes the 26-char Crockford form.
73                write!(f, "{}_{}", $prefix, self.0)
74            }
75        }
76
77        impl fmt::Debug for $name {
78            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79                f.debug_tuple(stringify!($name))
80                    .field(&self.to_string())
81                    .finish()
82            }
83        }
84
85        impl FromStr for $name {
86            type Err = CoreError;
87
88            fn from_str(s: &str) -> Result<Self, Self::Err> {
89                let expected_prefix = concat!($prefix, "_");
90                let body = s.strip_prefix(expected_prefix).ok_or_else(|| {
91                    CoreError::IdParse(format!(
92                        "id `{s}` does not start with expected prefix `{expected_prefix}`"
93                    ))
94                })?;
95                let ulid = Ulid::from_string(body).map_err(|e| {
96                    CoreError::IdParse(format!("invalid ULID body in id `{s}`: {e}"))
97                })?;
98                Ok(Self(ulid))
99            }
100        }
101
102        impl Serialize for $name {
103            fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
104                ser.collect_str(self)
105            }
106        }
107
108        impl<'de> Deserialize<'de> for $name {
109            fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
110                let s = String::deserialize(de)?;
111                s.parse().map_err(serde::de::Error::custom)
112            }
113        }
114
115        impl JsonSchema for $name {
116            fn schema_name() -> String {
117                stringify!($name).to_string()
118            }
119
120            fn json_schema(_: &mut SchemaGenerator) -> Schema {
121                let mut s = SchemaObject {
122                    instance_type: Some(InstanceType::String.into()),
123                    ..Default::default()
124                };
125                s.metadata().description = Some(format!(
126                    "Prefix-ULID identifier in the form `{prefix}_<26-char Crockford ULID>`",
127                    prefix = $prefix,
128                ));
129                let pattern = format!("^{prefix}_[0-9A-HJKMNP-TV-Z]{{26}}$", prefix = $prefix);
130                s.string().pattern = Some(pattern);
131                Schema::Object(s)
132            }
133        }
134    };
135}
136
137prefix_ulid_newtype!(EventId, "evt", "Append-only event identifier.");
138prefix_ulid_newtype!(TraceId, "trc", "Trace / causal chain identifier.");
139prefix_ulid_newtype!(EpisodeId, "epi", "Interpreted episode identifier.");
140prefix_ulid_newtype!(MemoryId, "mem", "Durable memory identifier.");
141prefix_ulid_newtype!(PrincipleId, "prn", "Hypothesis principle identifier.");
142prefix_ulid_newtype!(DoctrineId, "doc", "Promoted doctrine identifier.");
143prefix_ulid_newtype!(ContextPackId, "ctx", "Context pack identifier.");
144prefix_ulid_newtype!(
145    ContradictionId,
146    "con",
147    "Contradiction (first-class conflict) identifier."
148);
149prefix_ulid_newtype!(AuditRecordId, "aud", "Audit record identifier.");
150prefix_ulid_newtype!(
151    CorrelationId,
152    "cor",
153    "Cross-trace correlation identifier (joins related causal chains for audit)."
154);
155prefix_ulid_newtype!(
156    DecayJobId,
157    "dcy",
158    "Scheduled decay job identifier (Phase 4.D operator-fired compression)."
159);
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    /// Round-trip every ID through `Display` -> `FromStr`. One body, nine types.
166    macro_rules! round_trip_test {
167        ($fn_name:ident, $ty:ident, $prefix:literal) => {
168            #[test]
169            fn $fn_name() {
170                let id = $ty::new();
171                let s = id.to_string();
172                assert!(
173                    s.starts_with(concat!($prefix, "_")),
174                    "{} should start with `{}_`, got `{s}`",
175                    stringify!($ty),
176                    $prefix,
177                );
178                assert_eq!(s.len(), $prefix.len() + 1 + 26);
179                let back: $ty = s.parse().expect("parse back");
180                assert_eq!(id, back, "{} round-trip failed", stringify!($ty));
181
182                // Wrong-prefix rejection: swapping `evt_` etc. for `xxx_` must fail.
183                let bad = format!("xxx_{}", id.as_ulid());
184                assert!($ty::from_str(&bad).is_err());
185            }
186        };
187    }
188
189    round_trip_test!(round_trip_event_id, EventId, "evt");
190    round_trip_test!(round_trip_trace_id, TraceId, "trc");
191    round_trip_test!(round_trip_episode_id, EpisodeId, "epi");
192    round_trip_test!(round_trip_memory_id, MemoryId, "mem");
193    round_trip_test!(round_trip_principle_id, PrincipleId, "prn");
194    round_trip_test!(round_trip_doctrine_id, DoctrineId, "doc");
195    round_trip_test!(round_trip_context_pack_id, ContextPackId, "ctx");
196    round_trip_test!(round_trip_contradiction_id, ContradictionId, "con");
197    round_trip_test!(round_trip_audit_record_id, AuditRecordId, "aud");
198    round_trip_test!(round_trip_correlation_id, CorrelationId, "cor");
199    round_trip_test!(round_trip_decay_job_id, DecayJobId, "dcy");
200
201    #[test]
202    fn ids_serialize_as_transparent_strings() {
203        let id = EventId::new();
204        let j = serde_json::to_value(id).unwrap();
205        assert_eq!(j, serde_json::Value::String(id.to_string()));
206        let back: EventId = serde_json::from_value(j).unwrap();
207        assert_eq!(id, back);
208    }
209
210    #[test]
211    fn ids_reject_garbage_ulid_body() {
212        // Right prefix, wrong body length / charset.
213        assert!("evt_NOT_A_VALID_ULID".parse::<EventId>().is_err());
214        assert!("evt_".parse::<EventId>().is_err());
215        // Right prefix, but body uses `I` which is excluded from Crockford.
216        assert!("evt_IIIIIIIIIIIIIIIIIIIIIIIIII".parse::<EventId>().is_err());
217    }
218
219    #[test]
220    fn ids_round_trip_a_known_ulid() {
221        let u = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap();
222        let id = EventId::from_ulid(u);
223        assert_eq!(id.to_string(), "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV");
224        let back: EventId = id.to_string().parse().unwrap();
225        assert_eq!(back, id);
226    }
227}