Skip to main content

made_core/entities/
agentic_system.rs

1//! [`AgenticSystem`] — the design of a system of agents, people and
2//! ceremonies.
3//!
4//! A ceremony coordinates one procedure. This is the level above it: a
5//! named system with business roles, logical participants, a
6//! collaboration topology, several ceremonies composed together, and
7//! the supervision the whole thing runs under.
8//!
9//! It references ceremonies and never owns them. Every composition
10//! carries an immutable pin, and publishing resolves each pin against
11//! what is actually published, so a design cannot quietly come to mean
12//! something else because a version was republished underneath it
13//! (ADR-021).
14//!
15//! It is not an execution engine either. A run is
16//! [`AgenticSystemExecution`](super::AgenticSystemExecution), with its
17//! own store, so a run never rewrites the design it is running.
18
19use std::collections::BTreeMap;
20
21use serde::{Deserialize, Serialize};
22use time::OffsetDateTime;
23
24use crate::error::DomainError;
25use crate::value_objects::{
26    AgenticSystemDigest, AgenticSystemId, AgenticSystemLifecycle, AgenticSystemRevision,
27    AttentionPolicy, CeremonyComposition, CollaborationLink, LogicalParticipant, ParticipantId,
28    RequestedExecutionProfile, SupervisionPolicy, SystemCeremonyId, SystemPin, SystemPurpose,
29    SystemRole, SystemRoleId,
30};
31
32mod agentic_system_content;
33mod agentic_system_parts;
34mod dependency_order;
35
36use agentic_system_content::AgenticSystemContent;
37
38pub use agentic_system_parts::AgenticSystemParts;
39
40/// One revision of one agentic system design.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct AgenticSystem {
43    id: AgenticSystemId,
44    revision: AgenticSystemRevision,
45    lifecycle: AgenticSystemLifecycle,
46    purpose: SystemPurpose,
47    integrator: SystemRoleId,
48    roles: BTreeMap<SystemRoleId, SystemRole>,
49    participants: BTreeMap<ParticipantId, LogicalParticipant>,
50    topology: Vec<CollaborationLink>,
51    profiles: BTreeMap<SystemRoleId, RequestedExecutionProfile>,
52    ceremonies: BTreeMap<SystemCeremonyId, CeremonyComposition>,
53    #[serde(default)]
54    supervision: SupervisionPolicy,
55    attention: AttentionPolicy,
56    #[serde(with = "time::serde::rfc3339")]
57    created_at: OffsetDateTime,
58    #[serde(with = "time::serde::rfc3339")]
59    updated_at: OffsetDateTime,
60}
61
62impl AgenticSystem {
63    /// Start a design at its first revision.
64    ///
65    /// Deliberately permissive about the *content*: a draft is
66    /// something an author is still working on, and refusing to write
67    /// down a half-finished system would mean the only way to get
68    /// advice about one is to have finished it. What is refused here
69    /// is what no revision of the design could ever repair — an empty
70    /// system, or one whose own indexes disagree with themselves.
71    #[allow(clippy::too_many_arguments)]
72    pub fn draft(
73        id: AgenticSystemId,
74        purpose: SystemPurpose,
75        integrator: SystemRoleId,
76        roles: impl IntoIterator<Item = SystemRole>,
77        participants: impl IntoIterator<Item = LogicalParticipant>,
78        topology: impl IntoIterator<Item = CollaborationLink>,
79        profiles: impl IntoIterator<Item = (SystemRoleId, RequestedExecutionProfile)>,
80        ceremonies: impl IntoIterator<Item = CeremonyComposition>,
81        supervision: SupervisionPolicy,
82        attention: AttentionPolicy,
83        now: OffsetDateTime,
84    ) -> Result<Self, DomainError> {
85        let roles = index(roles, SystemRole::id, "agentic_system.roles")?;
86        let participants = index(
87            participants,
88            LogicalParticipant::id,
89            "agentic_system.participants",
90        )?;
91        let ceremonies = index(
92            ceremonies,
93            CeremonyComposition::id,
94            "agentic_system.ceremonies",
95        )?;
96        if roles.is_empty() {
97            return Err(DomainError::EmptyCollection {
98                field: "agentic_system.roles",
99            });
100        }
101        if ceremonies.is_empty() {
102            return Err(DomainError::EmptyCollection {
103                field: "agentic_system.ceremonies",
104            });
105        }
106        Ok(Self {
107            id,
108            revision: AgenticSystemRevision::INITIAL,
109            lifecycle: AgenticSystemLifecycle::Draft,
110            purpose,
111            integrator,
112            roles,
113            participants,
114            topology: topology.into_iter().collect(),
115            profiles: profiles.into_iter().collect(),
116            ceremonies,
117            supervision,
118            attention,
119            created_at: now,
120            updated_at: now,
121        })
122    }
123
124    #[must_use]
125    pub const fn id(&self) -> &AgenticSystemId {
126        &self.id
127    }
128
129    #[must_use]
130    pub const fn revision(&self) -> AgenticSystemRevision {
131        self.revision
132    }
133
134    #[must_use]
135    pub const fn lifecycle(&self) -> AgenticSystemLifecycle {
136        self.lifecycle
137    }
138
139    #[must_use]
140    pub const fn purpose(&self) -> &SystemPurpose {
141        &self.purpose
142    }
143
144    /// The business role that drives the system from outside it.
145    #[must_use]
146    pub const fn integrator(&self) -> &SystemRoleId {
147        &self.integrator
148    }
149
150    #[must_use]
151    pub const fn roles(&self) -> &BTreeMap<SystemRoleId, SystemRole> {
152        &self.roles
153    }
154
155    #[must_use]
156    pub const fn participants(&self) -> &BTreeMap<ParticipantId, LogicalParticipant> {
157        &self.participants
158    }
159
160    #[must_use]
161    pub fn topology(&self) -> &[CollaborationLink] {
162        &self.topology
163    }
164
165    #[must_use]
166    pub const fn profiles(&self) -> &BTreeMap<SystemRoleId, RequestedExecutionProfile> {
167        &self.profiles
168    }
169
170    #[must_use]
171    pub const fn ceremonies(&self) -> &BTreeMap<SystemCeremonyId, CeremonyComposition> {
172        &self.ceremonies
173    }
174
175    #[must_use]
176    pub const fn supervision(&self) -> &SupervisionPolicy {
177        &self.supervision
178    }
179
180    /// What the integrator asked to be told about while it runs.
181    #[must_use]
182    pub const fn attention(&self) -> &AttentionPolicy {
183        &self.attention
184    }
185
186    #[must_use]
187    pub const fn created_at(&self) -> OffsetDateTime {
188        self.created_at
189    }
190
191    #[must_use]
192    pub const fn updated_at(&self) -> OffsetDateTime {
193        self.updated_at
194    }
195
196    /// The content identity of this design.
197    pub fn digest(&self) -> Result<AgenticSystemDigest, DomainError> {
198        let canonical = serde_json::to_vec(&AgenticSystemContent::of(self)).map_err(|_| {
199            DomainError::InvariantViolated {
200                reason: "agentic system cannot be rendered canonically",
201            }
202        })?;
203        Ok(AgenticSystemDigest::of_canonical_form(&canonical))
204    }
205
206    /// This design, named the way a run refers to it.
207    pub fn pin(&self) -> Result<SystemPin, DomainError> {
208        Ok(SystemPin::new(
209            self.id.clone(),
210            self.revision,
211            self.digest()?,
212        ))
213    }
214
215    /// The same design saved as the next revision.
216    ///
217    /// Editing produces a new revision rather than mutating this one,
218    /// because the store's compare-and-swap is what protects one
219    /// author's edit from another's, and it can only compare revisions
220    /// it was told about.
221    #[must_use]
222    pub fn edited(&self, now: OffsetDateTime) -> Self {
223        Self {
224            revision: self.revision.next(),
225            lifecycle: AgenticSystemLifecycle::Draft,
226            updated_at: now,
227            ..self.clone()
228        }
229    }
230
231    /// The same design, recorded at a revision the store assigned.
232    #[must_use]
233    pub fn at_revision(&self, revision: AgenticSystemRevision) -> Self {
234        Self {
235            revision,
236            ..self.clone()
237        }
238    }
239
240    /// Seal this revision.
241    ///
242    /// Refused from anything but a draft: publishing a published
243    /// revision again would either be a no-op dressed as an event or a
244    /// silent overwrite of something a run may already be pinned to.
245    pub fn published(&self, now: OffsetDateTime) -> Result<Self, DomainError> {
246        match self.lifecycle {
247            AgenticSystemLifecycle::Draft => Ok(Self {
248                lifecycle: AgenticSystemLifecycle::Published,
249                updated_at: now,
250                ..self.clone()
251            }),
252            AgenticSystemLifecycle::Published | AgenticSystemLifecycle::Deprecated => {
253                Err(DomainError::InvalidTransition {
254                    from: self.lifecycle.as_str(),
255                    to: "published",
256                })
257            }
258        }
259    }
260
261    /// Retire this revision from new runs without invalidating the
262    /// ones already sealed against it.
263    pub fn deprecated(&self, now: OffsetDateTime) -> Result<Self, DomainError> {
264        match self.lifecycle {
265            AgenticSystemLifecycle::Published => Ok(Self {
266                lifecycle: AgenticSystemLifecycle::Deprecated,
267                updated_at: now,
268                ..self.clone()
269            }),
270            AgenticSystemLifecycle::Draft | AgenticSystemLifecycle::Deprecated => {
271                Err(DomainError::InvalidTransition {
272                    from: self.lifecycle.as_str(),
273                    to: "deprecated",
274                })
275            }
276        }
277    }
278
279    /// What each composition must wait for before it can run.
280    ///
281    /// Not simply `depends_on`: a bounded loop declares which edge is
282    /// the way back round, and that edge is cut here so a system that
283    /// sends work back for revision can start at all.
284    #[must_use]
285    pub fn blocking_dependencies(&self) -> BTreeMap<SystemCeremonyId, Vec<SystemCeremonyId>> {
286        dependency_order::blocking(&self.ceremonies)
287    }
288
289    /// The compositions nothing else has to finish first.
290    ///
291    /// These are where a run begins. A design where every composition
292    /// waits for another has a cycle nothing bounded, and the analysis
293    /// says so before a run has to discover it by starting nothing.
294    #[must_use]
295    pub fn root_ceremonies(&self) -> Vec<&CeremonyComposition> {
296        let blocking = self.blocking_dependencies();
297        self.ceremonies
298            .iter()
299            .filter(|(id, _)| blocking.get(*id).is_none_or(Vec::is_empty))
300            .map(|(_, composition)| composition)
301            .collect()
302    }
303}
304
305fn index<Item, Key>(
306    items: impl IntoIterator<Item = Item>,
307    key: impl Fn(&Item) -> &Key,
308    field: &'static str,
309) -> Result<BTreeMap<Key, Item>, DomainError>
310where
311    Key: Ord + Clone,
312{
313    let mut indexed = BTreeMap::new();
314    for item in items {
315        if indexed.insert(key(&item).clone(), item).is_some() {
316            return Err(DomainError::AlreadyExists { what: field });
317        }
318    }
319    Ok(indexed)
320}