1use std::collections::BTreeSet;
6use std::fmt;
7
8use base64::{Engine as _, engine::general_purpose::STANDARD};
9use ed25519_dalek::{Signature, VerifyingKey};
10use lean_ctx_protocol::{
11 AcceptanceState, AcceptedOutcomeV1, CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN,
12 ContextCheckpointArtifactLineageV1, ContextCheckpointLineageV2, ContextCheckpointV1,
13 ContextCheckpointV2, ExecutionPlanV1, ReceiptDocumentV1, ReceiptId, Sha256Digest,
14 TaskEnvelopeV1,
15};
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18
19use crate::core::canonical;
20use crate::core::execution_ledger::{ReceiptSignerAdmissionV1, producer};
21
22const MAX_RECEIPT_BYTES: usize = 1_048_576;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct CheckpointLineageError(String);
28
29impl fmt::Display for CheckpointLineageError {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter.write_str(&self.0)
32 }
33}
34
35impl std::error::Error for CheckpointLineageError {}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct VerifiedReceiptDocumentV1 {
44 document: ReceiptDocumentV1,
45 canonical_bytes: Vec<u8>,
46 canonical_digest: Sha256Digest,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct VerifiedContextCheckpointV2 {
57 checkpoint: ContextCheckpointV2,
58}
59
60impl VerifiedContextCheckpointV2 {
61 pub fn checkpoint(&self) -> &ContextCheckpointV2 {
63 &self.checkpoint
64 }
65
66 pub fn signing_payload(
68 &self,
69 ) -> Result<VerifiedContextCheckpointSigningPayloadV2, CheckpointLineageError> {
70 let canonical = self.checkpoint.canonical_bytes().map_err(protocol_error)?;
71 let mut bytes =
72 Vec::with_capacity(CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN.len() + canonical.len());
73 bytes.extend_from_slice(CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN);
74 bytes.extend_from_slice(&canonical);
75 let digest = digest_bytes(&bytes)?;
76 Ok(VerifiedContextCheckpointSigningPayloadV2 { bytes, digest })
77 }
78}
79
80impl std::ops::Deref for VerifiedContextCheckpointV2 {
81 type Target = ContextCheckpointV2;
82
83 fn deref(&self) -> &Self::Target {
84 &self.checkpoint
85 }
86}
87
88#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct VerifiedContextCheckpointSigningPayloadV2 {
91 bytes: Vec<u8>,
92 digest: Sha256Digest,
93}
94
95impl VerifiedContextCheckpointSigningPayloadV2 {
96 pub fn bytes(&self) -> &[u8] {
97 &self.bytes
98 }
99
100 pub fn digest(&self) -> &Sha256Digest {
101 &self.digest
102 }
103}
104
105impl VerifiedReceiptDocumentV1 {
106 pub fn from_canonical_bytes(
109 bytes: &[u8],
110 signer_admission: &ReceiptSignerAdmissionV1,
111 verifying_key: &VerifyingKey,
112 ) -> Result<Self, CheckpointLineageError> {
113 if bytes.len() > MAX_RECEIPT_BYTES {
114 return fail(format!(
115 "verified receipt exceeds the {MAX_RECEIPT_BYTES} byte limit"
116 ));
117 }
118 let document = ReceiptDocumentV1::from_canonical_bytes(bytes).map_err(protocol_error)?;
119 if document.signer.key_id != signer_admission.key_id {
120 return fail("receipt signer key_id is not the admitted key".to_owned());
121 }
122 producer::validate_signer_admission(signer_admission, verifying_key, &document.issued_at)
123 .map_err(protocol_error)?;
124 let signature_bytes = STANDARD.decode(&document.signature).map_err(|error| {
125 CheckpointLineageError(format!("decode receipt signature: {error}"))
126 })?;
127 let signature = Signature::from_slice(&signature_bytes)
128 .map_err(|error| CheckpointLineageError(format!("parse receipt signature: {error}")))?;
129 verifying_key
130 .verify_strict(
131 &document.signature_bytes().map_err(protocol_error)?,
132 &signature,
133 )
134 .map_err(|error| {
135 CheckpointLineageError(format!("verify receipt signature: {error}"))
136 })?;
137 let canonical = document.canonical_bytes().map_err(protocol_error)?;
138 if canonical != bytes {
139 return fail("verified receipt bytes changed after canonical decoding".to_owned());
140 }
141 Ok(Self {
142 document,
143 canonical_bytes: bytes.to_vec(),
144 canonical_digest: digest_bytes(bytes)?,
145 })
146 }
147
148 pub fn verify_canonical_bytes(
150 bytes: &[u8],
151 signer_admission: &ReceiptSignerAdmissionV1,
152 verifying_key: &VerifyingKey,
153 ) -> Result<Self, CheckpointLineageError> {
154 Self::from_canonical_bytes(bytes, signer_admission, verifying_key)
155 }
156
157 pub fn document(&self) -> &ReceiptDocumentV1 {
159 &self.document
160 }
161
162 pub fn canonical_bytes(&self) -> &[u8] {
164 &self.canonical_bytes
165 }
166
167 pub fn canonical_digest(&self) -> &Sha256Digest {
169 &self.canonical_digest
170 }
171
172 pub fn receipt_id(&self) -> &Sha256Digest {
174 &self.document.receipt_id
175 }
176}
177
178pub fn build_checkpoint_lineage(
180 task: &TaskEnvelopeV1,
181 plan: Option<&ExecutionPlanV1>,
182 receipts: &[VerifiedReceiptDocumentV1],
183 outcomes: &[AcceptedOutcomeV1],
184) -> Result<ContextCheckpointArtifactLineageV1, CheckpointLineageError> {
185 task.validate().map_err(protocol_error)?;
186 let task_ref = canonical_digest(task)?;
187 let plan_ref = if let Some(plan) = plan {
188 plan.validate().map_err(protocol_error)?;
189 if plan.task_id != task.task_id {
190 return fail("execution plan task_id does not match task envelope".to_owned());
191 }
192 Some(canonical_digest(plan)?)
193 } else {
194 if !receipts.is_empty() || !outcomes.is_empty() {
195 return fail("receipts and outcomes require an execution plan".to_owned());
196 }
197 None
198 };
199
200 let mut receipt_refs = Vec::with_capacity(receipts.len());
201 let mut seen_receipts = BTreeSet::new();
202 for receipt in receipts {
203 let document = receipt.document();
204 document.validate().map_err(protocol_error)?;
205 if document.lineage.task_id != task.task_id || document.lineage.task_ref != task_ref {
206 return fail(
207 "receipt task identity or task_ref disagrees with task envelope".to_owned(),
208 );
209 }
210 let Some(plan) = plan else {
211 return fail("receipt cannot be attached without an execution plan".to_owned());
212 };
213 let Some(plan_ref) = plan_ref.as_ref() else {
214 return fail("execution plan reference is missing".to_owned());
215 };
216 if document.lineage.plan_id != plan.plan_id || document.lineage.plan_ref != *plan_ref {
217 return fail(
218 "receipt plan identity or plan_ref disagrees with execution plan".to_owned(),
219 );
220 }
221 if !seen_receipts.insert(document.receipt_id.as_str().to_owned()) {
222 return fail("receipt_refs contain a duplicate receipt identity".to_owned());
223 }
224 receipt_refs.push(receipt.canonical_digest().clone());
225 }
226
227 validate_outcome_links(task, plan, receipts, outcomes)?;
228 receipt_refs.sort_unstable();
229 let lineage = ContextCheckpointArtifactLineageV1 {
230 task_ref,
231 plan_ref,
232 receipt_refs,
233 };
234 lineage.validate().map_err(protocol_error)?;
235 Ok(lineage)
236}
237
238pub fn build_checkpoint_v2(
240 checkpoint: &ContextCheckpointV1,
241 task: &TaskEnvelopeV1,
242 plan: Option<&ExecutionPlanV1>,
243 receipts: &[VerifiedReceiptDocumentV1],
244 outcomes: &[AcceptedOutcomeV1],
245) -> Result<VerifiedContextCheckpointV2, CheckpointLineageError> {
246 checkpoint.validate().map_err(protocol_error)?;
247 if checkpoint.lineage.task_id != task.task_id
248 || checkpoint.lineage.project_id != task.project_id
249 {
250 return fail("checkpoint task or project identity disagrees with task envelope".to_owned());
251 }
252 let Some(task_tenant) = task.tenant_id.as_ref() else {
253 return fail("task envelope must bind a tenant identity".to_owned());
254 };
255 if task_tenant != &checkpoint.lineage.tenant_id {
256 return fail("checkpoint tenant identity disagrees with task envelope".to_owned());
257 }
258 if checkpoint.lineage.plan_id.as_ref() != plan.map(|value| &value.plan_id) {
259 return fail("checkpoint plan_id disagrees with supplied execution plan".to_owned());
260 }
261 let artifact_lineage = build_checkpoint_lineage(task, plan, receipts, outcomes)?;
262 let mut expected_receipt_ids = receipts
263 .iter()
264 .map(|receipt| {
265 ReceiptId::try_from(receipt.receipt_id().as_str().to_owned()).map_err(protocol_error)
266 })
267 .collect::<Result<Vec<_>, _>>()?;
268 expected_receipt_ids.sort_unstable();
269 if !checkpoint.lineage.receipt_ids.is_empty()
270 && checkpoint.lineage.receipt_ids != expected_receipt_ids
271 {
272 return fail("checkpoint receipt_ids disagree with verified receipt identities".to_owned());
273 }
274 let mut compatibility = checkpoint.clone();
275 compatibility.lineage.receipt_ids = expected_receipt_ids;
276 let checkpoint = ContextCheckpointV2::from_v1_unverified(compatibility, artifact_lineage)
277 .map_err(protocol_error)?;
278 Ok(VerifiedContextCheckpointV2 { checkpoint })
279}
280
281pub fn validate_checkpoint_lineage(
283 checkpoint: &ContextCheckpointV2,
284 task: &TaskEnvelopeV1,
285 plan: Option<&ExecutionPlanV1>,
286 receipts: &[VerifiedReceiptDocumentV1],
287 outcomes: &[AcceptedOutcomeV1],
288) -> Result<(), CheckpointLineageError> {
289 checkpoint.validate().map_err(protocol_error)?;
290 validate_artifact_lineage(&checkpoint.lineage, task, plan, receipts, outcomes)
291}
292
293pub(super) fn validate_artifact_lineage(
295 lineage: &ContextCheckpointLineageV2,
296 task: &TaskEnvelopeV1,
297 plan: Option<&ExecutionPlanV1>,
298 receipts: &[VerifiedReceiptDocumentV1],
299 outcomes: &[AcceptedOutcomeV1],
300) -> Result<(), CheckpointLineageError> {
301 lineage.validate().map_err(protocol_error)?;
302 let expected = build_checkpoint_lineage(task, plan, receipts, outcomes)?;
303 if lineage.task_id != task.task_id
304 || lineage.project_id != task.project_id
305 || lineage.artifact_lineage != expected
306 {
307 return fail(
308 "checkpoint artifact lineage disagrees with authoritative artifacts".to_owned(),
309 );
310 }
311 let Some(task_tenant) = task.tenant_id.as_ref() else {
312 return fail("task envelope must bind a tenant identity".to_owned());
313 };
314 if task_tenant != &lineage.tenant_id {
315 return fail("checkpoint tenant identity disagrees with task envelope".to_owned());
316 }
317 if lineage.plan_id.as_ref() != plan.map(|value| &value.plan_id) {
318 return fail("checkpoint plan_id disagrees with supplied execution plan".to_owned());
319 }
320 let mut expected_receipt_ids = receipts
321 .iter()
322 .map(|receipt| {
323 ReceiptId::try_from(receipt.receipt_id().as_str().to_owned()).map_err(protocol_error)
324 })
325 .collect::<Result<Vec<_>, _>>()?;
326 expected_receipt_ids.sort_unstable();
327 if lineage.receipt_ids != expected_receipt_ids {
328 return fail("checkpoint receipt_ids disagree with verified receipt identities".to_owned());
329 }
330 Ok(())
331}
332
333pub fn verify_checkpoint_v2(
339 checkpoint: ContextCheckpointV2,
340 task: &TaskEnvelopeV1,
341 plan: Option<&ExecutionPlanV1>,
342 receipts: &[VerifiedReceiptDocumentV1],
343 outcomes: &[AcceptedOutcomeV1],
344) -> Result<VerifiedContextCheckpointV2, CheckpointLineageError> {
345 validate_checkpoint_lineage(&checkpoint, task, plan, receipts, outcomes)?;
346 Ok(VerifiedContextCheckpointV2 { checkpoint })
347}
348
349fn validate_outcome_links(
350 task: &TaskEnvelopeV1,
351 plan: Option<&ExecutionPlanV1>,
352 receipts: &[VerifiedReceiptDocumentV1],
353 outcomes: &[AcceptedOutcomeV1],
354) -> Result<(), CheckpointLineageError> {
355 if outcomes.len() > receipts.len() {
356 return fail("outcome set contains entries without receipts".to_owned());
357 }
358 for outcome in outcomes {
359 outcome.validate().map_err(protocol_error)?;
360 if outcome.task_id != task.task_id {
361 return fail("outcome task_id disagrees with task envelope".to_owned());
362 }
363 if outcome.plan_id.as_ref() != plan.map(|value| &value.plan_id) {
364 return fail("outcome plan_id disagrees with execution plan".to_owned());
365 }
366 let Some(receipt_id) = outcome.receipt_id.as_ref() else {
367 return fail("outcome must bind a receipt identity".to_owned());
368 };
369 let matching = receipts
370 .iter()
371 .filter(|receipt| receipt.receipt_id().as_str() == receipt_id.as_str())
372 .collect::<Vec<_>>();
373 if matching.len() != 1 {
374 return fail(
375 "outcome receipt_id does not identify exactly one verified receipt".to_owned(),
376 );
377 }
378 if matching[0].document().outcome.state == AcceptanceState::Unknown {
379 return fail("unknown receipt outcome cannot bind an AcceptedOutcomeV1".to_owned());
380 }
381 }
382
383 let mut matched_outcomes = BTreeSet::new();
384 for receipt in receipts {
385 let document = receipt.document();
386 let Some(outcome_id) = document.outcome.outcome_id.as_ref() else {
387 if document.outcome.state == AcceptanceState::Unknown {
388 continue;
389 }
390 return fail("terminal receipt outcome is missing outcome_id".to_owned());
391 };
392 let matching = outcomes
393 .iter()
394 .filter(|outcome| outcome.outcome_id == *outcome_id)
395 .collect::<Vec<_>>();
396 if matching.len() != 1 {
397 return fail("receipt outcome link does not identify exactly one outcome".to_owned());
398 }
399 let outcome = matching[0];
400 if !matched_outcomes.insert(outcome.outcome_id.as_str().to_owned()) {
401 return fail("one outcome is linked by multiple receipts".to_owned());
402 }
403 if outcome.accepted != document.outcome.state
404 || outcome.receipt_id.as_ref().map(ReceiptId::as_str)
405 != Some(document.receipt_id.as_str())
406 {
407 return fail("receipt outcome state or receipt identity was mutated".to_owned());
408 }
409 let Some(outcome_ref) = document.outcome.outcome_ref.as_ref() else {
410 return fail("terminal receipt outcome is missing outcome_ref".to_owned());
411 };
412 if !outcome
413 .evidence_refs
414 .iter()
415 .any(|evidence| evidence.digest == outcome_ref.as_str())
416 {
417 return fail("outcome evidence does not bind receipt outcome_ref".to_owned());
418 }
419 if let Some(acceptance_ref) = document.outcome.acceptance_evidence_digest.as_ref()
420 && !outcome
421 .evidence_refs
422 .iter()
423 .any(|evidence| evidence.digest == acceptance_ref.as_str())
424 {
425 return fail("outcome evidence does not bind receipt acceptance evidence".to_owned());
426 }
427 }
428 if matched_outcomes.len() != outcomes.len() {
429 return fail("outcome set contains an unbound or foreign outcome".to_owned());
430 }
431 Ok(())
432}
433
434fn canonical_digest<T: Serialize>(value: &T) -> Result<Sha256Digest, CheckpointLineageError> {
435 digest_bytes(&canonical::canonical_serialize(value))
436}
437
438fn digest_bytes(bytes: &[u8]) -> Result<Sha256Digest, CheckpointLineageError> {
439 let mut value = String::from("sha256:");
440 for byte in Sha256::digest(bytes) {
441 use fmt::Write as _;
442 write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
443 }
444 Sha256Digest::new(value).map_err(protocol_error)
445}
446
447pub(super) fn protocol_error(error: impl fmt::Display) -> CheckpointLineageError {
448 CheckpointLineageError(error.to_string())
449}
450
451fn fail<T>(message: String) -> Result<T, CheckpointLineageError> {
452 Err(CheckpointLineageError(message))
453}
454
455#[cfg(test)]
456mod test_support;
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use lean_ctx_protocol::TaskEnvelopeV1;
462 use serde_json::json;
463
464 use super::test_support::checkpoint;
465
466 fn make_task(task_id: &str) -> TaskEnvelopeV1 {
467 serde_json::from_value(json!({
468 "schema_version": 1,
469 "task_id": task_id,
470 "trace_id": "trace-1",
471 "project_id": "project-1",
472 "session_id": "session-1",
473 "agent_id": "agent-1",
474 "complexity": "medium",
475 "created_at": "2026-08-23T11:59:00Z",
476 "tenant_id": "tenant-1"
477 }))
478 .expect("valid task")
479 }
480
481 #[test]
482 fn verified_receipt_rejects_tampered_signature() {
483 let task = make_task("task-1");
484 let fixture = crate::core::execution_protocol::test_support::build(&task);
485 let mut tampered = fixture.protocol.receipt.clone();
486 tampered.signature = base64::engine::general_purpose::STANDARD.encode([0_u8; 64]);
487 let bytes = tampered
488 .canonical_bytes()
489 .expect("canonical tampered receipt");
490 assert!(
491 VerifiedReceiptDocumentV1::from_canonical_bytes(
492 &bytes,
493 &fixture.signer_admission,
494 &fixture.verifying_key,
495 )
496 .is_err()
497 );
498 }
499
500 #[test]
501 fn builder_rejects_forged_task_plan_foreign_receipt_and_mutated_outcome() {
502 let task = make_task("task-1");
503 let fixture = crate::core::execution_protocol::test_support::build(&task);
504 let receipt = VerifiedReceiptDocumentV1::from_canonical_bytes(
505 &fixture
506 .protocol
507 .receipt
508 .canonical_bytes()
509 .expect("receipt bytes"),
510 &fixture.signer_admission,
511 &fixture.verifying_key,
512 )
513 .expect("verified receipt");
514 let plan = &fixture.protocol.execution_plan;
515 let outcome = fixture.protocol.accepted_outcome.clone();
516 assert!(
517 build_checkpoint_lineage(
518 &task,
519 Some(plan),
520 std::slice::from_ref(&receipt),
521 std::slice::from_ref(&outcome),
522 )
523 .is_ok()
524 );
525
526 let mut forged_task = task.clone();
527 forged_task.intent = Some("different payload".to_owned());
528 assert!(
529 build_checkpoint_lineage(
530 &forged_task,
531 Some(plan),
532 std::slice::from_ref(&receipt),
533 std::slice::from_ref(&outcome),
534 )
535 .is_err()
536 );
537
538 let mut forged_plan = plan.clone();
539 forged_plan.model = "different-model".to_owned();
540 assert!(
541 build_checkpoint_lineage(
542 &task,
543 Some(&forged_plan),
544 std::slice::from_ref(&receipt),
545 std::slice::from_ref(&outcome),
546 )
547 .is_err()
548 );
549
550 let foreign_task = make_task("task-foreign");
551 let foreign_fixture = crate::core::execution_protocol::test_support::build(&foreign_task);
552 let foreign_receipt = VerifiedReceiptDocumentV1::from_canonical_bytes(
553 &foreign_fixture
554 .protocol
555 .receipt
556 .canonical_bytes()
557 .expect("foreign receipt bytes"),
558 &foreign_fixture.signer_admission,
559 &foreign_fixture.verifying_key,
560 )
561 .expect("verified foreign receipt");
562 assert!(
563 build_checkpoint_lineage(
564 &task,
565 Some(plan),
566 &[foreign_receipt],
567 std::slice::from_ref(&foreign_fixture.protocol.accepted_outcome),
568 )
569 .is_err()
570 );
571
572 let mut mutated_outcome = outcome;
573 mutated_outcome.accepted = AcceptanceState::Rejected;
574 assert!(
575 build_checkpoint_lineage(
576 &task,
577 Some(plan),
578 std::slice::from_ref(&receipt),
579 std::slice::from_ref(&mutated_outcome),
580 )
581 .is_err()
582 );
583 assert!(build_checkpoint_lineage(&task, None, &[receipt], &[]).is_err());
584 }
585
586 #[test]
587 fn checkpoint_builder_and_validator_bind_exact_receipt_ids() {
588 let task = make_task("task-1");
589 let fixture = crate::core::execution_protocol::test_support::build(&task);
590 let receipt = VerifiedReceiptDocumentV1::from_canonical_bytes(
591 &fixture
592 .protocol
593 .receipt
594 .canonical_bytes()
595 .expect("receipt bytes"),
596 &fixture.signer_admission,
597 &fixture.verifying_key,
598 )
599 .expect("verified receipt");
600 let checkpoint = checkpoint(&task, &fixture.protocol.execution_plan.plan_id);
601 let built = build_checkpoint_v2(
602 &checkpoint,
603 &task,
604 Some(&fixture.protocol.execution_plan),
605 std::slice::from_ref(&receipt),
606 std::slice::from_ref(&fixture.protocol.accepted_outcome),
607 )
608 .expect("V2 checkpoint");
609 assert_eq!(
610 built.lineage.receipt_ids,
611 vec![
612 ReceiptId::try_from(receipt.receipt_id().as_str().to_owned())
613 .expect("receipt identity")
614 ]
615 );
616 assert_eq!(
617 built.lineage.artifact_lineage.receipt_refs,
618 vec![receipt.canonical_digest().clone()]
619 );
620 validate_checkpoint_lineage(
621 &built,
622 &task,
623 Some(&fixture.protocol.execution_plan),
624 std::slice::from_ref(&receipt),
625 std::slice::from_ref(&fixture.protocol.accepted_outcome),
626 )
627 .expect("lineage validates");
628
629 assert!(
630 !built
631 .signing_payload()
632 .expect("signing payload")
633 .bytes()
634 .is_empty(),
635 "`built .signing_payload() .expect(\"signing payload\") .bytes()` is empty"
636 );
637 let mut foreign = built.checkpoint().clone();
638 foreign.lineage.artifact_lineage.task_ref =
639 Sha256Digest::new(format!("sha256:{}", "f".repeat(64))).expect("digest");
640 assert!(
641 validate_checkpoint_lineage(
642 &foreign,
643 &task,
644 Some(&fixture.protocol.execution_plan),
645 std::slice::from_ref(&receipt),
646 std::slice::from_ref(&fixture.protocol.accepted_outcome),
647 )
648 .is_err()
649 );
650
651 let mut tenantless_task = task.clone();
652 tenantless_task.tenant_id = None;
653 assert!(
654 build_checkpoint_v2(
655 &checkpoint,
656 &tenantless_task,
657 Some(&fixture.protocol.execution_plan),
658 std::slice::from_ref(&receipt),
659 std::slice::from_ref(&fixture.protocol.accepted_outcome),
660 )
661 .is_err()
662 );
663 assert!(
664 validate_checkpoint_lineage(
665 &built,
666 &tenantless_task,
667 Some(&fixture.protocol.execution_plan),
668 std::slice::from_ref(&receipt),
669 std::slice::from_ref(&fixture.protocol.accepted_outcome),
670 )
671 .is_err()
672 );
673
674 let mut wrong_plan = built.checkpoint().clone();
675 let foreign_plan =
676 lean_ctx_protocol::PlanId::try_from("plan-foreign".to_owned()).expect("plan id");
677 wrong_plan.lineage.plan_id = Some(foreign_plan.clone());
678 wrong_plan.live_state.task.plan_id = Some(foreign_plan);
679 assert!(
680 validate_checkpoint_lineage(
681 &wrong_plan,
682 &task,
683 Some(&fixture.protocol.execution_plan),
684 &[receipt],
685 std::slice::from_ref(&fixture.protocol.accepted_outcome),
686 )
687 .is_err()
688 );
689 }
690}