1use crate::core::session::SessionState;
5use anyhow::{Result, ensure};
6use lean_ctx_protocol::{
7 ContextCheckpointDecisionStatusV1, ContextCheckpointDecisionV1, ContextCheckpointDecisionV2,
8 ContextCheckpointDeviceIdV1, ContextCheckpointIdV1, ContextCheckpointLiveStateV1,
9 ContextCheckpointLiveStateV2, ContextCheckpointProgressV1, ContextCheckpointTaskV1,
10 ContextCheckpointTextV1, ContextCheckpointTextV2, ContextCheckpointV2, ContextCheckpointV3,
11 DecisionId, Sha256Digest, UtcTimestamp, ValidationError,
12};
13use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError};
14use serde_json::value::RawValue;
15
16const SCHEMA: &str = "leanctx.session-checkpoint/v1";
17const NO_RATIONALE: &str = "No rationale supplied";
18const MAX_BYTES: usize = 4 * 1024 * 1024;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum CanonicalCheckpoint {
26 V2(ContextCheckpointV2),
27 V3(ContextCheckpointV3),
28}
29
30impl CanonicalCheckpoint {
31 pub fn as_v2(&self) -> Option<&ContextCheckpointV2> {
32 match self {
33 Self::V2(checkpoint) => Some(checkpoint),
34 Self::V3(_) => None,
35 }
36 }
37
38 pub fn as_v3(&self) -> Option<&ContextCheckpointV3> {
39 match self {
40 Self::V2(_) => None,
41 Self::V3(checkpoint) => Some(checkpoint),
42 }
43 }
44
45 pub(crate) fn validate(&self) -> Result<(), ValidationError> {
46 match self {
47 Self::V2(checkpoint) => checkpoint.validate(),
48 Self::V3(checkpoint) => checkpoint.validate(),
49 }
50 }
51
52 pub(crate) fn digest(&self) -> Result<Sha256Digest, ValidationError> {
53 match self {
54 Self::V2(checkpoint) => checkpoint.digest(),
55 Self::V3(checkpoint) => checkpoint.digest(),
56 }
57 }
58
59 pub(crate) fn identity(&self) -> &lean_ctx_protocol::ContextCheckpointIdentityV1 {
60 match self {
61 Self::V2(checkpoint) => &checkpoint.identity,
62 Self::V3(checkpoint) => &checkpoint.identity,
63 }
64 }
65
66 pub(crate) fn identity_mut(&mut self) -> &mut lean_ctx_protocol::ContextCheckpointIdentityV1 {
67 match self {
68 Self::V2(checkpoint) => &mut checkpoint.identity,
69 Self::V3(checkpoint) => &mut checkpoint.identity,
70 }
71 }
72
73 pub(crate) fn set_updated_at(&mut self, updated_at: UtcTimestamp) {
74 match self {
75 Self::V2(checkpoint) => checkpoint.updated_at = updated_at,
76 Self::V3(checkpoint) => checkpoint.updated_at = updated_at,
77 }
78 }
79
80 pub(crate) fn clear_protected_metadata(&mut self) {
82 match self {
83 Self::V2(checkpoint) => {
84 checkpoint.carrier = None;
85 checkpoint.encryption_metadata = None;
86 }
87 Self::V3(checkpoint) => {
88 checkpoint.carrier = None;
89 checkpoint.encryption_metadata = None;
90 }
91 }
92 }
93
94 pub(crate) fn unicode_live_state(
97 &self,
98 ) -> Result<ContextCheckpointLiveStateV2, ValidationError> {
99 match self {
100 Self::V2(checkpoint) => {
101 Ok(ContextCheckpointV3::from_v2_unverified(checkpoint.clone())?.live_state)
102 }
103 Self::V3(checkpoint) => Ok(checkpoint.live_state.clone()),
104 }
105 }
106
107 pub(crate) fn replace_unicode_live_state(
110 &mut self,
111 live_state: ContextCheckpointLiveStateV2,
112 ) -> Result<(), ValidationError> {
113 match self {
114 Self::V2(checkpoint) => {
115 checkpoint.live_state = unicode_live_state_to_v1(live_state)?;
116 checkpoint.validate()
117 }
118 Self::V3(checkpoint) => {
119 live_state.validate()?;
120 checkpoint.live_state = live_state;
121 checkpoint.validate()
122 }
123 }
124 }
125}
126
127impl Serialize for CanonicalCheckpoint {
128 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
129 where
130 S: Serializer,
131 {
132 match self {
133 Self::V2(checkpoint) => checkpoint.serialize(serializer),
134 Self::V3(checkpoint) => checkpoint.serialize(serializer),
135 }
136 }
137}
138
139impl<'de> Deserialize<'de> for CanonicalCheckpoint {
140 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
141 where
142 D: Deserializer<'de>,
143 {
144 #[derive(Deserialize)]
145 struct SchemaDiscriminator {
146 schema_version: u32,
147 }
148
149 let raw = Box::<RawValue>::deserialize(deserializer)?;
150 let schema_version = serde_json::from_str::<SchemaDiscriminator>(raw.get())
151 .map_err(DeError::custom)?
152 .schema_version;
153 match schema_version {
154 2 => serde_json::from_str::<ContextCheckpointV2>(raw.get())
155 .map(Self::V2)
156 .map_err(DeError::custom),
157 3 => serde_json::from_str::<ContextCheckpointV3>(raw.get())
158 .map(Self::V3)
159 .map_err(DeError::custom),
160 _ => Err(DeError::custom(format!(
161 "unsupported canonical checkpoint schema_version {schema_version}"
162 ))),
163 }
164 }
165}
166
167fn unicode_live_state_to_v1(
168 live: ContextCheckpointLiveStateV2,
169) -> Result<ContextCheckpointLiveStateV1, ValidationError> {
170 Ok(ContextCheckpointLiveStateV1 {
171 schema_version: ContextCheckpointLiveStateV1::SCHEMA_VERSION,
172 task: ContextCheckpointTaskV1 {
173 task_id: live.task.task_id,
174 title: ContextCheckpointTextV1::new(live.task.title.into_inner())?,
175 status: live.task.status,
176 plan_id: live.task.plan_id,
177 },
178 progress: ContextCheckpointProgressV1 {
179 completed_steps: live.progress.completed_steps,
180 total_steps: live.progress.total_steps,
181 confidence_milliunits: live.progress.confidence_milliunits,
182 summary: ContextCheckpointTextV1::new(live.progress.summary.into_inner())?,
183 },
184 decisions: live
185 .decisions
186 .into_iter()
187 .map(|decision| {
188 Ok(ContextCheckpointDecisionV1 {
189 decision_id: decision.decision_id,
190 statement: ContextCheckpointTextV1::new(decision.statement.into_inner())?,
191 rationale: ContextCheckpointTextV1::new(decision.rationale.into_inner())?,
192 status: decision.status,
193 evidence_refs: decision.evidence_refs,
194 })
195 })
196 .collect::<Result<_, ValidationError>>()?,
197 findings: live
198 .findings
199 .into_iter()
200 .map(|text| ContextCheckpointTextV1::new(text.into_inner()))
201 .collect::<Result<_, ValidationError>>()?,
202 next_steps: live
203 .next_steps
204 .into_iter()
205 .map(|text| ContextCheckpointTextV1::new(text.into_inner()))
206 .collect::<Result<_, ValidationError>>()?,
207 handoff_summary: ContextCheckpointTextV1::new(live.handoff_summary.into_inner())?,
208 files: live.files,
209 profile_id: live.profile_id,
210 policy_pins: live.policy_pins,
211 package_pins: live.package_pins,
212 session_state: live.session_state,
213 learning_state: live.learning_state,
214 })
215}
216
217#[derive(Clone, Debug, Serialize, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct CanonicalSession {
220 #[serde(skip)]
222 pub(crate) storage_digest: Option<Sha256Digest>,
223 pub checkpoint: CanonicalCheckpoint,
224 source_artifact_digest: Sha256Digest,
225 local_device: ContextCheckpointDeviceIdV1,
226 project_root: String,
227}
228
229#[derive(Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231struct StoredSession {
232 storage_schema: String,
233 version: u32,
234 canonical: CanonicalSession,
235 checkpoint_digest: Sha256Digest,
236 #[serde(default)]
237 previous_storage_digest: Option<Sha256Digest>,
238 view: SessionState,
239}
240
241impl CanonicalSession {
242 pub(crate) fn admitted(
243 verified: &super::VerifiedContextCheckpointV2,
244 source_artifact_digest: Sha256Digest,
245 project_root: String,
246 ) -> Result<Self> {
247 let checkpoint = CanonicalCheckpoint::V2(verified.checkpoint().clone());
248 Self::admitted_checkpoint(checkpoint, source_artifact_digest, project_root)
249 }
250
251 pub(crate) fn admitted_v3(
252 verified: &super::VerifiedContextCheckpointV3,
253 source_artifact_digest: Sha256Digest,
254 project_root: String,
255 ) -> Result<Self> {
256 let checkpoint = CanonicalCheckpoint::V3(verified.checkpoint().clone());
257 Self::admitted_checkpoint(checkpoint, source_artifact_digest, project_root)
258 }
259
260 fn admitted_checkpoint(
261 checkpoint: CanonicalCheckpoint,
262 source_artifact_digest: Sha256Digest,
263 project_root: String,
264 ) -> Result<Self> {
265 checkpoint.validate()?;
266 Ok(Self {
267 storage_digest: None,
268 checkpoint,
269 source_artifact_digest,
270 local_device: ContextCheckpointDeviceIdV1::new(format!(
271 "local-{}",
272 uuid::Uuid::new_v4()
273 ))?,
274 project_root,
275 })
276 }
277
278 fn synchronize(&self, session: &SessionState) -> Result<CanonicalCheckpoint> {
279 ensure!(
280 session.project_root.as_ref() == Some(&self.project_root),
281 "canonical session scope changed"
282 );
283 let mut next = self.checkpoint.clone();
284 let mut live = next.unicode_live_state()?;
285 let task = session
286 .task
287 .as_ref()
288 .ok_or_else(|| anyhow::anyhow!("canonical task cannot be cleared"))?;
289 live.task.title = ContextCheckpointTextV2::new(task.description.clone())?;
290 let current_pct = if live.progress.total_steps == 0 {
291 None
292 } else {
293 Some(u8::try_from(
294 u64::from(live.progress.completed_steps) * 100
295 / u64::from(live.progress.total_steps),
296 )?)
297 };
298 if task.progress_pct != current_pct {
299 if let Some(percent) = task.progress_pct {
300 ensure!(percent <= 100, "invalid progress percentage");
301 live.progress.completed_steps = u32::from(percent);
302 live.progress.total_steps = 100;
303 } else {
304 anyhow::bail!("canonical progress cannot be cleared");
305 }
306 }
307 live.findings = session
308 .findings
309 .iter()
310 .map(|finding| ContextCheckpointTextV2::new(finding.summary.clone()))
311 .collect::<std::result::Result<_, _>>()?;
312 live.next_steps = session
313 .next_steps
314 .iter()
315 .map(|step| ContextCheckpointTextV2::new(step.clone()))
316 .collect::<std::result::Result<_, _>>()?;
317 let accepted: Vec<_> = live
318 .decisions
319 .iter()
320 .filter(|decision| decision.status == ContextCheckpointDecisionStatusV1::Accepted)
321 .collect();
322 let decisions_unchanged = accepted.len() == session.decisions.len()
323 && accepted
324 .iter()
325 .zip(&session.decisions)
326 .all(|(canonical, view)| {
327 canonical.statement.as_str() == view.summary
328 && canonical.rationale.as_str()
329 == view.rationale.as_deref().unwrap_or(NO_RATIONALE)
330 });
331 if !decisions_unchanged {
332 let mut decisions: Vec<_> = live
333 .decisions
334 .iter()
335 .filter(|decision| decision.status != ContextCheckpointDecisionStatusV1::Accepted)
336 .cloned()
337 .collect();
338 for view in &session.decisions {
339 let rationale = view.rationale.as_deref().unwrap_or(NO_RATIONALE);
340 let existing = accepted.iter().find(|decision| {
341 decision.statement.as_str() == view.summary
342 && decision.rationale.as_str() == rationale
343 });
344 decisions.push(if let Some(existing) = existing {
345 (*existing).clone()
346 } else {
347 ContextCheckpointDecisionV2 {
348 decision_id: DecisionId::new(format!("decision-{}", uuid::Uuid::new_v4()))?,
349 statement: ContextCheckpointTextV2::new(view.summary.clone())?,
350 rationale: ContextCheckpointTextV2::new(rationale.to_owned())?,
351 status: ContextCheckpointDecisionStatusV1::Accepted,
352 evidence_refs: Vec::new(),
353 }
354 });
355 }
356 decisions.sort_by(|left, right| left.decision_id.cmp(&right.decision_id));
357 live.decisions = decisions;
358 }
359 live.validate()?;
360 next.replace_unicode_live_state(live)?;
361 next.validate()?;
362 Ok(next)
363 }
364
365 fn advance(&self, session: &SessionState) -> Result<Self> {
366 let mut next = self.synchronize(session)?;
367 if next != self.checkpoint {
368 let live = next.unicode_live_state()?;
369 if let Some(lifecycle) = &live.session_state {
370 use lean_ctx_protocol::{ContextSessionPhaseV1, ContextSessionRecoveryStateV1};
371 ensure!(
372 !matches!(
373 lifecycle.state.phase,
374 ContextSessionPhaseV1::Closed | ContextSessionPhaseV1::Aborted
375 ) && !matches!(
376 lifecycle.state.recovery_state,
377 ContextSessionRecoveryStateV1::InspectOnly
378 | ContextSessionRecoveryStateV1::Corrupt
379 ),
380 "checkpoint lifecycle refuses local continuation"
381 );
382 }
383 let parent = self.checkpoint.identity();
384 *next.identity_mut() = lean_ctx_protocol::ContextCheckpointIdentityV1::try_new(
385 ContextCheckpointIdV1::new(uuid::Uuid::new_v4().to_string())?,
386 Some(parent.checkpoint_id.clone()),
387 Some(parent.device_id.clone()),
388 Some(parent.branch_id.clone()),
389 Some(parent.device_sequence),
390 parent.branch_id.clone(),
391 self.local_device.clone(),
392 if parent.device_id == self.local_device {
393 parent
394 .device_sequence
395 .checked_add(1)
396 .ok_or_else(|| anyhow::anyhow!("checkpoint sequence exhausted"))?
397 } else {
398 1
399 },
400 )?;
401 next.set_updated_at(UtcTimestamp::new(
402 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
403 )?);
404 next.clear_protected_metadata();
406 next.validate()?;
407 }
408 Ok(Self {
409 checkpoint: next,
410 ..self.clone()
411 })
412 }
413}
414
415impl SessionState {
416 pub fn canonical_checkpoint_for_export(
418 id: &str,
419 ) -> Result<(CanonicalCheckpoint, String), String> {
420 const NO_CHECKPOINT: &str =
421 "session has no canonical checkpoint; use the legacy export path";
422 let session = Self::load_by_id(id).ok_or("canonical session cannot be loaded")?;
423 let root = session
424 .canonical_checkpoint
425 .as_ref()
426 .ok_or(NO_CHECKPOINT)?
427 .project_root
428 .clone();
429 let every_finding_served =
433 crate::core::admitted_sources::as_agent_acquisition(&root, || {
434 session
435 .findings
436 .iter()
437 .all(|finding| session.finding_served(finding))
438 });
439 if !every_finding_served {
440 return Err(
441 "checkpoint export refused: a finding's source is no longer admitted".to_string(),
442 );
443 }
444 let binding = session.canonical_checkpoint.ok_or(NO_CHECKPOINT)?;
445 Ok((binding.checkpoint, binding.project_root))
446 }
447
448 pub fn from_storage_json(json: &str) -> Result<Self, String> {
451 decode(json).map_err(|error| error.to_string())
452 }
453
454 pub(crate) fn storage_json(&self) -> Result<(String, Option<Box<CanonicalSession>>), String> {
455 let mut view = self.clone();
456 view.stats.unsaved_changes = 0;
457 let Some(current) = self.canonical_checkpoint.as_ref() else {
458 return serde_json::to_string_pretty(&view)
459 .map(|json| (json, None))
460 .map_err(|error| error.to_string());
461 };
462 let advanced = current.advance(self).map_err(|error| error.to_string())?;
463 let checkpoint_digest = advanced
464 .checkpoint
465 .digest()
466 .map_err(|error| error.to_string())?;
467 let stored = StoredSession {
468 storage_schema: SCHEMA.into(),
469 version: self.version,
470 canonical: advanced.clone(),
471 checkpoint_digest,
472 previous_storage_digest: current.storage_digest.clone(),
473 view,
474 };
475 let json = serde_json::to_string_pretty(&stored).map_err(|error| error.to_string())?;
476 if json.len() > MAX_BYTES {
477 return Err("canonical session exceeds storage bound".into());
478 }
479 Ok((json, Some(Box::new(advanced))))
480 }
481}
482
483fn decode(json: &str) -> Result<SessionState> {
484 let value: serde_json::Value = serde_json::from_str(json)?;
485 if value.get("storage_schema").is_none() {
486 return Ok(serde_json::from_str(json)?);
487 }
488 ensure!(json.len() <= MAX_BYTES, "session exceeds storage bound");
489 let mut stored: StoredSession = serde_json::from_str(json)?;
490 ensure!(
491 stored.storage_schema == SCHEMA,
492 "unsupported session storage schema"
493 );
494 ensure!(
495 stored.version == stored.view.version,
496 "session version mismatch"
497 );
498 ensure!(
499 stored.checkpoint_digest == stored.canonical.checkpoint.digest()?,
500 "checkpoint digest mismatch"
501 );
502 ensure!(
503 stored.canonical.synchronize(&stored.view)? == stored.canonical.checkpoint,
504 "session view disagrees with canonical checkpoint"
505 );
506 stored.canonical.storage_digest = Some(storage_digest(json.as_bytes())?);
507 stored.view.canonical_checkpoint = Some(Box::new(stored.canonical));
508 Ok(stored.view)
509}
510
511fn storage_digest(bytes: &[u8]) -> Result<Sha256Digest> {
512 use sha2::{Digest as _, Sha256};
513 Ok(Sha256Digest::new(format!(
514 "sha256:{}",
515 crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
516 ))?)
517}
518
519pub(crate) fn prepare_commit(candidate: &str, previous: Option<&[u8]>) -> Result<String, String> {
522 commit(candidate, previous).map_err(|error| error.to_string())
523}
524
525fn commit(candidate: &str, previous: Option<&[u8]>) -> Result<String> {
526 let mut incoming = decode(candidate)?;
527 let prior = previous
528 .map(|bytes| -> Result<SessionState> { decode(std::str::from_utf8(bytes)?) })
529 .transpose()?;
530 let Some(binding) = incoming.canonical_checkpoint.as_ref() else {
531 ensure!(
532 prior
533 .as_ref()
534 .is_none_or(|state| state.canonical_checkpoint.is_none()),
535 "refusing canonical session downgrade"
536 );
537 return Ok(candidate.into());
538 };
539 let mut stored: StoredSession = serde_json::from_str(candidate)?;
540 ensure!(
541 stored.previous_storage_digest == previous.map(storage_digest).transpose()?,
542 "canonical session changed since it was read; reload before retrying"
543 );
544 if let Some(prior) = prior {
545 ensure!(
546 prior.id == incoming.id,
547 "canonical session identity mismatch"
548 );
549 let head = prior
550 .canonical_checkpoint
551 .as_ref()
552 .ok_or_else(|| anyhow::anyhow!("canonical adoption requires a fresh session"))?;
553 ensure!(
554 head.source_artifact_digest == binding.source_artifact_digest
555 && head.project_root == binding.project_root
556 && head.local_device == binding.local_device,
557 "canonical session authority changed"
558 );
559 stored.canonical = head.advance(&incoming)?;
560 ensure!(
561 prior.version < incoming.version || stored.canonical.checkpoint == head.checkpoint,
562 "conflicting canonical session revision"
563 );
564 stored.checkpoint_digest = stored.canonical.checkpoint.digest()?;
565 if let Some(bytes) = previous {
566 let hash = storage_digest(bytes)?;
567 drop(
568 crate::core::engine_artifact::persist_content(
569 "sessions/checkpoints",
570 hash.hex(),
571 "json",
572 bytes,
573 )
574 .map_err(anyhow::Error::msg)?,
575 );
576 stored.previous_storage_digest = Some(hash);
577 }
578 }
579 incoming.canonical_checkpoint = None;
582 stored.view = incoming;
583 let encoded = serde_json::to_string_pretty(&stored)?;
584 ensure!(
585 encoded.len() <= MAX_BYTES,
586 "canonical session exceeds storage bound"
587 );
588 Ok(encoded)
589}