Skip to main content

lean_ctx_protocol/
task.rs

1//! Task admission and lineage contract.
2
3use 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/// Coarse task complexity used by policy and scheduling decisions.
12#[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/// Risk class used for safety-sensitive routing.
23#[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/// Canonical task envelope for a V1 execution lineage.
33#[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    /// Unknown additive V1 fields retained for lossless forwarding.
75    #[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    /// Schema version represented by this type.
105    pub const SCHEMA_VERSION: u32 = 1;
106
107    /// Compact UTF-8 JSON with recursively sorted object keys. These bytes
108    /// retain all additive fields and identify the complete task, not just its ID.
109    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    /// Validate invariants that also apply to values constructed in Rust.
118    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    /// Validate that this envelope is a child of `parent` in the same trace.
146    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}