Skip to main content

lean_ctx/core/context_checkpoint/
lineage.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Fail-closed join between checkpoints and authoritative execution artifacts.
4
5use 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/// Error returned when checkpoint lineage cannot be proven from authoritative
25/// task, plan, outcome, and signed receipt artifacts.
26#[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/// Receipt document whose canonical bytes and Ed25519 signature were verified
38/// against a server-owned signer admission.
39///
40/// Fields remain private so callers cannot manufacture the verified state from
41/// a raw `ReceiptDocumentV1` or legacy `ExecutionReceiptV1` value.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct VerifiedReceiptDocumentV1 {
44    document: ReceiptDocumentV1,
45    canonical_bytes: Vec<u8>,
46    canonical_digest: Sha256Digest,
47}
48
49/// V2 checkpoint whose artifact lineage was recomputed from authoritative task,
50/// plan, signed receipt, and accepted-outcome owners.
51///
52/// The inner wire value is private. Only this proof-carrying wrapper exposes the
53/// V2 signing payload, so structurally valid untrusted wire data cannot reach a
54/// signer through the safe API.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct VerifiedContextCheckpointV2 {
57    checkpoint: ContextCheckpointV2,
58}
59
60impl VerifiedContextCheckpointV2 {
61    /// Borrow the validated wire value for persistence or transport.
62    pub fn checkpoint(&self) -> &ContextCheckpointV2 {
63        &self.checkpoint
64    }
65
66    /// Produce the domain-separated bytes that a checkpoint V2 signer covers.
67    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/// Sealed signing material obtainable only from a verified V2 checkpoint.
89#[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    /// Decode canonical bytes and verify identity, signer admission, and
107    /// Ed25519 signature before exposing the receipt to a lineage builder.
108    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    /// Alias that makes the verification boundary explicit at call sites.
149    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    /// Borrow the verified receipt document.
158    pub fn document(&self) -> &ReceiptDocumentV1 {
159        &self.document
160    }
161
162    /// Borrow the exact canonical bytes whose signature was verified.
163    pub fn canonical_bytes(&self) -> &[u8] {
164        &self.canonical_bytes
165    }
166
167    /// Digest of the exact persisted canonical receipt bytes.
168    pub fn canonical_digest(&self) -> &Sha256Digest {
169        &self.canonical_digest
170    }
171
172    /// Receipt identity digest derived by `ReceiptDocumentV1`'s owner.
173    pub fn receipt_id(&self) -> &Sha256Digest {
174        &self.document.receipt_id
175    }
176}
177
178/// Build authoritative checkpoint artifact references from validated owners.
179pub 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
238/// Attach authoritative lineage through the explicit V1→V2 migration seam.
239pub 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
281/// Recompute and compare all authoritative refs against a V2 checkpoint.
282pub 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
293/// One artifact admission join shared by checkpoint wire versions.
294pub(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
333/// Re-establish the proof-carrying wrapper for a decoded or transformed V2 checkpoint.
334///
335/// Branching, merging, persistence, and transport boundaries must use this seam
336/// before requesting signing material; structural V2 validation alone does not
337/// prove that its artifact digests belong to the supplied authoritative owners.
338pub 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}