Skip to main content

lean_ctx_protocol/
task.rs

1//! Task admission and lineage contract.
2
3use 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/// 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)]
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    /// Schema version represented by this type.
79    pub const SCHEMA_VERSION: u32 = 1;
80
81    /// Validate invariants that also apply to values constructed in Rust.
82    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    /// Validate that this envelope is a child of `parent` in the same trace.
91    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}