Skip to main content

perspt_sdk/
checkpoint.rs

1//! Context checkpoints (PSP-9 system 14).
2//!
3//! Conversation compaction is a **recorded context projection, never ledger
4//! deletion**: the model context is rebuilt from the immutable ledger, and a
5//! deterministic [`ControlFrame`] is always inserted verbatim — it is never
6//! summarized. An optional cheap-model narrative improves recall but is an
7//! untrusted recorded observation; it cannot supply a state witness,
8//! authority, tool result, or verifier verdict.
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Result, SdkError};
13use crate::model::ModelId;
14
15/// The verbatim, never-summarized control state of a compacted conversation.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct ControlFrame {
18    /// Canonical digest of the ledger-folded conversation projection.
19    #[serde(default)]
20    pub projection_digest: String,
21    /// Schema version used by conversation seed/delta events.
22    #[serde(default)]
23    pub event_schema_version: u32,
24    /// PSP-10 prompt provenance (Gate Z). Empty marks a pre-PSP-10
25    /// checkpoint; a nonempty value requires exact reconstruction from
26    /// built-ins or pinned bundle artifacts on resume.
27    #[serde(default)]
28    pub prompt_invocation_digest: String,
29    #[serde(default)]
30    pub prompt_manifest_digest: String,
31    /// Resident-context digest (Gate AF); empty until the paged assembler
32    /// runs per turn.
33    #[serde(default)]
34    pub resident_context_digest: String,
35    /// The work-graph node this checkpoint belongs to. Empty marks a
36    /// legacy checkpoint; resume falls back to the historical single-node
37    /// identity for those.
38    #[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    /// Active route after sticky failover and its unconsumed fallback suffix.
49    /// Resume must not silently refill or reroute this recovery state.
50    pub active_model: ModelId,
51    pub remaining_fallback_models: Vec<ModelId>,
52    /// Deferred tools activated by governed discovery before this checkpoint.
53    pub activated_tools: Vec<String>,
54    /// Tool calls whose results are still outstanding; a checkpoint must
55    /// preserve these exactly.
56    pub unresolved_call_ids: Vec<String>,
57    /// The current residual vector, by class name and magnitude.
58    pub residual_summary: Vec<(String, f64)>,
59}
60
61/// A provider-neutral context checkpoint over a covered event range.
62#[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    /// Chain head over the covered range.
68    pub covered_event_root: String,
69    pub control: ControlFrame,
70    /// Content-addressed artifact references the projection keeps.
71    pub artifact_refs: Vec<String>,
72    /// Optional narrative summary, as a recorded-observation handle. It is
73    /// untrusted and can never substitute for control-frame facts.
74    pub narrative_observation: Option<String>,
75}
76
77impl ContextCheckpoint {
78    /// Structural validation before use (PSP-9 system 14): range sanity,
79    /// and equality of the live accepted root, revision, and epoch. A stale
80    /// checkpoint is rebuilt, never patched by a model.
81    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}