1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
//! Context checkpoints (PSP-9 system 14).
//!
//! Conversation compaction is a **recorded context projection, never ledger
//! deletion**: the model context is rebuilt from the immutable ledger, and a
//! deterministic [`ControlFrame`] is always inserted verbatim — it is never
//! summarized. An optional cheap-model narrative improves recall but is an
//! untrusted recorded observation; it cannot supply a state witness,
//! authority, tool result, or verifier verdict.
use serde::{Deserialize, Serialize};
use crate::error::{Result, SdkError};
use crate::model::ModelId;
/// The verbatim, never-summarized control state of a compacted conversation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ControlFrame {
/// Canonical digest of the ledger-folded conversation projection.
#[serde(default)]
pub projection_digest: String,
/// Schema version used by conversation seed/delta events.
#[serde(default)]
pub event_schema_version: u32,
/// PSP-10 prompt provenance (Gate Z). Empty marks a pre-PSP-10
/// checkpoint; a nonempty value requires exact reconstruction from
/// built-ins or pinned bundle artifacts on resume.
#[serde(default)]
pub prompt_invocation_digest: String,
#[serde(default)]
pub prompt_manifest_digest: String,
/// Resident-context digest (Gate AF); empty until the paged assembler
/// runs per turn.
#[serde(default)]
pub resident_context_digest: String,
/// The work-graph node this checkpoint belongs to. Empty marks a
/// legacy checkpoint; resume falls back to the historical single-node
/// identity for those.
#[serde(default)]
pub node_id: String,
pub goal: String,
pub node_generation: u32,
pub accepted_state_root: String,
pub graph_revision: String,
pub capability_ids: Vec<String>,
pub authority_epoch: u64,
pub remaining_rejection_budget: u32,
pub remaining_turns: u32,
/// Active route after sticky failover and its unconsumed fallback suffix.
/// Resume must not silently refill or reroute this recovery state.
pub active_model: ModelId,
pub remaining_fallback_models: Vec<ModelId>,
/// Deferred tools activated by governed discovery before this checkpoint.
pub activated_tools: Vec<String>,
/// Tool calls whose results are still outstanding; a checkpoint must
/// preserve these exactly.
pub unresolved_call_ids: Vec<String>,
/// The current residual vector, by class name and magnitude.
pub residual_summary: Vec<(String, f64)>,
}
/// A provider-neutral context checkpoint over a covered event range.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContextCheckpoint {
pub parent: Option<String>,
pub covered_from: u64,
pub covered_to: u64,
/// Chain head over the covered range.
pub covered_event_root: String,
pub control: ControlFrame,
/// Content-addressed artifact references the projection keeps.
pub artifact_refs: Vec<String>,
/// Optional narrative summary, as a recorded-observation handle. It is
/// untrusted and can never substitute for control-frame facts.
pub narrative_observation: Option<String>,
}
impl ContextCheckpoint {
/// Structural validation before use (PSP-9 system 14): range sanity,
/// and equality of the live accepted root, revision, and epoch. A stale
/// checkpoint is rebuilt, never patched by a model.
pub fn validate_against(
&self,
live_accepted_root: &str,
live_graph_revision: &str,
live_authority_epoch: u64,
) -> Result<()> {
if self.covered_from > self.covered_to {
return Err(SdkError::Domain(
"checkpoint covers an inverted range".into(),
));
}
if self.control.accepted_state_root != live_accepted_root {
return Err(SdkError::Domain(
"stale checkpoint: accepted state root changed; rebuild it".into(),
));
}
if self.control.graph_revision != live_graph_revision {
return Err(SdkError::Domain(
"stale checkpoint: graph revision changed; rebuild it".into(),
));
}
if self.control.authority_epoch != live_authority_epoch {
return Err(SdkError::Domain(
"stale checkpoint: authority epoch changed; rebuild it".into(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn checkpoint() -> ContextCheckpoint {
ContextCheckpoint {
parent: None,
covered_from: 0,
covered_to: 41,
covered_event_root: "head".into(),
control: ControlFrame {
projection_digest: "projection".into(),
prompt_invocation_digest: String::new(),
prompt_manifest_digest: String::new(),
resident_context_digest: String::new(),
node_id: "implement-1".into(),
event_schema_version: crate::CONVERSATION_EVENT_SCHEMA_VERSION,
goal: "fix the build".into(),
node_generation: 2,
accepted_state_root: "root-a".into(),
graph_revision: "rev-3".into(),
capability_ids: vec!["cap-1".into()],
authority_epoch: 7,
remaining_rejection_budget: 2,
remaining_turns: 5,
active_model: ModelId::new("test", "model"),
remaining_fallback_models: vec![ModelId::new("test", "fallback")],
activated_tools: vec!["read_file".into()],
unresolved_call_ids: vec!["c9".into()],
residual_summary: vec![("build".into(), 2.0)],
},
artifact_refs: vec![],
narrative_observation: None,
}
}
#[test]
fn a_current_checkpoint_validates() {
assert!(checkpoint().validate_against("root-a", "rev-3", 7).is_ok());
}
#[test]
fn any_staleness_axis_rejects_the_checkpoint() {
let c = checkpoint();
assert!(c.validate_against("root-b", "rev-3", 7).is_err());
assert!(c.validate_against("root-a", "rev-4", 7).is_err());
assert!(c.validate_against("root-a", "rev-3", 8).is_err());
}
#[test]
fn unresolved_calls_survive_the_projection() {
let json = serde_json::to_string(&checkpoint()).unwrap();
let back: ContextCheckpoint = serde_json::from_str(&json).unwrap();
assert_eq!(back.control.unresolved_call_ids, ["c9"]);
}
}