Skip to main content

a3s_code_core/
session_checkpoint.rs

1//! Portable, content-addressed A3S Code session checkpoints.
2//!
3//! A checkpoint artifact binds one complete [`SessionSnapshotV1`] and,
4//! optionally, the exact [`LoopCheckpoint`] from which Code can continue a
5//! non-terminal run. The payload contains provider state only. A host may put
6//! the canonical bytes in its authorized immutable-object store, while
7//! checkpoint identity, retention, approval, and fork lineage remain outside
8//! Code.
9
10use 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    /// Stable machine-readable code for host and protocol adapters.
53    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/// Exact content identity of the aggregate Session snapshot component.
64#[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/// Exact, content-free evidence for Code's tool-round resume boundary.
142#[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/// Secret-free descriptor a host can store beside its own checkpoint record.
237#[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/// Canonical provider payload stored behind the descriptor's content digest.
321#[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    /// Exact recovery always requires a logical-resume component. Fail closed
336    /// here so callers that already validated the descriptor still get a typed
337    /// payload error if the opened body omits it.
338    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/// Cross-language wire form for [`SessionCheckpointExportV1`] (SDK-CP1).
357///
358/// Hosts receive the secret-free descriptor plus base64 payload bytes. Content
359/// may include conversation and Tool data; authorization and retention remain
360/// host responsibilities.
361#[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/// Host-owned destination for exact checkpoints captured from a live Run.
379///
380/// Code invokes the sink only at a completed tool-round boundary, after the
381/// Run's preceding events have been materialized into the same semantic
382/// snapshot. The supplied export is already canonical, bounded, and fully
383/// validated. It can contain conversation and Tool data, so authorization,
384/// encryption, immutable-object storage, retention, and replication remain
385/// host responsibilities.
386///
387/// Sink failures are logged and do not halt the live Run. Implementations
388/// should therefore be idempotent by descriptor identity and return only after
389/// the export has reached the durability level required by the host.
390#[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}