Skip to main content

made_core/entities/
council.rs

1//! [`Council`] aggregate — a group of agents for a given specialty.
2//!
3//! The Council is an aggregate root that protects the invariants of
4//! its agent membership. Callers interact with it through behavioural
5//! methods (`add_agent`, `remove_agent`), not by mutating fields.
6//!
7//! Domain-agnostic port: the Python reference's `CouncilRegistry`
8//! keyed councils by a SWE-specific `role` string. Here councils are
9//! keyed by [`Specialty`] — free-form, operator-chosen.
10
11use 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/// A council owns the set of agent identities that participate in
20/// deliberations for its specialty. The membership must be non-empty
21/// for the council to be capable of deliberating.
22#[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    /// Create a council seeded with at least one agent.
33    ///
34    /// A zero-agent council cannot deliberate; constructing one is
35    /// rejected here rather than later in the use-case layer.
36    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    /// Add an agent. Idempotent: re-adding the same agent is a no-op,
57    /// not an error.
58    pub fn add_agent(&mut self, agent: AgentId) {
59        self.agents.insert(agent);
60    }
61
62    /// Remove an agent. Rejects the removal if it would empty the
63    /// council — an empty council cannot deliberate.
64    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        // Regression: the council must not enumerate specialties.
177        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}