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::fmt;
26use std::num::NonZeroU64;
27
28use serde::{Deserialize, Deserializer, Serialize};
29
30/// The grammar shared by the topology identities that appear in a persisted
31/// runtime document.
32///
33/// This intentionally lives in the process-contract crate rather than in the
34/// source compiler. A participant id is read by a process that may not have
35/// any authored sources installed, so validating it cannot require the
36/// compiler or its identifier types.
37fn is_participant_token(value: &str) -> bool {
38    !value.is_empty()
39        && value.chars().all(|character| {
40            character.is_ascii_lowercase()
41                || character.is_ascii_digit()
42                || matches!(character, '_' | '-')
43        })
44}
45
46/// The identity of one participant instance in a compiled runtime topology.
47///
48/// This is deliberately distinct from [`ParticipantArtifactId`]: an instance
49/// is the thing the supervisor launches, while an artifact is the reusable
50/// compiled role/executable selected by that instance. It is also distinct
51/// from [`ProducerId`], which names one transport session incarnation.
52#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub struct ParticipantId(String);
54
55impl ParticipantId {
56    /// Validate and construct a participant id.
57    pub fn new(value: impl Into<String>) -> Result<Self, ParticipantIdError> {
58        let value = value.into();
59        if is_participant_token(&value) {
60            Ok(Self(value))
61        } else {
62            Err(ParticipantIdError(value))
63        }
64    }
65
66    /// The canonical wire token.
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73impl std::fmt::Display for ParticipantId {
74    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        formatter.write_str(&self.0)
76    }
77}
78
79impl AsRef<str> for ParticipantId {
80    fn as_ref(&self) -> &str {
81        self.as_str()
82    }
83}
84
85impl std::str::FromStr for ParticipantId {
86    type Err = ParticipantIdError;
87
88    fn from_str(value: &str) -> Result<Self, Self::Err> {
89        Self::new(value)
90    }
91}
92
93impl TryFrom<String> for ParticipantId {
94    type Error = ParticipantIdError;
95
96    fn try_from(value: String) -> Result<Self, Self::Error> {
97        Self::new(value)
98    }
99}
100
101impl From<ParticipantId> for String {
102    fn from(value: ParticipantId) -> Self {
103        value.0
104    }
105}
106
107impl Serialize for ParticipantId {
108    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
109        serializer.serialize_str(self.as_str())
110    }
111}
112
113impl<'de> Deserialize<'de> for ParticipantId {
114    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
115        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
116    }
117}
118
119/// Why a [`ParticipantId`] is not a valid instance token.
120#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
121#[error("participant id must be a non-empty lowercase token, got '{0}'")]
122pub struct ParticipantIdError(String);
123
124/// The stable identity of a reusable compiled participant artifact.
125///
126/// The artifact id is the compile-time role identity embedded in the binary.
127/// Multiple [`ParticipantId`] instance records may point at the same artifact
128/// when one executable is mounted more than once in a runtime topology.
129#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
130pub struct ParticipantArtifactId(String);
131
132impl ParticipantArtifactId {
133    /// Validate and construct an artifact id.
134    pub fn new(value: impl Into<String>) -> Result<Self, ParticipantArtifactIdError> {
135        let value = value.into();
136        if is_participant_token(&value) {
137            Ok(Self(value))
138        } else {
139            Err(ParticipantArtifactIdError(value))
140        }
141    }
142
143    /// The canonical wire token.
144    #[must_use]
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148}
149
150impl fmt::Display for ParticipantArtifactId {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        formatter.write_str(self.as_str())
153    }
154}
155
156impl AsRef<str> for ParticipantArtifactId {
157    fn as_ref(&self) -> &str {
158        self.as_str()
159    }
160}
161
162impl std::str::FromStr for ParticipantArtifactId {
163    type Err = ParticipantArtifactIdError;
164
165    fn from_str(value: &str) -> Result<Self, Self::Err> {
166        Self::new(value)
167    }
168}
169
170impl TryFrom<String> for ParticipantArtifactId {
171    type Error = ParticipantArtifactIdError;
172
173    fn try_from(value: String) -> Result<Self, Self::Error> {
174        Self::new(value)
175    }
176}
177
178impl From<ParticipantArtifactId> for String {
179    fn from(value: ParticipantArtifactId) -> Self {
180        value.0
181    }
182}
183
184impl Serialize for ParticipantArtifactId {
185    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
186        serializer.serialize_str(self.as_str())
187    }
188}
189
190impl<'de> Deserialize<'de> for ParticipantArtifactId {
191    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
192        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
193    }
194}
195
196/// Why a [`ParticipantArtifactId`] is empty or contains a non-canonical token.
197#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
198#[error("participant artifact id must be a non-empty lowercase token, got '{0}'")]
199pub struct ParticipantArtifactIdError(String);
200
201/// Bytes in a full-width session identity.
202const ZID_BYTES: usize = 16;
203
204/// Rendered width of a full-width session identity.
205const ZID_HEX_LEN: usize = ZID_BYTES * 2;
206
207/// The repair applied to a minted identity whose draw came up with a zero most
208/// significant nibble, so its rendering never loses a leading nibble and
209/// therefore never renders narrower than [`ZID_HEX_LEN`]. A draw that is already
210/// nonzero up there is left alone, so every one of the fifteen nonzero leading
211/// digits stays reachable.
212const CANONICAL_TOP_NIBBLE: u128 = 1 << 124;
213
214/// Mint one canonical full-width session value.
215///
216/// Both transport identities use the same representation. Keep the random
217/// draw and the leading-nibble repair in one place so a future identity cannot
218/// accidentally drift to a different canonicalization rule.
219fn mint_canonical_value() -> u128 {
220    let mut bytes = [0_u8; ZID_BYTES];
221    #[expect(
222        clippy::expect_used,
223        reason = "a session identity is the root of bus provenance; a host without randomness cannot safely start one"
224    )]
225    getrandom::fill(&mut bytes).expect("the host must provide randomness");
226    let mut value = u128::from_be_bytes(bytes);
227    if value >> 124 == 0 {
228        value |= CANONICAL_TOP_NIBBLE;
229    }
230    value
231}
232
233fn canonical_hex(value: u128) -> String {
234    format!("{value:032x}")
235}
236
237/// One supervised run.
238///
239/// The supervisor mints it once per run and every bus participant carries it:
240/// services, drivers, simulators, ad hoc publishers, and later the operator. It
241/// is the bus session root (`phoxal/<execution-id>`), which turns "previous-run
242/// traffic is not observed as current" from an operational assumption into a
243/// structural property. It is transport scoping and never part of a contract
244/// name.
245///
246/// It is also the identity the run's router session opens with, so the router
247/// a trace names and the key root that trace carries are the same string.
248#[derive(Clone, Copy, PartialEq, Eq, Hash)]
249pub struct ExecutionId(u128);
250
251impl ExecutionId {
252    /// The rendered length of an execution id, in key-safe characters.
253    pub const LEN: usize = ZID_HEX_LEN;
254
255    /// Mint a fresh execution identity.
256    ///
257    /// The draw is repaired only when it would render narrower than
258    /// [`ExecutionId::LEN`], which is to say only when its most significant
259    /// nibble came up zero. Forcing the nibble unconditionally would pin the
260    /// leading digit to the odd half of the alphabet; leaving a nonzero draw
261    /// alone keeps the full nonzero leading-digit range that the transport's
262    /// own session ids cover.
263    pub fn mint() -> Self {
264        ExecutionId(mint_canonical_value())
265    }
266
267    /// Parse a rendered execution identity (as it appears in the launch
268    /// contract, the key root, and the router session id).
269    ///
270    /// Only the canonical form is accepted: exactly [`ExecutionId::LEN`]
271    /// lowercase hexadecimal characters, the first of which is not `0`.
272    /// Anything else - uppercase, a leading zero, a shorter or longer run of
273    /// digits - would render back differently than it was written, so it is
274    /// rejected rather than normalized.
275    pub fn parse(value: &str) -> Result<Self, IdentityError> {
276        if value.len() != ZID_HEX_LEN || !value.bytes().all(is_lowercase_hex) {
277            return Err(IdentityError(format!(
278                "an execution id is exactly {ZID_HEX_LEN} lowercase hexadecimal \
279                 characters, got '{value}'"
280            )));
281        }
282        let value = u128::from_str_radix(value, 16)
283            .map_err(|error| IdentityError(format!("'{value}' is not hexadecimal: {error}")))?;
284        ExecutionId::try_from(value)
285    }
286}
287
288impl TryFrom<u128> for ExecutionId {
289    type Error = IdentityError;
290
291    fn try_from(value: u128) -> Result<Self, IdentityError> {
292        if value >> 124 == 0 {
293            return Err(IdentityError(format!(
294                "an execution id renders as {ZID_HEX_LEN} characters, so its most \
295                 significant nibble is never zero"
296            )));
297        }
298        Ok(ExecutionId(value))
299    }
300}
301
302impl From<ExecutionId> for u128 {
303    fn from(execution: ExecutionId) -> Self {
304        execution.0
305    }
306}
307
308impl fmt::Display for ExecutionId {
309    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310        formatter.write_str(&canonical_hex(self.0))
311    }
312}
313
314impl fmt::Debug for ExecutionId {
315    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316        write!(formatter, "ExecutionId({self})")
317    }
318}
319
320impl Serialize for ExecutionId {
321    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
322        serializer.serialize_str(&self.to_string())
323    }
324}
325
326impl<'de> Deserialize<'de> for ExecutionId {
327    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
328        let value = String::deserialize(deserializer)?;
329        ExecutionId::parse(&value).map_err(serde::de::Error::custom)
330    }
331}
332
333/// One bus-session incarnation.
334///
335/// The unique bus owner mints this identity and pins it into the Zenoh client
336/// configuration before opening the session. The id is then read back and
337/// compared byte-for-byte; a transport that ignores or rewrites the requested
338/// id cannot publish under a mismatched provenance. Reopening therefore always
339/// creates a new producer, while every cloneable handle for one owner shares
340/// exactly one producer and sequence allocator.
341#[derive(Clone, Copy, PartialEq, Eq, Hash)]
342pub struct ProducerId(u128);
343
344impl ProducerId {
345    /// The rendered width of a canonical producer id.
346    pub const LEN: usize = ZID_HEX_LEN;
347
348    /// Parse a rendered producer identity.
349    ///
350    /// Only the canonical full-width lowercase hexadecimal form is accepted,
351    /// with a non-zero leading nibble.
352    pub fn parse(value: &str) -> Result<Self, IdentityError> {
353        if value.len() != ZID_HEX_LEN || !value.bytes().all(is_lowercase_hex) {
354            return Err(IdentityError(format!(
355                "a producer id is exactly {ZID_HEX_LEN} lowercase hexadecimal characters \
356                 and is not zero, got '{value}'"
357            )));
358        }
359        let value = u128::from_str_radix(value, 16)
360            .map_err(|error| IdentityError(format!("'{value}' is not hexadecimal: {error}")))?;
361        ProducerId::try_from(value)
362    }
363}
364
365impl TryFrom<u128> for ProducerId {
366    type Error = IdentityError;
367
368    fn try_from(value: u128) -> Result<Self, IdentityError> {
369        if value >> 124 == 0 {
370            return Err(IdentityError(
371                "a producer id must have a non-zero leading nibble".to_string(),
372            ));
373        }
374        Ok(ProducerId(value))
375    }
376}
377
378impl From<ProducerId> for u128 {
379    fn from(producer: ProducerId) -> Self {
380        producer.0
381    }
382}
383
384impl fmt::Display for ProducerId {
385    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
386        formatter.write_str(&canonical_hex(self.0))
387    }
388}
389
390impl fmt::Debug for ProducerId {
391    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
392        write!(formatter, "ProducerId({self})")
393    }
394}
395
396impl Serialize for ProducerId {
397    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
398        // Little-endian to match the transport's own byte order for the same
399        // value, so a reader comparing raw bytes against a session id sees the
400        // same ordering it would from the transport.
401        serializer.serialize_bytes(&self.0.to_le_bytes())
402    }
403}
404
405impl<'de> Deserialize<'de> for ProducerId {
406    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
407        let bytes = serde_bytes::ByteBuf::deserialize(deserializer)?;
408        let bytes = <[u8; ZID_BYTES]>::try_from(bytes.as_ref()).map_err(|_| {
409            serde::de::Error::custom(format!(
410                "producer id must be {ZID_BYTES} bytes, got {}",
411                bytes.len()
412            ))
413        })?;
414        ProducerId::try_from(u128::from_le_bytes(bytes)).map_err(serde::de::Error::custom)
415    }
416}
417
418/// One world history.
419///
420/// An opaque epoch. Timelines compare only for equality: a replacement
421/// timeline is not "newer", it is simply different, and any instant from a
422/// different timeline is incomparable. Zero is not a timeline - absence is
423/// `Option::None`, never a sentinel value.
424#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
425#[serde(transparent)]
426pub struct TimelineId(NonZeroU64);
427
428impl TimelineId {
429    /// Mint a fresh timeline identity.
430    pub fn mint() -> Self {
431        let mut bytes = [0_u8; 8];
432        #[expect(
433            clippy::expect_used,
434            reason = "a timeline names one world history, so two histories separated by a \
435                      predictable identity would be indistinguishable to every reader; a host \
436                      whose randomness source is unavailable has no correct value to return"
437        )]
438        getrandom::fill(&mut bytes).expect("the host must provide randomness");
439        // A zero draw is astronomically unlikely and trivially repaired; the
440        // point is that the type has no zero value at all.
441        TimelineId(NonZeroU64::new(u64::from_le_bytes(bytes)).unwrap_or(NonZeroU64::MIN))
442    }
443
444    /// Rebuild a timeline identity from its wire representation.
445    pub const fn from_raw(value: u64) -> Option<Self> {
446        match NonZeroU64::new(value) {
447            Some(value) => Some(TimelineId(value)),
448            None => None,
449        }
450    }
451
452    /// The wire representation.
453    pub const fn get(self) -> u64 {
454        self.0.get()
455    }
456}
457
458impl fmt::Display for TimelineId {
459    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
460        write!(formatter, "t{:016x}", self.0.get())
461    }
462}
463
464impl fmt::Debug for TimelineId {
465    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
466        write!(formatter, "TimelineId({self})")
467    }
468}
469
470/// A value this module refused to accept as one of its identities.
471///
472/// The message already names the rejected value and the shape that was
473/// required, because the caller that produced it - a process boundary value, a wire
474/// field, a transport session id - is never in a position to explain the
475/// identity grammar itself.
476#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
477#[error("{0}")]
478pub struct IdentityError(String);
479
480const fn is_lowercase_hex(byte: u8) -> bool {
481    byte.is_ascii_digit() || byte.is_ascii_lowercase() && byte <= b'f'
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn participant_ids_are_typed_canonical_tokens() {
490        let id = ParticipantId::new("front_camera").expect("a canonical participant id");
491        assert_eq!(id.as_str(), "front_camera");
492        assert_eq!(id.to_string(), "front_camera");
493        assert_eq!(
494            serde_json::to_string(&id).expect("id serializes"),
495            "\"front_camera\""
496        );
497        assert_eq!(
498            serde_json::from_str::<ParticipantId>("\"front_camera\"").expect("id deserializes"),
499            id
500        );
501    }
502
503    #[test]
504    fn participant_ids_reject_noncanonical_and_path_tokens() {
505        for value in ["", "FrontCamera", "front camera", "../brain", "brain/extra"] {
506            assert!(ParticipantId::new(value).is_err(), "{value:?}");
507            assert!(
508                serde_json::from_str::<ParticipantId>(&format!("\"{value}\"")).is_err(),
509                "{value:?}"
510            );
511        }
512    }
513
514    #[test]
515    fn a_minted_execution_always_renders_at_the_canonical_width() {
516        let first = ExecutionId::mint();
517        let second = ExecutionId::mint();
518        assert_ne!(first, second);
519
520        let rendered = first.to_string();
521        assert_eq!(rendered.len(), ExecutionId::LEN);
522        assert!(!rendered.starts_with('0'));
523        assert!(rendered.bytes().all(is_lowercase_hex));
524        assert!(!rendered.contains('/') && !rendered.contains('*'));
525        assert_eq!(ExecutionId::parse(&rendered), Ok(first));
526    }
527
528    #[test]
529    fn minting_does_not_pin_the_leading_digit_to_half_the_alphabet() {
530        // Forcing the top nibble unconditionally would leave only the odd
531        // leading digits reachable. Over this many draws, seeing no even one is
532        // astronomically less likely than any real flake.
533        let saw_even_leading_digit = (0..64).any(|_| {
534            let leading = ExecutionId::mint().to_string().as_bytes()[0];
535            let digit = if leading.is_ascii_digit() {
536                leading - b'0'
537            } else {
538                leading - b'a' + 10
539            };
540            digit % 2 == 0
541        });
542        assert!(
543            saw_even_leading_digit,
544            "a minted execution covers the whole nonzero leading-digit range"
545        );
546    }
547
548    #[test]
549    fn only_the_canonical_execution_form_parses() {
550        let canonical = ExecutionId::mint().to_string();
551
552        assert!(ExecutionId::parse("").is_err());
553        assert!(ExecutionId::parse("deadbeef").is_err());
554        assert!(
555            ExecutionId::parse(&canonical.to_uppercase()).is_err(),
556            "uppercase renders back differently, so it is not the same identity"
557        );
558        assert!(
559            ExecutionId::parse(&format!("0{}", &canonical[1..])).is_err(),
560            "a leading zero would render back one character shorter"
561        );
562        assert!(
563            ExecutionId::parse(&format!("{canonical}0")).is_err(),
564            "an over-long run of digits is not a session identity"
565        );
566        assert!(ExecutionId::parse(&"z".repeat(ExecutionId::LEN)).is_err());
567        assert!(
568            ExecutionId::parse(&format!("x{canonical}")).is_err(),
569            "the key root is bare, so there is no prefix to strip"
570        );
571    }
572
573    #[test]
574    fn an_execution_round_trips_through_its_session_identity_value() {
575        let execution = ExecutionId::mint();
576        let value = u128::from(execution);
577        assert_eq!(ExecutionId::try_from(value), Ok(execution));
578        assert_eq!(format!("{value:x}"), execution.to_string());
579        assert!(
580            ExecutionId::try_from(u128::from(execution) >> 4).is_err(),
581            "a value that renders narrower than the canonical width is not an execution"
582        );
583        assert!(ExecutionId::try_from(0).is_err());
584    }
585
586    #[test]
587    fn a_producer_round_trips_in_the_canonical_transport_form() {
588        let minted = ProducerId::try_from((1_u128 << 124) | 0x0123_4567_89ab_cdef).unwrap();
589        assert_eq!(minted.to_string().len(), ProducerId::LEN);
590        assert_eq!(ProducerId::parse(&minted.to_string()), Ok(minted));
591
592        let wide = ProducerId::try_from(u128::MAX).unwrap();
593        assert_eq!(wide.to_string(), "f".repeat(ZID_HEX_LEN));
594        assert_eq!(ProducerId::parse(&wide.to_string()), Ok(wide));
595
596        assert!(ProducerId::try_from(0).is_err());
597        assert!(ProducerId::parse("").is_err());
598        assert!(ProducerId::parse("01").is_err());
599        assert!(ProducerId::parse("AB").is_err());
600        assert!(ProducerId::parse(&"f".repeat(ZID_HEX_LEN + 1)).is_err());
601        assert!(ProducerId::parse(&format!("0{}", "f".repeat(ZID_HEX_LEN - 1))).is_err());
602    }
603
604    #[test]
605    fn producer_ids_round_trip_through_the_wire_encoding() {
606        let producer = ProducerId::try_from((1_u128 << 124) | 0x0123_4567_89ab_cdef).unwrap();
607        let encoded = rmp_serde::to_vec_named(&producer).unwrap();
608        let decoded: ProducerId = rmp_serde::from_slice(&encoded).unwrap();
609        assert_eq!(decoded, producer);
610        assert_ne!(producer, ProducerId::try_from((1_u128 << 124) | 1).unwrap());
611    }
612
613    #[test]
614    fn timelines_have_no_zero_value_and_no_generation_order() {
615        assert_eq!(TimelineId::from_raw(0), None);
616        let timeline = TimelineId::mint();
617        assert_eq!(TimelineId::from_raw(timeline.get()), Some(timeline));
618        // Equality is the only meaning: a replacement timeline is different,
619        // not newer. None of the three identities implements ordering, so no
620        // caller can read one as a generation counter.
621        assert_ne!(timeline, TimelineId::mint());
622    }
623}