1use 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#[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 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 pub const KIND: &'static str = $kind;
73
74 #[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 fn wire_schema() -> WireSchema {
149 WireSchema::opaque(stringify!($name), WireSchema::String)
150 }
151 }
152 };
153}
154
155topology_identifier!(
156 RobotId,
158 Robot,
159 "robot id"
160);
161
162topology_identifier!(
163 ComponentInstanceId,
165 ComponentInstance,
166 "component instance id"
167);
168
169#[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
185pub struct ParticipantId(String);
186
187impl ParticipantId {
188 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 #[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 fn wire_schema() -> WireSchema {
255 WireSchema::opaque("ParticipantId", WireSchema::String)
256 }
257}
258
259#[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
270pub struct ParticipantArtifactId(String);
271
272impl ParticipantArtifactId {
273 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 #[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 fn wire_schema() -> WireSchema {
340 WireSchema::opaque("ParticipantArtifactId", WireSchema::String)
341 }
342}
343
344#[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
349const ZID_BYTES: usize = 16;
351
352const ZID_HEX_LEN: usize = ZID_BYTES * 2;
354
355const CANONICAL_TOP_NIBBLE: u128 = 1 << 124;
361
362fn 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#[derive(Clone, Copy, PartialEq, Eq, Hash)]
397pub struct ExecutionId(u128);
398
399impl ExecutionId {
400 pub const LEN: usize = ZID_HEX_LEN;
402
403 pub fn mint() -> Self {
412 ExecutionId(mint_canonical_value())
413 }
414
415 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 fn wire_schema() -> WireSchema {
485 WireSchema::opaque("ExecutionId", WireSchema::String)
486 }
487}
488
489#[derive(Clone, Copy, PartialEq, Eq, Hash)]
498pub struct ProducerId(u128);
499
500impl ProducerId {
501 pub const LEN: usize = ZID_HEX_LEN;
503
504 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 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 fn wire_schema() -> WireSchema {
579 WireSchema::opaque("ProducerId", WireSchema::Bytes)
580 }
581}
582
583#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
590#[serde(transparent)]
591pub struct TimelineId(NonZeroU64);
592
593impl TimelineId {
594 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 TimelineId(NonZeroU64::new(u64::from_le_bytes(bytes)).unwrap_or(NonZeroU64::MIN))
607 }
608
609 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 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 fn wire_schema() -> WireSchema {
639 WireSchema::opaque("TimelineId", WireSchema::U64)
640 }
641}
642
643#[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 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 #[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 assert_ne!(timeline, TimelineId::mint());
866 }
867}