1use crate::common::{
6 ContextPlanId, ExtensionsV1, TaskId, ValidationError, deserialize_schema_version,
7 validate_bounded_string, validate_schema_version,
8};
9use crate::entitlement::sort_json;
10use crate::evidence::EvidenceRefV1;
11use crate::identity::Sha256Digest;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::{BTreeMap, BTreeSet};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ContextDispositionV1 {
20 Selected,
21 Excluded,
22 Deferred,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ContextReasonCodeV1 {
29 Relevant,
30 Required,
31 CacheHit,
32 BudgetExceeded,
33 LowerUtility,
34 PolicyExcluded,
35 DeferredForLater,
36 Other,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct ContextSelectionV1 {
43 pub source_ref: String,
44 pub provider: String,
45 pub disposition: ContextDispositionV1,
46 pub token_count: u64,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub sha256_digest: Option<String>,
49 pub reason_codes: Vec<ContextReasonCodeV1>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub reason_detail: Option<String>,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ContextProviderStatsV1 {
58 pub candidates_offered: u64,
59 pub candidates_selected: u64,
60 pub tokens_used: u64,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ContextPlanProjectionV1 {
66 #[serde(deserialize_with = "deserialize_schema_version")]
67 pub schema_version: u32,
68 pub context_plan_id: ContextPlanId,
69 pub task_id: TaskId,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub projection_digest: Option<Sha256Digest>,
74 pub budget_tokens: u64,
75 pub selections: Vec<ContextSelectionV1>,
76 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
77 pub provider_stats: BTreeMap<String, ContextProviderStatsV1>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub policy_decision_refs: Vec<String>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub evidence: Vec<EvidenceRefV1>,
82 #[serde(default, flatten)]
83 pub extensions: ExtensionsV1,
84}
85
86const CONTEXT_PLAN_RESERVED_FIELDS: &[&str] = &[
87 "schema_version",
88 "context_plan_id",
89 "task_id",
90 "projection_digest",
91 "budget_tokens",
92 "selections",
93 "provider_stats",
94 "policy_decision_refs",
95 "evidence",
96];
97
98impl ContextPlanProjectionV1 {
99 pub fn compute_projection_digest(&self) -> Result<Sha256Digest, ValidationError> {
104 let mut value = serde_json::to_value(self).map_err(|error| {
105 ValidationError::new(format!("serialize context projection: {error}"))
106 })?;
107 let object = value.as_object_mut().ok_or_else(|| {
108 ValidationError::new("context projection must serialize as an object")
109 })?;
110 object.remove("projection_digest");
111 let bytes = serde_json::to_vec(&sort_json(value))
112 .map_err(|error| ValidationError::new(format!("encode context projection: {error}")))?;
113 let hex = Sha256::digest(bytes)
114 .iter()
115 .map(|byte| format!("{byte:02x}"))
116 .collect::<String>();
117 Sha256Digest::new(format!("sha256:{hex}"))
118 }
119
120 pub fn validate(&self) -> Result<(), ValidationError> {
122 if let Some(actual_digest) = &self.projection_digest {
123 let expected_digest = self.compute_projection_digest()?;
124 if actual_digest != &expected_digest {
125 return Err(ValidationError::new(
126 "projection_digest does not match canonical projection content",
127 ));
128 }
129 }
130 self.extensions
131 .validate_reserved(CONTEXT_PLAN_RESERVED_FIELDS)?;
132 validate_schema_version(self.schema_version)?;
133 if self.selections.len() > crate::MAX_PROTOCOL_ITEMS {
134 return Err(ValidationError::new("selections exceeds item limit"));
135 }
136 let mut sources = BTreeSet::new();
137 let mut selected_tokens = 0_u64;
138 for selection in &self.selections {
139 validate_bounded_string(&selection.source_ref, "source_ref")?;
140 validate_bounded_string(&selection.provider, "provider")?;
141 if !sources.insert(selection.source_ref.as_str()) {
142 return Err(ValidationError::new("duplicate context source_ref"));
143 }
144 if selection.reason_codes.is_empty() {
145 return Err(ValidationError::new(
146 "context selection requires a reason_code",
147 ));
148 }
149 if selection.reason_codes.len() > crate::MAX_PROTOCOL_ITEMS
150 || selection.reason_codes.iter().collect::<BTreeSet<_>>().len()
151 != selection.reason_codes.len()
152 {
153 return Err(ValidationError::new(
154 "context reason_codes exceeds item limit or contains duplicates",
155 ));
156 }
157 if let Some(detail) = &selection.reason_detail {
158 validate_bounded_string(detail, "reason_detail")?;
159 }
160 if let Some(digest) = &selection.sha256_digest {
161 let digest = digest.strip_prefix("sha256:").unwrap_or(digest);
162 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
163 return Err(ValidationError::new("sha256_digest must be SHA-256"));
164 }
165 }
166 if selection.disposition == ContextDispositionV1::Selected {
167 selected_tokens = selected_tokens
168 .checked_add(selection.token_count)
169 .ok_or_else(|| ValidationError::new("selected token count overflow"))?;
170 }
171 }
172 if selected_tokens > self.budget_tokens {
173 return Err(ValidationError::new(
174 "selected context exceeds budget_tokens",
175 ));
176 }
177 if self.provider_stats.len() > crate::MAX_PROTOCOL_ITEMS {
178 return Err(ValidationError::new("provider_stats exceeds item limit"));
179 }
180 for (provider, stats) in &self.provider_stats {
181 validate_bounded_string(provider, "provider_stats key")?;
182 if stats.candidates_selected > stats.candidates_offered {
183 return Err(ValidationError::new(
184 "provider candidates_selected exceeds candidates_offered",
185 ));
186 }
187 }
188 crate::validate_unique_strings(&self.policy_decision_refs, "policy_decision_refs")?;
189 if self.evidence.len() > crate::MAX_PROTOCOL_ITEMS {
190 return Err(ValidationError::new("evidence exceeds item limit"));
191 }
192 for evidence in &self.evidence {
193 evidence.validate()?;
194 }
195 Ok(())
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::ContextPlanProjectionV1;
202 use crate::Sha256Digest;
203
204 #[test]
205 fn projection_digest_is_canonical_across_serde_json_map_features() {
206 let projection: ContextPlanProjectionV1 = serde_json::from_str(
207 r#"{"budget_tokens":0,"context_plan_id":"plan-1","schema_version":1,"selections":[{"disposition":"deferred","provider":"provider-1","reason_codes":["deferred_for_later"],"source_ref":"source-1","token_count":0}],"task_id":"task-1"}"#,
208 )
209 .unwrap();
210
211 assert_eq!(
213 projection.compute_projection_digest().unwrap(),
214 Sha256Digest::new(
215 "sha256:4df9c96aec0ef95ac5fda9d1d1cf6a826c3ea8acbfeaa254b64d02394740e568"
216 .to_owned(),
217 )
218 .unwrap()
219 );
220 }
221}