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