Skip to main content

lean_ctx/core/context_checkpoint/
session_storage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Versioned canonical continuation at the existing session persistence boundary.
3
4use 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/// One canonical checkpoint value carried by the existing session envelope.
21///
22/// The enum is an internal version dispatch only. It is serialized as the
23/// selected checkpoint object itself, never as an externally tagged enum.
24#[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    /// Clear protected/carrier metadata after a local plaintext revision.
81    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    /// Return the live state through the Unicode adapter without establishing
95    /// artifact ownership or signing authority.
96    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    /// Replace live state while keeping the stored wire version unchanged.
108    /// V2 therefore fails closed if a Unicode edit cannot be represented.
109    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    /// Exact committed primary bytes observed by this reader; not a wire field.
221    #[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            // Changed plaintext is not the old protected/carrier projection.
405            next.clear_protected_metadata();
406            next.validate()?;
407        }
408        Ok(Self {
409            checkpoint: next,
410            ..self.clone()
411        })
412    }
413}
414
415impl SessionState {
416    /// Export only a validated committed checkpoint, never a pending view or legacy projection.
417    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        // The committed checkpoint is exported whole or not at all: leaving a
430        // finding out would change its identity. A finding bound to a source
431        // that no longer admits therefore refuses the export.
432        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    /// Decode the current canonical storage envelope or the historical flat shape.
449    /// This is local state, never signer admission or a policy/entitlement grant.
450    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
519/// The existing per-session writer calls this while holding its save lock.
520/// Final lineage follows the actually committed head, not a deferred snapshot.
521pub(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    // The view is an explicitly checked compatibility cache, never a fallback
580    // authority when it disagrees with the checkpoint.
581    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}