1use crate::common::{
4 AgentId, ExtensionsV1, 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)]
34pub struct TaskEnvelopeV1 {
35 #[serde(deserialize_with = "deserialize_schema_version")]
36 pub schema_version: u32,
37 pub task_id: TaskId,
38 pub trace_id: TraceId,
39 pub project_id: ProjectId,
40 pub session_id: SessionId,
41 pub agent_id: AgentId,
42 pub complexity: TaskComplexity,
43 pub created_at: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub parent_task_id: Option<TaskId>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub tenant_id: Option<TenantId>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub intent: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub task_class: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub risk_class: Option<RiskClass>,
54 #[serde(
55 default,
56 skip_serializing_if = "Option::is_none",
57 deserialize_with = "deserialize_optional_milliunit"
58 )]
59 pub quality_requirement_milli: Option<u16>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub cost_budget_micros: Option<u64>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub latency_budget_ms: Option<u64>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub data_classification: Option<DataClassification>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub region_policy_ref: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub model_policy_ref: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub context_state_ref: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub outcome_contract_ref: Option<String>,
74 #[serde(default, flatten)]
76 pub extensions: ExtensionsV1,
77}
78
79const TASK_RESERVED_FIELDS: &[&str] = &[
80 "schema_version",
81 "task_id",
82 "trace_id",
83 "project_id",
84 "session_id",
85 "agent_id",
86 "complexity",
87 "created_at",
88 "parent_task_id",
89 "tenant_id",
90 "intent",
91 "task_class",
92 "risk_class",
93 "quality_requirement_milli",
94 "cost_budget_micros",
95 "latency_budget_ms",
96 "data_classification",
97 "region_policy_ref",
98 "model_policy_ref",
99 "context_state_ref",
100 "outcome_contract_ref",
101];
102
103impl TaskEnvelopeV1 {
104 pub const SCHEMA_VERSION: u32 = 1;
106
107 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
110 self.validate()?;
111 let value = serde_json::to_value(self)
112 .map_err(|error| ValidationError::new(format!("serialize task envelope: {error}")))?;
113 serde_json::to_vec(&crate::receipt_document::sort_json(value))
114 .map_err(|error| ValidationError::new(format!("canonicalize task envelope: {error}")))
115 }
116
117 pub fn validate(&self) -> Result<(), ValidationError> {
119 self.extensions.validate_reserved(TASK_RESERVED_FIELDS)?;
120 validate_schema_version(self.schema_version)?;
121 if self.parent_task_id.as_ref() == Some(&self.task_id) {
122 return Err(ValidationError::new("task cannot be its own parent"));
123 }
124 if let Some(value) = self.quality_requirement_milli {
125 validate_milliunit(value, "quality_requirement_milli")?;
126 }
127 if self.parent_task_id.as_ref() == Some(&self.task_id) {
128 return Err(ValidationError::new("task cannot be its own parent"));
129 }
130 for (value, field) in [
131 (&self.intent, "intent"),
132 (&self.task_class, "task_class"),
133 (&self.region_policy_ref, "region_policy_ref"),
134 (&self.model_policy_ref, "model_policy_ref"),
135 (&self.context_state_ref, "context_state_ref"),
136 (&self.outcome_contract_ref, "outcome_contract_ref"),
137 ] {
138 if let Some(value) = value {
139 crate::validate_bounded_string(value, field)?;
140 }
141 }
142 Ok(())
143 }
144
145 pub fn validate_child_of(&self, parent: &Self) -> Result<(), ValidationError> {
147 self.validate()?;
148 parent.validate()?;
149 if self.parent_task_id.as_ref() != Some(&parent.task_id) {
150 return Err(ValidationError::new(
151 "child parent_task_id does not match the parent task_id",
152 ));
153 }
154 if self.trace_id != parent.trace_id {
155 return Err(ValidationError::new(
156 "child task must retain the parent trace_id",
157 ));
158 }
159 Ok(())
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn id<T>(value: &str) -> T
168 where
169 T: TryFrom<String>,
170 <T as TryFrom<String>>::Error: std::fmt::Debug,
171 {
172 T::try_from(value.to_owned()).expect("identifier should be valid")
173 }
174
175 #[test]
176 fn serialization_round_trip() {
177 let task = TaskEnvelopeV1 {
178 schema_version: 1,
179 task_id: id("task-1"),
180 trace_id: id("trace-1"),
181 project_id: id("project-1"),
182 session_id: id("session-1"),
183 agent_id: id("agent-1"),
184 complexity: TaskComplexity::Medium,
185 created_at: "2026-08-09T12:00:00Z".to_owned(),
186 parent_task_id: None,
187 tenant_id: Some(id("tenant-1")),
188 intent: Some("implement".to_owned()),
189 task_class: Some("coding".to_owned()),
190 risk_class: Some(RiskClass::Low),
191 quality_requirement_milli: Some(900),
192 cost_budget_micros: Some(10_000),
193 latency_budget_ms: Some(2_000),
194 data_classification: Some(DataClassification::Internal),
195 region_policy_ref: Some("policy:region".to_owned()),
196 model_policy_ref: Some("policy:model".to_owned()),
197 context_state_ref: Some("context:state".to_owned()),
198 outcome_contract_ref: Some("contract:outcome".to_owned()),
199 extensions: Default::default(),
200 };
201 let json = serde_json::to_string(&task).expect("task should serialize");
202 let decoded: TaskEnvelopeV1 = serde_json::from_str(&json).expect("task should deserialize");
203 assert_eq!(task, decoded);
204 task.validate().expect("task should satisfy invariants");
205 }
206
207 #[test]
208 fn quality_requirement_is_bounded() {
209 let json = r#"{
210 "schema_version": 1,
211 "task_id": "task-1",
212 "trace_id": "trace-1",
213 "project_id": "project-1",
214 "session_id": "session-1",
215 "agent_id": "agent-1",
216 "complexity": "low",
217 "created_at": "2026-08-09T12:00:00Z",
218 "quality_requirement_milli": 1001
219 }"#;
220 assert!(serde_json::from_str::<TaskEnvelopeV1>(json).is_err());
221 }
222
223 #[test]
224 fn extension_reservation_is_scoped_to_this_dto() {
225 let mut task: TaskEnvelopeV1 = serde_json::from_value(serde_json::json!({
226 "schema_version": 1,
227 "task_id": "task-1",
228 "trace_id": "trace-1",
229 "project_id": "project-1",
230 "session_id": "session-1",
231 "agent_id": "agent-1",
232 "complexity": "low",
233 "created_at": "2026-08-09T12:00:00Z"
234 }))
235 .expect("task should deserialize");
236 task.extensions
237 .insert("provider", serde_json::Value::from("future"))
238 .expect("provider is not a task field");
239 task.validate()
240 .expect("cross-DTO key should remain available");
241 task.extensions
242 .insert("task_id", serde_json::Value::from("shadow"))
243 .expect("insert is checked by the owning DTO");
244 assert!(task.validate().is_err());
245 }
246
247 #[test]
248 fn self_parent_is_rejected_by_task_and_child_validation() {
249 let mut task: TaskEnvelopeV1 = serde_json::from_value(serde_json::json!({
250 "schema_version": 1,
251 "task_id": "task-1",
252 "trace_id": "trace-1",
253 "project_id": "project-1",
254 "session_id": "session-1",
255 "agent_id": "agent-1",
256 "complexity": "low",
257 "created_at": "2026-08-09T12:00:00Z"
258 }))
259 .expect("valid fixture");
260 let parent = task.clone();
261 task.parent_task_id = Some(task.task_id.clone());
262 assert!(task.validate().is_err());
263 assert!(task.validate_child_of(&parent).is_err());
264
265 task.task_id = id("task-2");
266 assert!(task.validate_child_of(&parent).is_ok());
267
268 let mut invalid_parent = parent;
269 invalid_parent.parent_task_id = Some(invalid_parent.task_id.clone());
270 assert!(task.validate_child_of(&invalid_parent).is_err());
271 }
272}