1use serde::{Deserialize, Serialize};
11
12use crate::error::{Result, SdkError};
13use crate::model::ModelId;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct ControlFrame {
18 #[serde(default)]
20 pub projection_digest: String,
21 #[serde(default)]
23 pub event_schema_version: u32,
24 #[serde(default)]
28 pub prompt_invocation_digest: String,
29 #[serde(default)]
30 pub prompt_manifest_digest: String,
31 #[serde(default)]
34 pub resident_context_digest: String,
35 #[serde(default)]
39 pub node_id: String,
40 pub goal: String,
41 pub node_generation: u32,
42 pub accepted_state_root: String,
43 pub graph_revision: String,
44 pub capability_ids: Vec<String>,
45 pub authority_epoch: u64,
46 pub remaining_rejection_budget: u32,
47 pub remaining_turns: u32,
48 pub active_model: ModelId,
51 pub remaining_fallback_models: Vec<ModelId>,
52 pub activated_tools: Vec<String>,
54 pub unresolved_call_ids: Vec<String>,
57 pub residual_summary: Vec<(String, f64)>,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct ContextCheckpoint {
64 pub parent: Option<String>,
65 pub covered_from: u64,
66 pub covered_to: u64,
67 pub covered_event_root: String,
69 pub control: ControlFrame,
70 pub artifact_refs: Vec<String>,
72 pub narrative_observation: Option<String>,
75}
76
77impl ContextCheckpoint {
78 pub fn validate_against(
82 &self,
83 live_accepted_root: &str,
84 live_graph_revision: &str,
85 live_authority_epoch: u64,
86 ) -> Result<()> {
87 if self.covered_from > self.covered_to {
88 return Err(SdkError::Domain(
89 "checkpoint covers an inverted range".into(),
90 ));
91 }
92 if self.control.accepted_state_root != live_accepted_root {
93 return Err(SdkError::Domain(
94 "stale checkpoint: accepted state root changed; rebuild it".into(),
95 ));
96 }
97 if self.control.graph_revision != live_graph_revision {
98 return Err(SdkError::Domain(
99 "stale checkpoint: graph revision changed; rebuild it".into(),
100 ));
101 }
102 if self.control.authority_epoch != live_authority_epoch {
103 return Err(SdkError::Domain(
104 "stale checkpoint: authority epoch changed; rebuild it".into(),
105 ));
106 }
107 Ok(())
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn checkpoint() -> ContextCheckpoint {
116 ContextCheckpoint {
117 parent: None,
118 covered_from: 0,
119 covered_to: 41,
120 covered_event_root: "head".into(),
121 control: ControlFrame {
122 projection_digest: "projection".into(),
123 prompt_invocation_digest: String::new(),
124 prompt_manifest_digest: String::new(),
125 resident_context_digest: String::new(),
126 node_id: "implement-1".into(),
127 event_schema_version: crate::CONVERSATION_EVENT_SCHEMA_VERSION,
128 goal: "fix the build".into(),
129 node_generation: 2,
130 accepted_state_root: "root-a".into(),
131 graph_revision: "rev-3".into(),
132 capability_ids: vec!["cap-1".into()],
133 authority_epoch: 7,
134 remaining_rejection_budget: 2,
135 remaining_turns: 5,
136 active_model: ModelId::new("test", "model"),
137 remaining_fallback_models: vec![ModelId::new("test", "fallback")],
138 activated_tools: vec!["read_file".into()],
139 unresolved_call_ids: vec!["c9".into()],
140 residual_summary: vec![("build".into(), 2.0)],
141 },
142 artifact_refs: vec![],
143 narrative_observation: None,
144 }
145 }
146
147 #[test]
148 fn a_current_checkpoint_validates() {
149 assert!(checkpoint().validate_against("root-a", "rev-3", 7).is_ok());
150 }
151
152 #[test]
153 fn any_staleness_axis_rejects_the_checkpoint() {
154 let c = checkpoint();
155 assert!(c.validate_against("root-b", "rev-3", 7).is_err());
156 assert!(c.validate_against("root-a", "rev-4", 7).is_err());
157 assert!(c.validate_against("root-a", "rev-3", 8).is_err());
158 }
159
160 #[test]
161 fn unresolved_calls_survive_the_projection() {
162 let json = serde_json::to_string(&checkpoint()).unwrap();
163 let back: ContextCheckpoint = serde_json::from_str(&json).unwrap();
164 assert_eq!(back.control.unresolved_call_ids, ["c9"]);
165 }
166}