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