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 pub fn into_exact_recovery_parts(
339 self,
340 ) -> SessionCheckpointResult<(SessionSnapshotV1, LoopCheckpoint)> {
341 let (snapshot, logical_resume) = self.into_parts();
342 let logical_resume = logical_resume.ok_or_else(|| {
343 SessionCheckpointError::InvalidPayload(
344 "exact recovery requires a logical-resume component".into(),
345 )
346 })?;
347 Ok((snapshot, logical_resume))
348 }
349}
350
351mod artifact;
352mod codec;
353
354pub use artifact::SessionCheckpointExportV1;
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct SdkSessionCheckpointExportV1 {
364 pub descriptor: SessionCheckpointDescriptorV1,
365 pub content_base64: String,
366}
367
368impl SdkSessionCheckpointExportV1 {
369 pub fn from_export(export: &SessionCheckpointExportV1) -> Self {
370 use base64::Engine;
371 Self {
372 descriptor: export.descriptor().clone(),
373 content_base64: base64::engine::general_purpose::STANDARD.encode(export.content()),
374 }
375 }
376}
377
378#[async_trait::async_trait]
391pub trait SessionCheckpointExportSink: Send + Sync {
392 async fn export_checkpoint(&self, checkpoint: SessionCheckpointExportV1) -> anyhow::Result<()>;
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::loop_checkpoint::{LoopCheckpoint, LOOP_CHECKPOINT_SCHEMA_VERSION};
399 use crate::store::{
400 SessionConfig, SessionData, SessionSnapshotV1, SessionState,
401 SESSION_SNAPSHOT_SCHEMA_VERSION,
402 };
403 use base64::Engine;
404
405 fn minimal_snapshot(session_id: &str) -> SessionSnapshotV1 {
406 SessionSnapshotV1::session_only(SessionData {
407 id: session_id.into(),
408 config: SessionConfig {
409 name: "checkpoint-test".into(),
410 workspace: "/tmp/checkpoint-test".into(),
411 max_context_length: 8_192,
412 ..SessionConfig::default()
413 },
414 state: SessionState::Active,
415 messages: Vec::new(),
416 context_usage: Default::default(),
417 total_usage: Default::default(),
418 total_cost: 0.0,
419 model_name: None,
420 cost_records: Vec::new(),
421 tool_names: Vec::new(),
422 thinking_enabled: false,
423 thinking_budget: None,
424 created_at: 1_724_000_000,
425 updated_at: 1_724_000_001,
426 llm_config: None,
427 tasks: Vec::new(),
428 parent_id: None,
429 tenant_id: None,
430 principal: None,
431 agent_template_id: None,
432 correlation_id: None,
433 durable_memory_binding: None,
434 cognitive_package_binding: None,
435 immutable_content_adapter_binding: None,
436 })
437 }
438
439 fn sample_loop_checkpoint(session_id: &str) -> LoopCheckpoint {
440 LoopCheckpoint {
441 schema_version: LOOP_CHECKPOINT_SCHEMA_VERSION,
442 run_id: "run-checkpoint-test".into(),
443 session_id: session_id.into(),
444 capability_binding: None,
445 turn: 1,
446 messages: Vec::new(),
447 total_usage: Default::default(),
448 tool_calls_count: 1,
449 verification_reports: Vec::new(),
450 convergence: Default::default(),
451 checkpoint_ms: 1_724_000_000_000,
452 }
453 }
454
455 #[test]
456 fn checkpoint_error_codes_are_stable() {
457 assert_eq!(
458 SessionCheckpointError::InvalidDescriptor("x".into()).code(),
459 "a3s.code.session_checkpoint.invalid_descriptor"
460 );
461 assert_eq!(
462 SessionCheckpointError::InvalidPayload("x".into()).code(),
463 "a3s.code.session_checkpoint.invalid_payload"
464 );
465 assert_eq!(
466 SessionCheckpointError::ContentDrift("x".into()).code(),
467 "a3s.code.session_checkpoint.content_drift"
468 );
469 assert_eq!(
470 SessionCheckpointError::Encoding("x".into()).code(),
471 "a3s.code.session_checkpoint.encoding"
472 );
473 }
474
475 #[test]
476 fn snapshot_evidence_validate_rejects_bad_fields() {
477 let snapshot = minimal_snapshot("session-evidence");
478 let mut evidence = SessionSnapshotEvidenceV1::from_snapshot(&snapshot).unwrap();
479
480 evidence.schema = "bad.schema".into();
481 assert!(evidence.validate().is_err());
482
483 evidence = SessionSnapshotEvidenceV1::from_snapshot(&snapshot).unwrap();
484 evidence.encoding = "bad-encoding".into();
485 assert!(evidence.validate().is_err());
486
487 evidence = SessionSnapshotEvidenceV1::from_snapshot(&snapshot).unwrap();
488 evidence.snapshot_schema_version = SESSION_SNAPSHOT_SCHEMA_VERSION + 1;
489 assert!(evidence.validate().is_err());
490
491 evidence = SessionSnapshotEvidenceV1::from_snapshot(&snapshot).unwrap();
492 evidence.evidence_digest = "sha256:".to_string() + &"0".repeat(64);
493 assert!(evidence.validate().is_err());
494 }
495
496 #[test]
497 fn logical_resume_evidence_validate_rejects_invalid_boundary() {
498 let checkpoint = sample_loop_checkpoint("session-logical");
499 let mut evidence = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
500
501 evidence.schema = "bad.schema".into();
502 assert!(evidence.validate().is_err());
503
504 evidence = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
505 evidence.completed_tool_rounds = 0;
506 assert!(evidence.validate().is_err());
507 }
508
509 #[test]
510 fn descriptor_validate_rejects_bad_envelope() {
511 let export =
512 SessionCheckpointExportV1::new(minimal_snapshot("session-desc"), None).unwrap();
513 let mut descriptor = export.descriptor().clone();
514
515 descriptor.schema = "bad.schema".into();
516 assert!(descriptor.validate().is_err());
517
518 descriptor = export.descriptor().clone();
519 descriptor.format = "bad-format".into();
520 assert!(descriptor.validate().is_err());
521
522 descriptor = export.descriptor().clone();
523 descriptor.media_type = "application/json".into();
524 assert!(descriptor.validate().is_err());
525 }
526
527 #[test]
528 fn exact_recovery_parts_require_logical_resume() {
529 let export =
530 SessionCheckpointExportV1::new(minimal_snapshot("session-exact"), None).unwrap();
531 let payload = export.into_open().unwrap();
532 let err = payload
533 .into_exact_recovery_parts()
534 .expect_err("exact recovery without logical resume must fail closed");
535 assert!(matches!(err, SessionCheckpointError::InvalidPayload(_)));
536 }
537
538 #[test]
539 fn sdk_export_round_trips_minimal_checkpoint() {
540 let export = SessionCheckpointExportV1::new(minimal_snapshot("session-sdk"), None).unwrap();
541 let sdk = SdkSessionCheckpointExportV1::from_export(&export);
542 let decoded = base64::engine::general_purpose::STANDARD
543 .decode(&sdk.content_base64)
544 .unwrap();
545 let restored = SessionCheckpointExportV1::from_parts(sdk.descriptor, decoded).unwrap();
546
547 assert_eq!(restored.descriptor(), export.descriptor());
548 assert_eq!(restored.content(), export.content());
549 assert_eq!(restored.open().unwrap().snapshot.session.id, "session-sdk");
550 }
551
552 #[test]
553 fn snapshot_and_logical_resume_validate_for_detect_content_drift() {
554 let snapshot = minimal_snapshot("session-drift");
555 let evidence = SessionSnapshotEvidenceV1::from_snapshot(&snapshot).unwrap();
556 evidence.validate_for(&snapshot).unwrap();
557
558 let other = minimal_snapshot("session-other");
559 assert!(matches!(
560 evidence.validate_for(&other),
561 Err(SessionCheckpointError::ContentDrift(_))
562 ));
563
564 let checkpoint = sample_loop_checkpoint("session-drift");
565 let logical = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
566 logical.validate_for(&checkpoint).unwrap();
567
568 let mut drifted = logical.clone();
569 drifted.completed_tool_rounds = logical.completed_tool_rounds + 1;
570 assert!(matches!(
571 drifted.validate_for(&checkpoint),
572 Err(SessionCheckpointError::ContentDrift(_))
573 | Err(SessionCheckpointError::InvalidDescriptor(_))
574 ));
575 }
576
577 #[test]
578 fn logical_resume_validate_rejects_semantics_and_digest_drift() {
579 let checkpoint = sample_loop_checkpoint("session-logical-2");
580 let mut evidence = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
581
582 evidence.resume_semantics = "bad-semantics".into();
583 assert!(evidence.validate().is_err());
584
585 evidence = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
586 evidence.checkpoint_schema_version = LOOP_CHECKPOINT_SCHEMA_VERSION + 1;
587 assert!(evidence.validate().is_err());
588
589 evidence = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
590 evidence.evidence_digest = format!("sha256:{}", "a".repeat(64));
591 assert!(evidence.validate().is_err());
592 }
593
594 #[test]
595 fn export_with_logical_resume_validates_descriptor_components() {
596 use crate::run::{RunRecord, RunSnapshot, RunStatus};
597
598 let mut snapshot = minimal_snapshot("session-with-resume");
599 let checkpoint = sample_loop_checkpoint("session-with-resume");
600 snapshot.run_records.push(RunRecord {
601 snapshot: RunSnapshot {
602 id: checkpoint.run_id.clone(),
603 session_id: "session-with-resume".into(),
604 status: RunStatus::Executing,
605 prompt: "continue".into(),
606 cognitive_package_binding: None,
607 capability_binding: None,
608 created_at_ms: 1,
609 updated_at_ms: 1,
610 result_text: None,
611 error: None,
612 event_count: 0,
613 workspace_change_set: None,
614 },
615 events: Vec::new(),
616 });
617 let export = SessionCheckpointExportV1::new(snapshot, Some(checkpoint)).unwrap();
618 export.descriptor().validate().unwrap();
619 assert!(export.descriptor().logical_resume.is_some());
620 assert_eq!(
621 export.open().unwrap().logical_resume.map(|cp| cp.run_id),
622 Some("run-checkpoint-test".to_string())
623 );
624 }
625
626 #[test]
627 fn export_from_parts_rejects_descriptor_content_drift() {
628 let export =
629 SessionCheckpointExportV1::new(minimal_snapshot("session-bytes"), None).unwrap();
630 let (descriptor, mut content) = export.into_parts();
631 content.push(b' ');
632 assert!(matches!(
633 SessionCheckpointExportV1::from_parts(descriptor, content),
634 Err(SessionCheckpointError::ContentDrift(_))
635 ));
636 }
637
638 #[test]
639 fn export_into_open_matches_open() {
640 let export =
641 SessionCheckpointExportV1::new(minimal_snapshot("session-open"), None).unwrap();
642 let opened = export.clone().into_open().unwrap();
643 assert_eq!(
644 export.open().unwrap().snapshot.session.id,
645 opened.snapshot.session.id
646 );
647 }
648
649 #[test]
650 fn export_from_parts_rejects_non_canonical_json_whitespace() {
651 let export =
652 SessionCheckpointExportV1::new(minimal_snapshot("session-canonical"), None).unwrap();
653 let (descriptor, mut content) = export.into_parts();
654 content.insert(1, b' ');
655 assert!(matches!(
656 SessionCheckpointExportV1::from_parts(descriptor, content),
657 Err(SessionCheckpointError::ContentDrift(_))
658 ));
659 }
660
661 #[test]
662 fn descriptor_rejects_logical_resume_session_mismatch() {
663 let checkpoint = sample_loop_checkpoint("session-mismatch");
664 let mut resume = SessionLogicalResumeEvidenceV1::from_checkpoint(&checkpoint).unwrap();
665 resume.session_id = "other-session".into();
666 let mut descriptor =
667 SessionCheckpointExportV1::new(minimal_snapshot("session-mismatch"), None)
668 .unwrap()
669 .descriptor()
670 .clone();
671 descriptor.logical_resume = Some(resume);
672 assert!(descriptor.validate().is_err());
673 }
674
675 #[test]
676 fn payload_into_parts_preserves_snapshot_and_resume() {
677 let checkpoint = sample_loop_checkpoint("session-parts");
678 let payload = SessionCheckpointPayloadV1 {
679 schema: SESSION_CHECKPOINT_PAYLOAD_SCHEMA_V1.to_string(),
680 snapshot: minimal_snapshot("session-parts"),
681 logical_resume: Some(checkpoint.clone()),
682 };
683 let (snapshot, resume) = payload.into_parts();
684 assert_eq!(snapshot.session.id, "session-parts");
685 assert_eq!(resume.map(|cp| cp.run_id), Some(checkpoint.run_id));
686 }
687}