mako_engine/ids.rs
1//! Typed identifier newtypes for all engine-layer concepts.
2//!
3//! All identifiers are UUID v4 wrappers to guarantee global uniqueness without
4//! coordination. They are distinct types so the compiler rejects mixing them up
5//! at the call site.
6
7use std::fmt;
8
9use uuid::Uuid;
10
11macro_rules! define_id {
12 ($name:ident, $doc:literal) => {
13 #[doc = $doc]
14 #[derive(
15 Debug,
16 Clone,
17 Copy,
18 PartialEq,
19 Eq,
20 PartialOrd,
21 Ord,
22 Hash,
23 serde::Serialize,
24 serde::Deserialize,
25 )]
26 pub struct $name(Uuid);
27
28 impl $name {
29 /// Generate a fresh random identifier.
30 #[must_use]
31 pub fn new() -> Self {
32 Self(Uuid::new_v4())
33 }
34
35 /// Wrap an existing UUID.
36 #[must_use]
37 pub fn from_uuid(u: Uuid) -> Self {
38 Self(u)
39 }
40
41 /// Return the underlying UUID.
42 #[must_use]
43 pub fn as_uuid(self) -> Uuid {
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, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 fmt::Display::fmt(&self.0, f)
57 }
58 }
59
60 impl From<Uuid> for $name {
61 fn from(u: Uuid) -> Self {
62 Self(u)
63 }
64 }
65 };
66}
67
68define_id!(
69 EventId,
70 "Globally unique identifier for a single persisted event."
71);
72define_id!(
73 CorrelationId,
74 "Groups all events and commands that originate from the same root operation."
75);
76
77impl CorrelationId {
78 /// Derive a deterministic `CorrelationId` from an EDIFACT interchange
79 /// reference string using UUID v5 (SHA-1 hash in a fixed namespace).
80 ///
81 /// EDIFACT interchanges carry a reference number in `UNB+…+…+<ref>`.
82 /// Messages retransmitted with the same reference must produce the same
83 /// `CorrelationId` so duplicate EDIFACT messages are correlated correctly
84 /// in traces and never generate spurious new process roots.
85 ///
86 /// The namespace UUID is fixed and private to this crate:
87 /// `a3c7e1f0-5b2d-4e80-9f6a-1b3c5d7e9a0b` (registered once).
88 ///
89 /// # Example
90 ///
91 /// ```rust
92 /// use mako_engine::ids::CorrelationId;
93 ///
94 /// let id = CorrelationId::from_interchange_ref("A000123");
95 /// // Same reference → same CorrelationId (idempotent dispatch).
96 /// assert_eq!(id, CorrelationId::from_interchange_ref("A000123"));
97 /// // Different reference → different CorrelationId.
98 /// assert_ne!(id, CorrelationId::from_interchange_ref("A000124"));
99 /// ```
100 #[must_use]
101 pub fn from_interchange_ref(interchange_ref: &str) -> Self {
102 const INTERCHANGE_NS: Uuid = Uuid::from_u128(0xa3c7_e1f0_5b2d_4e80_9f6a_1b3c_5d7e_9a0b);
103 Self(Uuid::new_v5(&INTERCHANGE_NS, interchange_ref.as_bytes()))
104 }
105}
106
107define_id!(
108 CausationId,
109 "Points to the event or command that directly caused this event."
110);
111define_id!(
112 ConversationId,
113 "Links events that belong to the same business conversation \
114 (e.g. a UTILMD exchange and its APERAK acknowledgement)."
115);
116define_id!(
117 ProcessId,
118 "Stable identifier for a single MaKo process instance."
119);
120define_id!(
121 TenantId,
122 "Scopes all streams and events to a single market participant or deployment tenant."
123);
124
125impl TenantId {
126 /// Derive a deterministic `TenantId` from a GLN or other opaque operator
127 /// identifier string using UUID v5 (SHA-1 hash in a fixed namespace).
128 ///
129 /// This allows the production binary (`makod`) to accept a GLN from the
130 /// Derive a deterministic `TenantId` from a market-participant identifier
131 /// (GLN, BDEW code, EIC, or any opaque operator string) using UUID v5
132 /// (SHA-1 hash in a fixed namespace).
133 ///
134 /// This allows the production binary (`makod`) to accept a BDEW code or
135 /// GLN from the CLI and produce a stable `TenantId` that is consistent
136 /// across process restarts, without requiring that the identifier already
137 /// be a UUID.
138 ///
139 /// The accepted identifier formats are:
140 /// - **BDEW code** (13-digit, agency `"293"`) — most common in German MaKo
141 /// - **GLN** (13-digit GS1, agency `"9"`) — global GS1 scheme, rare in MaKo
142 /// - **EIC** (16-char ENTSO-E, agency `"305"`) — used by TSOs / Regelzonen
143 /// - Any other opaque string used as `--tenant-id`
144 ///
145 /// The namespace UUID is fixed and private to this crate:
146 /// `7e4a6b1c-2d3e-5f60-8a9b-0c1d2e3f4a5b` (arbitrary, registered once).
147 ///
148 /// # Example
149 ///
150 /// ```rust
151 /// use mako_engine::ids::TenantId;
152 ///
153 /// // BDEW-issued market participant code (agency 293)
154 /// let id = TenantId::from_party_id("9900123456789");
155 /// assert_eq!(id, TenantId::from_party_id("9900123456789"));
156 /// assert_ne!(id, TenantId::from_party_id("9900357000004"));
157 ///
158 /// // EIC code (ENTSO-E, agency 305) — e.g. for a TSO
159 /// let tso = TenantId::from_party_id("10XDE-EON-NETZ--I");
160 /// assert_ne!(id, tso);
161 /// ```
162 #[must_use]
163 pub fn from_party_id(party_id: &str) -> Self {
164 // Fixed v5 namespace for MaKo tenant party identifiers.
165 const TENANT_NS: Uuid = Uuid::from_u128(0x7e4a_6b1c_2d3e_5f60_8a9b_0c1d_2e3f_4a5b);
166 Self(Uuid::new_v5(&TENANT_NS, party_id.as_bytes()))
167 }
168}
169
170define_id!(
171 OutboxMessageId,
172 "Unique identifier for a single outbox message entry."
173);
174
175define_id!(
176 DeadlineId,
177 "Unique identifier for a registered process deadline."
178);
179
180// ── Causation conversions ─────────────────────────────────────────────────────
181
182// `CausationId` tracks what *caused* an event. The cause is always an
183// `EventId` (a prior event) or a `CorrelationId` (a root command correlation).
184// These `From` impls enable ergonomic construction without a round-trip through
185// `as_uuid()`:
186//
187// ctx.with_causation(prior_event_id.into())
188// ctx.with_causation(correlation_id.into())
189
190impl From<EventId> for CausationId {
191 /// Treat a prior event as the direct cause of the next event.
192 fn from(id: EventId) -> Self {
193 Self(id.0)
194 }
195}
196
197impl From<CorrelationId> for CausationId {
198 /// Treat a correlation root as the direct cause (useful for first events).
199 fn from(id: CorrelationId) -> Self {
200 Self(id.0)
201 }
202}
203
204// ── StreamId ──────────────────────────────────────────────────────────────────
205
206/// An append-only event stream identifier.
207///
208/// Streams are named with a category prefix so routing and partitioning are
209/// explicit (e.g. `process/{tenant_id}/{process_id}`, `partner/{partner_id}`).
210#[derive(
211 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
212)]
213pub struct StreamId(Box<str>);
214
215impl StreamId {
216 /// Construct a stream identifier from any string-like value.
217 ///
218 /// # Panics
219 ///
220 /// Panics if `id` is empty or contains a NUL byte (`\0`).
221 /// Use this constructor only for **compile-time literals** where the value
222 /// is statically known to be valid. For runtime/externally-supplied strings
223 /// use [`StreamId::try_new`] or the typed constructors
224 /// ([`StreamId::for_process`], [`StreamId::for_partner`]).
225 #[must_use]
226 pub fn new(id: impl Into<Box<str>>) -> Self {
227 let id: Box<str> = id.into();
228 assert!(!id.is_empty(), "StreamId must not be empty");
229 assert!(
230 !id.contains('\0'),
231 "StreamId must not contain NUL bytes, got: {id:?}"
232 );
233 Self(id)
234 }
235
236 /// Fallible constructor — returns `Err` instead of panicking.
237 ///
238 /// Prefer this over [`StreamId::new`] whenever the input string originates
239 /// from user input, network data, or storage. The typed constructors
240 /// ([`StreamId::for_process`], [`StreamId::for_partner`]) call this
241 /// internally.
242 ///
243 /// # Errors
244 ///
245 /// Returns [`crate::error::EngineError::InvalidStreamId`] if `id` is empty or contains
246 /// a NUL byte.
247 pub fn try_new(id: impl Into<Box<str>>) -> Result<Self, crate::error::EngineError> {
248 let id: Box<str> = id.into();
249 if id.is_empty() {
250 return Err(crate::error::EngineError::InvalidStreamId {
251 input: id,
252 reason: "stream ID must not be empty",
253 });
254 }
255 if id.contains('\0') {
256 // Truncate the displayed input to avoid log injection via embedded
257 // NUL bytes or very long attacker-controlled strings.
258 let truncated: Box<str> = id.chars().take(200).collect::<String>().into();
259 return Err(crate::error::EngineError::InvalidStreamId {
260 input: truncated,
261 reason: "stream ID must not contain NUL bytes",
262 });
263 }
264 Ok(Self(id))
265 }
266
267 /// Canonical stream for a process instance: `process/{tenant_id}/{process_id}`.
268 ///
269 /// The tenant discriminator prevents cross-tenant event leakage when
270 /// `list_streams` is called with a tenant-scoped prefix
271 /// (`process/{tenant_id}/`).
272 #[must_use]
273 pub fn for_process(tenant_id: TenantId, process_id: &ProcessId) -> Self {
274 Self::new(format!("process/{tenant_id}/{process_id}"))
275 }
276
277 /// Canonical stream for a market partner: `partner/{partner_id}`.
278 ///
279 /// # Errors
280 ///
281 /// Returns an error if `partner_id` contains `/` or a NUL byte, which
282 /// would escape the `partner/` prefix used for range scans.
283 pub fn for_partner(partner_id: &str) -> Result<Self, crate::error::EngineError> {
284 if partner_id.contains('\0') || partner_id.contains('/') {
285 return Err(crate::error::EngineError::InvalidStreamId {
286 input: partner_id.chars().take(200).collect::<String>().into(),
287 reason: "partner_id must not contain '/' or NUL bytes",
288 });
289 }
290 Ok(Self::new(format!("partner/{partner_id}")))
291 }
292
293 /// The raw stream identifier string.
294 #[must_use]
295 pub fn as_str(&self) -> &str {
296 &self.0
297 }
298}
299
300impl fmt::Display for StreamId {
301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302 f.write_str(&self.0)
303 }
304}
305
306impl TryFrom<&str> for StreamId {
307 type Error = crate::error::EngineError;
308 fn try_from(s: &str) -> Result<Self, Self::Error> {
309 Self::try_new(s)
310 }
311}
312
313impl TryFrom<String> for StreamId {
314 type Error = crate::error::EngineError;
315 fn try_from(s: String) -> Result<Self, Self::Error> {
316 Self::try_new(s)
317 }
318}
319
320impl TryFrom<Box<str>> for StreamId {
321 type Error = crate::error::EngineError;
322 fn try_from(s: Box<str>) -> Result<Self, Self::Error> {
323 Self::try_new(s)
324 }
325}
326
327// ── ProcessIdentity ───────────────────────────────────────────────────────────
328
329/// A serializable value type that bundles all four process identifiers.
330///
331/// Use `ProcessIdentity` to persist process routing information without
332/// keeping a live [`Process`] handle. When a new inbound EDIFACT message
333/// arrives and must be routed to a running process, look up the identity in
334/// your routing table and call [`Process::from_identity`] to attach.
335///
336/// ## Example
337///
338/// ```rust,ignore
339/// // Persist after process creation:
340/// let identity = process.identity();
341/// routing_table.insert(utilmd_conversation_id, identity.clone());
342///
343/// // Restore on a subsequent message:
344/// let identity = routing_table.get(&aperak_conversation_id)?;
345/// let process = Process::<MyWorkflow, _>::from_identity(store, identity);
346/// process.execute(HandleAperak { .. }).await?;
347/// ```
348///
349/// [`Process`]: crate::process::Process
350/// [`Process::from_identity`]: crate::process::Process::from_identity
351#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
352pub struct ProcessIdentity {
353 /// The event stream identifier for this process.
354 stream_id: StreamId,
355 /// The stable process identifier.
356 pub process_id: ProcessId,
357 /// The tenant that owns this process.
358 pub tenant_id: TenantId,
359 /// The workflow version under which this process was created.
360 pub workflow_id: crate::version::WorkflowId,
361}
362
363impl ProcessIdentity {
364 /// Construct a `ProcessIdentity`, deriving `stream_id` automatically from
365 /// `tenant_id` and `process_id`.
366 ///
367 /// `stream_id` is always `StreamId::for_process(tenant_id, &process_id)` —
368 /// callers must not supply it independently to avoid accidental mismatches.
369 #[must_use]
370 pub fn new(
371 process_id: ProcessId,
372 tenant_id: TenantId,
373 workflow_id: crate::version::WorkflowId,
374 ) -> Self {
375 Self {
376 stream_id: StreamId::for_process(tenant_id, &process_id),
377 process_id,
378 tenant_id,
379 workflow_id,
380 }
381 }
382
383 /// The event stream identifier for this process.
384 #[must_use]
385 pub fn stream_id(&self) -> &StreamId {
386 &self.stream_id
387 }
388}
389
390// ── Pid ───────────────────────────────────────────────────────────────────────
391
392/// A BDEW Prüfidentifikator — the 5-digit numeric code that identifies a MaKo
393/// process family and is used to route inbound EDIFACT messages.
394///
395/// Valid range: `1..=99999`. Zero is reserved and never a valid PID.
396/// PIDs are 5 digits in BDEW documents (e.g. `55001`, `44022`, `17115`).
397///
398/// # Construction
399///
400/// - [`Pid::new`] — unchecked compile-time const (panics on out-of-range);
401/// use for known-valid literals only.
402/// - [`Pid::from_u32`] — checked runtime parse; returns `None` on invalid range.
403/// - [`Pid::parse_str`] — parse from a decimal string (leading zeros allowed).
404///
405/// # Display
406///
407/// `Pid` formats as a zero-padded 5-digit string (`55001`, not `55_001`).
408///
409/// # Example
410///
411/// ```rust
412/// use mako_engine::ids::Pid;
413///
414/// // Compile-time known literal:
415/// const LIEFERANTENWECHSEL: Pid = Pid::new(55001);
416///
417/// // Runtime-checked parse:
418/// let pid = Pid::from_u32(44022).expect("valid PID");
419/// assert_eq!(pid.as_u32(), 44022);
420/// assert_eq!(pid.to_string(), "44022");
421///
422/// // Out-of-range:
423/// assert!(Pid::from_u32(0).is_none());
424/// assert!(Pid::from_u32(100_000).is_none());
425/// ```
426#[derive(
427 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
428)]
429#[serde(transparent)]
430pub struct Pid(u32);
431
432impl Pid {
433 /// Create a `Pid` from a known-valid compile-time literal.
434 ///
435 /// This is a `const fn` so it can be used in `const` context.
436 ///
437 /// # Panics
438 ///
439 /// Panics at compile time (or runtime in debug builds) if `value == 0`
440 /// or `value > 99_999`.
441 #[must_use]
442 pub const fn new(value: u32) -> Self {
443 assert!(
444 value > 0 && value <= 99_999,
445 "Pid must be in range 1..=99999"
446 );
447 Self(value)
448 }
449
450 /// Parse a `Pid` from a runtime `u32`, returning `None` on invalid range.
451 #[must_use]
452 pub fn from_u32(value: u32) -> Option<Self> {
453 if value > 0 && value <= 99_999 {
454 Some(Self(value))
455 } else {
456 None
457 }
458 }
459
460 /// Parse a `Pid` from a decimal string, returning `None` if the string is
461 /// not a valid decimal integer in `1..=99999`.
462 ///
463 /// Leading zeros are allowed (e.g. `"05001"` → `Pid(5001)`).
464 #[must_use]
465 pub fn parse_str(s: &str) -> Option<Self> {
466 s.trim().parse::<u32>().ok().and_then(Self::from_u32)
467 }
468
469 /// Return the raw `u32` value.
470 #[must_use]
471 pub const fn as_u32(self) -> u32 {
472 self.0
473 }
474}
475
476impl fmt::Display for Pid {
477 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478 write!(f, "{:05}", self.0)
479 }
480}
481
482impl From<Pid> for u32 {
483 fn from(p: Pid) -> u32 {
484 p.0
485 }
486}