Skip to main content

phoxal_runtime_contract/
identity.rs

1//! The identity axes that reach the wire.
2//!
3//! - [`ExecutionId`] names one supervised run. It scopes participants, bus
4//!   traffic, and authority, and it is the bus session root, so traffic from a
5//!   previous execution cannot physically be observed as current.
6//! - [`ProducerId`] names one publishing session. It is minted by `phoxal-bus`
7//!   before opening the transport and pinned as the Zenoh session id.
8//! - [`TimelineId`] names one world history. A simulation reset or a replay
9//!   branch creates a new timeline within the same execution.
10//!
11//! `ExecutionId` and `ProducerId` are both Zenoh session identities and share
12//! one text form: exactly 32 lowercase hexadecimal characters with a non-zero
13//! leading nibble. That is the full 16-byte session value, so neither identity
14//! can be silently shortened or normalized at a transport boundary. Minting
15//! an execution or producer repairs only a zero most-significant nibble, so
16//! every non-zero leading digit remains reachable.
17//!
18//! All three are opaque. They compare only for equality and carry no
19//! generation order, no embedded host or path, and no secret.
20//!
21//! The supervisor-internal `ProcessKey` and project-lock identities are process
22//! management, not bus identity; they stay in the supervisor and never reach the
23//! wire.
24
25use std::borrow::Borrow;
26use std::fmt;
27use std::num::NonZeroU64;
28
29use serde::{Deserialize, Deserializer, Serialize};
30
31use crate::wire_schema::{DescribeWire, WireSchema};
32
33/// The grammar shared by the topology identities that appear in a persisted
34/// runtime document.
35///
36/// This intentionally lives in the process-contract crate rather than in the
37/// source compiler. A participant id is read by a process that may not have
38/// any authored sources installed, so validating it cannot require the
39/// compiler or its identifier types.
40/// Whether a value is one normalized topology token.
41///
42/// Process and source-model identifiers use this one predicate so their
43/// accepted alphabets cannot drift across crate boundaries.
44#[must_use]
45pub fn is_topology_token(value: &str) -> bool {
46    !value.is_empty()
47        && value.chars().all(|character| {
48            character.is_ascii_lowercase()
49                || character.is_ascii_digit()
50                || matches!(character, '_' | '-')
51        })
52}
53
54macro_rules! topology_identifier {
55    ($(#[$doc:meta])* $name:ident, $error:ident, $kind:literal) => {
56        $(#[$doc])*
57        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58        pub struct $name(String);
59
60        impl $name {
61            /// Validate and construct the identifier.
62            pub fn new(value: impl Into<String>) -> Result<Self, TopologyIdError> {
63                let value = value.into();
64                if is_topology_token(&value) {
65                    Ok(Self(value))
66                } else {
67                    Err(TopologyIdError::$error(value))
68                }
69            }
70
71            /// What this identifier names, used in diagnostics.
72            pub const KIND: &'static str = $kind;
73
74            /// The canonical wire token.
75            #[must_use]
76            pub fn as_str(&self) -> &str {
77                &self.0
78            }
79        }
80
81        impl fmt::Display for $name {
82            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83                formatter.write_str(self.as_str())
84            }
85        }
86
87        impl AsRef<str> for $name {
88            fn as_ref(&self) -> &str {
89                self.as_str()
90            }
91        }
92
93        impl Borrow<str> for $name {
94            fn borrow(&self) -> &str {
95                self.as_str()
96            }
97        }
98
99        impl PartialEq<str> for $name {
100            fn eq(&self, other: &str) -> bool {
101                self.as_str() == other
102            }
103        }
104
105        impl PartialEq<&str> for $name {
106            fn eq(&self, other: &&str) -> bool {
107                self.as_str() == *other
108            }
109        }
110
111        impl std::str::FromStr for $name {
112            type Err = TopologyIdError;
113
114            fn from_str(value: &str) -> Result<Self, Self::Err> {
115                Self::new(value)
116            }
117        }
118
119        impl TryFrom<String> for $name {
120            type Error = TopologyIdError;
121
122            fn try_from(value: String) -> Result<Self, Self::Error> {
123                Self::new(value)
124            }
125        }
126
127        impl From<$name> for String {
128            fn from(value: $name) -> Self {
129                value.0
130            }
131        }
132
133        impl Serialize for $name {
134            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
135                serializer.serialize_str(self.as_str())
136            }
137        }
138
139        impl<'de> Deserialize<'de> for $name {
140            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
141                Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
142            }
143        }
144
145        impl DescribeWire for $name {
146            // Invariant: this states what the `Serialize` above writes - the
147            // bare canonical token as one string, with no wrapper.
148            fn wire_schema() -> WireSchema {
149                WireSchema::opaque(stringify!($name), WireSchema::String)
150            }
151        }
152    };
153}
154
155topology_identifier!(
156    /// The canonical stable identity of one compiled robot.
157    RobotId,
158    Robot,
159    "robot id"
160);
161
162topology_identifier!(
163    /// The identity of one component instance in the compiled robot.
164    ComponentInstanceId,
165    ComponentInstance,
166    "component instance id"
167);
168
169topology_identifier!(
170    /// The identity of one service the compiled robot runs.
171    ///
172    /// A service's id is also the participant id it is launched under and the
173    /// name of its executable in `bin/`, so it obeys the same token grammar as
174    /// every other launched identity.
175    ServiceId,
176    Service,
177    "service id"
178);
179
180/// A topology identifier that is not one normalized token.
181#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
182pub enum TopologyIdError {
183    #[error("robot id must be a non-empty normalized token, got {0:?}")]
184    Robot(String),
185    #[error("component instance id must be a non-empty normalized token, got {0:?}")]
186    ComponentInstance(String),
187    #[error("service id must be a non-empty normalized token, got {0:?}")]
188    Service(String),
189}
190
191/// The identity of one participant instance in a compiled runtime topology.
192///
193/// This is deliberately distinct from [`ParticipantArtifactId`]: an instance
194/// is the thing the supervisor launches, while an artifact is the reusable
195/// compiled role/executable selected by that instance. It is also distinct
196/// from [`ProducerId`], which names one transport session incarnation.
197#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub struct ParticipantId(String);
199
200impl ParticipantId {
201    /// Validate and construct a participant id.
202    pub fn new(value: impl Into<String>) -> Result<Self, ParticipantIdError> {
203        let value = value.into();
204        if is_topology_token(&value) {
205            Ok(Self(value))
206        } else {
207            Err(ParticipantIdError(value))
208        }
209    }
210
211    /// The canonical wire token.
212    #[must_use]
213    pub fn as_str(&self) -> &str {
214        &self.0
215    }
216}
217
218impl std::fmt::Display for ParticipantId {
219    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        formatter.write_str(&self.0)
221    }
222}
223
224impl AsRef<str> for ParticipantId {
225    fn as_ref(&self) -> &str {
226        self.as_str()
227    }
228}
229
230impl std::str::FromStr for ParticipantId {
231    type Err = ParticipantIdError;
232
233    fn from_str(value: &str) -> Result<Self, Self::Err> {
234        Self::new(value)
235    }
236}
237
238impl TryFrom<String> for ParticipantId {
239    type Error = ParticipantIdError;
240
241    fn try_from(value: String) -> Result<Self, Self::Error> {
242        Self::new(value)
243    }
244}
245
246impl From<ParticipantId> for String {
247    fn from(value: ParticipantId) -> Self {
248        value.0
249    }
250}
251
252impl Serialize for ParticipantId {
253    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
254        serializer.serialize_str(self.as_str())
255    }
256}
257
258impl<'de> Deserialize<'de> for ParticipantId {
259    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
260        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
261    }
262}
263
264impl DescribeWire for ParticipantId {
265    // Invariant: this states what the `Serialize` above writes - the bare
266    // canonical token as one string.
267    fn wire_schema() -> WireSchema {
268        WireSchema::opaque("ParticipantId", WireSchema::String)
269    }
270}
271
272/// Why a [`ParticipantId`] is not a valid instance token.
273#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
274#[error("participant id must be a non-empty lowercase token, got '{0}'")]
275pub struct ParticipantIdError(String);
276
277/// The stable identity of a reusable compiled participant artifact.
278///
279/// The artifact id is the compile-time role identity embedded in the binary.
280/// Multiple [`ParticipantId`] instance records may point at the same artifact
281/// when one executable is mounted more than once in a runtime topology.
282#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
283pub struct ParticipantArtifactId(String);
284
285impl ParticipantArtifactId {
286    /// Validate and construct an artifact id.
287    pub fn new(value: impl Into<String>) -> Result<Self, ParticipantArtifactIdError> {
288        let value = value.into();
289        if is_topology_token(&value) {
290            Ok(Self(value))
291        } else {
292            Err(ParticipantArtifactIdError(value))
293        }
294    }
295
296    /// The canonical wire token.
297    #[must_use]
298    pub fn as_str(&self) -> &str {
299        &self.0
300    }
301}
302
303impl fmt::Display for ParticipantArtifactId {
304    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
305        formatter.write_str(self.as_str())
306    }
307}
308
309impl AsRef<str> for ParticipantArtifactId {
310    fn as_ref(&self) -> &str {
311        self.as_str()
312    }
313}
314
315impl std::str::FromStr for ParticipantArtifactId {
316    type Err = ParticipantArtifactIdError;
317
318    fn from_str(value: &str) -> Result<Self, Self::Err> {
319        Self::new(value)
320    }
321}
322
323impl TryFrom<String> for ParticipantArtifactId {
324    type Error = ParticipantArtifactIdError;
325
326    fn try_from(value: String) -> Result<Self, Self::Error> {
327        Self::new(value)
328    }
329}
330
331impl From<ParticipantArtifactId> for String {
332    fn from(value: ParticipantArtifactId) -> Self {
333        value.0
334    }
335}
336
337impl Serialize for ParticipantArtifactId {
338    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
339        serializer.serialize_str(self.as_str())
340    }
341}
342
343impl<'de> Deserialize<'de> for ParticipantArtifactId {
344    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
345        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
346    }
347}
348
349impl DescribeWire for ParticipantArtifactId {
350    // Invariant: this states what the `Serialize` above writes - the bare
351    // canonical token as one string.
352    fn wire_schema() -> WireSchema {
353        WireSchema::opaque("ParticipantArtifactId", WireSchema::String)
354    }
355}
356
357/// Why a [`ParticipantArtifactId`] is empty or contains a non-canonical token.
358#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
359#[error("participant artifact id must be a non-empty lowercase token, got '{0}'")]
360pub struct ParticipantArtifactIdError(String);
361
362/// Bytes in a full-width session identity.
363const ZID_BYTES: usize = 16;
364
365/// Rendered width of a full-width session identity.
366const ZID_HEX_LEN: usize = ZID_BYTES * 2;
367
368/// The repair applied to a minted identity whose draw came up with a zero most
369/// significant nibble, so its rendering never loses a leading nibble and
370/// therefore never renders narrower than [`ZID_HEX_LEN`]. A draw that is already
371/// nonzero up there is left alone, so every one of the fifteen nonzero leading
372/// digits stays reachable.
373const CANONICAL_TOP_NIBBLE: u128 = 1 << 124;
374
375/// Mint one canonical full-width session value.
376///
377/// Both transport identities use the same representation. Keep the random
378/// draw and the leading-nibble repair in one place so a future identity cannot
379/// accidentally drift to a different canonicalization rule.
380fn mint_canonical_value() -> u128 {
381    let mut bytes = [0_u8; ZID_BYTES];
382    #[expect(
383        clippy::expect_used,
384        reason = "a session identity is the root of bus provenance; a host without randomness cannot safely start one"
385    )]
386    getrandom::fill(&mut bytes).expect("the host must provide randomness");
387    let mut value = u128::from_be_bytes(bytes);
388    if value >> 124 == 0 {
389        value |= CANONICAL_TOP_NIBBLE;
390    }
391    value
392}
393
394fn canonical_hex(value: u128) -> String {
395    format!("{value:032x}")
396}
397
398/// One supervised run.
399///
400/// The supervisor mints it once per run and every bus participant carries it:
401/// the brain, services, drivers, ad hoc clients such as the external simulation
402/// controller, and later the operator. It
403/// is the bus session root (`phoxal/<execution-id>`), which turns "previous-run
404/// traffic is not observed as current" from an operational assumption into a
405/// structural property. It is transport scoping and never part of a contract
406/// name.
407///
408/// It is also the identity the run's router session opens with, so the router
409/// a trace names and the key root that trace carries are the same string.
410#[derive(Clone, Copy, PartialEq, Eq, Hash)]
411pub struct ExecutionId(u128);
412
413impl ExecutionId {
414    /// The rendered length of an execution id, in key-safe characters.
415    pub const LEN: usize = ZID_HEX_LEN;
416
417    /// Mint a fresh execution identity.
418    ///
419    /// The draw is repaired only when it would render narrower than
420    /// [`ExecutionId::LEN`], which is to say only when its most significant
421    /// nibble came up zero. Forcing the nibble unconditionally would pin the
422    /// leading digit to the odd half of the alphabet; leaving a nonzero draw
423    /// alone keeps the full nonzero leading-digit range that the transport's
424    /// own session ids cover.
425    pub fn mint() -> Self {
426        ExecutionId(mint_canonical_value())
427    }
428
429    /// Parse a rendered execution identity (as it appears in the launch
430    /// contract, the key root, and the router session id).
431    ///
432    /// Only the canonical form is accepted: exactly [`ExecutionId::LEN`]
433    /// lowercase hexadecimal characters, the first of which is not `0`.
434    /// Anything else - uppercase, a leading zero, a shorter or longer run of
435    /// digits - would render back differently than it was written, so it is
436    /// rejected rather than normalized.
437    pub fn parse(value: &str) -> Result<Self, IdentityError> {
438        if value.len() != ZID_HEX_LEN || !value.bytes().all(is_lowercase_hex) {
439            return Err(IdentityError(format!(
440                "an execution id is exactly {ZID_HEX_LEN} lowercase hexadecimal \
441                 characters, got '{value}'"
442            )));
443        }
444        let value = u128::from_str_radix(value, 16)
445            .map_err(|error| IdentityError(format!("'{value}' is not hexadecimal: {error}")))?;
446        ExecutionId::try_from(value)
447    }
448}
449
450impl TryFrom<u128> for ExecutionId {
451    type Error = IdentityError;
452
453    fn try_from(value: u128) -> Result<Self, IdentityError> {
454        if value >> 124 == 0 {
455            return Err(IdentityError(format!(
456                "an execution id renders as {ZID_HEX_LEN} characters, so its most \
457                 significant nibble is never zero"
458            )));
459        }
460        Ok(ExecutionId(value))
461    }
462}
463
464impl From<ExecutionId> for u128 {
465    fn from(execution: ExecutionId) -> Self {
466        execution.0
467    }
468}
469
470impl fmt::Display for ExecutionId {
471    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
472        formatter.write_str(&canonical_hex(self.0))
473    }
474}
475
476impl fmt::Debug for ExecutionId {
477    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
478        write!(formatter, "ExecutionId({self})")
479    }
480}
481
482impl Serialize for ExecutionId {
483    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
484        serializer.serialize_str(&self.to_string())
485    }
486}
487
488impl<'de> Deserialize<'de> for ExecutionId {
489    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
490        let value = String::deserialize(deserializer)?;
491        ExecutionId::parse(&value).map_err(serde::de::Error::custom)
492    }
493}
494
495impl DescribeWire for ExecutionId {
496    // Invariant: this states what the `Serialize` above writes - the rendered
497    // hexadecimal session identity as one string, never the `u128` behind it.
498    fn wire_schema() -> WireSchema {
499        WireSchema::opaque("ExecutionId", WireSchema::String)
500    }
501}
502
503/// One bus-session incarnation.
504///
505/// The unique bus owner mints this identity and pins it into the Zenoh client
506/// configuration before opening the session. The id is then read back and
507/// compared byte-for-byte; a transport that ignores or rewrites the requested
508/// id cannot publish under a mismatched provenance. Reopening therefore always
509/// creates a new producer, while every cloneable handle for one owner shares
510/// exactly one producer and sequence allocator.
511#[derive(Clone, Copy, PartialEq, Eq, Hash)]
512pub struct ProducerId(u128);
513
514impl ProducerId {
515    /// The rendered width of a canonical producer id.
516    pub const LEN: usize = ZID_HEX_LEN;
517
518    /// Parse a rendered producer identity.
519    ///
520    /// Only the canonical full-width lowercase hexadecimal form is accepted,
521    /// with a non-zero leading nibble.
522    pub fn parse(value: &str) -> Result<Self, IdentityError> {
523        if value.len() != ZID_HEX_LEN || !value.bytes().all(is_lowercase_hex) {
524            return Err(IdentityError(format!(
525                "a producer id is exactly {ZID_HEX_LEN} lowercase hexadecimal characters \
526                 and is not zero, got '{value}'"
527            )));
528        }
529        let value = u128::from_str_radix(value, 16)
530            .map_err(|error| IdentityError(format!("'{value}' is not hexadecimal: {error}")))?;
531        ProducerId::try_from(value)
532    }
533}
534
535impl TryFrom<u128> for ProducerId {
536    type Error = IdentityError;
537
538    fn try_from(value: u128) -> Result<Self, IdentityError> {
539        if value >> 124 == 0 {
540            return Err(IdentityError(
541                "a producer id must have a non-zero leading nibble".to_string(),
542            ));
543        }
544        Ok(ProducerId(value))
545    }
546}
547
548impl From<ProducerId> for u128 {
549    fn from(producer: ProducerId) -> Self {
550        producer.0
551    }
552}
553
554impl fmt::Display for ProducerId {
555    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
556        formatter.write_str(&canonical_hex(self.0))
557    }
558}
559
560impl fmt::Debug for ProducerId {
561    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
562        write!(formatter, "ProducerId({self})")
563    }
564}
565
566impl Serialize for ProducerId {
567    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
568        // Little-endian to match the transport's own byte order for the same
569        // value, so a reader comparing raw bytes against a session id sees the
570        // same ordering it would from the transport.
571        serializer.serialize_bytes(&self.0.to_le_bytes())
572    }
573}
574
575impl<'de> Deserialize<'de> for ProducerId {
576    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
577        let bytes = serde_bytes::ByteBuf::deserialize(deserializer)?;
578        let bytes = <[u8; ZID_BYTES]>::try_from(bytes.as_ref()).map_err(|_| {
579            serde::de::Error::custom(format!(
580                "producer id must be {ZID_BYTES} bytes, got {}",
581                bytes.len()
582            ))
583        })?;
584        ProducerId::try_from(u128::from_le_bytes(bytes)).map_err(serde::de::Error::custom)
585    }
586}
587
588impl DescribeWire for ProducerId {
589    // Invariant: this states what the `Serialize` above writes - a byte string
590    // of the little-endian session value, which is a different wire shape from
591    // the hexadecimal text `Display` renders.
592    fn wire_schema() -> WireSchema {
593        WireSchema::opaque("ProducerId", WireSchema::Bytes)
594    }
595}
596
597/// One world history.
598///
599/// An opaque epoch. Timelines compare only for equality: a replacement
600/// timeline is not "newer", it is simply different, and any instant from a
601/// different timeline is incomparable. Zero is not a timeline - absence is
602/// `Option::None`, never a sentinel value.
603#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
604#[serde(transparent)]
605pub struct TimelineId(NonZeroU64);
606
607impl TimelineId {
608    /// Mint a fresh timeline identity.
609    pub fn mint() -> Self {
610        let mut bytes = [0_u8; 8];
611        #[expect(
612            clippy::expect_used,
613            reason = "a timeline names one world history, so two histories separated by a \
614                      predictable identity would be indistinguishable to every reader; a host \
615                      whose randomness source is unavailable has no correct value to return"
616        )]
617        getrandom::fill(&mut bytes).expect("the host must provide randomness");
618        // A zero draw is astronomically unlikely and trivially repaired; the
619        // point is that the type has no zero value at all.
620        TimelineId(NonZeroU64::new(u64::from_le_bytes(bytes)).unwrap_or(NonZeroU64::MIN))
621    }
622
623    /// Rebuild a timeline identity from its wire representation.
624    pub const fn from_raw(value: u64) -> Option<Self> {
625        match NonZeroU64::new(value) {
626            Some(value) => Some(TimelineId(value)),
627            None => None,
628        }
629    }
630
631    /// The wire representation.
632    pub const fn get(self) -> u64 {
633        self.0.get()
634    }
635}
636
637impl fmt::Display for TimelineId {
638    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
639        write!(formatter, "t{:016x}", self.0.get())
640    }
641}
642
643impl fmt::Debug for TimelineId {
644    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
645        write!(formatter, "TimelineId({self})")
646    }
647}
648
649impl DescribeWire for TimelineId {
650    // Invariant: this states what `#[serde(transparent)]` writes above - the
651    // bare 64-bit epoch, never the `t`-prefixed text `Display` renders.
652    fn wire_schema() -> WireSchema {
653        WireSchema::opaque("TimelineId", WireSchema::U64)
654    }
655}
656
657/// A value this module refused to accept as one of its identities.
658///
659/// The message already names the rejected value and the shape that was
660/// required, because the caller that produced it - a process boundary value, a wire
661/// field, a transport session id - is never in a position to explain the
662/// identity grammar itself.
663#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
664#[error("{0}")]
665pub struct IdentityError(String);
666
667const fn is_lowercase_hex(byte: u8) -> bool {
668    byte.is_ascii_digit() || byte.is_ascii_lowercase() && byte <= b'f'
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn topology_ids_share_one_grammar_and_bare_string_wire_form() {
677        let robot = RobotId::new("warehouse_rover").expect("canonical robot id");
678        let component =
679            ComponentInstanceId::new("front-lidar").expect("canonical component instance");
680        assert_eq!(RobotId::KIND, "robot id");
681        assert_eq!(ComponentInstanceId::KIND, "component instance id");
682        assert_eq!(
683            serde_json::to_string(&robot).unwrap(),
684            "\"warehouse_rover\""
685        );
686        assert_eq!(
687            serde_json::from_str::<ComponentInstanceId>("\"front-lidar\"").unwrap(),
688            component
689        );
690
691        assert_eq!(
692            RobotId::new("Warehouse Rover"),
693            Err(TopologyIdError::Robot("Warehouse Rover".to_string()))
694        );
695        assert_eq!(
696            ComponentInstanceId::new("front/lidar"),
697            Err(TopologyIdError::ComponentInstance(
698                "front/lidar".to_string()
699            ))
700        );
701    }
702
703    #[test]
704    fn participant_ids_are_typed_canonical_tokens() {
705        let id = ParticipantId::new("front_camera").expect("a canonical participant id");
706        assert_eq!(id.as_str(), "front_camera");
707        assert_eq!(id.to_string(), "front_camera");
708        assert_eq!(
709            serde_json::to_string(&id).expect("id serializes"),
710            "\"front_camera\""
711        );
712        assert_eq!(
713            serde_json::from_str::<ParticipantId>("\"front_camera\"").expect("id deserializes"),
714            id
715        );
716    }
717
718    #[test]
719    fn participant_ids_reject_noncanonical_and_path_tokens() {
720        for value in ["", "FrontCamera", "front camera", "../brain", "brain/extra"] {
721            assert!(ParticipantId::new(value).is_err(), "{value:?}");
722            assert!(
723                serde_json::from_str::<ParticipantId>(&format!("\"{value}\"")).is_err(),
724                "{value:?}"
725            );
726        }
727    }
728
729    #[test]
730    fn a_minted_execution_always_renders_at_the_canonical_width() {
731        let first = ExecutionId::mint();
732        let second = ExecutionId::mint();
733        assert_ne!(first, second);
734
735        let rendered = first.to_string();
736        assert_eq!(rendered.len(), ExecutionId::LEN);
737        assert!(!rendered.starts_with('0'));
738        assert!(rendered.bytes().all(is_lowercase_hex));
739        assert!(!rendered.contains('/') && !rendered.contains('*'));
740        assert_eq!(ExecutionId::parse(&rendered), Ok(first));
741    }
742
743    #[test]
744    fn minting_does_not_pin_the_leading_digit_to_half_the_alphabet() {
745        // Forcing the top nibble unconditionally would leave only the odd
746        // leading digits reachable. Over this many draws, seeing no even one is
747        // astronomically less likely than any real flake.
748        let saw_even_leading_digit = (0..64).any(|_| {
749            let leading = ExecutionId::mint().to_string().as_bytes()[0];
750            let digit = if leading.is_ascii_digit() {
751                leading - b'0'
752            } else {
753                leading - b'a' + 10
754            };
755            digit % 2 == 0
756        });
757        assert!(
758            saw_even_leading_digit,
759            "a minted execution covers the whole nonzero leading-digit range"
760        );
761    }
762
763    #[test]
764    fn only_the_canonical_execution_form_parses() {
765        let canonical = ExecutionId::mint().to_string();
766
767        assert!(ExecutionId::parse("").is_err());
768        assert!(ExecutionId::parse("deadbeef").is_err());
769        assert!(
770            ExecutionId::parse(&canonical.to_uppercase()).is_err(),
771            "uppercase renders back differently, so it is not the same identity"
772        );
773        assert!(
774            ExecutionId::parse(&format!("0{}", &canonical[1..])).is_err(),
775            "a leading zero would render back one character shorter"
776        );
777        assert!(
778            ExecutionId::parse(&format!("{canonical}0")).is_err(),
779            "an over-long run of digits is not a session identity"
780        );
781        assert!(ExecutionId::parse(&"z".repeat(ExecutionId::LEN)).is_err());
782        assert!(
783            ExecutionId::parse(&format!("x{canonical}")).is_err(),
784            "the key root is bare, so there is no prefix to strip"
785        );
786    }
787
788    #[test]
789    fn an_execution_round_trips_through_its_session_identity_value() {
790        let execution = ExecutionId::mint();
791        let value = u128::from(execution);
792        assert_eq!(ExecutionId::try_from(value), Ok(execution));
793        assert_eq!(format!("{value:x}"), execution.to_string());
794        assert!(
795            ExecutionId::try_from(u128::from(execution) >> 4).is_err(),
796            "a value that renders narrower than the canonical width is not an execution"
797        );
798        assert!(ExecutionId::try_from(0).is_err());
799    }
800
801    #[test]
802    fn a_producer_round_trips_in_the_canonical_transport_form() {
803        let minted = ProducerId::try_from((1_u128 << 124) | 0x0123_4567_89ab_cdef).unwrap();
804        assert_eq!(minted.to_string().len(), ProducerId::LEN);
805        assert_eq!(ProducerId::parse(&minted.to_string()), Ok(minted));
806
807        let wide = ProducerId::try_from(u128::MAX).unwrap();
808        assert_eq!(wide.to_string(), "f".repeat(ZID_HEX_LEN));
809        assert_eq!(ProducerId::parse(&wide.to_string()), Ok(wide));
810
811        assert!(ProducerId::try_from(0).is_err());
812        assert!(ProducerId::parse("").is_err());
813        assert!(ProducerId::parse("01").is_err());
814        assert!(ProducerId::parse("AB").is_err());
815        assert!(ProducerId::parse(&"f".repeat(ZID_HEX_LEN + 1)).is_err());
816        assert!(ProducerId::parse(&format!("0{}", "f".repeat(ZID_HEX_LEN - 1))).is_err());
817    }
818
819    #[test]
820    fn producer_ids_round_trip_through_the_wire_encoding() {
821        let producer = ProducerId::try_from((1_u128 << 124) | 0x0123_4567_89ab_cdef).unwrap();
822        let encoded = rmp_serde::to_vec_named(&producer).unwrap();
823        let decoded: ProducerId = rmp_serde::from_slice(&encoded).unwrap();
824        assert_eq!(decoded, producer);
825        assert_ne!(producer, ProducerId::try_from((1_u128 << 124) | 1).unwrap());
826    }
827
828    /// Every identity here has a hand-written serializer, so each declared
829    /// wire shape is checked against a real serialized value rather than
830    /// asserted. Three of these are text, one is a byte string, and one is a
831    /// bare integer: nothing about the Rust type predicts which.
832    #[test]
833    fn each_declared_identity_shape_is_the_shape_its_serializer_writes() {
834        fn declared<T: Serialize + DescribeWire>(value: &T) -> WireSchema {
835            let json = serde_json::to_value(value).expect("the identity serializes");
836            let schema = T::wire_schema();
837            assert_eq!(schema.conforms(&json), Ok(()), "{json}");
838            schema
839        }
840
841        assert_eq!(
842            declared(&RobotId::new("rover").expect("canonical robot id")),
843            WireSchema::opaque("RobotId", WireSchema::String)
844        );
845        assert_eq!(
846            declared(&ComponentInstanceId::new("base").expect("canonical component")),
847            WireSchema::opaque("ComponentInstanceId", WireSchema::String)
848        );
849        assert_eq!(
850            declared(&ParticipantId::new("drive").expect("canonical participant")),
851            WireSchema::opaque("ParticipantId", WireSchema::String)
852        );
853        assert_eq!(
854            declared(&ParticipantArtifactId::new("drive").expect("canonical artifact")),
855            WireSchema::opaque("ParticipantArtifactId", WireSchema::String)
856        );
857        assert_eq!(
858            declared(&ExecutionId::mint()),
859            WireSchema::opaque("ExecutionId", WireSchema::String)
860        );
861        assert_eq!(
862            declared(&ProducerId::try_from(1_u128 << 124).expect("canonical producer")),
863            WireSchema::opaque("ProducerId", WireSchema::Bytes)
864        );
865        assert_eq!(
866            declared(&TimelineId::mint()),
867            WireSchema::opaque("TimelineId", WireSchema::U64)
868        );
869    }
870
871    #[test]
872    fn timelines_have_no_zero_value_and_no_generation_order() {
873        assert_eq!(TimelineId::from_raw(0), None);
874        let timeline = TimelineId::mint();
875        assert_eq!(TimelineId::from_raw(timeline.get()), Some(timeline));
876        // Equality is the only meaning: a replacement timeline is different,
877        // not newer. None of the three identities implements ordering, so no
878        // caller can read one as a generation counter.
879        assert_ne!(timeline, TimelineId::mint());
880    }
881}