Skip to main content

lean_ctx_protocol/
common.rs

1//! Shared wire primitives used by the version-one protocol contracts.
2
3use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
4use std::{error::Error, fmt};
5
6/// The schema version implemented by this crate.
7pub const V1_SCHEMA_VERSION: u32 = 1;
8
9/// Maximum size for an opaque identifier on the wire.
10pub const MAX_IDENTIFIER_LENGTH: usize = 256;
11
12/// Validate an opaque wire identifier before it crosses a protocol boundary.
13pub(crate) fn validate_bounded_opaque_identifier(
14    value: &str,
15    type_name: &str,
16) -> Result<(), ValidationError> {
17    if value.trim().is_empty() {
18        return Err(ValidationError::new(format!(
19            "{type_name} must not be empty"
20        )));
21    }
22    if value.len() > MAX_IDENTIFIER_LENGTH {
23        return Err(ValidationError::new(format!(
24            "{type_name} exceeds the {MAX_IDENTIFIER_LENGTH} byte limit"
25        )));
26    }
27    if value.chars().any(char::is_control) {
28        return Err(ValidationError::new(format!(
29            "{type_name} must not contain control characters"
30        )));
31    }
32    Ok(())
33}
34
35/// Error returned when a contract value cannot satisfy a wire invariant.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ValidationError(pub(crate) String);
38
39impl ValidationError {
40    pub(crate) fn new(message: impl Into<String>) -> Self {
41        Self(message.into())
42    }
43}
44
45impl fmt::Display for ValidationError {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        formatter.write_str(&self.0)
48    }
49}
50
51impl Error for ValidationError {}
52
53macro_rules! bounded_identifier {
54    ($name:ident) => {
55        /// Opaque, bounded identifier used by a protocol contract.
56        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
57        #[serde(transparent)]
58        pub struct $name(String);
59
60        impl $name {
61            /// Construct an identifier after applying the wire bounds.
62            pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
63                let value = value.into();
64                $crate::common::validate_bounded_opaque_identifier(&value, stringify!($name))?;
65                Ok(Self(value))
66            }
67
68            /// Borrow the identifier's opaque wire value.
69            pub fn as_str(&self) -> &str {
70                &self.0
71            }
72
73            /// Consume the identifier and return its wire value.
74            pub fn into_inner(self) -> String {
75                self.0
76            }
77        }
78
79        impl std::str::FromStr for $name {
80            type Err = ValidationError;
81
82            fn from_str(value: &str) -> Result<Self, Self::Err> {
83                Self::new(value)
84            }
85        }
86
87        impl TryFrom<String> for $name {
88            type Error = ValidationError;
89
90            fn try_from(value: String) -> Result<Self, Self::Error> {
91                Self::new(value)
92            }
93        }
94
95        impl TryFrom<&str> for $name {
96            type Error = ValidationError;
97
98            fn try_from(value: &str) -> Result<Self, Self::Error> {
99                Self::new(value)
100            }
101        }
102
103        impl From<$name> for String {
104            fn from(value: $name) -> Self {
105                value.0
106            }
107        }
108
109        impl<'de> Deserialize<'de> for $name {
110            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111            where
112                D: Deserializer<'de>,
113            {
114                let value = String::deserialize(deserializer)?;
115                Self::new(value).map_err(DeError::custom)
116            }
117        }
118    };
119}
120
121bounded_identifier!(TaskId);
122bounded_identifier!(TraceId);
123bounded_identifier!(ProjectId);
124bounded_identifier!(SessionId);
125bounded_identifier!(AgentId);
126bounded_identifier!(TenantId);
127bounded_identifier!(PlanId);
128bounded_identifier!(ReceiptId);
129bounded_identifier!(OutcomeId);
130bounded_identifier!(CapabilityId);
131bounded_identifier!(DecisionId);
132
133/// Require the one schema version implemented by this crate during decoding.
134pub fn deserialize_schema_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
135where
136    D: Deserializer<'de>,
137{
138    let version = u32::deserialize(deserializer)?;
139    if version == V1_SCHEMA_VERSION {
140        Ok(version)
141    } else {
142        Err(DeError::custom(format!(
143            "unsupported schema_version {version}; expected {V1_SCHEMA_VERSION}"
144        )))
145    }
146}
147
148/// Decode a required milliunit constrained to the inclusive 0..=1000 range.
149pub fn deserialize_milliunit<'de, D>(deserializer: D) -> Result<u16, D::Error>
150where
151    D: Deserializer<'de>,
152{
153    let value = u16::deserialize(deserializer)?;
154    if value <= 1000 {
155        Ok(value)
156    } else {
157        Err(DeError::custom("milliunit must be between 0 and 1000"))
158    }
159}
160
161/// Decode an optional milliunit constrained to the inclusive 0..=1000 range.
162pub fn deserialize_optional_milliunit<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
163where
164    D: Deserializer<'de>,
165{
166    let value = Option::<u16>::deserialize(deserializer)?;
167    value.map_or(Ok(None), |value| {
168        if value <= 1000 {
169            Ok(Some(value))
170        } else {
171            Err(DeError::custom("milliunit must be between 0 and 1000"))
172        }
173    })
174}
175
176/// Validate a schema version on values built directly in Rust.
177pub fn validate_schema_version(version: u32) -> Result<(), ValidationError> {
178    if version == V1_SCHEMA_VERSION {
179        Ok(())
180    } else {
181        Err(ValidationError::new(format!(
182            "unsupported schema_version {version}; expected {V1_SCHEMA_VERSION}"
183        )))
184    }
185}
186
187/// Validate a 0..=1000 milliunit value on values built directly in Rust.
188pub fn validate_milliunit(value: u16, field: &str) -> Result<(), ValidationError> {
189    if value <= 1000 {
190        Ok(())
191    } else {
192        Err(ValidationError::new(format!(
193            "{field} must be between 0 and 1000"
194        )))
195    }
196}