Skip to main content

lean_ctx_protocol/
context_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Deterministic wire projection of the context kernel's semantic plan.
4
5use 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/// Stable disposition assigned to a context candidate by the semantic planner.
17#[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/// Stable, machine-readable reason for a context selection decision.
26#[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/// One selected, excluded, or deferred context object.
40#[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/// Deterministic aggregate accounting for one context provider.
55#[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/// Public projection of a context plan. The context kernel remains semantic owner.
64#[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    /// Optional SHA-256 digest of the canonical projection without this field.
71    /// Old V1 readers may omit it; new projectors always populate it.
72    #[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    /// Compute the SHA-256 digest over canonical projection content.
100    ///
101    /// The digest field is excluded from its own input, allowing consumers to
102    /// verify a projection without a second unsigned envelope.
103    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    /// Validate projection bounds, joins, and deterministic uniqueness invariants.
121    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        // The golden includes the nested selection's canonical object order.
212        assert_eq!(
213            projection.compute_projection_digest().unwrap(),
214            Sha256Digest::new(
215                "sha256:4df9c96aec0ef95ac5fda9d1d1cf6a826c3ea8acbfeaa254b64d02394740e568"
216                    .to_owned(),
217            )
218            .unwrap()
219        );
220    }
221}