1use 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#[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#[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#[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 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}