1use crate::common::{
4 AgentId, ProjectId, SessionId, TaskId, TenantId, TraceId, ValidationError,
5 deserialize_optional_milliunit, deserialize_schema_version, validate_milliunit,
6 validate_schema_version,
7};
8use crate::experiment::DataClassification;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum TaskComplexity {
15 Unknown,
16 Low,
17 Medium,
18 High,
19 Critical,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum RiskClass {
26 Low,
27 Medium,
28 High,
29 Critical,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct TaskEnvelopeV1 {
36 #[serde(deserialize_with = "deserialize_schema_version")]
37 pub schema_version: u32,
38 pub task_id: TaskId,
39 pub trace_id: TraceId,
40 pub project_id: ProjectId,
41 pub session_id: SessionId,
42 pub agent_id: AgentId,
43 pub complexity: TaskComplexity,
44 pub created_at: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub parent_task_id: Option<TaskId>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub tenant_id: Option<TenantId>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub intent: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub task_class: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub risk_class: Option<RiskClass>,
55 #[serde(
56 default,
57 skip_serializing_if = "Option::is_none",
58 deserialize_with = "deserialize_optional_milliunit"
59 )]
60 pub quality_requirement_milli: Option<u16>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cost_budget_micros: Option<u64>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub latency_budget_ms: Option<u64>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub data_classification: Option<DataClassification>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub region_policy_ref: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub model_policy_ref: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub context_state_ref: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub outcome_contract_ref: Option<String>,
75}
76
77impl TaskEnvelopeV1 {
78 pub const SCHEMA_VERSION: u32 = 1;
80
81 pub fn validate(&self) -> Result<(), ValidationError> {
83 validate_schema_version(self.schema_version)?;
84 if let Some(value) = self.quality_requirement_milli {
85 validate_milliunit(value, "quality_requirement_milli")?;
86 }
87 Ok(())
88 }
89
90 pub fn validate_child_of(&self, parent: &Self) -> Result<(), ValidationError> {
92 self.validate()?;
93 parent.validate()?;
94 if self.parent_task_id.as_ref() != Some(&parent.task_id) {
95 return Err(ValidationError::new(
96 "child parent_task_id does not match the parent task_id",
97 ));
98 }
99 if self.trace_id != parent.trace_id {
100 return Err(ValidationError::new(
101 "child task must retain the parent trace_id",
102 ));
103 }
104 Ok(())
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn id<T>(value: &str) -> T
113 where
114 T: TryFrom<String>,
115 <T as TryFrom<String>>::Error: std::fmt::Debug,
116 {
117 T::try_from(value.to_owned()).expect("identifier should be valid")
118 }
119
120 #[test]
121 fn serialization_round_trip() {
122 let task = TaskEnvelopeV1 {
123 schema_version: 1,
124 task_id: id("task-1"),
125 trace_id: id("trace-1"),
126 project_id: id("project-1"),
127 session_id: id("session-1"),
128 agent_id: id("agent-1"),
129 complexity: TaskComplexity::Medium,
130 created_at: "2026-08-09T12:00:00Z".to_owned(),
131 parent_task_id: None,
132 tenant_id: Some(id("tenant-1")),
133 intent: Some("implement".to_owned()),
134 task_class: Some("coding".to_owned()),
135 risk_class: Some(RiskClass::Low),
136 quality_requirement_milli: Some(900),
137 cost_budget_micros: Some(10_000),
138 latency_budget_ms: Some(2_000),
139 data_classification: Some(DataClassification::Internal),
140 region_policy_ref: Some("policy:region".to_owned()),
141 model_policy_ref: Some("policy:model".to_owned()),
142 context_state_ref: Some("context:state".to_owned()),
143 outcome_contract_ref: Some("contract:outcome".to_owned()),
144 };
145 let json = serde_json::to_string(&task).expect("task should serialize");
146 let decoded: TaskEnvelopeV1 = serde_json::from_str(&json).expect("task should deserialize");
147 assert_eq!(task, decoded);
148 task.validate().expect("task should satisfy invariants");
149 }
150
151 #[test]
152 fn quality_requirement_is_bounded() {
153 let json = r#"{
154 "schema_version": 1,
155 "task_id": "task-1",
156 "trace_id": "trace-1",
157 "project_id": "project-1",
158 "session_id": "session-1",
159 "agent_id": "agent-1",
160 "complexity": "low",
161 "created_at": "2026-08-09T12:00:00Z",
162 "quality_requirement_milli": 1001
163 }"#;
164 assert!(serde_json::from_str::<TaskEnvelopeV1>(json).is_err());
165 }
166}