1use crate::loop_checkpoint::{LoopCheckpoint, LOOP_CHECKPOINT_SCHEMA_VERSION};
11use crate::store::{SessionSnapshotV1, SESSION_SNAPSHOT_SCHEMA_VERSION};
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use self::codec::{
16 domain_digest, invalid_descriptor, validate_content_identity, validate_id, validate_sha256,
17};
18
19pub const SESSION_CHECKPOINT_DESCRIPTOR_SCHEMA_V1: &str =
20 "a3s.code.session-checkpoint-descriptor.v1";
21pub const SESSION_CHECKPOINT_PAYLOAD_SCHEMA_V1: &str = "a3s.code.session-checkpoint-payload.v1";
22pub const SESSION_SNAPSHOT_EVIDENCE_SCHEMA_V1: &str = "a3s.code.session-snapshot-evidence.v1";
23pub const SESSION_LOGICAL_RESUME_EVIDENCE_SCHEMA_V1: &str = "a3s.code.logical-resume-evidence.v1";
24pub const SESSION_CHECKPOINT_FORMAT_V1: &str = "a3s_code_session_checkpoint_v1";
25pub const SESSION_CHECKPOINT_MEDIA_TYPE_V1: &str =
26 "application/vnd.a3s.code.session-checkpoint.v1+json";
27pub const SESSION_CHECKPOINT_ENCODING_V1: &str = "canonical_json_v1";
28pub const SESSION_CHECKPOINT_LOGICAL_RESUME_SEMANTICS_V1: &str = "between_tool_rounds_v1";
29pub const SESSION_CHECKPOINT_MAX_CONTENT_BYTES: u64 = 256 * 1024 * 1024;
30
31const SNAPSHOT_EVIDENCE_DIGEST_DOMAIN_V1: &str = "a3s.code.session-snapshot-evidence-digest.v1";
32const LOGICAL_RESUME_EVIDENCE_DIGEST_DOMAIN_V1: &str = "a3s.code.logical-resume-evidence-digest.v1";
33const CHECKPOINT_DESCRIPTOR_DIGEST_DOMAIN_V1: &str =
34 "a3s.code.session-checkpoint-descriptor-digest.v1";
35pub(super) const MAX_ID_BYTES: usize = 256;
36
37#[derive(Debug, Clone, PartialEq, Eq, Error)]
38pub enum SessionCheckpointError {
39 #[error("invalid session checkpoint descriptor: {0}")]
40 InvalidDescriptor(String),
41 #[error("invalid session checkpoint payload: {0}")]
42 InvalidPayload(String),
43 #[error("session checkpoint content drift: {0}")]
44 ContentDrift(String),
45 #[error("session checkpoint encoding failed: {0}")]
46 Encoding(String),
47}
48
49pub type SessionCheckpointResult<T> = std::result::Result<T, SessionCheckpointError>;
50
51impl SessionCheckpointError {
52 pub const fn code(&self) -> &'static str {
54 match self {
55 Self::InvalidDescriptor(_) => "a3s.code.session_checkpoint.invalid_descriptor",
56 Self::InvalidPayload(_) => "a3s.code.session_checkpoint.invalid_payload",
57 Self::ContentDrift(_) => "a3s.code.session_checkpoint.content_drift",
58 Self::Encoding(_) => "a3s.code.session_checkpoint.encoding",
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct SessionSnapshotEvidenceV1 {
67 pub schema: String,
68 pub encoding: String,
69 pub session_id: String,
70 pub snapshot_schema_version: u32,
71 pub size_bytes: u64,
72 pub content_digest: String,
73 pub evidence_digest: String,
74}
75
76impl SessionSnapshotEvidenceV1 {
77 pub fn from_snapshot(snapshot: &SessionSnapshotV1) -> SessionCheckpointResult<Self> {
78 codec::snapshot_evidence(snapshot)
79 }
80
81 pub fn validate(&self) -> SessionCheckpointResult<()> {
82 if self.schema != SESSION_SNAPSHOT_EVIDENCE_SCHEMA_V1 {
83 return Err(invalid_descriptor(
84 "snapshot evidence schema is unsupported",
85 ));
86 }
87 if self.encoding != SESSION_CHECKPOINT_ENCODING_V1 {
88 return Err(invalid_descriptor("snapshot encoding is unsupported"));
89 }
90 validate_id("snapshot session_id", &self.session_id)?;
91 if self.snapshot_schema_version != SESSION_SNAPSHOT_SCHEMA_VERSION {
92 return Err(invalid_descriptor(
93 "snapshot schema version is not the exact portable v1 version",
94 ));
95 }
96 validate_content_identity(self.size_bytes, &self.content_digest)?;
97 validate_sha256("snapshot evidence_digest", &self.evidence_digest)?;
98 if self.evidence_digest != self.expected_digest()? {
99 return Err(invalid_descriptor(
100 "snapshot evidence digest does not bind the exact snapshot identity",
101 ));
102 }
103 Ok(())
104 }
105
106 pub fn validate_for(&self, snapshot: &SessionSnapshotV1) -> SessionCheckpointResult<()> {
107 self.validate()?;
108 if self != &Self::from_snapshot(snapshot)? {
109 return Err(SessionCheckpointError::ContentDrift(
110 "snapshot evidence does not match the exact supplied Session snapshot".into(),
111 ));
112 }
113 Ok(())
114 }
115
116 fn expected_digest(&self) -> SessionCheckpointResult<String> {
117 #[derive(Serialize)]
118 struct DigestInput<'a> {
119 schema: &'a str,
120 encoding: &'a str,
121 session_id: &'a str,
122 snapshot_schema_version: u32,
123 size_bytes: u64,
124 content_digest: &'a str,
125 }
126
127 domain_digest(
128 SNAPSHOT_EVIDENCE_DIGEST_DOMAIN_V1,
129 &DigestInput {
130 schema: &self.schema,
131 encoding: &self.encoding,
132 session_id: &self.session_id,
133 snapshot_schema_version: self.snapshot_schema_version,
134 size_bytes: self.size_bytes,
135 content_digest: &self.content_digest,
136 },
137 )
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct SessionLogicalResumeEvidenceV1 {
145 pub schema: String,
146 pub resume_semantics: String,
147 pub session_id: String,
148 pub source_run_id: String,
149 pub checkpoint_schema_version: u32,
150 pub completed_tool_rounds: u64,
151 pub checkpoint_ms: u64,
152 pub size_bytes: u64,
153 pub content_digest: String,
154 pub evidence_digest: String,
155}
156
157impl SessionLogicalResumeEvidenceV1 {
158 pub fn from_checkpoint(checkpoint: &LoopCheckpoint) -> SessionCheckpointResult<Self> {
159 codec::logical_resume_evidence(checkpoint)
160 }
161
162 pub fn validate(&self) -> SessionCheckpointResult<()> {
163 if self.schema != SESSION_LOGICAL_RESUME_EVIDENCE_SCHEMA_V1 {
164 return Err(invalid_descriptor(
165 "logical-resume evidence schema is unsupported",
166 ));
167 }
168 if self.resume_semantics != SESSION_CHECKPOINT_LOGICAL_RESUME_SEMANTICS_V1 {
169 return Err(invalid_descriptor(
170 "logical-resume semantics are unsupported",
171 ));
172 }
173 validate_id("logical-resume session_id", &self.session_id)?;
174 validate_id("logical-resume source_run_id", &self.source_run_id)?;
175 if self.checkpoint_schema_version != LOOP_CHECKPOINT_SCHEMA_VERSION {
176 return Err(invalid_descriptor(
177 "loop checkpoint schema version is not the exact portable v1 version",
178 ));
179 }
180 if self.completed_tool_rounds == 0 {
181 return Err(invalid_descriptor(
182 "logical resume requires a completed tool-round boundary",
183 ));
184 }
185 validate_content_identity(self.size_bytes, &self.content_digest)?;
186 validate_sha256("logical-resume evidence_digest", &self.evidence_digest)?;
187 if self.evidence_digest != self.expected_digest()? {
188 return Err(invalid_descriptor(
189 "logical-resume evidence digest does not bind the exact boundary",
190 ));
191 }
192 Ok(())
193 }
194
195 pub fn validate_for(&self, checkpoint: &LoopCheckpoint) -> SessionCheckpointResult<()> {
196 self.validate()?;
197 if self != &Self::from_checkpoint(checkpoint)? {
198 return Err(SessionCheckpointError::ContentDrift(
199 "logical-resume evidence does not match the exact supplied loop checkpoint".into(),
200 ));
201 }
202 Ok(())
203 }
204
205 fn expected_digest(&self) -> SessionCheckpointResult<String> {
206 #[derive(Serialize)]
207 struct DigestInput<'a> {
208 schema: &'a str,
209 resume_semantics: &'a str,
210 session_id: &'a str,
211 source_run_id: &'a str,
212 checkpoint_schema_version: u32,
213 completed_tool_rounds: u64,
214 checkpoint_ms: u64,
215 size_bytes: u64,
216 content_digest: &'a str,
217 }
218
219 domain_digest(
220 LOGICAL_RESUME_EVIDENCE_DIGEST_DOMAIN_V1,
221 &DigestInput {
222 schema: &self.schema,
223 resume_semantics: &self.resume_semantics,
224 session_id: &self.session_id,
225 source_run_id: &self.source_run_id,
226 checkpoint_schema_version: self.checkpoint_schema_version,
227 completed_tool_rounds: self.completed_tool_rounds,
228 checkpoint_ms: self.checkpoint_ms,
229 size_bytes: self.size_bytes,
230 content_digest: &self.content_digest,
231 },
232 )
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct SessionCheckpointDescriptorV1 {
240 pub schema: String,
241 pub format: String,
242 pub media_type: String,
243 pub snapshot: SessionSnapshotEvidenceV1,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub logical_resume: Option<SessionLogicalResumeEvidenceV1>,
246 pub size_bytes: u64,
247 pub content_digest: String,
248 pub descriptor_digest: String,
249}
250
251impl SessionCheckpointDescriptorV1 {
252 pub fn validate(&self) -> SessionCheckpointResult<()> {
253 if self.schema != SESSION_CHECKPOINT_DESCRIPTOR_SCHEMA_V1 {
254 return Err(invalid_descriptor(
255 "checkpoint descriptor schema is unsupported",
256 ));
257 }
258 if self.format != SESSION_CHECKPOINT_FORMAT_V1 {
259 return Err(invalid_descriptor("checkpoint format is unsupported"));
260 }
261 if self.media_type != SESSION_CHECKPOINT_MEDIA_TYPE_V1 {
262 return Err(invalid_descriptor("checkpoint media type is unsupported"));
263 }
264 self.snapshot.validate()?;
265 if let Some(logical_resume) = &self.logical_resume {
266 logical_resume.validate()?;
267 if logical_resume.session_id != self.snapshot.session_id {
268 return Err(invalid_descriptor(
269 "logical-resume and snapshot session identities differ",
270 ));
271 }
272 }
273 validate_content_identity(self.size_bytes, &self.content_digest)?;
274 if self.snapshot.size_bytes > self.size_bytes
275 || self
276 .logical_resume
277 .as_ref()
278 .is_some_and(|evidence| evidence.size_bytes > self.size_bytes)
279 {
280 return Err(invalid_descriptor(
281 "checkpoint component is larger than its containing payload",
282 ));
283 }
284 validate_sha256("checkpoint descriptor_digest", &self.descriptor_digest)?;
285 if self.descriptor_digest != self.expected_digest()? {
286 return Err(invalid_descriptor(
287 "descriptor digest does not bind the exact checkpoint components",
288 ));
289 }
290 Ok(())
291 }
292
293 fn expected_digest(&self) -> SessionCheckpointResult<String> {
294 #[derive(Serialize)]
295 struct DigestInput<'a> {
296 schema: &'a str,
297 format: &'a str,
298 media_type: &'a str,
299 snapshot: &'a SessionSnapshotEvidenceV1,
300 logical_resume: &'a Option<SessionLogicalResumeEvidenceV1>,
301 size_bytes: u64,
302 content_digest: &'a str,
303 }
304
305 domain_digest(
306 CHECKPOINT_DESCRIPTOR_DIGEST_DOMAIN_V1,
307 &DigestInput {
308 schema: &self.schema,
309 format: &self.format,
310 media_type: &self.media_type,
311 snapshot: &self.snapshot,
312 logical_resume: &self.logical_resume,
313 size_bytes: self.size_bytes,
314 content_digest: &self.content_digest,
315 },
316 )
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct SessionCheckpointPayloadV1 {
324 pub schema: String,
325 pub snapshot: SessionSnapshotV1,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub logical_resume: Option<LoopCheckpoint>,
328}
329
330impl SessionCheckpointPayloadV1 {
331 pub fn into_parts(self) -> (SessionSnapshotV1, Option<LoopCheckpoint>) {
332 (self.snapshot, self.logical_resume)
333 }
334}
335
336mod artifact;
337mod codec;
338
339pub use artifact::SessionCheckpointExportV1;
340
341#[async_trait::async_trait]
354pub trait SessionCheckpointExportSink: Send + Sync {
355 async fn export_checkpoint(&self, checkpoint: SessionCheckpointExportV1) -> anyhow::Result<()>;
356}