Skip to main content

converge_pack/
types.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Shared semantic value types for the public Converge contract.
5
6use serde::de;
7use serde::{Deserialize, Serialize};
8use std::borrow::Borrow;
9use std::fmt;
10use std::ops::Deref;
11use std::str::FromStr;
12use thiserror::Error;
13
14macro_rules! string_newtype {
15    ($(#[$meta:meta])* $name:ident) => {
16        $(#[$meta])*
17        #[derive(
18            Debug,
19            Clone,
20            PartialEq,
21            Eq,
22            Hash,
23            PartialOrd,
24            Ord,
25            Serialize,
26            Deserialize,
27        )]
28        #[serde(transparent)]
29        pub struct $name(String);
30
31        impl $name {
32            /// Create a new typed string value.
33            #[must_use]
34            pub fn new(value: impl Into<String>) -> Self {
35                Self(value.into())
36            }
37
38            /// Borrow the underlying string.
39            #[must_use]
40            pub fn as_str(&self) -> &str {
41                &self.0
42            }
43        }
44
45        impl Deref for $name {
46            type Target = str;
47
48            fn deref(&self) -> &Self::Target {
49                self.as_str()
50            }
51        }
52
53        impl AsRef<str> for $name {
54            fn as_ref(&self) -> &str {
55                self.as_str()
56            }
57        }
58
59        impl Borrow<str> for $name {
60            fn borrow(&self) -> &str {
61                self.as_str()
62            }
63        }
64
65        impl fmt::Display for $name {
66            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67                f.write_str(self.as_str())
68            }
69        }
70
71        impl From<&str> for $name {
72            fn from(value: &str) -> Self {
73                Self::new(value)
74            }
75        }
76
77        impl From<String> for $name {
78            fn from(value: String) -> Self {
79                Self::new(value)
80            }
81        }
82
83        impl From<&$name> for $name {
84            fn from(value: &$name) -> Self {
85                value.clone()
86            }
87        }
88
89        impl From<$name> for String {
90            fn from(value: $name) -> Self {
91                value.0
92            }
93        }
94
95        impl From<&$name> for String {
96            fn from(value: &$name) -> Self {
97                value.as_str().to_string()
98            }
99        }
100
101        impl PartialEq<&str> for $name {
102            fn eq(&self, other: &&str) -> bool {
103                self.as_str() == *other
104            }
105        }
106
107        impl PartialEq<str> for $name {
108            fn eq(&self, other: &str) -> bool {
109                self.as_str() == other
110            }
111        }
112
113        impl PartialEq<$name> for &str {
114            fn eq(&self, other: &$name) -> bool {
115                *self == other.as_str()
116            }
117        }
118
119        impl PartialEq<$name> for str {
120            fn eq(&self, other: &$name) -> bool {
121                self == other.as_str()
122            }
123        }
124
125        impl PartialEq<String> for $name {
126            fn eq(&self, other: &String) -> bool {
127                self.as_str() == other.as_str()
128            }
129        }
130
131        impl PartialEq<$name> for String {
132            fn eq(&self, other: &$name) -> bool {
133                self.as_str() == other.as_str()
134            }
135        }
136
137        impl PartialEq<&$name> for $name {
138            fn eq(&self, other: &&$name) -> bool {
139                self == *other
140            }
141        }
142    };
143}
144
145/// Error returned when a unit interval value is outside `[0.0, 1.0]`.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct UnitIntervalError;
148
149impl fmt::Display for UnitIntervalError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.write_str("value must be finite and in the inclusive range 0.0..=1.0")
152    }
153}
154
155impl std::error::Error for UnitIntervalError {}
156
157/// A finite value in the inclusive `[0.0, 1.0]` range.
158///
159/// Use this for confidence, normalized scores, prior weights, and thresholds.
160#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
161#[serde(transparent)]
162pub struct UnitInterval(f64);
163
164impl UnitInterval {
165    /// The minimum unit interval value.
166    pub const ZERO: Self = Self(0.0);
167    /// The maximum unit interval value.
168    pub const ONE: Self = Self(1.0);
169
170    /// Create a validated unit interval.
171    ///
172    /// Returns an error for NaN, infinity, or values outside `[0.0, 1.0]`.
173    pub fn new(value: f64) -> Result<Self, UnitIntervalError> {
174        if value.is_finite() && (0.0..=1.0).contains(&value) {
175            Ok(Self(value))
176        } else {
177            Err(UnitIntervalError)
178        }
179    }
180
181    /// Create a unit interval by clamping finite input.
182    ///
183    /// Non-finite values become `0.0`.
184    #[must_use]
185    pub fn clamped(value: f64) -> Self {
186        if value.is_finite() {
187            Self(value.clamp(0.0, 1.0))
188        } else {
189            Self::ZERO
190        }
191    }
192
193    /// Return the underlying `f64`.
194    #[must_use]
195    pub fn as_f64(self) -> f64 {
196        self.0
197    }
198
199    /// Add a delta and clamp the result back into the valid range.
200    #[must_use]
201    pub fn saturating_add(self, delta: f64) -> Self {
202        Self::clamped(self.0 + delta)
203    }
204
205    /// Multiply two unit interval values.
206    #[must_use]
207    pub fn scale_by(self, factor: Self) -> Self {
208        Self(self.0 * factor.0)
209    }
210
211    /// Convert to basis points, rounded to the nearest basis point.
212    #[must_use]
213    pub fn to_basis_points(self) -> u16 {
214        (self.0 * 10_000.0).round() as u16
215    }
216}
217
218impl Default for UnitInterval {
219    fn default() -> Self {
220        Self::ZERO
221    }
222}
223
224impl TryFrom<f64> for UnitInterval {
225    type Error = UnitIntervalError;
226
227    fn try_from(value: f64) -> Result<Self, Self::Error> {
228        Self::new(value)
229    }
230}
231
232impl From<UnitInterval> for f64 {
233    fn from(value: UnitInterval) -> Self {
234        value.as_f64()
235    }
236}
237
238impl<'de> Deserialize<'de> for UnitInterval {
239    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
240    where
241        D: serde::Deserializer<'de>,
242    {
243        let value = f64::deserialize(deserializer)?;
244        Self::new(value).map_err(de::Error::custom)
245    }
246}
247
248/// Error returned when a basis-point value is outside `0..=10_000`.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct BasisPointsError;
251
252impl fmt::Display for BasisPointsError {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        f.write_str("basis points must be in the inclusive range 0..=10000")
255    }
256}
257
258impl std::error::Error for BasisPointsError {}
259
260/// A unit-range value represented as basis points (`0..=10_000`).
261#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
262#[serde(transparent)]
263pub struct BasisPoints(u16);
264
265impl BasisPoints {
266    /// Zero basis points.
267    pub const ZERO: Self = Self(0);
268    /// Ten thousand basis points, equivalent to `1.0`.
269    pub const FULL: Self = Self(10_000);
270
271    /// Create a validated basis-point value.
272    pub fn new(value: u16) -> Result<Self, BasisPointsError> {
273        if value <= 10_000 {
274            Ok(Self(value))
275        } else {
276            Err(BasisPointsError)
277        }
278    }
279
280    /// Create a basis-point value by clamping input to `0..=10_000`.
281    #[must_use]
282    pub fn clamped(value: u16) -> Self {
283        Self(value.min(10_000))
284    }
285
286    /// Return the raw basis-point value.
287    #[must_use]
288    pub fn get(self) -> u16 {
289        self.0
290    }
291
292    /// Convert to a unit interval.
293    #[must_use]
294    pub fn as_unit_interval(self) -> UnitInterval {
295        UnitInterval::clamped(f64::from(self.0) / 10_000.0)
296    }
297}
298
299impl Default for BasisPoints {
300    fn default() -> Self {
301        Self::ZERO
302    }
303}
304
305impl TryFrom<u16> for BasisPoints {
306    type Error = BasisPointsError;
307
308    fn try_from(value: u16) -> Result<Self, Self::Error> {
309        Self::new(value)
310    }
311}
312
313impl From<BasisPoints> for u16 {
314    fn from(value: BasisPoints) -> Self {
315        value.get()
316    }
317}
318
319impl<'de> Deserialize<'de> for BasisPoints {
320    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
321    where
322        D: serde::Deserializer<'de>,
323    {
324        let value = u16::deserialize(deserializer)?;
325        Self::new(value).map_err(de::Error::custom)
326    }
327}
328
329string_newtype!(
330    /// Unique identifier for a promoted fact.
331    FactId
332);
333string_newtype!(
334    /// Unique identifier for a proposal.
335    ProposalId
336);
337string_newtype!(
338    /// Unique identifier for a raw observation.
339    ObservationId
340);
341string_newtype!(
342    /// Unique identifier for a human approval.
343    ApprovalId
344);
345string_newtype!(
346    /// Unique identifier for a derived artifact.
347    ArtifactId
348);
349string_newtype!(
350    /// Unique identifier for a promotion gate.
351    GateId
352);
353string_newtype!(
354    /// Identifier for a recorded actor.
355    ActorId
356);
357string_newtype!(
358    /// Identifier for a named validation check.
359    ValidationCheckId
360);
361string_newtype!(
362    /// Identifier for a trace.
363    TraceId
364);
365string_newtype!(
366    /// Identifier for a trace span.
367    SpanId
368);
369string_newtype!(
370    /// Identifier for an external trace system.
371    TraceSystemId
372);
373string_newtype!(
374    /// Reference into an external trace system.
375    TraceReference
376);
377string_newtype!(
378    /// Identifier for a flow-gate principal.
379    PrincipalId
380);
381string_newtype!(
382    /// Identifier for an experience event envelope.
383    EventId
384);
385string_newtype!(
386    /// Identifier for a tenant scope.
387    TenantId
388);
389string_newtype!(
390    /// Identifier for correlating related events or runs.
391    CorrelationId
392);
393string_newtype!(
394    /// Identifier for a convergence chain or run.
395    ChainId
396);
397string_newtype!(
398    /// Identifier for a stored replay trace link.
399    TraceLinkId
400);
401string_newtype!(
402    /// Identifier for a backend, provider, or adapter.
403    BackendId
404);
405string_newtype!(
406    /// Identifier for a named pack.
407    PackId
408);
409string_newtype!(
410    /// Identifier for a truth definition.
411    TruthId
412);
413string_newtype!(
414    /// Identifier for a policy definition.
415    PolicyId
416);
417string_newtype!(
418    /// Identifier for an approval point or workflow reference.
419    ApprovalPointId
420);
421string_newtype!(
422    /// Identifier for an individual vote cast on a topic.
423    VoteId
424);
425string_newtype!(
426    /// Identifier for the topic a vote or disagreement is about.
427    VoteTopicId
428);
429string_newtype!(
430    /// Identifier for a recorded disagreement.
431    DisagreementId
432);
433string_newtype!(
434    /// Identifier for a success criterion.
435    CriterionId
436);
437string_newtype!(
438    /// Consumer-owned name for a constraint.
439    ConstraintName
440);
441string_newtype!(
442    /// Consumer-owned value for a constraint.
443    ConstraintValue
444);
445string_newtype!(
446    /// Identifier for a business or governance domain.
447    DomainId
448);
449string_newtype!(
450    /// Identifier for a bound policy version label.
451    PolicyVersionId
452);
453string_newtype!(
454    /// Identifier for a converging resource or flow.
455    ResourceId
456);
457string_newtype!(
458    /// Open identifier for a resource kind owned by the consumer domain.
459    ResourceKind
460);
461
462/// Error returned when constructing or parsing a [`SubjectRef`].
463#[derive(Debug, Clone, PartialEq, Eq, Error)]
464pub enum SubjectRefError {
465    /// Subject scheme is empty.
466    #[error("subject scheme is empty")]
467    EmptyScheme,
468    /// Subject kind is empty.
469    #[error("subject kind is empty")]
470    EmptyKind,
471    /// Subject id is empty.
472    #[error("subject id is empty")]
473    EmptyId,
474    /// Subject scheme contains an invalid character.
475    #[error("subject scheme contains invalid character {0:?}; allow [a-z][a-z0-9-]*")]
476    InvalidSchemeChar(char),
477    /// Subject kind contains an invalid character.
478    #[error("subject kind contains invalid character {0:?}; allow [a-z][a-z0-9-]*")]
479    InvalidKindChar(char),
480    /// Subject id contains an invalid character.
481    #[error("subject id contains invalid character {0:?}; disallow '/' and whitespace")]
482    InvalidIdChar(char),
483    /// Subject ref string is not in canonical `scheme://kind/id` form.
484    #[error("could not parse subject ref from {0:?}; expected scheme://kind/id")]
485    Malformed(String),
486}
487
488/// Typed reference to the subject a fact, proposal, intent, or citation is
489/// about.
490///
491/// `ActorId` answers "who"; `ContextKey` answers "where in the convergence
492/// state"; `SubjectRef` answers "what object is being reasoned about." The
493/// canonical wire form is `scheme://kind/id`, where `scheme` is the owning app
494/// or domain, `kind` is an app-owned noun, and `id` is opaque to Converge.
495#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
496pub struct SubjectRef {
497    scheme: String,
498    kind: String,
499    id: String,
500}
501
502impl SubjectRef {
503    /// Creates a typed subject reference after validating and normalizing its
504    /// structural parts.
505    ///
506    /// `scheme` and `kind` are normalized to ASCII lowercase. `id` is
507    /// preserved exactly after validation because apps own its semantics.
508    pub fn new(
509        scheme: impl Into<String>,
510        kind: impl Into<String>,
511        id: impl Into<String>,
512    ) -> Result<Self, SubjectRefError> {
513        let scheme = scheme.into().to_ascii_lowercase();
514        let kind = kind.into().to_ascii_lowercase();
515        let id = id.into();
516
517        validate_subject_label(&scheme, SubjectPart::Scheme)?;
518        validate_subject_label(&kind, SubjectPart::Kind)?;
519        validate_subject_id(&id)?;
520
521        Ok(Self { scheme, kind, id })
522    }
523
524    /// Parses the canonical `scheme://kind/id` wire form.
525    pub fn parse(value: &str) -> Result<Self, SubjectRefError> {
526        value.parse()
527    }
528
529    /// Returns the subject scheme, usually the owning app or domain id.
530    #[must_use]
531    pub fn scheme(&self) -> &str {
532        &self.scheme
533    }
534
535    /// Returns the app-owned subject kind.
536    #[must_use]
537    pub fn kind(&self) -> &str {
538        &self.kind
539    }
540
541    /// Returns the app-owned subject id.
542    #[must_use]
543    pub fn id(&self) -> &str {
544        &self.id
545    }
546
547    /// Renders the canonical `scheme://kind/id` wire form.
548    #[must_use]
549    pub fn to_uri(&self) -> String {
550        format!("{}://{}/{}", self.scheme, self.kind, self.id)
551    }
552}
553
554impl fmt::Display for SubjectRef {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        f.write_str(&self.to_uri())
557    }
558}
559
560impl FromStr for SubjectRef {
561    type Err = SubjectRefError;
562
563    fn from_str(value: &str) -> Result<Self, Self::Err> {
564        let Some((scheme, rest)) = value.split_once("://") else {
565            return Err(SubjectRefError::Malformed(value.to_string()));
566        };
567        let Some((kind, id)) = rest.split_once('/') else {
568            return Err(SubjectRefError::Malformed(value.to_string()));
569        };
570        Self::new(scheme, kind, id)
571    }
572}
573
574impl Serialize for SubjectRef {
575    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
576    where
577        S: serde::Serializer,
578    {
579        serializer.serialize_str(&self.to_uri())
580    }
581}
582
583impl<'de> Deserialize<'de> for SubjectRef {
584    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
585    where
586        D: serde::Deserializer<'de>,
587    {
588        let value = String::deserialize(deserializer)?;
589        Self::parse(&value).map_err(de::Error::custom)
590    }
591}
592
593enum SubjectPart {
594    Scheme,
595    Kind,
596}
597
598fn validate_subject_label(value: &str, part: SubjectPart) -> Result<(), SubjectRefError> {
599    if value.is_empty() {
600        return match part {
601            SubjectPart::Scheme => Err(SubjectRefError::EmptyScheme),
602            SubjectPart::Kind => Err(SubjectRefError::EmptyKind),
603        };
604    }
605
606    for (index, ch) in value.chars().enumerate() {
607        let valid = if index == 0 {
608            ch.is_ascii_lowercase()
609        } else {
610            ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'
611        };
612        if !valid {
613            return match part {
614                SubjectPart::Scheme => Err(SubjectRefError::InvalidSchemeChar(ch)),
615                SubjectPart::Kind => Err(SubjectRefError::InvalidKindChar(ch)),
616            };
617        }
618    }
619
620    Ok(())
621}
622
623fn validate_subject_id(value: &str) -> Result<(), SubjectRefError> {
624    if value.is_empty() {
625        return Err(SubjectRefError::EmptyId);
626    }
627
628    for ch in value.chars() {
629        if ch == '/' || ch.is_whitespace() {
630            return Err(SubjectRefError::InvalidIdChar(ch));
631        }
632    }
633
634    Ok(())
635}
636
637/// Content-addressable hash encoded as 32 raw bytes and serialized as hex.
638#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
639#[serde(transparent)]
640pub struct ContentHash(#[serde(with = "hex_bytes")] [u8; 32]);
641
642impl ContentHash {
643    /// Create a new content hash from raw bytes.
644    #[must_use]
645    pub fn new(bytes: [u8; 32]) -> Self {
646        Self(bytes)
647    }
648
649    /// Create a content hash from a hex string.
650    ///
651    /// # Panics
652    ///
653    /// Panics if the hex string is not exactly 64 hexadecimal characters.
654    #[must_use]
655    pub fn from_hex(hex: &str) -> Self {
656        let mut bytes = [0u8; 32];
657        hex::decode_to_slice(hex, &mut bytes).expect("invalid hex string");
658        Self(bytes)
659    }
660
661    /// Borrow the raw bytes.
662    #[must_use]
663    pub fn as_bytes(&self) -> &[u8; 32] {
664        &self.0
665    }
666
667    /// Convert to lowercase hex.
668    #[must_use]
669    pub fn to_hex(&self) -> String {
670        hex::encode(self.0)
671    }
672
673    /// The zero hash, useful for deterministic stubs.
674    #[must_use]
675    pub fn zero() -> Self {
676        Self([0u8; 32])
677    }
678}
679
680impl Default for ContentHash {
681    fn default() -> Self {
682        Self::zero()
683    }
684}
685
686impl fmt::Display for ContentHash {
687    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
688        f.write_str(&self.to_hex())
689    }
690}
691
692mod hex_bytes {
693    use serde::{Deserialize, Deserializer, Serializer};
694
695    pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
696    where
697        S: Serializer,
698    {
699        serializer.serialize_str(&hex::encode(bytes))
700    }
701
702    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
703    where
704        D: Deserializer<'de>,
705    {
706        let raw = String::deserialize(deserializer)?;
707        let mut bytes = [0u8; 32];
708        hex::decode_to_slice(raw, &mut bytes).map_err(serde::de::Error::custom)?;
709        Ok(bytes)
710    }
711}
712
713/// Timestamp string used at Converge boundaries.
714///
715/// Runtime-visible timestamps may be wall-clock strings supplied by a host or
716/// logical Lamport clock stamps produced by the kernel. Core deterministic
717/// promotion paths use Lamport stamps instead of reading wall-clock time.
718#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
719#[serde(transparent)]
720pub struct Timestamp(String);
721
722impl Timestamp {
723    /// Create a new timestamp from an already formatted string.
724    #[must_use]
725    pub fn new(value: impl Into<String>) -> Self {
726        Self(value.into())
727    }
728
729    /// Borrow the timestamp string.
730    #[must_use]
731    pub fn as_str(&self) -> &str {
732        &self.0
733    }
734
735    /// The Unix epoch in ISO-8601 form.
736    #[must_use]
737    pub fn epoch() -> Self {
738        Self::new("1970-01-01T00:00:00Z")
739    }
740
741    /// Create a deterministic timestamp from Lamport logical time.
742    #[must_use]
743    pub fn lamport(time: u64) -> Self {
744        Self(format!("lamport:{time}"))
745    }
746
747    /// A deterministic zero logical timestamp.
748    ///
749    /// Hosts that need wall-clock timestamps should construct them explicitly at
750    /// the runtime boundary rather than letting core code read a hidden clock.
751    #[must_use]
752    pub fn now() -> Self {
753        Self::lamport(0)
754    }
755}
756
757impl Deref for Timestamp {
758    type Target = str;
759
760    fn deref(&self) -> &Self::Target {
761        self.as_str()
762    }
763}
764
765impl AsRef<str> for Timestamp {
766    fn as_ref(&self) -> &str {
767        self.as_str()
768    }
769}
770
771impl fmt::Display for Timestamp {
772    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
773        f.write_str(self.as_str())
774    }
775}
776
777impl From<&str> for Timestamp {
778    fn from(value: &str) -> Self {
779        Self::new(value)
780    }
781}
782
783impl From<String> for Timestamp {
784    fn from(value: String) -> Self {
785        Self::new(value)
786    }
787}
788
789impl From<Timestamp> for String {
790    fn from(value: Timestamp) -> Self {
791        value.0
792    }
793}
794
795impl From<&Timestamp> for String {
796    fn from(value: &Timestamp) -> Self {
797        value.as_str().to_string()
798    }
799}
800
801impl PartialEq<&str> for Timestamp {
802    fn eq(&self, other: &&str) -> bool {
803        self.as_str() == *other
804    }
805}
806
807impl PartialEq<str> for Timestamp {
808    fn eq(&self, other: &str) -> bool {
809        self.as_str() == other
810    }
811}
812
813impl PartialEq<Timestamp> for &str {
814    fn eq(&self, other: &Timestamp) -> bool {
815        *self == other.as_str()
816    }
817}
818
819impl PartialEq<Timestamp> for str {
820    fn eq(&self, other: &Timestamp) -> bool {
821        self == other.as_str()
822    }
823}
824
825impl PartialEq<String> for Timestamp {
826    fn eq(&self, other: &String) -> bool {
827        self.as_str() == other.as_str()
828    }
829}
830
831impl PartialEq<Timestamp> for String {
832    fn eq(&self, other: &Timestamp) -> bool {
833        self.as_str() == other.as_str()
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn string_newtypes_compare_like_strings_without_erasing_type_identity() {
843        let fact_id = FactId::new("fact-1");
844        let proposal_id = ProposalId::new("fact-1");
845
846        assert_eq!(fact_id, "fact-1");
847        assert_eq!("fact-1", fact_id);
848        assert_ne!(fact_id.to_string(), "");
849        assert_ne!(fact_id.as_str(), "");
850        assert_eq!(proposal_id.as_str(), "fact-1");
851    }
852
853    #[test]
854    fn content_hash_hex_roundtrip() {
855        let hash = ContentHash::from_hex(
856            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
857        );
858        assert_eq!(
859            hash.to_hex(),
860            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
861        );
862    }
863
864    #[test]
865    fn timestamp_now_is_deterministic_logical_zero() {
866        assert_eq!(Timestamp::now().as_str(), "lamport:0");
867    }
868
869    #[test]
870    fn timestamp_can_represent_lamport_time() {
871        assert_eq!(Timestamp::lamport(42).as_str(), "lamport:42");
872    }
873
874    #[test]
875    fn unit_interval_accepts_only_finite_closed_range_values() {
876        assert_eq!(UnitInterval::new(0.0).unwrap().as_f64(), 0.0);
877        assert_eq!(UnitInterval::new(1.0).unwrap().as_f64(), 1.0);
878        assert!(UnitInterval::new(-0.1).is_err());
879        assert!(UnitInterval::new(1.1).is_err());
880        assert!(UnitInterval::new(f64::NAN).is_err());
881    }
882
883    #[test]
884    fn unit_interval_deserialization_rejects_out_of_range_values() {
885        assert!(serde_json::from_str::<UnitInterval>("0.75").is_ok());
886        assert!(serde_json::from_str::<UnitInterval>("1.75").is_err());
887    }
888
889    #[test]
890    fn basis_points_accepts_only_unit_range_basis_points() {
891        assert_eq!(BasisPoints::new(0).unwrap().get(), 0);
892        assert_eq!(BasisPoints::new(10_000).unwrap().get(), 10_000);
893        assert!(BasisPoints::new(10_001).is_err());
894        assert_eq!(BasisPoints::clamped(20_000).get(), 10_000);
895    }
896
897    #[test]
898    fn subject_ref_normalizes_scheme_and_kind_but_preserves_id() {
899        let subject = SubjectRef::new("Atlas", "Acquisition-Assets", "SharedIdentityCore")
900            .expect("valid subject");
901
902        assert_eq!(subject.scheme(), "atlas");
903        assert_eq!(subject.kind(), "acquisition-assets");
904        assert_eq!(subject.id(), "SharedIdentityCore");
905        assert_eq!(
906            subject.to_uri(),
907            "atlas://acquisition-assets/SharedIdentityCore"
908        );
909    }
910
911    #[test]
912    fn subject_ref_parses_canonical_uri() {
913        let subject = SubjectRef::parse("quorum://unresolved-questions/identity-owner-coverage")
914            .expect("valid subject");
915
916        assert_eq!(subject.scheme(), "quorum");
917        assert_eq!(subject.kind(), "unresolved-questions");
918        assert_eq!(subject.id(), "identity-owner-coverage");
919        assert_eq!(
920            subject.to_string(),
921            "quorum://unresolved-questions/identity-owner-coverage"
922        );
923    }
924
925    #[test]
926    fn subject_ref_rejects_malformed_or_ambiguous_parts() {
927        assert!(matches!(
928            SubjectRef::parse("atlas/acquisition-assets/shared"),
929            Err(SubjectRefError::Malformed(_))
930        ));
931        assert!(matches!(
932            SubjectRef::new("1atlas", "asset", "id"),
933            Err(SubjectRefError::InvalidSchemeChar('1'))
934        ));
935        assert!(matches!(
936            SubjectRef::new("atlas", "asset_kind", "id"),
937            Err(SubjectRefError::InvalidKindChar('_'))
938        ));
939        assert!(matches!(
940            SubjectRef::new("atlas", "asset", "id with space"),
941            Err(SubjectRefError::InvalidIdChar(' '))
942        ));
943        assert!(matches!(
944            SubjectRef::parse("atlas://asset/id/extra"),
945            Err(SubjectRefError::InvalidIdChar('/'))
946        ));
947    }
948
949    #[test]
950    fn subject_ref_serializes_as_canonical_string() {
951        let subject = SubjectRef::parse("warden://dd-gates/dd-evidence.identity-data-residency")
952            .expect("valid subject");
953        let json = serde_json::to_string(&subject).expect("subject should serialize");
954
955        assert_eq!(
956            json,
957            r#""warden://dd-gates/dd-evidence.identity-data-residency""#
958        );
959        assert_eq!(
960            serde_json::from_str::<SubjectRef>(&json).expect("subject should deserialize"),
961            subject
962        );
963    }
964
965    #[test]
966    fn timestamp_is_transparent() {
967        let timestamp = Timestamp::epoch();
968        let json = serde_json::to_string(&timestamp).expect("timestamp should serialize");
969        assert_eq!(json, r#""1970-01-01T00:00:00Z""#);
970    }
971}