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
336mod artifact;
337mod codec;
338
339pub use artifact::SessionCheckpointExportV1;
340
341/// Host-owned destination for exact checkpoints captured from a live Run.
342///
343/// Code invokes the sink only at a completed tool-round boundary, after the
344/// Run's preceding events have been materialized into the same semantic
345/// snapshot. The supplied export is already canonical, bounded, and fully
346/// validated. It can contain conversation and Tool data, so authorization,
347/// encryption, immutable-object storage, retention, and replication remain
348/// host responsibilities.
349///
350/// Sink failures are logged and do not halt the live Run. Implementations
351/// should therefore be idempotent by descriptor identity and return only after
352/// the export has reached the durability level required by the host.
353#[async_trait::async_trait]
354pub trait SessionCheckpointExportSink: Send + Sync {
355    async fn export_checkpoint(&self, checkpoint: SessionCheckpointExportV1) -> anyhow::Result<()>;
356}