made_core/entities/
council.rs1use std::collections::BTreeSet;
12
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15
16use crate::error::DomainError;
17use crate::value_objects::{AgentId, CouncilId, Specialty};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Council {
24 id: CouncilId,
25 specialty: Specialty,
26 agents: BTreeSet<AgentId>,
27 #[serde(with = "time::serde::rfc3339")]
28 created_at: OffsetDateTime,
29}
30
31impl Council {
32 pub fn new(
37 id: CouncilId,
38 specialty: Specialty,
39 agents: impl IntoIterator<Item = AgentId>,
40 now: OffsetDateTime,
41 ) -> Result<Self, DomainError> {
42 let agents: BTreeSet<AgentId> = agents.into_iter().collect();
43 if agents.is_empty() {
44 return Err(DomainError::EmptyCollection {
45 field: "council.agents",
46 });
47 }
48 Ok(Self {
49 id,
50 specialty,
51 agents,
52 created_at: now,
53 })
54 }
55
56 pub fn add_agent(&mut self, agent: AgentId) {
59 self.agents.insert(agent);
60 }
61
62 pub fn remove_agent(&mut self, agent: &AgentId) -> Result<(), DomainError> {
65 if self.agents.len() <= 1 && self.agents.contains(agent) {
66 return Err(DomainError::InvariantViolated {
67 reason: "council must retain at least one agent",
68 });
69 }
70 if !self.agents.remove(agent) {
71 return Err(DomainError::NotFound {
72 what: "council.agent",
73 });
74 }
75 Ok(())
76 }
77
78 #[must_use]
79 pub fn id(&self) -> &CouncilId {
80 &self.id
81 }
82 #[must_use]
83 pub fn specialty(&self) -> &Specialty {
84 &self.specialty
85 }
86 #[must_use]
87 pub fn agents(&self) -> &BTreeSet<AgentId> {
88 &self.agents
89 }
90 #[must_use]
91 pub fn size(&self) -> usize {
92 self.agents.len()
93 }
94 #[must_use]
95 pub fn created_at(&self) -> OffsetDateTime {
96 self.created_at
97 }
98 #[must_use]
99 pub fn has_agent(&self, agent: &AgentId) -> bool {
100 self.agents.contains(agent)
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use time::macros::datetime;
108
109 fn aid(s: &str) -> AgentId {
110 AgentId::new(s).unwrap()
111 }
112
113 fn make(agents: Vec<AgentId>) -> Result<Council, DomainError> {
114 Council::new(
115 CouncilId::new("c1").unwrap(),
116 Specialty::new("reviewer").unwrap(),
117 agents,
118 datetime!(2026-04-15 12:00:00 UTC),
119 )
120 }
121
122 #[test]
123 fn empty_council_is_rejected_at_construction() {
124 assert!(matches!(
125 make(vec![]).unwrap_err(),
126 DomainError::EmptyCollection {
127 field: "council.agents"
128 }
129 ));
130 }
131
132 #[test]
133 fn construction_deduplicates_agents() {
134 let c = make(vec![aid("a"), aid("a"), aid("b")]).unwrap();
135 assert_eq!(c.size(), 2);
136 }
137
138 #[test]
139 fn add_agent_is_idempotent() {
140 let mut c = make(vec![aid("a")]).unwrap();
141 c.add_agent(aid("b"));
142 c.add_agent(aid("b"));
143 assert_eq!(c.size(), 2);
144 }
145
146 #[test]
147 fn remove_agent_requires_council_remains_non_empty() {
148 let mut c = make(vec![aid("a")]).unwrap();
149 let err = c.remove_agent(&aid("a")).unwrap_err();
150 assert!(matches!(err, DomainError::InvariantViolated { .. }));
151 assert_eq!(c.size(), 1);
152 }
153
154 #[test]
155 fn remove_unknown_agent_reports_not_found() {
156 let mut c = make(vec![aid("a"), aid("b")]).unwrap();
157 assert!(matches!(
158 c.remove_agent(&aid("missing")).unwrap_err(),
159 DomainError::NotFound {
160 what: "council.agent"
161 }
162 ));
163 }
164
165 #[test]
166 fn remove_agent_shrinks_council_when_allowed() {
167 let mut c = make(vec![aid("a"), aid("b")]).unwrap();
168 c.remove_agent(&aid("a")).unwrap();
169 assert_eq!(c.size(), 1);
170 assert!(c.has_agent(&aid("b")));
171 assert!(!c.has_agent(&aid("a")));
172 }
173
174 #[test]
175 fn specialty_is_free_form_label() {
176 for label in ["triage", "planner", "clinical-intake", "sourcing"] {
178 Council::new(
179 CouncilId::new(label).unwrap(),
180 Specialty::new(label).unwrap(),
181 vec![aid("a")],
182 datetime!(2026-04-15 12:00:00 UTC),
183 )
184 .unwrap();
185 }
186 }
187}