Skip to main content

made_core/entities/
task.rs

1//! [`Task`] entity — a unit of work submitted for deliberation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::entities::ExternalContextBundle;
6use crate::error::DomainError;
7use crate::events::EventEnvelope;
8use crate::value_objects::{
9    Attributes, DurationMs, EventId, NumAgents, OutputContract, Rounds, Rubric, Specialty,
10    TaskDescription, TaskId,
11};
12
13const MAX_METADATA_TEXT_LEN: usize = 128;
14
15/// The per-task configuration that shapes a deliberation.
16///
17/// Kept as a nested value object so a `Task` stays a small entity with
18/// clear ownership of its configuration.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(default)]
21pub struct TaskConstraints {
22    rubric: Rubric,
23    rounds: Rounds,
24    num_agents: Option<NumAgents>,
25    deadline: Option<DurationMs>,
26    output_contract: Option<OutputContract>,
27}
28
29impl TaskConstraints {
30    #[must_use]
31    pub fn new(
32        rubric: Rubric,
33        rounds: Rounds,
34        num_agents: Option<NumAgents>,
35        deadline: Option<DurationMs>,
36    ) -> Self {
37        Self {
38            rubric,
39            rounds,
40            num_agents,
41            deadline,
42            output_contract: None,
43        }
44    }
45
46    #[must_use]
47    pub fn rubric(&self) -> &Rubric {
48        &self.rubric
49    }
50    #[must_use]
51    pub fn rounds(&self) -> Rounds {
52        self.rounds
53    }
54    #[must_use]
55    pub fn num_agents(&self) -> Option<NumAgents> {
56        self.num_agents
57    }
58    #[must_use]
59    pub fn deadline(&self) -> Option<DurationMs> {
60        self.deadline
61    }
62
63    #[must_use]
64    pub fn output_contract(&self) -> Option<&OutputContract> {
65        self.output_contract.as_ref()
66    }
67
68    #[must_use]
69    pub fn with_output_contract(mut self, output_contract: OutputContract) -> Self {
70        self.output_contract = Some(output_contract);
71        self
72    }
73}
74
75impl Default for TaskConstraints {
76    fn default() -> Self {
77        Self {
78            rubric: Rubric::empty(),
79            rounds: Rounds::default(),
80            num_agents: None,
81            deadline: None,
82            output_contract: None,
83        }
84    }
85}
86
87/// A task submitted to MADE.
88///
89/// `description` is the free-form prompt that agents consume;
90/// `attributes` carries arbitrary, opaque domain data that
91/// MADE does not interpret. The `specialty` selects which
92/// council deliberates.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Task {
95    id: TaskId,
96    specialty: Specialty,
97    description: TaskDescription,
98    constraints: TaskConstraints,
99    attributes: Attributes,
100    external_context: Option<ExternalContextBundle>,
101    #[serde(default)]
102    metadata: TaskMetadata,
103}
104
105/// First-class metadata that travels with a task through deliberation,
106/// execution, and lifecycle events.
107///
108/// The fields are deliberately integration-neutral. Application-owned
109/// identifiers stay in `Task::attributes` or `ExternalContextBundle`
110/// metadata; the core only understands causality and MADE
111/// contract/execution hints.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[serde(default)]
114pub struct TaskMetadata {
115    source_event_id: Option<EventId>,
116    causation_id: Option<EventId>,
117    correlation_id: Option<EventId>,
118    council_contract_id: Option<String>,
119    output_contract_id: Option<String>,
120    execution_profile: Attributes,
121}
122
123impl TaskMetadata {
124    pub fn new(
125        source_event_id: Option<EventId>,
126        causation_id: Option<EventId>,
127        correlation_id: Option<EventId>,
128        council_contract_id: Option<String>,
129        output_contract_id: Option<String>,
130        execution_profile: Attributes,
131    ) -> Result<Self, DomainError> {
132        Ok(Self {
133            source_event_id,
134            causation_id,
135            correlation_id,
136            council_contract_id: validate_optional_text(
137                council_contract_id,
138                "task_metadata.council_contract_id",
139            )?,
140            output_contract_id: validate_optional_text(
141                output_contract_id,
142                "task_metadata.output_contract_id",
143            )?,
144            execution_profile,
145        })
146    }
147
148    #[must_use]
149    pub fn from_trigger_envelope(envelope: &EventEnvelope) -> Self {
150        Self::default().with_trigger_envelope(envelope)
151    }
152
153    #[must_use]
154    pub fn with_trigger_envelope(mut self, envelope: &EventEnvelope) -> Self {
155        if self.source_event_id.is_none() {
156            self.source_event_id = Some(envelope.event_id().clone());
157        }
158        if self.causation_id.is_none() {
159            self.causation_id = envelope
160                .causation_id()
161                .cloned()
162                .or_else(|| Some(envelope.event_id().clone()));
163        }
164        if self.correlation_id.is_none() {
165            self.correlation_id = envelope
166                .correlation_id()
167                .cloned()
168                .or_else(|| Some(envelope.event_id().clone()));
169        }
170        self
171    }
172
173    #[must_use]
174    pub fn source_event_id(&self) -> Option<&EventId> {
175        self.source_event_id.as_ref()
176    }
177
178    #[must_use]
179    pub fn causation_id(&self) -> Option<&EventId> {
180        self.causation_id.as_ref()
181    }
182
183    #[must_use]
184    pub fn correlation_id(&self) -> Option<&EventId> {
185        self.correlation_id.as_ref()
186    }
187
188    #[must_use]
189    pub fn council_contract_id(&self) -> Option<&str> {
190        self.council_contract_id.as_deref()
191    }
192
193    #[must_use]
194    pub fn output_contract_id(&self) -> Option<&str> {
195        self.output_contract_id.as_deref()
196    }
197
198    #[must_use]
199    pub fn execution_profile(&self) -> &Attributes {
200        &self.execution_profile
201    }
202}
203
204impl Task {
205    #[must_use]
206    pub fn new(
207        id: TaskId,
208        specialty: Specialty,
209        description: TaskDescription,
210        constraints: TaskConstraints,
211        attributes: Attributes,
212    ) -> Self {
213        Self::new_with_context(id, specialty, description, constraints, attributes, None)
214    }
215
216    #[must_use]
217    pub fn new_with_context(
218        id: TaskId,
219        specialty: Specialty,
220        description: TaskDescription,
221        constraints: TaskConstraints,
222        attributes: Attributes,
223        external_context: Option<ExternalContextBundle>,
224    ) -> Self {
225        Self::new_with_metadata(
226            id,
227            specialty,
228            description,
229            constraints,
230            attributes,
231            external_context,
232            TaskMetadata::default(),
233        )
234    }
235
236    #[must_use]
237    pub fn new_with_metadata(
238        id: TaskId,
239        specialty: Specialty,
240        description: TaskDescription,
241        constraints: TaskConstraints,
242        attributes: Attributes,
243        external_context: Option<ExternalContextBundle>,
244        metadata: TaskMetadata,
245    ) -> Self {
246        Self {
247            id,
248            specialty,
249            description,
250            constraints,
251            attributes,
252            external_context,
253            metadata,
254        }
255    }
256
257    #[must_use]
258    pub fn id(&self) -> &TaskId {
259        &self.id
260    }
261    #[must_use]
262    pub fn specialty(&self) -> &Specialty {
263        &self.specialty
264    }
265    #[must_use]
266    pub fn description(&self) -> &TaskDescription {
267        &self.description
268    }
269    #[must_use]
270    pub fn constraints(&self) -> &TaskConstraints {
271        &self.constraints
272    }
273    #[must_use]
274    pub fn attributes(&self) -> &Attributes {
275        &self.attributes
276    }
277
278    #[must_use]
279    pub fn external_context(&self) -> Option<&ExternalContextBundle> {
280        self.external_context.as_ref()
281    }
282
283    #[must_use]
284    pub fn metadata(&self) -> &TaskMetadata {
285        &self.metadata
286    }
287}
288
289fn validate_optional_text(
290    value: Option<String>,
291    field: &'static str,
292) -> Result<Option<String>, DomainError> {
293    let Some(value) = value else {
294        return Ok(None);
295    };
296    let trimmed = value.trim();
297    if trimmed.is_empty() {
298        return Ok(None);
299    }
300    if trimmed.len() > MAX_METADATA_TEXT_LEN {
301        return Err(DomainError::FieldTooLong {
302            field,
303            actual: trimmed.len(),
304            max: MAX_METADATA_TEXT_LEN,
305        });
306    }
307    Ok(Some(trimmed.to_owned()))
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn make() -> Task {
315        Task::new(
316            TaskId::new("t1").unwrap(),
317            Specialty::new("triage").unwrap(),
318            TaskDescription::new("investigate alert").unwrap(),
319            TaskConstraints::default(),
320            Attributes::empty(),
321        )
322    }
323
324    #[test]
325    fn default_constraints_are_sane() {
326        let c = TaskConstraints::default();
327        assert_eq!(c.rounds(), Rounds::default());
328        assert!(c.num_agents().is_none());
329        assert!(c.deadline().is_none());
330        assert!(c.rubric().is_empty());
331    }
332
333    #[test]
334    fn task_accessors_return_fields() {
335        let t = make();
336        assert_eq!(t.id().as_str(), "t1");
337        assert_eq!(t.specialty().as_str(), "triage");
338        assert_eq!(t.description().as_str(), "investigate alert");
339        assert!(t.attributes().is_empty());
340        assert!(t.external_context().is_none());
341        assert_eq!(t.metadata(), &TaskMetadata::default());
342    }
343
344    #[test]
345    fn constraints_accepts_optional_bounds() {
346        let c = TaskConstraints::new(
347            Rubric::empty(),
348            Rounds::new(3).unwrap(),
349            Some(NumAgents::new(4).unwrap()),
350            Some(DurationMs::from_millis(1500)),
351        );
352        assert_eq!(c.rounds().get(), 3);
353        assert_eq!(c.num_agents().unwrap().get(), 4);
354        assert_eq!(c.deadline().unwrap().get(), 1500);
355    }
356
357    #[test]
358    fn constraints_can_enable_structured_output_contract() {
359        use crate::value_objects::{OutputFieldRule, OutputFormat};
360        use std::collections::BTreeMap;
361
362        let contract = OutputContract::new(
363            "decision-contract",
364            OutputFormat::JsonObject,
365            BTreeMap::from([(
366                "decision".to_owned(),
367                OutputFieldRule::new(true, ["emit_event", "escalate"]).unwrap(),
368            )]),
369        )
370        .unwrap();
371
372        let constraints = TaskConstraints::default().with_output_contract(contract.clone());
373        assert_eq!(constraints.output_contract(), Some(&contract));
374    }
375
376    #[test]
377    fn empty_json_object_deserializes_to_default_constraints() {
378        let c: TaskConstraints = serde_json::from_str("{}").unwrap();
379        assert_eq!(c, TaskConstraints::default());
380    }
381
382    #[test]
383    fn task_has_no_hardcoded_domain_vocabulary() {
384        // Regression: neutral specialty + neutral description must
385        // form a valid Task.
386        let _ = Task::new(
387            TaskId::new("t-clinical-01").unwrap(),
388            Specialty::new("clinical-intake").unwrap(),
389            TaskDescription::new("classify protocol deviation").unwrap(),
390            TaskConstraints::default(),
391            Attributes::empty(),
392        );
393    }
394
395    #[test]
396    fn metadata_can_be_derived_from_trigger_envelope() {
397        use crate::value_objects::EventId;
398        use time::macros::datetime;
399
400        let envelope = EventEnvelope::new_with_causation(
401            EventId::new("trigger-1").unwrap(),
402            datetime!(2026-04-15 12:00:00 UTC),
403            "pir",
404            Some(EventId::new("corr-1").unwrap()),
405            Some(EventId::new("cause-1").unwrap()),
406        )
407        .unwrap();
408
409        let metadata = TaskMetadata::from_trigger_envelope(&envelope);
410
411        assert_eq!(metadata.source_event_id().unwrap().as_str(), "trigger-1");
412        assert_eq!(metadata.causation_id().unwrap().as_str(), "cause-1");
413        assert_eq!(metadata.correlation_id().unwrap().as_str(), "corr-1");
414    }
415}