Skip to main content

phoxal_runtime_contract/
identity.rs

1//! The two identity axes plus producer identity (#952 section B / G).
2//!
3//! - [`ExecutionId`] names one supervised run. It scopes participants, bus
4//!   traffic, and authority, and it is part of the bus session root, so traffic
5//!   from a previous execution cannot physically be observed as current.
6//! - [`TimelineId`] names one world history. A simulation reset or a replay
7//!   branch creates a new timeline within the same execution.
8//! - [`ProducerId`] names one producing process. It replaces the
9//!   participant-plus-incarnation pair: a fresh process is a fresh producer
10//!   whose sequence starts at zero, so sequence-reset rules collapse into
11//!   identity freshness.
12//!
13//! All three are opaque. They compare only for equality and carry no
14//! generation order, no embedded host or path, and no secret.
15//!
16//! The supervisor-internal `ProcessKey` and project-lock identities are process
17//! management, not bus identity; they stay in the supervisor and never reach the
18//! wire.
19
20use std::fmt;
21use std::num::NonZeroU64;
22
23use serde::{Deserialize, Deserializer, Serialize};
24
25/// Bytes of opaque identity minted per execution and per producer.
26const OPAQUE_BYTES: usize = 16;
27
28/// One supervised run.
29///
30/// The supervisor mints it once per run and every bus participant carries it:
31/// services, drivers, simulators, tools, ad hoc publishers, and later the
32/// operator. It is part of the bus session root
33/// (`<namespace>/robots/<robot-id>/x<execution-id>`), which turns "previous-run
34/// traffic is not observed as current" from an operational assumption into a
35/// structural property. It is transport scoping and never part of a contract
36/// name.
37#[derive(Clone, Copy, PartialEq, Eq, Hash)]
38pub struct ExecutionId([u8; OPAQUE_BYTES]);
39
40impl ExecutionId {
41    /// The rendered length of an execution id, in key-safe characters.
42    pub const LEN: usize = OPAQUE_BYTES * 2;
43
44    /// Mint a fresh execution identity.
45    pub fn mint() -> Self {
46        ExecutionId(random_bytes())
47    }
48
49    /// Parse a rendered execution identity (as it appears in the launch
50    /// contract and the key root).
51    pub fn parse(value: &str) -> Result<Self, InvalidIdentity> {
52        parse_hex(value).map(ExecutionId)
53    }
54
55    /// The identity as it appears in the bus key root and the launch contract.
56    pub fn as_key_segment(&self) -> String {
57        // A leading letter keeps the segment a legal Zenoh chunk regardless of
58        // the random first nibble, and makes an execution-scoped key visually
59        // obvious in a trace.
60        format!("x{self}")
61    }
62}
63
64impl fmt::Display for ExecutionId {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write_hex(&self.0, formatter)
67    }
68}
69
70impl fmt::Debug for ExecutionId {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(formatter, "ExecutionId({self})")
73    }
74}
75
76impl Serialize for ExecutionId {
77    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78        serializer.serialize_str(&self.to_string())
79    }
80}
81
82impl<'de> Deserialize<'de> for ExecutionId {
83    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
84        let value = String::deserialize(deserializer)?;
85        ExecutionId::parse(&value).map_err(serde::de::Error::custom)
86    }
87}
88
89/// One producing process.
90///
91/// Every publisher carries one: a spawned participant receives a
92/// supervisor-pre-minted id through the launch contract, and an ad hoc
93/// publisher mints its own. Because it is fresh per process, repeated ad hoc
94/// invocations never collide under strict per-producer sequence rejection, and
95/// a restarted participant is structurally a different producer.
96#[derive(Clone, Copy, PartialEq, Eq, Hash)]
97pub struct ProducerId([u8; OPAQUE_BYTES]);
98
99impl ProducerId {
100    /// Mint a fresh producer identity.
101    pub fn mint() -> Self {
102        ProducerId(random_bytes())
103    }
104
105    /// Parse a rendered producer identity.
106    pub fn parse(value: &str) -> Result<Self, InvalidIdentity> {
107        parse_hex(value).map(ProducerId)
108    }
109}
110
111impl fmt::Display for ProducerId {
112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write_hex(&self.0, formatter)
114    }
115}
116
117impl fmt::Debug for ProducerId {
118    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119        write!(formatter, "ProducerId({self})")
120    }
121}
122
123impl Serialize for ProducerId {
124    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
125        serializer.serialize_bytes(&self.0)
126    }
127}
128
129impl<'de> Deserialize<'de> for ProducerId {
130    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
131        let bytes = serde_bytes::ByteBuf::deserialize(deserializer)?;
132        <[u8; OPAQUE_BYTES]>::try_from(bytes.as_ref())
133            .map(ProducerId)
134            .map_err(|_| {
135                serde::de::Error::custom(format!(
136                    "producer id must be {OPAQUE_BYTES} bytes, got {}",
137                    bytes.len()
138                ))
139            })
140    }
141}
142
143/// One world history.
144///
145/// This is #931's opaque epoch, renamed. Timelines compare only for equality:
146/// a replacement timeline is not "newer", it is simply different, and any
147/// instant from a different timeline is incomparable. Zero is not a timeline -
148/// absence is `Option::None`, never a sentinel value.
149#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
150#[serde(transparent)]
151pub struct TimelineId(NonZeroU64);
152
153impl TimelineId {
154    /// Mint a fresh timeline identity.
155    pub fn mint() -> Self {
156        let mut bytes = [0_u8; 8];
157        getrandom::fill(&mut bytes).expect("the host must provide randomness");
158        // A zero draw is astronomically unlikely and trivially repaired; the
159        // point is that the type has no zero value at all.
160        TimelineId(NonZeroU64::new(u64::from_le_bytes(bytes)).unwrap_or(NonZeroU64::MIN))
161    }
162
163    /// Rebuild a timeline identity from its wire representation.
164    pub const fn from_raw(value: u64) -> Option<Self> {
165        match NonZeroU64::new(value) {
166            Some(value) => Some(TimelineId(value)),
167            None => None,
168        }
169    }
170
171    /// The wire representation.
172    pub const fn get(self) -> u64 {
173        self.0.get()
174    }
175}
176
177impl fmt::Display for TimelineId {
178    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179        write!(formatter, "t{:016x}", self.0.get())
180    }
181}
182
183impl fmt::Debug for TimelineId {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        write!(formatter, "TimelineId({self})")
186    }
187}
188
189/// A rendered identity that is not a valid one.
190#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
191#[error("{0}")]
192pub struct InvalidIdentity(String);
193
194fn random_bytes() -> [u8; OPAQUE_BYTES] {
195    let mut bytes = [0_u8; OPAQUE_BYTES];
196    getrandom::fill(&mut bytes).expect("the host must provide randomness");
197    bytes
198}
199
200fn write_hex(bytes: &[u8; OPAQUE_BYTES], formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201    for byte in bytes {
202        write!(formatter, "{byte:02x}")?;
203    }
204    Ok(())
205}
206
207fn parse_hex(value: &str) -> Result<[u8; OPAQUE_BYTES], InvalidIdentity> {
208    let value = value.strip_prefix('x').unwrap_or(value);
209    if value.len() != OPAQUE_BYTES * 2 {
210        return Err(InvalidIdentity(format!(
211            "expected {} hex characters, got {}",
212            OPAQUE_BYTES * 2,
213            value.len()
214        )));
215    }
216    let mut bytes = [0_u8; OPAQUE_BYTES];
217    for (index, byte) in bytes.iter_mut().enumerate() {
218        let pair = &value[index * 2..index * 2 + 2];
219        *byte = u8::from_str_radix(pair, 16)
220            .map_err(|_| InvalidIdentity(format!("'{pair}' is not a hex byte")))?;
221    }
222    Ok(bytes)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn execution_ids_are_opaque_unique_and_key_safe() {
231        let first = ExecutionId::mint();
232        let second = ExecutionId::mint();
233        assert_ne!(first, second);
234
235        let segment = first.as_key_segment();
236        assert_eq!(segment.len(), ExecutionId::LEN + 1);
237        assert!(segment.starts_with('x'));
238        assert!(!segment.contains('/') && !segment.contains('*'));
239        assert_eq!(ExecutionId::parse(&segment), Ok(first));
240        assert_eq!(ExecutionId::parse(&first.to_string()), Ok(first));
241    }
242
243    #[test]
244    fn a_malformed_execution_id_is_rejected_rather_than_truncated() {
245        assert!(ExecutionId::parse("").is_err());
246        assert!(ExecutionId::parse("xdeadbeef").is_err());
247        assert!(ExecutionId::parse(&"z".repeat(ExecutionId::LEN)).is_err());
248    }
249
250    #[test]
251    fn producer_ids_round_trip_through_the_wire_encoding() {
252        let producer = ProducerId::mint();
253        let encoded = rmp_serde::to_vec_named(&producer).unwrap();
254        let decoded: ProducerId = rmp_serde::from_slice(&encoded).unwrap();
255        assert_eq!(decoded, producer);
256        assert_ne!(producer, ProducerId::mint());
257    }
258
259    #[test]
260    fn timelines_have_no_zero_value_and_no_generation_order() {
261        assert_eq!(TimelineId::from_raw(0), None);
262        let timeline = TimelineId::mint();
263        assert_eq!(TimelineId::from_raw(timeline.get()), Some(timeline));
264        // Equality is the only meaning: a replacement timeline is different,
265        // not newer. None of the three identities implements ordering, so no
266        // caller can read one as a generation counter.
267        assert_ne!(timeline, TimelineId::mint());
268    }
269}