1use 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
32pub const CONTEXT_CHECKPOINT_V3_SCHEMA_ID: &str = "leanctx.context-checkpoint-live/v3";
34
35pub const CONTEXT_CHECKPOINT_V3_DIGEST_DOMAIN: &[u8] = b"leanctx/context-checkpoint/v3\0";
37
38pub const CONTEXT_CHECKPOINT_V3_SIGNATURE_DOMAIN: &[u8] =
40 b"leanctx/context-checkpoint-signature/v3\0";
41
42pub 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
69#[serde(transparent)]
70pub struct ContextCheckpointTextV2(String);
71
72impl ContextCheckpointTextV2 {
73 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 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84
85 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138#[serde(deny_unknown_fields)]
139pub struct ContextCheckpointTaskV2 {
140 pub task_id: TaskId,
142 pub title: ContextCheckpointTextV2,
144 pub status: ContextCheckpointTaskStatusV1,
146 pub plan_id: Option<PlanId>,
148}
149
150impl ContextCheckpointTaskV2 {
151 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170#[serde(deny_unknown_fields)]
171pub struct ContextCheckpointProgressV2 {
172 pub completed_steps: u32,
174 pub total_steps: u32,
176 #[serde(deserialize_with = "deserialize_milliunit")]
178 pub confidence_milliunits: u16,
179 pub summary: ContextCheckpointTextV2,
181}
182
183impl ContextCheckpointProgressV2 {
184 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
209#[serde(deny_unknown_fields)]
210pub struct ContextCheckpointDecisionV2 {
211 pub decision_id: DecisionId,
213 pub statement: ContextCheckpointTextV2,
215 pub rationale: ContextCheckpointTextV2,
217 pub status: ContextCheckpointDecisionStatusV1,
219 pub evidence_refs: Vec<ProtocolReference>,
221}
222
223impl ContextCheckpointDecisionV2 {
224 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
253#[serde(deny_unknown_fields)]
254pub struct ContextCheckpointLiveStateV2 {
255 pub schema_version: u32,
257 pub task: ContextCheckpointTaskV2,
259 pub progress: ContextCheckpointProgressV2,
261 pub decisions: Vec<ContextCheckpointDecisionV2>,
263 pub findings: Vec<ContextCheckpointTextV2>,
265 pub next_steps: Vec<ContextCheckpointTextV2>,
267 pub handoff_summary: ContextCheckpointTextV2,
269 pub files: Vec<ContextCheckpointFileV1>,
271 pub profile_id: Option<crate::ProfileId>,
273 pub policy_pins: Vec<ContextCheckpointPolicyPinV1>,
275 pub package_pins: Vec<ContextCheckpointPackagePinV1>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub session_state: Option<ContextCheckpointSessionStateV1>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub learning_state: Option<ContextCheckpointLearningStateV1>,
283}
284
285impl ContextCheckpointLiveStateV2 {
286 pub const SCHEMA_VERSION: u32 = 2;
288
289 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#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct ContextCheckpointV3 {
390 pub schema_version: u32,
392 pub identity: ContextCheckpointIdentityV1,
394 pub lineage: ContextCheckpointLineageV2,
396 pub live_state: ContextCheckpointLiveStateV2,
398 pub carrier: Option<ContextCheckpointCarrierBindingV1>,
400 pub engine_version: SemanticVersion,
402 pub created_at: UtcTimestamp,
404 pub updated_at: UtcTimestamp,
406 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 pub const SCHEMA_VERSION: u32 = 3;
450
451 pub const SCHEMA_ID: &'static str = CONTEXT_CHECKPOINT_V3_SCHEMA_ID;
453
454 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 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 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
588 self.validate()?;
589 canonical_bytes_of(self, "checkpoint V3")
590 }
591
592 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 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 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}