Skip to main content

made_core/value_objects/
ids.rs

1//! Identifier value objects.
2//!
3//! Each domain concept that needs identity gets its own newtype so that
4//! the compiler rejects mixing, e.g., an [`AgentId`] where a [`TaskId`]
5//! is expected. Identifiers are opaque strings at the wire level but
6//! validated for basic hygiene here.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14const MAX_ID_LEN: usize = 256;
15
16fn validate_id(field: &'static str, raw: &str) -> Result<String, DomainError> {
17    let trimmed = raw.trim();
18    if trimmed.is_empty() {
19        return Err(DomainError::EmptyField { field });
20    }
21    if trimmed.len() > MAX_ID_LEN {
22        return Err(DomainError::FieldTooLong {
23            field,
24            actual: trimmed.len(),
25            max: MAX_ID_LEN,
26        });
27    }
28    if trimmed.chars().any(char::is_control) {
29        return Err(DomainError::InvalidCharacters { field });
30    }
31    Ok(trimmed.to_owned())
32}
33
34macro_rules! id_newtype {
35    ($(#[$meta:meta])* $name:ident, $field:literal) => {
36        $(#[$meta])*
37        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38        #[serde(transparent)]
39        pub struct $name(String);
40
41        impl $name {
42            /// Construct a validated identifier.
43            pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
44                Ok(Self(validate_id($field, &raw.into())?))
45            }
46
47            /// Borrow the underlying string.
48            #[must_use]
49            pub fn as_str(&self) -> &str {
50                &self.0
51            }
52
53            /// Consume the value object and return the raw string.
54            #[must_use]
55            pub fn into_inner(self) -> String {
56                self.0
57            }
58        }
59
60        impl fmt::Display for $name {
61            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62                f.write_str(&self.0)
63            }
64        }
65
66        impl TryFrom<String> for $name {
67            type Error = DomainError;
68            fn try_from(value: String) -> Result<Self, Self::Error> {
69                Self::new(value)
70            }
71        }
72
73        impl TryFrom<&str> for $name {
74            type Error = DomainError;
75            fn try_from(value: &str) -> Result<Self, Self::Error> {
76                Self::new(value)
77            }
78        }
79    };
80}
81
82id_newtype!(
83    /// Identifier of an [`Agent`](crate::value_objects) within MADE.
84    AgentId,
85    "agent_id"
86);
87
88id_newtype!(
89    /// Identifier of a task submitted for deliberation.
90    TaskId,
91    "task_id"
92);
93
94id_newtype!(
95    /// Identifier of a concrete proposal produced during deliberation.
96    ProposalId,
97    "proposal_id"
98);
99
100id_newtype!(
101    /// Identifier of a council (group of agents for a given specialty).
102    CouncilId,
103    "council_id"
104);
105
106id_newtype!(
107    /// Identifier of a domain event as emitted by MADE.
108    EventId,
109    "event_id"
110);
111
112id_newtype!(
113    /// Identifier of one run of a composed agentic system.
114    ///
115    /// Declared here rather than beside the aggregate that owns it, so
116    /// that a binding can be scoped to a system run without the
117    /// delivery core depending on the design aggregate at all.
118    AgenticSystemExecutionId,
119    "agentic_system_execution_id"
120);
121
122id_newtype!(
123    /// Identifier of one agentic system design, stable across every
124    /// revision of it.
125    AgenticSystemId,
126    "agentic_system_id"
127);
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn new_accepts_valid_id() {
135        let id = AgentId::new("agent-42").expect("should parse");
136        assert_eq!(id.as_str(), "agent-42");
137    }
138
139    #[test]
140    fn new_trims_whitespace() {
141        let id = TaskId::new("  t-1  ").expect("should parse");
142        assert_eq!(id.as_str(), "t-1");
143    }
144
145    #[test]
146    fn empty_is_rejected() {
147        let err = ProposalId::new("   ").expect_err("should reject");
148        assert!(matches!(
149            err,
150            DomainError::EmptyField {
151                field: "proposal_id"
152            }
153        ));
154    }
155
156    #[test]
157    fn control_chars_are_rejected() {
158        let err = CouncilId::new("bad\x00id").expect_err("should reject");
159        assert!(matches!(
160            err,
161            DomainError::InvalidCharacters {
162                field: "council_id"
163            }
164        ));
165    }
166
167    #[test]
168    fn overlong_is_rejected() {
169        let too_long = "x".repeat(super::MAX_ID_LEN + 1);
170        let err = EventId::new(too_long).expect_err("should reject");
171        assert!(matches!(err, DomainError::FieldTooLong { .. }));
172    }
173
174    #[test]
175    fn distinct_newtypes_do_not_mix() {
176        fn takes_agent(_: AgentId) {}
177        let task = TaskId::new("t").unwrap();
178        // The following must not compile:
179        // takes_agent(task);
180        let _ = task;
181        takes_agent(AgentId::new("a").unwrap());
182    }
183
184    #[test]
185    fn try_from_str_works() {
186        let id: AgentId = "a1".try_into().unwrap();
187        assert_eq!(id.as_str(), "a1");
188    }
189
190    #[test]
191    fn try_from_string_works() {
192        let id: TaskId = String::from("t1").try_into().unwrap();
193        assert_eq!(id.as_str(), "t1");
194    }
195
196    #[test]
197    fn display_matches_inner() {
198        let id = AgentId::new("x").unwrap();
199        assert_eq!(id.to_string(), "x");
200    }
201
202    #[test]
203    fn into_inner_returns_string() {
204        let id = AgentId::new("x").unwrap();
205        assert_eq!(id.into_inner(), "x");
206    }
207
208    #[test]
209    fn serde_roundtrip_is_transparent() {
210        let id = AgentId::new("abc").unwrap();
211        let s = serde_json::to_string(&id).unwrap();
212        assert_eq!(s, "\"abc\"");
213        let back: AgentId = serde_json::from_str(&s).unwrap();
214        assert_eq!(back, id);
215    }
216}