Skip to main content

lean_ctx_protocol/context_checkpoint/
v1.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use super::*;
4
5/// Lifecycle status of the task a checkpoint continues.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ContextCheckpointTaskStatusV1 {
9    /// Planned but not started.
10    Planned,
11    /// Actively being worked.
12    InProgress,
13    /// Blocked on an external dependency.
14    Blocked,
15    /// Awaiting review.
16    Review,
17    /// Finished successfully.
18    Completed,
19    /// Abandoned without completion.
20    Abandoned,
21}
22
23/// Status of a decision recorded in the live state.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ContextCheckpointDecisionStatusV1 {
27    /// Proposed and not yet resolved.
28    Proposed,
29    /// Accepted and in force.
30    Accepted,
31    /// Rejected and retained for provenance.
32    Rejected,
33    /// Replaced by a later decision.
34    Superseded,
35}
36
37/// Role a file plays in the checkpointed work.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ContextCheckpointFileRoleV1 {
41    /// Read as an input.
42    Input,
43    /// Consulted for reference only.
44    Reference,
45    /// Modified in place.
46    Modified,
47    /// Newly created.
48    Created,
49    /// Removed.
50    Deleted,
51}
52
53/// Portable, bounded session state needed to continue work.
54///
55/// This intentionally contains counters and references only.  It does not
56/// carry the live session cache, process state, locks, PIDs, or flush times.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct ContextCheckpointSessionStateV1 {
60    /// Existing immutable session identity, including tenant and workspace scope.
61    pub identity: SessionIdentityV1,
62    /// Existing portable session continuation state.
63    pub state: ContextSessionStateV1,
64}
65
66impl ContextCheckpointSessionStateV1 {
67    /// Construct session metadata after applying lifecycle invariants.
68    pub fn try_new(
69        identity: SessionIdentityV1,
70        state: ContextSessionStateV1,
71    ) -> Result<Self, ValidationError> {
72        let checkpoint_state = Self { identity, state };
73        checkpoint_state.validate()?;
74        Ok(checkpoint_state)
75    }
76
77    /// Validate portable session lifecycle state.
78    pub fn validate(&self) -> Result<(), ValidationError> {
79        self.identity.validate()?;
80        validate_checkpoint_identifier(self.identity.session_id.as_str(), "session_id")?;
81        validate_checkpoint_identifier(self.identity.task_id.as_str(), "session task_id")?;
82        validate_checkpoint_identifier(self.identity.run_id.as_str(), "session run_id")?;
83        validate_checkpoint_identifier(self.identity.trace_id.as_str(), "session trace_id")?;
84        validate_checkpoint_identifier(self.identity.agent_id.as_str(), "session agent_id")?;
85        validate_checkpoint_identifier(self.identity.project_id.as_str(), "session project_id")?;
86        if let Some(parent_task_id) = &self.identity.parent_task_id {
87            validate_checkpoint_identifier(parent_task_id.as_str(), "session parent_task_id")?;
88        }
89        if let Some(active_plan_id) = &self.state.active_plan_id {
90            validate_checkpoint_identifier(active_plan_id.as_str(), "session active_plan_id")?;
91        }
92        if let Some(receipt_id) = &self.state.receipt_id {
93            validate_checkpoint_identifier(receipt_id.as_str(), "session receipt_id")?;
94        }
95        validate_checkpoint_reference(
96            self.identity.project_root_ref.as_str(),
97            "session project_root_ref",
98        )?;
99        if let Some(reference) = &self.identity.project_revision_ref {
100            validate_checkpoint_reference(reference.as_str(), "session project_revision_ref")?;
101        }
102        self.state.validate()?;
103        if let Some(abort_reason) = &self.state.abort_reason {
104            validate_checkpoint_reference(abort_reason.as_str(), "session abort_reason")?;
105        }
106        Ok(())
107    }
108}
109
110validated_deserialize!(ContextCheckpointSessionStateV1 {
111    identity: SessionIdentityV1,
112    state: ContextSessionStateV1,
113});
114
115/// Portable reference to a separately stored learning-state projection.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117#[serde(deny_unknown_fields)]
118pub struct ContextCheckpointLearningStateV1 {
119    /// Non-path reference to the exported learning state.
120    pub state_ref: ProtocolReference,
121    /// Content identity of the exported learning state.
122    pub state_digest: Sha256Digest,
123    /// Monotonic learning-state revision.
124    pub revision: u64,
125}
126
127impl ContextCheckpointLearningStateV1 {
128    /// Construct a reference-only learning-state pin.
129    pub fn try_new(
130        state_ref: ProtocolReference,
131        state_digest: Sha256Digest,
132        revision: u64,
133    ) -> Result<Self, ValidationError> {
134        let state = Self {
135            state_ref,
136            state_digest,
137            revision,
138        };
139        state.validate()?;
140        Ok(state)
141    }
142
143    /// Validate the learning-state reference and revision.
144    pub fn validate(&self) -> Result<(), ValidationError> {
145        validate_checkpoint_reference(self.state_ref.as_str(), "learning state_ref")
146    }
147}
148
149validated_deserialize!(ContextCheckpointLearningStateV1 {
150    state_ref: ProtocolReference,
151    state_digest: Sha256Digest,
152    revision: u64,
153});
154
155/// Encryption metadata for a separately encrypted checkpoint projection.
156///
157/// This is metadata only: the key is never carried, and this domain does not
158/// implement encryption, decryption, key management, or trust evaluation.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum ContextCheckpointEncryptionAlgorithmV1 {
162    /// AES-256-GCM metadata.
163    Aes256Gcm,
164    /// XChaCha20-Poly1305 is an additional metadata-only algorithm option.
165    XChaCha20Poly1305,
166}
167
168/// Reference-only encryption metadata bound into the checkpoint digest.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170#[serde(deny_unknown_fields)]
171pub struct ContextCheckpointEncryptionMetadataV1 {
172    /// Encryption algorithm used by a downstream private consumer.
173    pub algorithm: ContextCheckpointEncryptionAlgorithmV1,
174    /// Non-secret reference to an external key-management object.
175    pub key_ref: ProtocolReference,
176    /// Digest of the encrypted projection bytes.
177    pub ciphertext_digest: Sha256Digest,
178}
179
180impl ContextCheckpointEncryptionMetadataV1 {
181    /// Construct encryption metadata without accepting key material.
182    pub fn try_new(
183        algorithm: ContextCheckpointEncryptionAlgorithmV1,
184        key_ref: ProtocolReference,
185        ciphertext_digest: Sha256Digest,
186    ) -> Result<Self, ValidationError> {
187        let metadata = Self {
188            algorithm,
189            key_ref,
190            ciphertext_digest,
191        };
192        metadata.validate()?;
193        Ok(metadata)
194    }
195
196    /// Validate the non-secret key reference and ciphertext identity.
197    pub fn validate(&self) -> Result<(), ValidationError> {
198        validate_checkpoint_reference(self.key_ref.as_str(), "encryption key_ref")
199    }
200}
201
202validated_deserialize!(ContextCheckpointEncryptionMetadataV1 {
203    algorithm: ContextCheckpointEncryptionAlgorithmV1,
204    key_ref: ProtocolReference,
205    ciphertext_digest: Sha256Digest,
206});
207
208/// Parent, branch, and device identity of one checkpoint.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210#[serde(deny_unknown_fields)]
211pub struct ContextCheckpointIdentityV1 {
212    /// Identity of this checkpoint.
213    pub checkpoint_id: ContextCheckpointIdV1,
214    /// Identity of the checkpoint this one continues, if any.
215    pub parent_checkpoint_id: Option<ContextCheckpointIdV1>,
216    /// Device that produced the parent checkpoint.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub parent_device_id: Option<ContextCheckpointDeviceIdV1>,
219    /// Branch advanced by the parent checkpoint.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub parent_branch_id: Option<ContextCheckpointBranchIdV1>,
222    /// Parent's monotonic sequence on its producing device.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub parent_device_sequence: Option<u64>,
225    /// Branch the writing device advances.
226    pub branch_id: ContextCheckpointBranchIdV1,
227    /// Device that produced this checkpoint.
228    pub device_id: ContextCheckpointDeviceIdV1,
229    /// Monotonic per-device sequence number, starting at one.
230    pub device_sequence: u64,
231}
232
233impl ContextCheckpointIdentityV1 {
234    /// Construct a validated identity.
235    pub fn try_new(
236        checkpoint_id: ContextCheckpointIdV1,
237        parent_checkpoint_id: Option<ContextCheckpointIdV1>,
238        parent_device_id: Option<ContextCheckpointDeviceIdV1>,
239        parent_branch_id: Option<ContextCheckpointBranchIdV1>,
240        parent_device_sequence: Option<u64>,
241        branch_id: ContextCheckpointBranchIdV1,
242        device_id: ContextCheckpointDeviceIdV1,
243        device_sequence: u64,
244    ) -> Result<Self, ValidationError> {
245        let identity = Self {
246            checkpoint_id,
247            parent_checkpoint_id,
248            parent_device_id,
249            parent_branch_id,
250            parent_device_sequence,
251            branch_id,
252            device_id,
253            device_sequence,
254        };
255        identity.validate()?;
256        Ok(identity)
257    }
258
259    /// Validate every identity and sequence invariant.
260    pub fn validate(&self) -> Result<(), ValidationError> {
261        if self.device_sequence == 0 {
262            return Err(ValidationError::new(
263                "ContextCheckpointIdentityV1 device_sequence must start at 1",
264            ));
265        }
266        match (
267            &self.parent_checkpoint_id,
268            &self.parent_device_id,
269            &self.parent_branch_id,
270            self.parent_device_sequence,
271        ) {
272            (Some(parent), Some(_), Some(_), Some(_)) if parent == &self.checkpoint_id => {
273                return Err(ValidationError::new(
274                    "ContextCheckpointIdentityV1 parent_checkpoint_id must differ from checkpoint_id",
275                ));
276            }
277            (Some(_), Some(parent_device), Some(_), Some(parent_sequence)) => {
278                if parent_sequence == 0 {
279                    return Err(ValidationError::new(
280                        "ContextCheckpointIdentityV1 parent_device_sequence must start at 1",
281                    ));
282                }
283                let expected = parent_sequence.checked_add(1).ok_or_else(|| {
284                    ValidationError::new(
285                        "ContextCheckpointIdentityV1 parent_device_sequence cannot overflow",
286                    )
287                })?;
288                if parent_device == &self.device_id && self.device_sequence != expected {
289                    return Err(ValidationError::new(
290                        "ContextCheckpointIdentityV1 same-device sequence must directly follow parent",
291                    ));
292                }
293            }
294            (None, None, None, None) if self.device_sequence != 1 => {
295                return Err(ValidationError::new(
296                    "ContextCheckpointIdentityV1 root checkpoints must use device_sequence 1",
297                ));
298            }
299            (None, None, None, None) => {}
300            _ => {
301                return Err(ValidationError::new(
302                    "ContextCheckpointIdentityV1 parent identity fields must be all present or all absent",
303                ));
304            }
305        }
306        Ok(())
307    }
308}
309
310validated_deserialize!(ContextCheckpointIdentityV1 {
311    checkpoint_id: ContextCheckpointIdV1,
312    parent_checkpoint_id: Option<ContextCheckpointIdV1>,
313    parent_device_id: Option<ContextCheckpointDeviceIdV1>,
314    parent_branch_id: Option<ContextCheckpointBranchIdV1>,
315    parent_device_sequence: Option<u64>,
316    branch_id: ContextCheckpointBranchIdV1,
317    device_id: ContextCheckpointDeviceIdV1,
318    device_sequence: u64,
319});
320
321/// Lineage references binding a checkpoint to the rest of the work graph.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
323#[serde(deny_unknown_fields)]
324pub struct ContextCheckpointLineageV1 {
325    /// Project the checkpoint belongs to.
326    pub project_id: ProjectId,
327    /// Workspace the checkpoint belongs to.
328    pub workspace_id: WorkspaceId,
329    /// Tenant/account security boundary for synced continuation state.
330    pub tenant_id: TenantId,
331    /// Task the checkpoint continues.
332    pub task_id: TaskId,
333    /// Plan the task executes, when one is pinned.
334    pub plan_id: Option<PlanId>,
335    /// Receipts produced so far, sorted and unique.
336    pub receipt_ids: Vec<ReceiptId>,
337    /// Context IR content identity, when one is pinned.
338    pub context_ir_digest: Option<Sha256Digest>,
339    /// Hosted index content identity, when one is pinned.
340    pub hosted_index_digest: Option<Sha256Digest>,
341    /// Evidence references, sorted and unique.
342    pub evidence_refs: Vec<ProtocolReference>,
343    /// Knowledge-state references, sorted and unique.
344    pub knowledge_refs: Vec<ProtocolReference>,
345    /// Gotcha references, sorted and unique.
346    pub gotcha_refs: Vec<ProtocolReference>,
347    /// Context Snapshot references, sorted and unique.
348    pub snapshot_refs: Vec<ProtocolReference>,
349}
350
351impl ContextCheckpointLineageV1 {
352    /// Validate every lineage identity, reference, bound, and ordering rule.
353    pub fn validate(&self) -> Result<(), ValidationError> {
354        validate_checkpoint_identifier(self.project_id.as_str(), "project_id")?;
355        validate_checkpoint_identifier(self.workspace_id.as_str(), "workspace_id")?;
356        validate_checkpoint_identifier(self.tenant_id.as_str(), "tenant_id")?;
357        validate_checkpoint_identifier(self.task_id.as_str(), "task_id")?;
358        if let Some(plan_id) = &self.plan_id {
359            validate_checkpoint_identifier(plan_id.as_str(), "plan_id")?;
360        }
361        if self.receipt_ids.len() > MAX_CONTEXT_CHECKPOINT_LINEAGE_REFS {
362            return Err(ValidationError::new(format!(
363                "receipt_ids exceeds the {MAX_CONTEXT_CHECKPOINT_LINEAGE_REFS} item limit"
364            )));
365        }
366        for receipt_id in &self.receipt_ids {
367            validate_checkpoint_identifier(receipt_id.as_str(), "receipt_ids")?;
368        }
369        require_sorted_unique(&self.receipt_ids, "receipt_ids", ReceiptId::as_str)?;
370        for (refs, field) in [
371            (&self.evidence_refs, "evidence_refs"),
372            (&self.knowledge_refs, "knowledge_refs"),
373            (&self.gotcha_refs, "gotcha_refs"),
374            (&self.snapshot_refs, "snapshot_refs"),
375        ] {
376            validate_reference_list(refs, field)?;
377        }
378        Ok(())
379    }
380}
381
382validated_deserialize!(ContextCheckpointLineageV1 {
383    project_id: ProjectId,
384    workspace_id: WorkspaceId,
385    tenant_id: TenantId,
386    task_id: TaskId,
387    plan_id: Option<PlanId>,
388    receipt_ids: Vec<ReceiptId>,
389    context_ir_digest: Option<Sha256Digest>,
390    hosted_index_digest: Option<Sha256Digest>,
391    evidence_refs: Vec<ProtocolReference>,
392    knowledge_refs: Vec<ProtocolReference>,
393    gotcha_refs: Vec<ProtocolReference>,
394    snapshot_refs: Vec<ProtocolReference>,
395});
396
397/// Authoritative content references for the task, plan, and signed receipts
398/// represented by a V2 checkpoint.
399///
400/// The task reference is always required.  A plan reference is paired with the
401/// checkpoint's `plan_id`; receipt references are receipt identity digests and
402/// are only meaningful when that plan pair exists.  The bytes and signatures
403/// behind these digests are verified by the core lineage adapter before this
404/// value is attached to a checkpoint.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
406#[serde(deny_unknown_fields)]
407pub struct ContextCheckpointArtifactLineageV1 {
408    /// Canonical digest of the admitted `TaskEnvelopeV1`.
409    pub task_ref: Sha256Digest,
410    /// Canonical digest of the admitted `ExecutionPlanV1`, when pinned.
411    pub plan_ref: Option<Sha256Digest>,
412    /// SHA-256 digests of exact canonical ReceiptDocumentV1 bytes, sorted and unique.
413    pub receipt_refs: Vec<Sha256Digest>,
414}
415
416impl ContextCheckpointArtifactLineageV1 {
417    /// Validate shape, digest identity, pairing, ordering, and bounds.
418    pub fn validate(&self) -> Result<(), ValidationError> {
419        if self.plan_ref.is_none() && !self.receipt_refs.is_empty() {
420            return Err(ValidationError::new(
421                "receipt_refs require an explicit plan_ref",
422            ));
423        }
424        if self.receipt_refs.len() > MAX_CONTEXT_CHECKPOINT_LINEAGE_REFS {
425            return Err(ValidationError::new(format!(
426                "receipt_refs exceeds the {MAX_CONTEXT_CHECKPOINT_LINEAGE_REFS} item limit"
427            )));
428        }
429        require_sorted_unique(&self.receipt_refs, "receipt_refs", Sha256Digest::as_str)
430    }
431}
432
433validated_deserialize!(ContextCheckpointArtifactLineageV1 {
434    task_ref: Sha256Digest,
435    plan_ref: Option<Sha256Digest>,
436    receipt_refs: Vec<Sha256Digest>,
437});
438
439/// V2 checkpoint lineage that adds authoritative artifact references without
440/// changing the deployed `ContextCheckpointLineageV1` wire shape.
441#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
442#[serde(deny_unknown_fields)]
443pub struct ContextCheckpointLineageV2 {
444    pub project_id: ProjectId,
445    pub workspace_id: WorkspaceId,
446    pub tenant_id: TenantId,
447    pub task_id: TaskId,
448    pub plan_id: Option<PlanId>,
449    pub receipt_ids: Vec<ReceiptId>,
450    pub artifact_lineage: ContextCheckpointArtifactLineageV1,
451    pub context_ir_digest: Option<Sha256Digest>,
452    pub hosted_index_digest: Option<Sha256Digest>,
453    pub evidence_refs: Vec<ProtocolReference>,
454    pub knowledge_refs: Vec<ProtocolReference>,
455    pub gotcha_refs: Vec<ProtocolReference>,
456    pub snapshot_refs: Vec<ProtocolReference>,
457}
458
459impl ContextCheckpointLineageV2 {
460    /// Return the unchanged V1 compatibility projection.
461    pub fn legacy(&self) -> ContextCheckpointLineageV1 {
462        ContextCheckpointLineageV1 {
463            project_id: self.project_id.clone(),
464            workspace_id: self.workspace_id.clone(),
465            tenant_id: self.tenant_id.clone(),
466            task_id: self.task_id.clone(),
467            plan_id: self.plan_id.clone(),
468            receipt_ids: self.receipt_ids.clone(),
469            context_ir_digest: self.context_ir_digest.clone(),
470            hosted_index_digest: self.hosted_index_digest.clone(),
471            evidence_refs: self.evidence_refs.clone(),
472            knowledge_refs: self.knowledge_refs.clone(),
473            gotcha_refs: self.gotcha_refs.clone(),
474            snapshot_refs: self.snapshot_refs.clone(),
475        }
476    }
477
478    /// Validate legacy lineage plus authoritative artifact-ref pairing.
479    pub fn validate(&self) -> Result<(), ValidationError> {
480        self.legacy().validate()?;
481        self.artifact_lineage.validate()?;
482        if self.plan_id.is_some() != self.artifact_lineage.plan_ref.is_some() {
483            return Err(ValidationError::new(
484                "plan_id and artifact_lineage.plan_ref must be present together",
485            ));
486        }
487        if self.plan_id.is_none()
488            && (!self.receipt_ids.is_empty() || !self.artifact_lineage.receipt_refs.is_empty())
489        {
490            return Err(ValidationError::new(
491                "receipts require paired plan_id and plan_ref",
492            ));
493        }
494        if self.receipt_ids.len() != self.artifact_lineage.receipt_refs.len() {
495            return Err(ValidationError::new(
496                "receipt_ids and artifact_lineage.receipt_refs must pair exactly",
497            ));
498        }
499        Ok(())
500    }
501}
502
503validated_deserialize!(ContextCheckpointLineageV2 {
504    project_id: ProjectId,
505    workspace_id: WorkspaceId,
506    tenant_id: TenantId,
507    task_id: TaskId,
508    plan_id: Option<PlanId>,
509    receipt_ids: Vec<ReceiptId>,
510    artifact_lineage: ContextCheckpointArtifactLineageV1,
511    context_ir_digest: Option<Sha256Digest>,
512    hosted_index_digest: Option<Sha256Digest>,
513    evidence_refs: Vec<ProtocolReference>,
514    knowledge_refs: Vec<ProtocolReference>,
515    gotcha_refs: Vec<ProtocolReference>,
516    snapshot_refs: Vec<ProtocolReference>,
517});
518
519/// The task a checkpoint continues.
520#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
521#[serde(deny_unknown_fields)]
522pub struct ContextCheckpointTaskV1 {
523    /// Identity of the task.
524    pub task_id: TaskId,
525    /// Bounded human-readable title.
526    pub title: ContextCheckpointTextV1,
527    /// Lifecycle status.
528    pub status: ContextCheckpointTaskStatusV1,
529    /// Plan the task executes, when one is pinned.
530    pub plan_id: Option<PlanId>,
531}
532
533impl ContextCheckpointTaskV1 {
534    /// Validate the task identity bounds.
535    pub fn validate(&self) -> Result<(), ValidationError> {
536        validate_checkpoint_identifier(self.task_id.as_str(), "task.task_id")?;
537        if let Some(plan_id) = &self.plan_id {
538            validate_checkpoint_identifier(plan_id.as_str(), "task.plan_id")?;
539        }
540        Ok(())
541    }
542}
543
544validated_deserialize!(ContextCheckpointTaskV1 {
545    task_id: TaskId,
546    title: ContextCheckpointTextV1,
547    status: ContextCheckpointTaskStatusV1,
548    plan_id: Option<PlanId>,
549});
550
551/// Bounded progress report for the active task.
552#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
553#[serde(deny_unknown_fields)]
554pub struct ContextCheckpointProgressV1 {
555    /// Steps finished so far.
556    pub completed_steps: u32,
557    /// Total steps planned.
558    pub total_steps: u32,
559    /// Confidence in the reported progress, in 0..=1000 milliunits.
560    #[serde(deserialize_with = "deserialize_milliunit")]
561    pub confidence_milliunits: u16,
562    /// Bounded human-readable summary.
563    pub summary: ContextCheckpointTextV1,
564}
565
566impl ContextCheckpointProgressV1 {
567    /// Validate the progress counters and confidence bound.
568    pub fn validate(&self) -> Result<(), ValidationError> {
569        if self.total_steps == 0 || self.total_steps > MAX_CONTEXT_CHECKPOINT_STEPS {
570            return Err(ValidationError::new(format!(
571                "total_steps must be within 1..={MAX_CONTEXT_CHECKPOINT_STEPS}"
572            )));
573        }
574        if self.completed_steps > self.total_steps {
575            return Err(ValidationError::new(
576                "completed_steps must not exceed total_steps",
577            ));
578        }
579        validate_milliunit(self.confidence_milliunits, "confidence_milliunits")
580    }
581}
582
583validated_deserialize!(ContextCheckpointProgressV1 {
584    completed_steps: u32,
585    total_steps: u32,
586    confidence_milliunits: u16,
587    summary: ContextCheckpointTextV1,
588});
589
590/// One decision taken while working the task.
591#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
592#[serde(deny_unknown_fields)]
593pub struct ContextCheckpointDecisionV1 {
594    /// Identity of the decision.
595    pub decision_id: DecisionId,
596    /// Bounded statement of what was decided.
597    pub statement: ContextCheckpointTextV1,
598    /// Bounded rationale for the decision.
599    pub rationale: ContextCheckpointTextV1,
600    /// Current status of the decision.
601    pub status: ContextCheckpointDecisionStatusV1,
602    /// Evidence supporting the decision, sorted and unique.
603    pub evidence_refs: Vec<ProtocolReference>,
604}
605
606impl ContextCheckpointDecisionV1 {
607    /// Validate the decision identity, bounds, and reference ordering.
608    pub fn validate(&self) -> Result<(), ValidationError> {
609        validate_checkpoint_identifier(self.decision_id.as_str(), "decision_id")?;
610        if self.evidence_refs.len() > MAX_CONTEXT_CHECKPOINT_DECISION_REFS {
611            return Err(ValidationError::new(format!(
612                "decision evidence_refs exceeds the {MAX_CONTEXT_CHECKPOINT_DECISION_REFS} item limit"
613            )));
614        }
615        for reference in &self.evidence_refs {
616            validate_checkpoint_reference(reference.as_str(), "decision evidence_refs")?;
617        }
618        require_sorted_unique(
619            &self.evidence_refs,
620            "decision evidence_refs",
621            ProtocolReference::as_str,
622        )
623    }
624}
625
626validated_deserialize!(ContextCheckpointDecisionV1 {
627    decision_id: DecisionId,
628    statement: ContextCheckpointTextV1,
629    rationale: ContextCheckpointTextV1,
630    status: ContextCheckpointDecisionStatusV1,
631    evidence_refs: Vec<ProtocolReference>,
632});
633
634/// One file in scope for the checkpointed work, identified without any path.
635#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
636#[serde(deny_unknown_fields)]
637pub struct ContextCheckpointFileV1 {
638    /// Opaque workspace-scoped source identity; never a filesystem path.
639    pub source_id: SourceId,
640    /// Role the file plays in the work.
641    pub role: ContextCheckpointFileRoleV1,
642    /// Content identity of the file at checkpoint time.
643    pub content_digest: Sha256Digest,
644}
645
646impl ContextCheckpointFileV1 {
647    /// Validate that the file entry carries no machine-local material.
648    pub fn validate(&self) -> Result<(), ValidationError> {
649        validate_checkpoint_identifier(self.source_id.as_str(), "file source_id")
650    }
651}
652
653validated_deserialize!(ContextCheckpointFileV1 {
654    source_id: SourceId,
655    role: ContextCheckpointFileRoleV1,
656    content_digest: Sha256Digest,
657});
658
659/// A policy pinned by the checkpoint.
660#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
661#[serde(deny_unknown_fields)]
662pub struct ContextCheckpointPolicyPinV1 {
663    /// Identity of the pinned policy.
664    pub policy_id: PolicyId,
665    /// Content identity of the pinned policy document.
666    pub policy_digest: Sha256Digest,
667}
668
669impl ContextCheckpointPolicyPinV1 {
670    /// Validate the pinned policy identity.
671    pub fn validate(&self) -> Result<(), ValidationError> {
672        validate_checkpoint_identifier(self.policy_id.as_str(), "policy_pins.policy_id")
673    }
674}
675
676validated_deserialize!(ContextCheckpointPolicyPinV1 {
677    policy_id: PolicyId,
678    policy_digest: Sha256Digest,
679});
680
681/// A package pinned by the checkpoint.
682#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
683#[serde(deny_unknown_fields)]
684pub struct ContextCheckpointPackagePinV1 {
685    /// Identity of the pinned package.
686    pub package_id: PackageId,
687    /// Pinned package version.
688    pub version: SemanticVersion,
689    /// Content identity of the pinned package.
690    pub package_digest: Sha256Digest,
691}
692
693impl ContextCheckpointPackagePinV1 {
694    /// Validate the pinned package identity.
695    pub fn validate(&self) -> Result<(), ValidationError> {
696        validate_checkpoint_identifier(self.package_id.as_str(), "package_pins.package_id")
697    }
698}
699
700validated_deserialize!(ContextCheckpointPackagePinV1 {
701    package_id: PackageId,
702    version: SemanticVersion,
703    package_digest: Sha256Digest,
704});
705
706/// The portable live state needed to continue the task.
707#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
708#[serde(deny_unknown_fields)]
709pub struct ContextCheckpointLiveStateV1 {
710    /// Schema version of the live-state contract.
711    pub schema_version: u32,
712    /// Active task.
713    pub task: ContextCheckpointTaskV1,
714    /// Progress on the active task.
715    pub progress: ContextCheckpointProgressV1,
716    /// Decisions taken, sorted by decision identity.
717    pub decisions: Vec<ContextCheckpointDecisionV1>,
718    /// Findings worth carrying forward, in author order and unique.
719    pub findings: Vec<ContextCheckpointTextV1>,
720    /// Next steps, in execution order and unique.
721    pub next_steps: Vec<ContextCheckpointTextV1>,
722    /// Bounded handoff summary for the next worker.
723    pub handoff_summary: ContextCheckpointTextV1,
724    /// Files in scope, sorted by source identity.
725    pub files: Vec<ContextCheckpointFileV1>,
726    /// Tuning profile in force, when one is pinned.
727    pub profile_id: Option<ProfileId>,
728    /// Policies pinned, sorted by policy identity.
729    pub policy_pins: Vec<ContextCheckpointPolicyPinV1>,
730    /// Packages pinned, sorted by package identity.
731    pub package_pins: Vec<ContextCheckpointPackagePinV1>,
732    /// Relevant portable session state, when this checkpoint resumes one.
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub session_state: Option<ContextCheckpointSessionStateV1>,
735    /// Reference-only learning state, when one is pinned.
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub learning_state: Option<ContextCheckpointLearningStateV1>,
738}
739
740impl ContextCheckpointLiveStateV1 {
741    /// Schema version represented by this type.
742    pub const SCHEMA_VERSION: u32 = 1;
743
744    /// Validate every live-state bound, ordering rule, and identity.
745    pub fn validate(&self) -> Result<(), ValidationError> {
746        validate_schema_version(self.schema_version)?;
747        self.task.validate()?;
748        self.progress.validate()?;
749
750        require_capacity(
751            self.decisions.len(),
752            MAX_CONTEXT_CHECKPOINT_DECISIONS,
753            "decisions",
754        )?;
755        for decision in &self.decisions {
756            decision.validate()?;
757        }
758        require_sorted_unique(&self.decisions, "decisions", |decision| {
759            decision.decision_id.as_str()
760        })?;
761
762        require_capacity(
763            self.findings.len(),
764            MAX_CONTEXT_CHECKPOINT_FINDINGS,
765            "findings",
766        )?;
767        require_unique(&self.findings, "findings", ContextCheckpointTextV1::as_str)?;
768        require_capacity(
769            self.next_steps.len(),
770            MAX_CONTEXT_CHECKPOINT_NEXT_STEPS,
771            "next_steps",
772        )?;
773        require_unique(
774            &self.next_steps,
775            "next_steps",
776            ContextCheckpointTextV1::as_str,
777        )?;
778
779        require_capacity(self.files.len(), MAX_CONTEXT_CHECKPOINT_FILES, "files")?;
780        for file in &self.files {
781            file.validate()?;
782        }
783        require_sorted_unique(&self.files, "files", |file| file.source_id.as_str())?;
784
785        if let Some(profile_id) = &self.profile_id {
786            validate_checkpoint_identifier(profile_id.as_str(), "profile_id")?;
787        }
788
789        require_capacity(
790            self.policy_pins.len(),
791            MAX_CONTEXT_CHECKPOINT_POLICY_PINS,
792            "policy_pins",
793        )?;
794        for pin in &self.policy_pins {
795            pin.validate()?;
796        }
797        require_sorted_unique(&self.policy_pins, "policy_pins", |pin| {
798            pin.policy_id.as_str()
799        })?;
800
801        require_capacity(
802            self.package_pins.len(),
803            MAX_CONTEXT_CHECKPOINT_PACKAGE_PINS,
804            "package_pins",
805        )?;
806        for pin in &self.package_pins {
807            pin.validate()?;
808        }
809        require_sorted_unique(&self.package_pins, "package_pins", |pin| {
810            pin.package_id.as_str()
811        })?;
812        if let Some(session_state) = &self.session_state {
813            session_state.validate()?;
814        }
815        if let Some(learning_state) = &self.learning_state {
816            learning_state.validate()?;
817        }
818        Ok(())
819    }
820}
821
822validated_deserialize!(ContextCheckpointLiveStateV1 {
823    schema_version: u32,
824    task: ContextCheckpointTaskV1,
825    progress: ContextCheckpointProgressV1,
826    decisions: Vec<ContextCheckpointDecisionV1>,
827    findings: Vec<ContextCheckpointTextV1>,
828    next_steps: Vec<ContextCheckpointTextV1>,
829    handoff_summary: ContextCheckpointTextV1,
830    files: Vec<ContextCheckpointFileV1>,
831    profile_id: Option<ProfileId>,
832    policy_pins: Vec<ContextCheckpointPolicyPinV1>,
833    package_pins: Vec<ContextCheckpointPackagePinV1>,
834    session_state: Option<ContextCheckpointSessionStateV1>,
835    learning_state: Option<ContextCheckpointLearningStateV1>,
836});
837
838/// Binding to an existing P6 `.ctxpkg` checkpoint carrier envelope.
839///
840/// The binding carries only the carrier's schema identities and content
841/// digests.  It lets a canonical checkpoint travel inside the already deployed
842/// v2 carrier without this crate re-implementing the carrier projection, which
843/// belongs to a downstream slice.
844#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
845#[serde(deny_unknown_fields)]
846pub struct ContextCheckpointCarrierBindingV1 {
847    /// Schema identity of the carrier envelope.
848    pub carrier_schema_id: String,
849    /// Schema identity of the carrier `logical_state` object.
850    pub logical_state_schema_id: String,
851    /// Workspace identity asserted by the carrier envelope.
852    pub workspace_id: WorkspaceId,
853    /// Carrier `state_digest` over the `logical_state` object.
854    pub state_digest: Sha256Digest,
855    /// Carrier `envelope_digest` over the unsigned envelope.
856    pub envelope_digest: Sha256Digest,
857}
858
859impl ContextCheckpointCarrierBindingV1 {
860    /// Construct a validated binding using the deployed P6 v2 identities.
861    pub fn try_new(
862        workspace_id: WorkspaceId,
863        state_digest: Sha256Digest,
864        envelope_digest: Sha256Digest,
865    ) -> Result<Self, ValidationError> {
866        let binding = Self {
867            carrier_schema_id: CONTEXT_CHECKPOINT_CARRIER_SCHEMA_ID.to_owned(),
868            logical_state_schema_id: CONTEXT_CHECKPOINT_CARRIER_LOGICAL_STATE_SCHEMA_ID.to_owned(),
869            workspace_id,
870            state_digest,
871            envelope_digest,
872        };
873        binding.validate()?;
874        Ok(binding)
875    }
876
877    /// Validate the carrier schema identities.
878    pub fn validate(&self) -> Result<(), ValidationError> {
879        if self.carrier_schema_id != CONTEXT_CHECKPOINT_CARRIER_SCHEMA_ID {
880            return Err(ValidationError::new(format!(
881                "carrier_schema_id must be {CONTEXT_CHECKPOINT_CARRIER_SCHEMA_ID}"
882            )));
883        }
884        if self.logical_state_schema_id != CONTEXT_CHECKPOINT_CARRIER_LOGICAL_STATE_SCHEMA_ID {
885            return Err(ValidationError::new(format!(
886                "logical_state_schema_id must be {CONTEXT_CHECKPOINT_CARRIER_LOGICAL_STATE_SCHEMA_ID}"
887            )));
888        }
889        // The carrier verifier requires a canonical UUID workspace identity.
890        validate_canonical_uuid(self.workspace_id.as_str(), "carrier workspace_id")
891    }
892}
893
894validated_deserialize!(ContextCheckpointCarrierBindingV1 {
895    carrier_schema_id: String,
896    logical_state_schema_id: String,
897    workspace_id: WorkspaceId,
898    state_digest: Sha256Digest,
899    envelope_digest: Sha256Digest,
900});