Skip to main content

lean_ctx_protocol/
context_checkpoint_unicode.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Additive Unicode live-state checkpoint contract.
4//!
5//! `ContextCheckpointV3` is deliberately separate from the deployed V1/V2
6//! checkpoint types.  The older contracts remain ASCII-only and retain their
7//! existing canonical bytes and signature domains; this module gives the
8//! Unicode evolution its own schema and digest domain so a reader cannot
9//! silently reinterpret old signed material.
10
11use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
12
13use crate::context_checkpoint::{
14    ContextCheckpointCarrierBindingV1, ContextCheckpointDecisionStatusV1,
15    ContextCheckpointEncryptionMetadataV1, ContextCheckpointFileV1, ContextCheckpointIdentityV1,
16    ContextCheckpointLearningStateV1, ContextCheckpointLineageV2, ContextCheckpointPackagePinV1,
17    ContextCheckpointPolicyPinV1, ContextCheckpointSessionStateV1, ContextCheckpointTaskStatusV1,
18    MAX_CONTEXT_CHECKPOINT_DECISION_REFS, MAX_CONTEXT_CHECKPOINT_DECISIONS,
19    MAX_CONTEXT_CHECKPOINT_ENCODED_BYTES, MAX_CONTEXT_CHECKPOINT_FILES,
20    MAX_CONTEXT_CHECKPOINT_FINDINGS, MAX_CONTEXT_CHECKPOINT_NEXT_STEPS,
21    MAX_CONTEXT_CHECKPOINT_PACKAGE_PINS, MAX_CONTEXT_CHECKPOINT_POLICY_PINS,
22    MAX_CONTEXT_CHECKPOINT_STEPS, MAX_CONTEXT_CHECKPOINT_TEXT_BYTES, canonical_bytes_of,
23    digest_with_domain, is_unicode_format_character, reject_machine_local_content,
24    require_capacity, require_sorted_unique, require_unique, validate_checkpoint_identifier,
25    validate_checkpoint_reference,
26};
27use crate::{
28    DecisionId, PlanId, ProtocolReference, SemanticVersion, Sha256Digest, TaskId, UtcTimestamp,
29    ValidationError, validate_milliunit,
30};
31
32/// Schema identity of the additive Unicode checkpoint contract.
33pub const CONTEXT_CHECKPOINT_V3_SCHEMA_ID: &str = "leanctx.context-checkpoint-live/v3";
34
35/// Domain prefix for the complete V3 checkpoint digest.
36pub const CONTEXT_CHECKPOINT_V3_DIGEST_DOMAIN: &[u8] = b"leanctx/context-checkpoint/v3\0";
37
38/// Domain prefix reserved for a downstream V3 checkpoint signer.
39pub const CONTEXT_CHECKPOINT_V3_SIGNATURE_DOMAIN: &[u8] =
40    b"leanctx/context-checkpoint-signature/v3\0";
41
42/// Maximum encoded V3 checkpoint size accepted before JSON parsing/allocation.
43pub const MAX_CONTEXT_CHECKPOINT_V3_ENCODED_BYTES: usize = MAX_CONTEXT_CHECKPOINT_ENCODED_BYTES;
44
45macro_rules! validated_deserialize {
46    ($target:ident { $($field:ident : $field_type:ty),+ $(,)? }) => {
47        impl<'de> Deserialize<'de> for $target {
48            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49            where
50                D: Deserializer<'de>,
51            {
52                #[derive(Deserialize)]
53                #[serde(deny_unknown_fields)]
54                struct Wire {
55                    $($field: $field_type),+
56                }
57
58                let Wire { $($field),+ } = Wire::deserialize(deserializer)?;
59                let value = Self { $($field),+ };
60                value.validate().map_err(DeError::custom)?;
61                Ok(value)
62            }
63        }
64    };
65}
66
67/// Bounded human-readable Unicode text used only by V3.
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
69#[serde(transparent)]
70pub struct ContextCheckpointTextV2(String);
71
72impl ContextCheckpointTextV2 {
73    /// Construct text after applying the portable Unicode security profile.
74    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
75        let value = value.into();
76        validate_unicode_checkpoint_text(&value, "ContextCheckpointTextV2")?;
77        Ok(Self(value))
78    }
79
80    /// Borrow the validated wire value.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84
85    /// Consume the value and return its wire representation.
86    pub fn into_inner(self) -> String {
87        self.0
88    }
89}
90
91impl AsRef<str> for ContextCheckpointTextV2 {
92    fn as_ref(&self) -> &str {
93        self.as_str()
94    }
95}
96
97impl std::str::FromStr for ContextCheckpointTextV2 {
98    type Err = ValidationError;
99
100    fn from_str(value: &str) -> Result<Self, Self::Err> {
101        Self::new(value)
102    }
103}
104
105impl TryFrom<&str> for ContextCheckpointTextV2 {
106    type Error = ValidationError;
107
108    fn try_from(value: &str) -> Result<Self, Self::Error> {
109        Self::new(value)
110    }
111}
112
113impl TryFrom<String> for ContextCheckpointTextV2 {
114    type Error = ValidationError;
115
116    fn try_from(value: String) -> Result<Self, Self::Error> {
117        Self::new(value)
118    }
119}
120
121impl From<ContextCheckpointTextV2> for String {
122    fn from(value: ContextCheckpointTextV2) -> Self {
123        value.0
124    }
125}
126
127impl<'de> Deserialize<'de> for ContextCheckpointTextV2 {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        Self::new(String::deserialize(deserializer)?).map_err(DeError::custom)
133    }
134}
135
136/// The task carried by a V3 checkpoint.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138#[serde(deny_unknown_fields)]
139pub struct ContextCheckpointTaskV2 {
140    /// Identity of the task.
141    pub task_id: TaskId,
142    /// Bounded human-readable title.
143    pub title: ContextCheckpointTextV2,
144    /// Lifecycle status.
145    pub status: ContextCheckpointTaskStatusV1,
146    /// Plan the task executes, when one is pinned.
147    pub plan_id: Option<PlanId>,
148}
149
150impl ContextCheckpointTaskV2 {
151    /// Validate task identity and cross-field bounds.
152    pub fn validate(&self) -> Result<(), ValidationError> {
153        validate_checkpoint_identifier(self.task_id.as_str(), "task.task_id")?;
154        if let Some(plan_id) = &self.plan_id {
155            validate_checkpoint_identifier(plan_id.as_str(), "task.plan_id")?;
156        }
157        Ok(())
158    }
159}
160
161validated_deserialize!(ContextCheckpointTaskV2 {
162    task_id: TaskId,
163    title: ContextCheckpointTextV2,
164    status: ContextCheckpointTaskStatusV1,
165    plan_id: Option<PlanId>,
166});
167
168/// Bounded progress report for the active task.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170#[serde(deny_unknown_fields)]
171pub struct ContextCheckpointProgressV2 {
172    /// Steps finished so far.
173    pub completed_steps: u32,
174    /// Total steps planned.
175    pub total_steps: u32,
176    /// Confidence in the reported progress, in 0..=1000 milliunits.
177    #[serde(deserialize_with = "deserialize_milliunit")]
178    pub confidence_milliunits: u16,
179    /// Bounded human-readable summary.
180    pub summary: ContextCheckpointTextV2,
181}
182
183impl ContextCheckpointProgressV2 {
184    /// Validate progress counters and confidence bounds.
185    pub fn validate(&self) -> Result<(), ValidationError> {
186        if self.total_steps == 0 || self.total_steps > MAX_CONTEXT_CHECKPOINT_STEPS {
187            return Err(ValidationError::new(format!(
188                "total_steps must be within 1..={MAX_CONTEXT_CHECKPOINT_STEPS}"
189            )));
190        }
191        if self.completed_steps > self.total_steps {
192            return Err(ValidationError::new(
193                "completed_steps must not exceed total_steps",
194            ));
195        }
196        validate_milliunit(self.confidence_milliunits, "confidence_milliunits")
197    }
198}
199
200validated_deserialize!(ContextCheckpointProgressV2 {
201    completed_steps: u32,
202    total_steps: u32,
203    confidence_milliunits: u16,
204    summary: ContextCheckpointTextV2,
205});
206
207/// One decision taken while working the task.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
209#[serde(deny_unknown_fields)]
210pub struct ContextCheckpointDecisionV2 {
211    /// Identity of the decision.
212    pub decision_id: DecisionId,
213    /// Bounded statement of what was decided.
214    pub statement: ContextCheckpointTextV2,
215    /// Bounded rationale for the decision.
216    pub rationale: ContextCheckpointTextV2,
217    /// Current status of the decision.
218    pub status: ContextCheckpointDecisionStatusV1,
219    /// Evidence supporting the decision, sorted and unique.
220    pub evidence_refs: Vec<ProtocolReference>,
221}
222
223impl ContextCheckpointDecisionV2 {
224    /// Validate decision identity, bounds, and reference ordering.
225    pub fn validate(&self) -> Result<(), ValidationError> {
226        validate_checkpoint_identifier(self.decision_id.as_str(), "decision_id")?;
227        if self.evidence_refs.len() > MAX_CONTEXT_CHECKPOINT_DECISION_REFS {
228            return Err(ValidationError::new(format!(
229                "decision evidence_refs exceeds the {MAX_CONTEXT_CHECKPOINT_DECISION_REFS} item limit"
230            )));
231        }
232        for reference in &self.evidence_refs {
233            validate_checkpoint_reference(reference.as_str(), "decision evidence_refs")?;
234        }
235        require_sorted_unique(
236            &self.evidence_refs,
237            "decision evidence_refs",
238            ProtocolReference::as_str,
239        )
240    }
241}
242
243validated_deserialize!(ContextCheckpointDecisionV2 {
244    decision_id: DecisionId,
245    statement: ContextCheckpointTextV2,
246    rationale: ContextCheckpointTextV2,
247    status: ContextCheckpointDecisionStatusV1,
248    evidence_refs: Vec<ProtocolReference>,
249});
250
251/// The portable live state needed to continue a V3 checkpoint.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
253#[serde(deny_unknown_fields)]
254pub struct ContextCheckpointLiveStateV2 {
255    /// Schema version of the live-state contract.
256    pub schema_version: u32,
257    /// Active task.
258    pub task: ContextCheckpointTaskV2,
259    /// Progress on the active task.
260    pub progress: ContextCheckpointProgressV2,
261    /// Decisions taken, sorted by decision identity.
262    pub decisions: Vec<ContextCheckpointDecisionV2>,
263    /// Findings worth carrying forward, in author order and unique.
264    pub findings: Vec<ContextCheckpointTextV2>,
265    /// Next steps, in execution order and unique.
266    pub next_steps: Vec<ContextCheckpointTextV2>,
267    /// Bounded handoff summary for the next worker.
268    pub handoff_summary: ContextCheckpointTextV2,
269    /// Files in scope, sorted by source identity.
270    pub files: Vec<ContextCheckpointFileV1>,
271    /// Tuning profile in force, when one is pinned.
272    pub profile_id: Option<crate::ProfileId>,
273    /// Policies pinned, sorted by policy identity.
274    pub policy_pins: Vec<ContextCheckpointPolicyPinV1>,
275    /// Packages pinned, sorted by package identity.
276    pub package_pins: Vec<ContextCheckpointPackagePinV1>,
277    /// Relevant portable session state, when one resumes a session.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub session_state: Option<ContextCheckpointSessionStateV1>,
280    /// Reference-only learning state, when one is pinned.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub learning_state: Option<ContextCheckpointLearningStateV1>,
283}
284
285impl ContextCheckpointLiveStateV2 {
286    /// Schema version represented by this type.
287    pub const SCHEMA_VERSION: u32 = 2;
288
289    /// Validate every live-state bound, ordering rule, and identity.
290    pub fn validate(&self) -> Result<(), ValidationError> {
291        if self.schema_version != Self::SCHEMA_VERSION {
292            return Err(ValidationError::new(format!(
293                "unsupported live-state V2 schema_version {}; expected {}",
294                self.schema_version,
295                Self::SCHEMA_VERSION
296            )));
297        }
298        self.task.validate()?;
299        self.progress.validate()?;
300
301        require_capacity(
302            self.decisions.len(),
303            MAX_CONTEXT_CHECKPOINT_DECISIONS,
304            "decisions",
305        )?;
306        for decision in &self.decisions {
307            decision.validate()?;
308        }
309        require_sorted_unique(&self.decisions, "decisions", |decision| {
310            decision.decision_id.as_str()
311        })?;
312
313        require_capacity(
314            self.findings.len(),
315            MAX_CONTEXT_CHECKPOINT_FINDINGS,
316            "findings",
317        )?;
318        require_unique(&self.findings, "findings", ContextCheckpointTextV2::as_str)?;
319        require_capacity(
320            self.next_steps.len(),
321            MAX_CONTEXT_CHECKPOINT_NEXT_STEPS,
322            "next_steps",
323        )?;
324        require_unique(
325            &self.next_steps,
326            "next_steps",
327            ContextCheckpointTextV2::as_str,
328        )?;
329
330        require_capacity(self.files.len(), MAX_CONTEXT_CHECKPOINT_FILES, "files")?;
331        for file in &self.files {
332            file.validate()?;
333        }
334        require_sorted_unique(&self.files, "files", |file| file.source_id.as_str())?;
335
336        if let Some(profile_id) = &self.profile_id {
337            validate_checkpoint_identifier(profile_id.as_str(), "profile_id")?;
338        }
339        require_capacity(
340            self.policy_pins.len(),
341            MAX_CONTEXT_CHECKPOINT_POLICY_PINS,
342            "policy_pins",
343        )?;
344        for pin in &self.policy_pins {
345            pin.validate()?;
346        }
347        require_sorted_unique(&self.policy_pins, "policy_pins", |pin| {
348            pin.policy_id.as_str()
349        })?;
350        require_capacity(
351            self.package_pins.len(),
352            MAX_CONTEXT_CHECKPOINT_PACKAGE_PINS,
353            "package_pins",
354        )?;
355        for pin in &self.package_pins {
356            pin.validate()?;
357        }
358        require_sorted_unique(&self.package_pins, "package_pins", |pin| {
359            pin.package_id.as_str()
360        })?;
361        if let Some(session_state) = &self.session_state {
362            session_state.validate()?;
363        }
364        if let Some(learning_state) = &self.learning_state {
365            learning_state.validate()?;
366        }
367        Ok(())
368    }
369}
370
371validated_deserialize!(ContextCheckpointLiveStateV2 {
372    schema_version: u32,
373    task: ContextCheckpointTaskV2,
374    progress: ContextCheckpointProgressV2,
375    decisions: Vec<ContextCheckpointDecisionV2>,
376    findings: Vec<ContextCheckpointTextV2>,
377    next_steps: Vec<ContextCheckpointTextV2>,
378    handoff_summary: ContextCheckpointTextV2,
379    files: Vec<ContextCheckpointFileV1>,
380    profile_id: Option<crate::ProfileId>,
381    policy_pins: Vec<ContextCheckpointPolicyPinV1>,
382    package_pins: Vec<ContextCheckpointPackagePinV1>,
383    session_state: Option<ContextCheckpointSessionStateV1>,
384    learning_state: Option<ContextCheckpointLearningStateV1>,
385});
386
387/// Canonical typed live-state checkpoint with additive Unicode prose.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct ContextCheckpointV3 {
390    /// Schema version of the checkpoint contract.
391    pub schema_version: u32,
392    /// Parent, branch, and device identity.
393    pub identity: ContextCheckpointIdentityV1,
394    /// Work-graph lineage references and authoritative artifact refs.
395    pub lineage: ContextCheckpointLineageV2,
396    /// Portable Unicode live state.
397    pub live_state: ContextCheckpointLiveStateV2,
398    /// Binding to a P6 carrier envelope, when the checkpoint travels in one.
399    pub carrier: Option<ContextCheckpointCarrierBindingV1>,
400    /// Engine version that produced the checkpoint.
401    pub engine_version: SemanticVersion,
402    /// Canonical UTC creation timestamp.
403    pub created_at: UtcTimestamp,
404    /// Canonical UTC timestamp of the latest portable semantic update.
405    pub updated_at: UtcTimestamp,
406    /// Metadata for an encrypted downstream projection, when present.
407    pub encryption_metadata: Option<ContextCheckpointEncryptionMetadataV1>,
408}
409
410#[derive(Deserialize)]
411#[serde(deny_unknown_fields)]
412struct ContextCheckpointWireV3 {
413    schema_version: u32,
414    identity: ContextCheckpointIdentityV1,
415    lineage: ContextCheckpointLineageV2,
416    live_state: ContextCheckpointLiveStateV2,
417    carrier: Option<ContextCheckpointCarrierBindingV1>,
418    engine_version: SemanticVersion,
419    created_at: UtcTimestamp,
420    updated_at: UtcTimestamp,
421    #[serde(default)]
422    encryption_metadata: Option<ContextCheckpointEncryptionMetadataV1>,
423}
424
425impl<'de> Deserialize<'de> for ContextCheckpointV3 {
426    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
427    where
428        D: Deserializer<'de>,
429    {
430        let wire = ContextCheckpointWireV3::deserialize(deserializer)?;
431        let checkpoint = Self {
432            schema_version: wire.schema_version,
433            identity: wire.identity,
434            lineage: wire.lineage,
435            live_state: wire.live_state,
436            carrier: wire.carrier,
437            engine_version: wire.engine_version,
438            created_at: wire.created_at,
439            updated_at: wire.updated_at,
440            encryption_metadata: wire.encryption_metadata,
441        };
442        checkpoint.validate().map_err(D::Error::custom)?;
443        Ok(checkpoint)
444    }
445}
446
447impl ContextCheckpointV3 {
448    /// Schema version represented by this type.
449    pub const SCHEMA_VERSION: u32 = 3;
450
451    /// Schema identity represented by this type.
452    pub const SCHEMA_ID: &'static str = CONTEXT_CHECKPOINT_V3_SCHEMA_ID;
453
454    /// Construct a V3 checkpoint from an already validated V2 value.
455    ///
456    /// This is a structural migration only.  It does not verify artifact
457    /// ownership and therefore does not expose a signing payload; production
458    /// callers must use the core lineage admission boundary first.
459    pub fn from_v2_unverified(
460        checkpoint: crate::ContextCheckpointV2,
461    ) -> Result<Self, ValidationError> {
462        checkpoint.validate()?;
463        let old = checkpoint.live_state;
464        let live_state = ContextCheckpointLiveStateV2 {
465            schema_version: ContextCheckpointLiveStateV2::SCHEMA_VERSION,
466            task: ContextCheckpointTaskV2 {
467                task_id: old.task.task_id,
468                title: ContextCheckpointTextV2::new(old.task.title.into_inner())?,
469                status: old.task.status,
470                plan_id: old.task.plan_id,
471            },
472            progress: ContextCheckpointProgressV2 {
473                completed_steps: old.progress.completed_steps,
474                total_steps: old.progress.total_steps,
475                confidence_milliunits: old.progress.confidence_milliunits,
476                summary: ContextCheckpointTextV2::new(old.progress.summary.into_inner())?,
477            },
478            decisions: old
479                .decisions
480                .into_iter()
481                .map(|decision| {
482                    Ok(ContextCheckpointDecisionV2 {
483                        decision_id: decision.decision_id,
484                        statement: ContextCheckpointTextV2::new(decision.statement.into_inner())?,
485                        rationale: ContextCheckpointTextV2::new(decision.rationale.into_inner())?,
486                        status: decision.status,
487                        evidence_refs: decision.evidence_refs,
488                    })
489                })
490                .collect::<Result<_, ValidationError>>()?,
491            findings: old
492                .findings
493                .into_iter()
494                .map(|text| ContextCheckpointTextV2::new(text.into_inner()))
495                .collect::<Result<_, ValidationError>>()?,
496            next_steps: old
497                .next_steps
498                .into_iter()
499                .map(|text| ContextCheckpointTextV2::new(text.into_inner()))
500                .collect::<Result<_, ValidationError>>()?,
501            handoff_summary: ContextCheckpointTextV2::new(old.handoff_summary.into_inner())?,
502            files: old.files,
503            profile_id: old.profile_id,
504            policy_pins: old.policy_pins,
505            package_pins: old.package_pins,
506            session_state: old.session_state,
507            learning_state: old.learning_state,
508        };
509        let migrated = Self {
510            schema_version: Self::SCHEMA_VERSION,
511            identity: checkpoint.identity,
512            lineage: checkpoint.lineage,
513            live_state,
514            carrier: checkpoint.carrier,
515            engine_version: checkpoint.engine_version,
516            created_at: checkpoint.created_at,
517            updated_at: checkpoint.updated_at,
518            encryption_metadata: checkpoint.encryption_metadata,
519        };
520        migrated.validate()?;
521        Ok(migrated)
522    }
523
524    /// Validate every V3 invariant, including lineage and cross-field bindings.
525    pub fn validate(&self) -> Result<(), ValidationError> {
526        if self.schema_version != Self::SCHEMA_VERSION {
527            return Err(ValidationError::new(format!(
528                "unsupported checkpoint V3 schema_version {}; expected {}",
529                self.schema_version,
530                Self::SCHEMA_VERSION
531            )));
532        }
533        self.identity.validate()?;
534        self.lineage.validate()?;
535        self.live_state.validate()?;
536        if self.live_state.task.task_id != self.lineage.task_id {
537            return Err(ValidationError::new(
538                "live_state task_id must equal lineage task_id",
539            ));
540        }
541        if self.live_state.task.plan_id != self.lineage.plan_id {
542            return Err(ValidationError::new(
543                "live_state plan_id must equal lineage plan_id",
544            ));
545        }
546        if let Some(carrier) = &self.carrier {
547            carrier.validate()?;
548            if carrier.workspace_id != self.lineage.workspace_id {
549                return Err(ValidationError::new(
550                    "carrier workspace_id must equal lineage workspace_id",
551                ));
552            }
553        }
554        if let Some(encryption_metadata) = &self.encryption_metadata {
555            encryption_metadata.validate()?;
556        }
557        if let Some(session) = &self.live_state.session_state {
558            if session.identity.tenant_id.as_ref() != Some(&self.lineage.tenant_id)
559                || session.identity.project_id != self.lineage.project_id
560                || session.identity.workspace_id.as_ref() != Some(&self.lineage.workspace_id)
561                || session.identity.task_id != self.lineage.task_id
562            {
563                return Err(ValidationError::new(
564                    "session identity must match checkpoint tenant, project, workspace, and task",
565                ));
566            }
567            if self.lineage.plan_id.is_none() {
568                return Err(ValidationError::new(
569                    "session checkpoints require an explicit plan_id",
570                ));
571            }
572            if session.state.active_plan_id != self.lineage.plan_id {
573                return Err(ValidationError::new(
574                    "session active_plan_id must equal checkpoint lineage plan_id",
575                ));
576            }
577        }
578        if self.updated_at < self.created_at {
579            return Err(ValidationError::new(
580                "updated_at must not precede created_at",
581            ));
582        }
583        Ok(())
584    }
585
586    /// Return canonical compact JSON bytes with lexicographically sorted keys.
587    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
588        self.validate()?;
589        canonical_bytes_of(self, "checkpoint V3")
590    }
591
592    /// Return the canonical JSON encoding as UTF-8 text.
593    pub fn canonical_json(&self) -> Result<String, ValidationError> {
594        String::from_utf8(self.canonical_bytes()?)
595            .map_err(|error| ValidationError::new(format!("checkpoint V3 UTF-8: {error}")))
596    }
597
598    /// Decode exactly canonical V3 JSON and validate every invariant.
599    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
600        if bytes.len() > MAX_CONTEXT_CHECKPOINT_V3_ENCODED_BYTES {
601            return Err(ValidationError::new(format!(
602                "checkpoint V3 exceeds the {MAX_CONTEXT_CHECKPOINT_V3_ENCODED_BYTES} byte encoded limit"
603            )));
604        }
605        let checkpoint = serde_json::from_slice::<Self>(bytes)
606            .map_err(|error| ValidationError::new(format!("decode checkpoint V3: {error}")))?;
607        if checkpoint.canonical_bytes()? != bytes {
608            return Err(ValidationError::new(
609                "checkpoint V3 JSON is not canonical UTF-8, compactness or key order",
610            ));
611        }
612        Ok(checkpoint)
613    }
614
615    /// Domain-separated content identity of the complete V3 checkpoint.
616    pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
617        digest_with_domain(
618            CONTEXT_CHECKPOINT_V3_DIGEST_DOMAIN,
619            &self.canonical_bytes()?,
620        )
621    }
622}
623
624impl Serialize for ContextCheckpointV3 {
625    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
626    where
627        S: serde::Serializer,
628    {
629        self.validate().map_err(serde::ser::Error::custom)?;
630        #[derive(Serialize)]
631        struct Wire<'a> {
632            schema_version: u32,
633            identity: &'a ContextCheckpointIdentityV1,
634            lineage: &'a ContextCheckpointLineageV2,
635            live_state: &'a ContextCheckpointLiveStateV2,
636            carrier: &'a Option<ContextCheckpointCarrierBindingV1>,
637            engine_version: &'a SemanticVersion,
638            created_at: &'a UtcTimestamp,
639            updated_at: &'a UtcTimestamp,
640            #[serde(skip_serializing_if = "Option::is_none")]
641            encryption_metadata: &'a Option<ContextCheckpointEncryptionMetadataV1>,
642        }
643        Wire {
644            schema_version: self.schema_version,
645            identity: &self.identity,
646            lineage: &self.lineage,
647            live_state: &self.live_state,
648            carrier: &self.carrier,
649            engine_version: &self.engine_version,
650            created_at: &self.created_at,
651            updated_at: &self.updated_at,
652            encryption_metadata: &self.encryption_metadata,
653        }
654        .serialize(serializer)
655    }
656}
657
658fn validate_unicode_checkpoint_text(value: &str, field: &str) -> Result<(), ValidationError> {
659    if value.trim().is_empty() {
660        return Err(ValidationError::new(format!("{field} must not be empty")));
661    }
662    if value.len() > MAX_CONTEXT_CHECKPOINT_TEXT_BYTES {
663        return Err(ValidationError::new(format!(
664            "{field} exceeds the {MAX_CONTEXT_CHECKPOINT_TEXT_BYTES} byte limit"
665        )));
666    }
667    if value != value.trim() {
668        return Err(ValidationError::new(format!(
669            "{field} must not have leading or trailing whitespace"
670        )));
671    }
672    if value.chars().any(|character| {
673        character.is_control()
674            || matches!(character, '\u{2028}' | '\u{2029}')
675            || is_unicode_format_character(character)
676    }) {
677        return Err(ValidationError::new(format!(
678            "{field} must not contain controls, line separators, or Unicode format characters"
679        )));
680    }
681    reject_machine_local_content(value, field)
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::context_checkpoint::{
688        CONTEXT_CHECKPOINT_V2_SCHEMA_ID, ContextCheckpointArtifactLineageV1,
689        ContextCheckpointBranchIdV1, ContextCheckpointDeviceIdV1, ContextCheckpointIdV1,
690        ContextCheckpointTextV1,
691    };
692    use crate::{ProjectId, TenantId, WorkspaceId};
693
694    fn digest(seed: char) -> Sha256Digest {
695        Sha256Digest::new(format!("sha256:{}", seed.to_string().repeat(64))).expect("digest")
696    }
697
698    fn fixture() -> ContextCheckpointV3 {
699        let identity = ContextCheckpointIdentityV1::try_new(
700            ContextCheckpointIdV1::new("11111111-2222-4333-8444-555555555555").expect("id"),
701            None,
702            None,
703            None,
704            None,
705            ContextCheckpointBranchIdV1::new("main").expect("branch"),
706            ContextCheckpointDeviceIdV1::new("device-a").expect("device"),
707            1,
708        )
709        .expect("identity");
710        let task_id = TaskId::new("task-a").expect("task");
711        let lineage = ContextCheckpointLineageV2 {
712            project_id: ProjectId::new("project-a").expect("project"),
713            workspace_id: WorkspaceId::new("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")
714                .expect("workspace"),
715            tenant_id: TenantId::new("tenant-a").expect("tenant"),
716            task_id: task_id.clone(),
717            plan_id: None,
718            receipt_ids: Vec::new(),
719            artifact_lineage: ContextCheckpointArtifactLineageV1 {
720                task_ref: digest('1'),
721                plan_ref: None,
722                receipt_refs: Vec::new(),
723            },
724            context_ir_digest: None,
725            hosted_index_digest: None,
726            evidence_refs: Vec::new(),
727            knowledge_refs: Vec::new(),
728            gotcha_refs: Vec::new(),
729            snapshot_refs: Vec::new(),
730        };
731        ContextCheckpointV3 {
732            schema_version: ContextCheckpointV3::SCHEMA_VERSION,
733            identity,
734            lineage,
735            live_state: ContextCheckpointLiveStateV2 {
736                schema_version: ContextCheckpointLiveStateV2::SCHEMA_VERSION,
737                task: ContextCheckpointTaskV2 {
738                    task_id,
739                    title: ContextCheckpointTextV2::new("Résumé").expect("title"),
740                    status: ContextCheckpointTaskStatusV1::InProgress,
741                    plan_id: None,
742                },
743                progress: ContextCheckpointProgressV2 {
744                    completed_steps: 0,
745                    total_steps: 1,
746                    confidence_milliunits: 500,
747                    summary: ContextCheckpointTextV2::new("Étape prête").expect("summary"),
748                },
749                decisions: Vec::new(),
750                findings: vec![ContextCheckpointTextV2::new("Δ finding").expect("finding")],
751                next_steps: vec![ContextCheckpointTextV2::new("Étape suivante").expect("step")],
752                handoff_summary: ContextCheckpointTextV2::new("Prêt").expect("handoff"),
753                files: Vec::new(),
754                profile_id: None,
755                policy_pins: Vec::new(),
756                package_pins: Vec::new(),
757                session_state: None,
758                learning_state: None,
759            },
760            carrier: None,
761            engine_version: SemanticVersion::new("4.0.0").expect("version"),
762            created_at: UtcTimestamp::new("2026-09-06T12:00:00Z").expect("created"),
763            updated_at: UtcTimestamp::new("2026-09-06T12:00:00Z").expect("updated"),
764            encryption_metadata: None,
765        }
766    }
767
768    #[test]
769    fn unicode_text_round_trips_without_normalization() {
770        let composed = ContextCheckpointTextV2::new("café").expect("composed text");
771        let decomposed = ContextCheckpointTextV2::new("cafe\u{301}").expect("decomposed text");
772        assert_ne!(composed, decomposed);
773        assert_eq!(composed.as_str(), "café");
774        assert!(ContextCheckpointTextV1::new("café").is_err());
775    }
776
777    #[test]
778    fn unicode_text_rejects_controls_paths_and_credentials() {
779        for value in [
780            "line\nfeed",
781            "\u{2028}",
782            "\u{200b}",
783            "/private/state",
784            "token=secret",
785        ] {
786            assert!(ContextCheckpointTextV2::new(value).is_err(), "{value:?}");
787        }
788    }
789
790    #[test]
791    fn v3_domain_and_schema_are_distinct_from_v2() {
792        assert_ne!(
793            CONTEXT_CHECKPOINT_V3_SCHEMA_ID,
794            CONTEXT_CHECKPOINT_V2_SCHEMA_ID
795        );
796        assert_ne!(
797            CONTEXT_CHECKPOINT_V3_DIGEST_DOMAIN,
798            crate::CONTEXT_CHECKPOINT_V2_DIGEST_DOMAIN
799        );
800        assert_ne!(
801            CONTEXT_CHECKPOINT_V3_SIGNATURE_DOMAIN,
802            crate::CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN
803        );
804    }
805
806    #[test]
807    fn v3_unicode_fixture_round_trips_and_digest_changes() {
808        let checkpoint = fixture();
809        let bytes = checkpoint.canonical_bytes().expect("canonical bytes");
810        let decoded = ContextCheckpointV3::from_canonical_bytes(&bytes).expect("round trip");
811        assert_eq!(decoded, checkpoint);
812        assert!(String::from_utf8(bytes).expect("UTF-8").contains("Résumé"));
813
814        let original_digest = checkpoint.digest().expect("digest");
815        let mut changed = checkpoint;
816        changed.live_state.findings[0] =
817            ContextCheckpointTextV2::new("Δ changed").expect("changed");
818        assert_ne!(original_digest, changed.digest().expect("changed digest"));
819    }
820
821    #[test]
822    fn v3_rejects_pretty_escaped_unknown_and_wrong_version_wire() {
823        let checkpoint = fixture();
824        let bytes = checkpoint.canonical_bytes().expect("canonical bytes");
825        assert!(
826            ContextCheckpointV3::from_canonical_bytes(
827                &serde_json::to_vec_pretty(&checkpoint).expect("pretty")
828            )
829            .is_err()
830        );
831        let escaped = String::from_utf8(bytes.clone())
832            .expect("UTF-8")
833            .replace("Résumé", "R\\u00e9sum\\u00e9");
834        assert!(ContextCheckpointV3::from_canonical_bytes(escaped.as_bytes()).is_err());
835
836        let mut unknown: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON");
837        unknown["unknown"] = serde_json::Value::Bool(true);
838        assert!(
839            ContextCheckpointV3::from_canonical_bytes(
840                &serde_json::to_vec(&unknown).expect("unknown")
841            )
842            .is_err()
843        );
844
845        let mut wrong_version: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON");
846        wrong_version["live_state"]["schema_version"] = serde_json::Value::from(1);
847        assert!(
848            ContextCheckpointV3::from_canonical_bytes(
849                &serde_json::to_vec(&wrong_version).expect("wrong version")
850            )
851            .is_err()
852        );
853    }
854}