Skip to main content

dekopon_core/
lib.rs

1//! Dependency-light domain types for Dekopon.
2//!
3//! Identifiers are validated at construction and during deserialization. This prevents
4//! malformed resource references from leaking into the rest of the workspace while
5//! keeping transport, command-line, async-runtime, and policy concerns out of this crate.
6
7#![forbid(unsafe_code)]
8
9mod redaction;
10mod telemetry_payloads;
11
12use std::{fmt, str::FromStr};
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
16use thiserror::Error;
17
18pub use redaction::{Redacted, redaction_marker, serialize_exposed};
19pub use telemetry_payloads::{set_telemetry_payloads, telemetry_payloads};
20
21const MAX_IDENTIFIER_LENGTH: usize = 253;
22
23/// The reason a Dekopon identifier could not be parsed.
24#[derive(Clone, Debug, Eq, Error, PartialEq)]
25pub enum IdentifierError {
26    /// The identifier was empty.
27    #[error("{kind} identifier must not be empty")]
28    Empty {
29        /// Human-readable identifier kind.
30        kind: &'static str,
31    },
32    /// The identifier exceeded the supported wire limit.
33    #[error("{kind} identifier is {length} bytes; the maximum is {maximum}")]
34    TooLong {
35        /// Human-readable identifier kind.
36        kind: &'static str,
37        /// Actual byte length.
38        length: usize,
39        /// Maximum byte length.
40        maximum: usize,
41    },
42    /// The first character was not an ASCII lowercase letter or digit.
43    #[error(
44        "{kind} identifier must start with a lowercase ASCII letter or digit, found {character:?}"
45    )]
46    InvalidStart {
47        /// Human-readable identifier kind.
48        kind: &'static str,
49        /// Invalid character.
50        character: char,
51    },
52    /// The last character was a separator.
53    #[error(
54        "{kind} identifier must end with a lowercase ASCII letter or digit, found {character:?}"
55    )]
56    InvalidEnd {
57        /// Human-readable identifier kind.
58        kind: &'static str,
59        /// Invalid character.
60        character: char,
61    },
62    /// A character outside the portable identifier alphabet was present.
63    #[error(
64        "{kind} identifier contains invalid character {character:?} at byte {index}; use lowercase ASCII letters, digits, '.', '-', or '_'"
65    )]
66    InvalidCharacter {
67        /// Human-readable identifier kind.
68        kind: &'static str,
69        /// Byte offset in the submitted value.
70        index: usize,
71        /// Invalid character.
72        character: char,
73    },
74    /// Two separator characters appeared next to one another.
75    #[error("{kind} identifier contains adjacent separators at byte {index}")]
76    AdjacentSeparators {
77        /// Human-readable identifier kind.
78        kind: &'static str,
79        /// Byte offset of the second separator.
80        index: usize,
81    },
82}
83
84fn is_edge_character(character: char) -> bool {
85    character.is_ascii_lowercase() || character.is_ascii_digit()
86}
87
88fn is_separator(character: char) -> bool {
89    matches!(character, '.' | '-' | '_')
90}
91
92fn validate_identifier(value: &str, kind: &'static str) -> Result<(), IdentifierError> {
93    if value.is_empty() {
94        return Err(IdentifierError::Empty { kind });
95    }
96    if value.len() > MAX_IDENTIFIER_LENGTH {
97        return Err(IdentifierError::TooLong {
98            kind,
99            length: value.len(),
100            maximum: MAX_IDENTIFIER_LENGTH,
101        });
102    }
103
104    let mut characters = value.char_indices();
105    let (_, first) = characters.next().ok_or(IdentifierError::Empty { kind })?;
106    if !is_edge_character(first) {
107        return Err(IdentifierError::InvalidStart {
108            kind,
109            character: first,
110        });
111    }
112
113    let mut previous_was_separator = false;
114    for (index, character) in value.char_indices() {
115        if !is_edge_character(character) && !is_separator(character) {
116            return Err(IdentifierError::InvalidCharacter {
117                kind,
118                index,
119                character,
120            });
121        }
122        if is_separator(character) && previous_was_separator {
123            return Err(IdentifierError::AdjacentSeparators { kind, index });
124        }
125        previous_was_separator = is_separator(character);
126    }
127
128    let last = value
129        .chars()
130        .next_back()
131        .ok_or(IdentifierError::Empty { kind })?;
132    if !is_edge_character(last) {
133        return Err(IdentifierError::InvalidEnd {
134            kind,
135            character: last,
136        });
137    }
138
139    Ok(())
140}
141
142macro_rules! identifier {
143    ($name:ident, $label:literal, $docs:literal) => {
144        #[doc = $docs]
145        #[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
146        #[serde(transparent)]
147        pub struct $name(String);
148
149        impl $name {
150            /// Returns the validated identifier as a string slice.
151            #[must_use]
152            pub fn as_str(&self) -> &str {
153                &self.0
154            }
155        }
156
157        impl fmt::Display for $name {
158            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159                formatter.write_str(&self.0)
160            }
161        }
162
163        impl FromStr for $name {
164            type Err = IdentifierError;
165
166            fn from_str(value: &str) -> Result<Self, Self::Err> {
167                validate_identifier(value, $label)?;
168                Ok(Self(value.to_owned()))
169            }
170        }
171
172        impl TryFrom<String> for $name {
173            type Error = IdentifierError;
174
175            fn try_from(value: String) -> Result<Self, Self::Error> {
176                validate_identifier(&value, $label)?;
177                Ok(Self(value))
178            }
179        }
180
181        impl TryFrom<&str> for $name {
182            type Error = IdentifierError;
183
184            fn try_from(value: &str) -> Result<Self, Self::Error> {
185                value.parse()
186            }
187        }
188
189        impl From<$name> for String {
190            fn from(value: $name) -> Self {
191                value.0
192            }
193        }
194
195        impl<'de> Deserialize<'de> for $name {
196            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197            where
198                D: Deserializer<'de>,
199            {
200                let value = String::deserialize(deserializer)?;
201                value.parse().map_err(D::Error::custom)
202            }
203        }
204    };
205}
206
207identifier!(AgentId, "agent", "A validated agent resource identifier.");
208identifier!(
209    CapabilityId,
210    "capability",
211    "A validated capability resource identifier."
212);
213identifier!(
214    ProviderId,
215    "provider",
216    "A validated capability-provider identifier."
217);
218identifier!(TaskId, "task", "A validated task identifier.");
219identifier!(
220    InvocationId,
221    "invocation",
222    "A validated capability invocation identifier."
223);
224identifier!(TraceId, "trace", "A validated end-to-end trace identifier.");
225identifier!(
226    PrincipalId,
227    "principal",
228    "A validated authenticated principal identifier."
229);
230
231/// The authenticated actor responsible for an operation.
232#[derive(Clone, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)]
233#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
234pub enum Actor {
235    /// A human operator.
236    Human {
237        /// The operator's trusted principal identity.
238        principal: PrincipalId,
239    },
240    /// A Dekopon agent. The envelope carrying this value must authenticate it.
241    Agent {
242        /// The agent identity.
243        agent: AgentId,
244    },
245    /// A non-human service principal.
246    Service {
247        /// The service's trusted principal identity.
248        principal: PrincipalId,
249    },
250}
251
252/// Coarse risk classification used as policy input.
253#[derive(
254    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
255)]
256#[serde(rename_all = "PascalCase")]
257pub enum RiskLevel {
258    /// No expected external side effect and limited data exposure.
259    Low,
260    /// Meaningful data access or a reversible/local effect.
261    Medium,
262    /// An external write, sensitive data access, or difficult rollback.
263    High,
264    /// A potentially destructive or high-impact operation.
265    Critical,
266}
267
268impl fmt::Display for RiskLevel {
269    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
270        write!(formatter, "{self:?}")
271    }
272}
273
274/// Operational phase reported for an agent.
275#[derive(
276    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
277)]
278#[serde(rename_all = "PascalCase")]
279pub enum AgentStatus {
280    /// Configuration intentionally prevents the agent from running.
281    Disabled,
282    /// The agent is valid but not yet ready.
283    Pending,
284    /// The agent is ready for orchestration.
285    Ready,
286    /// The agent cannot operate because of an error.
287    Error,
288}
289
290impl fmt::Display for AgentStatus {
291    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292        write!(formatter, "{self:?}")
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::{AgentId, IdentifierError, RiskLevel};
299
300    #[test]
301    fn accepts_portable_identifiers() {
302        for value in ["reviewer", "github.pull-request.read", "agent_2"] {
303            let parsed = value.parse::<AgentId>();
304            assert!(parsed.is_ok(), "{value} should be valid: {parsed:?}");
305        }
306    }
307
308    #[test]
309    fn rejects_invalid_identifiers_with_context() {
310        assert!(matches!(
311            "Reviewer".parse::<AgentId>(),
312            Err(IdentifierError::InvalidStart { .. })
313        ));
314        assert!(matches!(
315            "github..read".parse::<AgentId>(),
316            Err(IdentifierError::AdjacentSeparators { .. })
317        ));
318        assert!(matches!(
319            "reviewer/one".parse::<AgentId>(),
320            Err(IdentifierError::InvalidCharacter { index: 8, .. })
321        ));
322        assert!(matches!(
323            "reviewer-".parse::<AgentId>(),
324            Err(IdentifierError::InvalidEnd { .. })
325        ));
326    }
327
328    #[test]
329    fn deserialization_cannot_bypass_validation() {
330        let error = serde_json::from_str::<AgentId>(r#""not valid""#)
331            .expect_err("whitespace must be rejected");
332        assert!(error.to_string().contains("invalid character"));
333    }
334
335    #[test]
336    fn display_is_stable() {
337        assert_eq!(RiskLevel::High.to_string(), "High");
338    }
339}