Skip to main content

lean_ctx/core/execution_ledger/host/
checkpoint_transfer.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Manual operator transfer of one already admitted canonical checkpoint.
3//!
4//! Export publishes the exact signed checkpoint together with the complete
5//! evidence its verifier requires, taken from this host's already admitted
6//! current lineage.  Import verifies that package against an independently
7//! operator-pinned source key and an operator-selected receiving scope.  The
8//! package never carries authority: no key, target, ledger record or admission
9//! inside it is trusted, and a successful import creates no receipt, no ledger
10//! entry and no second state store.
11
12use anyhow::{Result, ensure};
13use base64::{Engine as _, engine::general_purpose::STANDARD};
14use ed25519_dalek::{Signature, VerifyingKey};
15use lean_ctx_protocol::{
16    CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN, CONTEXT_CHECKPOINT_V3_SIGNATURE_DOMAIN,
17    ExecutionPlanV1, ProjectId, Sha256Digest, TaskEnvelopeV1, TaskId, TenantId, UtcTimestamp,
18    WorkspaceId,
19};
20use serde::{Deserialize, Serialize};
21use std::{collections::BTreeMap, path::PathBuf};
22
23use super::{
24    HostReceiptAuthority, ReceiptSignerAdmissionV1, canonical_serialize,
25    checkpoint::{
26        CheckpointEvidence, MAX_CHECKPOINT_RECEIPTS, read_task_and_plan, reconstruct_outcomes,
27        verify_receipts,
28    },
29    checkpoint_resume::{Envelope, Signer, UnicodeEnvelope},
30    digest, outcome,
31};
32use crate::core::{
33    context_checkpoint::{VerifiedReceiptDocumentV1, verify_checkpoint_v2, verify_checkpoint_v3},
34    engine_artifact,
35    engine_interface::persist_engine_artifact_content,
36    execution_ledger::producer::validate_signer_admission,
37};
38
39mod content;
40mod continuation;
41mod source_evidence;
42pub(super) use continuation::CheckpointContinueAdmission;
43
44/// The same for a checkpoint this host is asked to verify, sign and export:
45/// the Engine computes digest and signature itself, so the admitted form is
46/// what gets bound.
47pub(super) fn admit_fresh_checkpoint(checkpoint: &mut serde_json::Value) -> Result<bool> {
48    content::admit_fresh("checkpoint:", content::Document::Checkpoint, checkpoint)
49}
50
51/// The same for the one text a continuation renders and returns.
52pub(super) fn admit_fresh_text(reference: &str, text: String) -> Result<String> {
53    let mut rendered = serde_json::Value::String(text);
54    content::admit_fresh(reference, content::Document::Unlisted, &mut rendered)?;
55    match rendered {
56        serde_json::Value::String(text) => Ok(text),
57        other => Ok(other.to_string()),
58    }
59}
60
61/// The admission of a document another component produced and this host is
62/// about to write out: FOREIGN content without a table — every string is a
63/// source — admitted bound under `tool` (unchanged, or refused whole).
64#[cfg(unix)]
65pub(crate) fn admit_foreign_document_narrative(
66    document: &str,
67    value: &serde_json::Value,
68    tool: &'static str,
69) -> Result<()> {
70    let origin = content::Origin::Foreign(tool);
71    content::admit_bound(document, content::Document::Unlisted, value, origin)
72}
73pub(super) use source_evidence::{SourceDependency, SourceKind};
74
75/// A transferred package is another host's content: its narrative is admitted
76/// as a remote source under this tool label.
77const FOREIGN_PACKAGE: content::Origin = content::Origin::Foreign("context-checkpoint-import");
78
79pub(super) const TRANSFER_SCHEMA: &str = "leanctx.checkpoint-transfer/v1";
80const SOURCE_TRANSFER_SCHEMA: &str = "leanctx.checkpoint-transfer/v2";
81const ENVELOPE_SCHEMA_V2: &str = "leanctx.host-checkpoint/v1";
82const ENVELOPE_SCHEMA_V3: &str = "leanctx.host-checkpoint/v2";
83const MAX_PACKAGE_BYTES: usize = 512 * 1024;
84const MAX_ENVELOPE_BYTES: usize = 256 * 1024;
85const MAX_ENTRY_BYTES: usize = 128 * 1024;
86/// Task envelope, execution plan and at most one outcome snapshot per receipt.
87const MAX_EVIDENCE_ENTRIES: usize = 3 * MAX_CHECKPOINT_RECEIPTS + 2;
88/// Quarantined sibling of `execution/checkpoint-packages`: verified, not adopted.
89const STAGING_NAMESPACE: &str = "execution/checkpoint-packages-staged";
90const STAGED_SCHEMA: &str = "leanctx.checkpoint-transfer-staged/v1";
91const MAX_STAGED_BYTES: usize = MAX_PACKAGE_BYTES + 8 * 1024;
92
93/// Export request: one already exported local checkpoint artifact.
94#[derive(Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct HostCheckpointPackageRequest {
97    schema_version: u32,
98    artifact_digest: Sha256Digest,
99    #[serde(default)]
100    include_source_evidence: bool,
101}
102
103/// Import request: the operator-transferred package and its expected digest.
104#[derive(Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct HostCheckpointImportRequest {
107    schema_version: u32,
108    package_digest: Sha256Digest,
109    package: CheckpointTransferPackageV1,
110}
111
112/// Exact canonical bytes of one digest-addressed artifact, carried as text.
113#[derive(Clone, Deserialize, Serialize)]
114#[serde(deny_unknown_fields)]
115struct TransferEntry {
116    digest: Sha256Digest,
117    json: String,
118}
119
120/// Serializable view of the host-owned signer trust snapshot.
121///
122/// The snapshot type itself is deliberately not serializable; this transfer view
123/// carries the same fields so the receiving operator can compare a declared
124/// signer with their own pin. It is never an admission token.
125#[derive(Clone, Deserialize, Serialize)]
126#[serde(deny_unknown_fields)]
127struct TransferSigner {
128    key_id: String,
129    public_key_digest: Sha256Digest,
130    admitted_at: UtcTimestamp,
131    expires_at: UtcTimestamp,
132    revoked_at: Option<UtcTimestamp>,
133}
134
135impl From<&ReceiptSignerAdmissionV1> for TransferSigner {
136    fn from(value: &ReceiptSignerAdmissionV1) -> Self {
137        Self {
138            key_id: value.key_id.clone(),
139            public_key_digest: value.public_key_digest.clone(),
140            admitted_at: value.admitted_at.clone(),
141            expires_at: value.expires_at.clone(),
142            revoked_at: value.revoked_at.clone(),
143        }
144    }
145}
146
147/// Declared source lineage. Audit information only: import compares it with the
148/// verified checkpoint and the operator pin and never treats it as authority.
149#[derive(Clone, Deserialize, Serialize)]
150#[serde(deny_unknown_fields)]
151struct TransferLineage {
152    task_id: TaskId,
153    project_id: ProjectId,
154    tenant_id: TenantId,
155    workspace_id: WorkspaceId,
156    current_receipt_digest: Sha256Digest,
157}
158
159/// The bounded, versioned transfer package.
160#[derive(Clone, Deserialize, Serialize)]
161#[serde(deny_unknown_fields)]
162pub(crate) struct CheckpointTransferPackageV1 {
163    schema_version: String,
164    envelope_schema: String,
165    artifact_digest: Sha256Digest,
166    envelope_json: String,
167    checkpoint_digest: Sha256Digest,
168    source_signer: TransferSigner,
169    lineage: TransferLineage,
170    receipts: Vec<TransferEntry>,
171    evidence: Vec<TransferEntry>,
172}
173
174/// One independently pinned foreign source admitted for manual import.
175///
176/// The receiving project, tenant, workspace and root are the operator's, not the
177/// package's.  A source scope that differs from the receiving scope requires an
178/// explicit acknowledgement: the signed checkpoint keeps its own immutable
179/// lineage and is never rewritten to look local.
180#[derive(Deserialize)]
181#[serde(deny_unknown_fields)]
182pub(super) struct CheckpointImportAdmission {
183    schema_version: u32,
184    source_public_key_hex: String,
185    source_signer: super::SignerAdmission,
186    admit_source_scope: TransferScope,
187    receiving: ReceivingScope,
188    #[serde(default)]
189    allow_cross_scope_adoption: bool,
190}
191
192#[derive(Deserialize)]
193#[serde(deny_unknown_fields)]
194struct TransferScope {
195    project_id: ProjectId,
196    tenant_id: TenantId,
197    workspace_id: WorkspaceId,
198}
199
200/// Where a verified package is staged, and under whose identity it is recorded.
201/// Staging never selects or creates a session for this root.
202#[derive(Deserialize)]
203#[serde(deny_unknown_fields)]
204struct ReceivingScope {
205    project_root: PathBuf,
206    project_id: ProjectId,
207    tenant_id: TenantId,
208    workspace_id: WorkspaceId,
209}
210
211/// Evidence resolved from an operator-transferred package.
212///
213/// Every entry was digest-verified when the map was built, so a lookup cannot
214/// admit substituted bytes, and a missing digest fails closed instead of
215/// falling back to any local artifact.
216struct PackagedEvidence {
217    entries: BTreeMap<String, Vec<u8>>,
218}
219
220impl CheckpointEvidence for PackagedEvidence {
221    fn canonical_bytes(&self, expected: &Sha256Digest) -> Result<Vec<u8>> {
222        let bytes = self
223            .entries
224            .get(expected.as_str())
225            .ok_or_else(|| anyhow::anyhow!("transferred evidence missing"))?
226            .clone();
227        ensure!(
228            digest(&bytes).map_err(anyhow::Error::msg)? == *expected,
229            "transferred evidence digest mismatch"
230        );
231        Ok(bytes)
232    }
233}
234
235impl PackagedEvidence {
236    fn build(entries: &[TransferEntry]) -> Result<Self> {
237        let mut map = BTreeMap::new();
238        for entry in entries {
239            let bytes = entry_bytes(entry)?;
240            if let Some(previous) = map.insert(entry.digest.as_str().to_owned(), bytes) {
241                ensure!(
242                    previous == map[entry.digest.as_str()],
243                    "conflicting transferred evidence"
244                );
245            }
246        }
247        Ok(Self { entries: map })
248    }
249
250    fn digests(&self) -> Vec<String> {
251        self.entries.keys().cloned().collect()
252    }
253}
254
255/// Admit only bounded, canonical JSON text; binary or control content is not a
256/// supported transfer body even when its digest matches.
257fn entry_bytes(entry: &TransferEntry) -> Result<Vec<u8>> {
258    ensure!(
259        !entry.json.is_empty()
260            && entry.json.len() <= MAX_ENTRY_BYTES
261            && !entry.json.chars().any(char::is_control),
262        "unsupported transfer entry content"
263    );
264    ensure!(
265        serde_json::from_str::<serde_json::Value>(&entry.json)?.is_object(),
266        "unsupported transfer entry content"
267    );
268    let bytes = entry.json.as_bytes().to_vec();
269    ensure!(
270        digest(&bytes).map_err(anyhow::Error::msg)? == entry.digest,
271        "transfer entry digest mismatch"
272    );
273    Ok(bytes)
274}
275
276fn entry(digest_value: &Sha256Digest, bytes: &[u8]) -> Result<TransferEntry> {
277    let entry = TransferEntry {
278        digest: digest_value.clone(),
279        json: String::from_utf8(bytes.to_vec())?,
280    };
281    drop(entry_bytes(&entry)?);
282    Ok(entry)
283}
284
285fn parse_verifying_key(value: &str) -> Result<VerifyingKey> {
286    ensure!(
287        value.len() == 64
288            && value
289                .bytes()
290                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
291        "invalid pinned source key"
292    );
293    let mut bytes = [0_u8; 32];
294    for (index, byte) in bytes.iter_mut().enumerate() {
295        *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)?;
296    }
297    Ok(VerifyingKey::from_bytes(&bytes)?)
298}
299
300impl HostReceiptAuthority {
301    /// Package one exported local checkpoint with the complete evidence its
302    /// verifier requires, proven through this host's current lineage.
303    pub fn package_checkpoint(
304        &self,
305        request: &HostCheckpointPackageRequest,
306    ) -> Result<serde_json::Value> {
307        self.validate_current().map_err(anyhow::Error::msg)?;
308        ensure!(
309            self.allow_checkpoint_transfer_export,
310            "checkpoint transfer export not authorized"
311        );
312        ensure!(request.schema_version == 1, "unsupported package request");
313        let bytes = engine_artifact::read_content(
314            "execution/checkpoints",
315            request.artifact_digest.hex(),
316            "json",
317        )
318        .map_err(anyhow::Error::msg)?;
319        ensure!(
320            bytes.len() <= MAX_ENVELOPE_BYTES,
321            "checkpoint envelope exceeds transfer bound"
322        );
323        ensure!(
324            digest(&bytes).map_err(anyhow::Error::msg)? == request.artifact_digest,
325            "checkpoint artifact digest mismatch"
326        );
327        let envelope_text = String::from_utf8(bytes.clone())?;
328        ensure!(
329            crate::core::secret_detection::detect_secrets(&envelope_text).is_empty(),
330            "checkpoint contains credential-shaped material"
331        );
332        let schema = serde_json::from_slice::<serde_json::Value>(&bytes)?
333            .get("schema_version")
334            .and_then(serde_json::Value::as_str)
335            .unwrap_or_default()
336            .to_owned();
337        let package = if schema == ENVELOPE_SCHEMA_V3 {
338            let envelope: UnicodeEnvelope = serde_json::from_slice(&bytes)?;
339            ensure!(
340                envelope.schema_version == ENVELOPE_SCHEMA_V3
341                    && canonical_serialize(&envelope) == bytes,
342                "unsupported or noncanonical Unicode checkpoint envelope"
343            );
344            self.admit_local_envelope(&envelope.signer, &envelope.signature, &{
345                let mut payload = CONTEXT_CHECKPOINT_V3_SIGNATURE_DOMAIN.to_vec();
346                payload.extend(envelope.checkpoint.canonical_bytes()?);
347                payload
348            })?;
349            let checkpoint = &envelope.checkpoint;
350            let lineage = &checkpoint.lineage;
351            self.with_checkpoint_lineage(
352                &lineage.artifact_lineage.receipt_refs,
353                |task, plan, receipts, outcomes| {
354                    let verified = verify_checkpoint_v3(
355                        checkpoint.clone(),
356                        task,
357                        Some(plan),
358                        receipts,
359                        outcomes,
360                    )?;
361                    self.build_package(
362                        ENVELOPE_SCHEMA_V3,
363                        &envelope_text,
364                        &request.artifact_digest,
365                        &verified.checkpoint().digest()?,
366                        TransferLineage {
367                            task_id: lineage.task_id.clone(),
368                            project_id: lineage.project_id.clone(),
369                            tenant_id: lineage.tenant_id.clone(),
370                            workspace_id: lineage.workspace_id.clone(),
371                            current_receipt_digest: self.current_receipt_digest(task)?,
372                        },
373                        task,
374                        plan,
375                        receipts,
376                        request.include_source_evidence,
377                    )
378                },
379            )?
380        } else {
381            let envelope: Envelope = serde_json::from_slice(&bytes)?;
382            ensure!(
383                envelope.schema_version == ENVELOPE_SCHEMA_V2
384                    && canonical_serialize(&envelope) == bytes,
385                "unsupported or noncanonical checkpoint envelope"
386            );
387            self.admit_local_envelope(&envelope.signer, &envelope.signature, &{
388                let mut payload = CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN.to_vec();
389                payload.extend(envelope.checkpoint.canonical_bytes()?);
390                payload
391            })?;
392            let checkpoint = &envelope.checkpoint;
393            let lineage = &checkpoint.lineage;
394            self.with_checkpoint_lineage(
395                &lineage.artifact_lineage.receipt_refs,
396                |task, plan, receipts, outcomes| {
397                    let verified = verify_checkpoint_v2(
398                        checkpoint.clone(),
399                        task,
400                        Some(plan),
401                        receipts,
402                        outcomes,
403                    )?;
404                    self.build_package(
405                        ENVELOPE_SCHEMA_V2,
406                        &envelope_text,
407                        &request.artifact_digest,
408                        &verified.digest()?,
409                        TransferLineage {
410                            task_id: lineage.task_id.clone(),
411                            project_id: lineage.project_id.clone(),
412                            tenant_id: lineage.tenant_id.clone(),
413                            workspace_id: lineage.workspace_id.clone(),
414                            current_receipt_digest: self.current_receipt_digest(task)?,
415                        },
416                        task,
417                        plan,
418                        receipts,
419                        request.include_source_evidence,
420                    )
421                },
422            )?
423        };
424        self.validate_current().map_err(anyhow::Error::msg)?;
425        let package_bytes = canonical_serialize(&package);
426        ensure!(
427            package_bytes.len() <= MAX_PACKAGE_BYTES,
428            "checkpoint package exceeds transfer bound"
429        );
430        // This operator CLI's project is local authority, never a root supplied
431        // by the portable package. A pinned task scope additionally binds it.
432        let root = std::env::current_dir()?;
433        let root = canonical_receiving_root(&root)?;
434        if let Some(identity) = self
435            .admit_task_identity(Some(&root))
436            .map_err(anyhow::Error::msg)?
437        {
438            ensure!(
439                identity.project_id == package.lineage.project_id
440                    && identity.tenant_id == package.lineage.tenant_id,
441                "checkpoint export scope mismatch"
442            );
443        }
444        content::admit(
445            &package,
446            std::path::Path::new(&root),
447            content::Origin::Local,
448        )?;
449        let package_digest = digest(&package_bytes).map_err(anyhow::Error::msg)?;
450        drop(
451            persist_engine_artifact_content(
452                "execution/checkpoint-packages",
453                package_digest.hex(),
454                "json",
455                &package_bytes,
456            )
457            .map_err(anyhow::Error::msg)?,
458        );
459        Ok(serde_json::json!({
460            "schema_version":"leanctx.host-checkpoint-package-result/v1",
461            "package_digest":package_digest,
462            "artifact_digest":package.artifact_digest,
463            "checkpoint_digest":package.checkpoint_digest,
464            "envelope_schema":package.envelope_schema,
465            "receipt_count":package.receipts.len(),
466            "evidence_count":package.evidence.len(),
467            "package_bytes":package_bytes.len(),
468            "package":package,
469            "session_adopted":false,
470            "source_rights_revalidation_required":true,
471        }))
472    }
473
474    /// The envelope must be this host's own admitted and signed artifact.
475    fn admit_local_envelope(&self, signer: &Signer, signature: &str, payload: &[u8]) -> Result<()> {
476        ensure!(
477            signer.key_id == self.signer_admission.key_id
478                && signer.public_key_digest == self.signer_admission.public_key_digest,
479            "checkpoint signer not admitted"
480        );
481        Ok(self.signing_key.verifying_key().verify_strict(
482            payload,
483            &Signature::from_slice(&STANDARD.decode(signature)?)?,
484        )?)
485    }
486
487    fn current_receipt_digest(&self, task: &TaskEnvelopeV1) -> Result<Sha256Digest> {
488        let head = self
489            .ledger
490            .canonical_receipt_for_task_verified(task.task_id.as_str())?
491            .ok_or_else(|| anyhow::anyhow!("host receipt missing"))?;
492        Ok(Sha256Digest::new(head.receipt_digest)?)
493    }
494
495    #[allow(clippy::too_many_arguments)]
496    fn build_package(
497        &self,
498        envelope_schema: &str,
499        envelope_text: &str,
500        artifact_digest: &Sha256Digest,
501        checkpoint_digest: &Sha256Digest,
502        lineage: TransferLineage,
503        task: &TaskEnvelopeV1,
504        plan: &ExecutionPlanV1,
505        receipts: &[VerifiedReceiptDocumentV1],
506        include_source_evidence: bool,
507    ) -> Result<CheckpointTransferPackageV1> {
508        let first = receipts
509            .first()
510            .ok_or_else(|| anyhow::anyhow!("missing receipt"))?;
511        let receipt_lineage = &first.document().lineage;
512        // The verifier rebuilds the lineage from these bytes, so an exported
513        // task or plan that no longer hashes to its signed reference is refused
514        // here instead of failing opaquely on the receiving host.
515        ensure!(
516            digest(&canonical_serialize(task)).map_err(anyhow::Error::msg)?
517                == receipt_lineage.task_ref
518                && digest(&canonical_serialize(plan)).map_err(anyhow::Error::msg)?
519                    == receipt_lineage.plan_ref,
520            "exported task or plan disagrees with its signed reference"
521        );
522        let mut evidence = vec![
523            entry(
524                &receipt_lineage.task_ref,
525                &outcome::read_bytes(&receipt_lineage.task_ref)?,
526            )?,
527            entry(
528                &receipt_lineage.plan_ref,
529                &outcome::read_bytes(&receipt_lineage.plan_ref)?,
530            )?,
531        ];
532        let mut receipt_entries = Vec::with_capacity(receipts.len());
533        for receipt in receipts {
534            receipt_entries.push(entry(
535                receipt.canonical_digest(),
536                receipt.canonical_bytes(),
537            )?);
538            if let Some(reference) = receipt.document().outcome.outcome_ref.as_ref() {
539                evidence.push(entry(reference, &outcome::read_bytes(reference)?)?);
540            }
541        }
542        if include_source_evidence {
543            let sources = source_evidence::derive(receipts, outcome::read_bytes)?;
544            for reference in sources.references {
545                let hash = Sha256Digest::new(reference)?;
546                evidence.push(entry(&hash, &outcome::read_bytes(&hash)?)?);
547            }
548        }
549        ensure!(
550            receipt_entries.len() <= MAX_CHECKPOINT_RECEIPTS
551                && evidence.len() <= MAX_EVIDENCE_ENTRIES,
552            "checkpoint package evidence exceeds transfer bound"
553        );
554        ensure!(
555            receipts
556                .iter()
557                .any(|receipt| *receipt.canonical_digest() == lineage.current_receipt_digest),
558            "checkpoint omits the current receipt"
559        );
560        Ok(CheckpointTransferPackageV1 {
561            schema_version: if include_source_evidence {
562                SOURCE_TRANSFER_SCHEMA
563            } else {
564                TRANSFER_SCHEMA
565            }
566            .to_owned(),
567            envelope_schema: envelope_schema.to_owned(),
568            artifact_digest: artifact_digest.clone(),
569            envelope_json: envelope_text.to_owned(),
570            checkpoint_digest: checkpoint_digest.clone(),
571            source_signer: TransferSigner::from(&self.signer_admission),
572            lineage,
573            receipts: receipt_entries,
574            evidence,
575        })
576    }
577
578    /// Admit one transferred checkpoint against an independently pinned source
579    /// and an operator-selected receiving scope.
580    pub fn import_checkpoint(
581        &self,
582        request: &HostCheckpointImportRequest,
583    ) -> Result<serde_json::Value> {
584        match self.import_checked(request, false, false)? {
585            ImportOutcome::Staged(value) => Ok(value),
586            _ => anyhow::bail!("invalid import action"),
587        }
588    }
589
590    pub(super) fn inspect_checkpoint_sources(
591        &self,
592        request: &HostCheckpointImportRequest,
593    ) -> Result<VerifiedTransferSources> {
594        match self.import_checked(request, true, false)? {
595            ImportOutcome::Inspected(value) => Ok(value),
596            _ => anyhow::bail!("invalid import action"),
597        }
598    }
599
600    fn import_checked(
601        &self,
602        request: &HostCheckpointImportRequest,
603        inspect_sources: bool,
604        continue_copy: bool,
605    ) -> Result<ImportOutcome> {
606        self.validate_current().map_err(anyhow::Error::msg)?;
607        ensure!(request.schema_version == 1, "unsupported import request");
608        let admission = self
609            .checkpoint_import
610            .as_ref()
611            .ok_or_else(|| anyhow::anyhow!("checkpoint import not authorized"))?;
612        ensure!(
613            admission.schema_version == 1,
614            "unsupported import admission"
615        );
616        let package = &request.package;
617        let package_bytes = canonical_serialize(package);
618        ensure!(
619            package_bytes.len() <= MAX_PACKAGE_BYTES,
620            "checkpoint package exceeds transfer bound"
621        );
622        ensure!(
623            digest(&package_bytes).map_err(anyhow::Error::msg)? == request.package_digest,
624            "checkpoint package digest mismatch"
625        );
626        ensure!(
627            matches!(
628                package.schema_version.as_str(),
629                TRANSFER_SCHEMA | SOURCE_TRANSFER_SCHEMA
630            ) && (!inspect_sources || package.schema_version == SOURCE_TRANSFER_SCHEMA),
631            "unsupported checkpoint package"
632        );
633        ensure!(
634            matches!(
635                package.envelope_schema.as_str(),
636                ENVELOPE_SCHEMA_V2 | ENVELOPE_SCHEMA_V3
637            ),
638            "unsupported checkpoint envelope schema"
639        );
640        ensure!(
641            package.envelope_json.len() <= MAX_ENVELOPE_BYTES
642                && !package.envelope_json.chars().any(char::is_control)
643                && package.receipts.len() <= MAX_CHECKPOINT_RECEIPTS
644                && !package.receipts.is_empty()
645                && package.evidence.len() <= MAX_EVIDENCE_ENTRIES,
646            "checkpoint package exceeds transfer bound"
647        );
648        ensure!(
649            crate::core::secret_detection::detect_secrets(&package.envelope_json).is_empty(),
650            "checkpoint contains credential-shaped material"
651        );
652
653        // The pinned source authority is the operator's configuration, never the
654        // package.  A stale or revoked pin fails here, before any evidence is read.
655        let verifying_key = parse_verifying_key(&admission.source_public_key_hex)?;
656        let pinned = admission.source_signer.snapshot();
657        // This also rejects a pin whose digest disagrees with its own key, and a
658        // pin that is expired or revoked at the current host clock.
659        validate_signer_admission(
660            &pinned,
661            &verifying_key,
662            &super::now().map_err(anyhow::Error::msg)?,
663        )?;
664        ensure!(
665            canonical_serialize(&package.source_signer)
666                == canonical_serialize(&TransferSigner::from(&pinned)),
667            "declared source signer is not the pinned source"
668        );
669
670        let root = canonical_receiving_root(&admission.receiving.project_root)?;
671        let continue_grant = if continue_copy {
672            let grant = self
673                .checkpoint_continue
674                .as_ref()
675                .ok_or_else(|| anyhow::anyhow!("copied context use not authorized"))?;
676            grant.validate(&request.package_digest)?;
677            if let Some(identity) = self
678                .admit_task_identity(Some(&root))
679                .map_err(anyhow::Error::msg)?
680            {
681                ensure!(
682                    identity.project_id == admission.receiving.project_id
683                        && identity.tenant_id == admission.receiving.tenant_id,
684                    "copied context receiving scope mismatch"
685                );
686            }
687            Some(grant.clone())
688        } else {
689            None
690        };
691        ensure!(
692            digest(package.envelope_json.as_bytes()).map_err(anyhow::Error::msg)?
693                == package.artifact_digest,
694            "checkpoint artifact digest mismatch"
695        );
696        let envelope_bytes = package.envelope_json.as_bytes().to_vec();
697        let evidence = PackagedEvidence::build(&package.evidence)?;
698        let receipt_source = PackagedEvidence::build(&package.receipts)?;
699
700        if package.envelope_schema == ENVELOPE_SCHEMA_V3 {
701            self.import_unicode(
702                package,
703                admission,
704                &pinned,
705                &verifying_key,
706                &ImportInputs {
707                    envelope_bytes,
708                    evidence,
709                    receipt_source,
710                    root,
711                    inspect_sources,
712                    continue_grant,
713                },
714            )
715        } else {
716            self.import_legacy(
717                package,
718                admission,
719                &pinned,
720                &verifying_key,
721                &ImportInputs {
722                    envelope_bytes,
723                    evidence,
724                    receipt_source,
725                    root,
726                    inspect_sources,
727                    continue_grant,
728                },
729            )
730        }
731    }
732
733    fn import_legacy(
734        &self,
735        package: &CheckpointTransferPackageV1,
736        admission: &CheckpointImportAdmission,
737        pinned: &ReceiptSignerAdmissionV1,
738        verifying_key: &VerifyingKey,
739        inputs: &ImportInputs,
740    ) -> Result<ImportOutcome> {
741        let envelope: Envelope = serde_json::from_slice(&inputs.envelope_bytes)?;
742        ensure!(
743            envelope.schema_version == ENVELOPE_SCHEMA_V2
744                && canonical_serialize(&envelope) == inputs.envelope_bytes,
745            "unsupported or noncanonical checkpoint envelope"
746        );
747        admit_pinned_envelope(
748            &envelope.signer,
749            &envelope.signature,
750            pinned,
751            verifying_key,
752            &{
753                let mut payload = CONTEXT_CHECKPOINT_V2_SIGNATURE_DOMAIN.to_vec();
754                payload.extend(envelope.checkpoint.canonical_bytes()?);
755                payload
756            },
757        )?;
758        let checkpoint = &envelope.checkpoint;
759        admit_scope(
760            admission,
761            &checkpoint.lineage.project_id,
762            &checkpoint.lineage.tenant_id,
763            &checkpoint.lineage.workspace_id,
764        )?;
765        let (receipts, task, plan, outcomes) = admit_evidence(
766            &checkpoint.lineage.artifact_lineage.receipt_refs,
767            package,
768            pinned,
769            verifying_key,
770            inputs,
771        )?;
772        let verified =
773            verify_checkpoint_v2(checkpoint.clone(), &task, Some(&plan), &receipts, &outcomes)?;
774        ensure!(
775            verified.digest()? == package.checkpoint_digest,
776            "declared checkpoint digest mismatch"
777        );
778        self.validate_current().map_err(anyhow::Error::msg)?;
779        let lineage = &verified.checkpoint().lineage;
780        let continuation = if inputs.continue_grant.is_some() {
781            let mut session = super::checkpoint_resume::project_session(
782                verified.checkpoint(),
783                &inputs.root,
784                &inputs.envelope_bytes,
785                &package.artifact_digest,
786            )?;
787            session.canonical_checkpoint = Some(Box::new(
788                crate::core::context_checkpoint::CanonicalSession::admitted(
789                    &verified,
790                    package.artifact_digest.clone(),
791                    inputs.root.clone(),
792                )?,
793            ));
794            Some(session)
795        } else {
796            None
797        };
798        stage_verified_package(
799            package,
800            admission,
801            inputs,
802            &VerifiedScope {
803                task_id: lineage.task_id.clone(),
804                project_id: lineage.project_id.clone(),
805                tenant_id: lineage.tenant_id.clone(),
806                workspace_id: lineage.workspace_id.clone(),
807            },
808            "leanctx.host-checkpoint-import-result/v1",
809            &receipts,
810            continuation,
811        )
812    }
813
814    fn import_unicode(
815        &self,
816        package: &CheckpointTransferPackageV1,
817        admission: &CheckpointImportAdmission,
818        pinned: &ReceiptSignerAdmissionV1,
819        verifying_key: &VerifyingKey,
820        inputs: &ImportInputs,
821    ) -> Result<ImportOutcome> {
822        let envelope: UnicodeEnvelope = serde_json::from_slice(&inputs.envelope_bytes)?;
823        ensure!(
824            envelope.schema_version == ENVELOPE_SCHEMA_V3
825                && canonical_serialize(&envelope) == inputs.envelope_bytes,
826            "unsupported or noncanonical Unicode checkpoint envelope"
827        );
828        admit_pinned_envelope(
829            &envelope.signer,
830            &envelope.signature,
831            pinned,
832            verifying_key,
833            &{
834                let mut payload = CONTEXT_CHECKPOINT_V3_SIGNATURE_DOMAIN.to_vec();
835                payload.extend(envelope.checkpoint.canonical_bytes()?);
836                payload
837            },
838        )?;
839        let checkpoint = &envelope.checkpoint;
840        admit_scope(
841            admission,
842            &checkpoint.lineage.project_id,
843            &checkpoint.lineage.tenant_id,
844            &checkpoint.lineage.workspace_id,
845        )?;
846        let (receipts, task, plan, outcomes) = admit_evidence(
847            &checkpoint.lineage.artifact_lineage.receipt_refs,
848            package,
849            pinned,
850            verifying_key,
851            inputs,
852        )?;
853        let verified =
854            verify_checkpoint_v3(checkpoint.clone(), &task, Some(&plan), &receipts, &outcomes)?;
855        ensure!(
856            verified.checkpoint().digest()? == package.checkpoint_digest,
857            "declared checkpoint digest mismatch"
858        );
859        self.validate_current().map_err(anyhow::Error::msg)?;
860        let lineage = &verified.checkpoint().lineage;
861        let continuation = if inputs.continue_grant.is_some() {
862            let mut session = super::checkpoint_resume::project_session_v3(
863                verified.checkpoint(),
864                &inputs.root,
865                &inputs.envelope_bytes,
866                &package.artifact_digest,
867            )?;
868            session.canonical_checkpoint = Some(Box::new(
869                crate::core::context_checkpoint::CanonicalSession::admitted_v3(
870                    &verified,
871                    package.artifact_digest.clone(),
872                    inputs.root.clone(),
873                )?,
874            ));
875            Some(session)
876        } else {
877            None
878        };
879        stage_verified_package(
880            package,
881            admission,
882            inputs,
883            &VerifiedScope {
884                task_id: lineage.task_id.clone(),
885                project_id: lineage.project_id.clone(),
886                tenant_id: lineage.tenant_id.clone(),
887                workspace_id: lineage.workspace_id.clone(),
888            },
889            "leanctx.host-checkpoint-import-result/v2",
890            &receipts,
891            continuation,
892        )
893    }
894}
895
896/// The scope the signed checkpoint itself binds, taken from the verified value.
897struct VerifiedScope {
898    task_id: TaskId,
899    project_id: ProjectId,
900    tenant_id: TenantId,
901    workspace_id: WorkspaceId,
902}
903
904pub(super) struct VerifiedTransferSources {
905    pub(super) root: PathBuf,
906    pub(super) package_digest: Sha256Digest,
907    pub(super) dependencies: BTreeMap<String, SourceDependency>,
908}
909
910enum ImportOutcome {
911    Staged(serde_json::Value),
912    Inspected(VerifiedTransferSources),
913    Continued(serde_json::Value),
914}
915
916/// Verify-and-stage.
917///
918/// Verify current content rules before staging the immutable package. Ordinary
919/// imports select no session; explicit personal-copy continuation additionally
920/// requires a current host admission and conflict-safe canonical publication.
921/// Neither path imports original source rights or execution approval authority.
922fn stage_verified_package(
923    package: &CheckpointTransferPackageV1,
924    admission: &CheckpointImportAdmission,
925    inputs: &ImportInputs,
926    scope: &VerifiedScope,
927    schema_version: &str,
928    receipts: &[VerifiedReceiptDocumentV1],
929    continuation: Option<crate::core::session::SessionState>,
930) -> Result<ImportOutcome> {
931    // The declared lineage is operator-facing audit information. It must agree
932    // with the verified checkpoint before anything is persisted, so a staged
933    // artifact can never misreport the source or a cross-scope transfer.
934    ensure!(
935        package.lineage.task_id == scope.task_id
936            && package.lineage.project_id == scope.project_id
937            && package.lineage.tenant_id == scope.tenant_id
938            && package.lineage.workspace_id == scope.workspace_id,
939        "declared package lineage disagrees with the verified checkpoint"
940    );
941    let receiving = &admission.receiving;
942    // Foreign content enters this project here: project policy, secret floor
943    // and the gateway's admission of its narrative all run BEFORE the first
944    // local store sees it (the staging store below, then — for a
945    // continuation — the session store).
946    content::admit(package, std::path::Path::new(&inputs.root), FOREIGN_PACKAGE)?;
947    if inputs.inspect_sources {
948        let sources =
949            source_evidence::derive(receipts, |hash| inputs.evidence.canonical_bytes(hash))?;
950        return Ok(ImportOutcome::Inspected(VerifiedTransferSources {
951            root: PathBuf::from(&inputs.root),
952            package_digest: request_digest(package)?,
953            dependencies: sources.dependencies,
954        }));
955    }
956    let cross_scope = receiving.project_id != scope.project_id
957        || receiving.tenant_id != scope.tenant_id
958        || receiving.workspace_id != scope.workspace_id;
959    let package_digest = request_digest(package)?;
960    let staged = serde_json::json!({
961        "schema_version":STAGED_SCHEMA,
962        "package_digest":package_digest,
963        "package":package,
964        "receiving":{
965            "project_root":inputs.root,
966            "project_id":receiving.project_id,
967            "tenant_id":receiving.tenant_id,
968            "workspace_id":receiving.workspace_id,
969        },
970        "verified_source_scope":{
971            "task_id":scope.task_id,
972            "project_id":scope.project_id,
973            "tenant_id":scope.tenant_id,
974            "workspace_id":scope.workspace_id,
975        },
976        "source_key_id":package.source_signer.key_id,
977        "cross_scope_adoption":cross_scope,
978        "staged":true,
979        "canonical_session_adopted":false,
980        "legacy_projection_created":false,
981        "source_ledger_adopted":false,
982        "source_rights_revalidation_required":true,
983    });
984    let bytes = canonical_serialize(&staged);
985    ensure!(
986        bytes.len() <= MAX_STAGED_BYTES,
987        "staged checkpoint package exceeds transfer bound"
988    );
989    let staged_digest = digest(&bytes).map_err(anyhow::Error::msg)?;
990    drop(
991        persist_engine_artifact_content(STAGING_NAMESPACE, staged_digest.hex(), "json", &bytes)
992            .map_err(anyhow::Error::msg)?,
993    );
994    if let Some(session) = continuation {
995        let grant = inputs
996            .continue_grant
997            .as_ref()
998            .ok_or_else(|| anyhow::anyhow!("copied context use not authorized"))?;
999        return continuation::publish(package, &inputs.root, grant, session)
1000            .map(ImportOutcome::Continued);
1001    }
1002    Ok(ImportOutcome::Staged(serde_json::json!({
1003        "schema_version":schema_version,
1004        "staged":true,
1005        "staged_digest":staged_digest,
1006        "staged_ref":format!("artifact://{STAGING_NAMESPACE}/{}", staged_digest.hex()),
1007        "package_digest":package_digest,
1008        "artifact_digest":package.artifact_digest,
1009        "checkpoint_digest":package.checkpoint_digest,
1010        "source_key_id":package.source_signer.key_id,
1011        "source_project_id":scope.project_id,
1012        "receiving_project_id":receiving.project_id,
1013        "receiving_tenant_id":receiving.tenant_id,
1014        "receiving_workspace_id":receiving.workspace_id,
1015        "cross_scope_adoption":cross_scope,
1016        "canonical_session_adopted":false,
1017        "legacy_projection_created":false,
1018        "active_context_selected":false,
1019        "source_ledger_adopted":false,
1020        "source_preserved":true,
1021        "source_rights_revalidation_required":true,
1022    })))
1023}
1024
1025struct ImportInputs {
1026    envelope_bytes: Vec<u8>,
1027    evidence: PackagedEvidence,
1028    receipt_source: PackagedEvidence,
1029    root: String,
1030    inspect_sources: bool,
1031    continue_grant: Option<CheckpointContinueAdmission>,
1032}
1033
1034fn request_digest(package: &CheckpointTransferPackageV1) -> Result<Sha256Digest> {
1035    digest(&canonical_serialize(package)).map_err(anyhow::Error::msg)
1036}
1037
1038fn admit_pinned_envelope(
1039    signer: &Signer,
1040    signature: &str,
1041    pinned: &ReceiptSignerAdmissionV1,
1042    verifying_key: &VerifyingKey,
1043    payload: &[u8],
1044) -> Result<()> {
1045    ensure!(
1046        signer.key_id == pinned.key_id && signer.public_key_digest == pinned.public_key_digest,
1047        "checkpoint signer is not the pinned source"
1048    );
1049    Ok(verifying_key.verify_strict(
1050        payload,
1051        &Signature::from_slice(&STANDARD.decode(signature)?)?,
1052    )?)
1053}
1054
1055/// The admitted source scope is exact; the receiving scope is separate and a
1056/// difference must be acknowledged rather than silently discarded.
1057fn admit_scope(
1058    admission: &CheckpointImportAdmission,
1059    project_id: &ProjectId,
1060    tenant_id: &TenantId,
1061    workspace_id: &WorkspaceId,
1062) -> Result<()> {
1063    let admitted = &admission.admit_source_scope;
1064    ensure!(
1065        admitted.project_id == *project_id
1066            && admitted.tenant_id == *tenant_id
1067            && admitted.workspace_id == *workspace_id,
1068        "checkpoint source scope not admitted"
1069    );
1070    let receiving = &admission.receiving;
1071    ensure!(
1072        admission.allow_cross_scope_adoption
1073            || (receiving.project_id == *project_id
1074                && receiving.tenant_id == *tenant_id
1075                && receiving.workspace_id == *workspace_id),
1076        "checkpoint receiving scope mismatch"
1077    );
1078    Ok(())
1079}
1080
1081/// Resolve exactly the evidence set the signed checkpoint binds. An omitted,
1082/// injected or substituted artifact fails closed.
1083fn admit_evidence(
1084    receipt_refs: &[Sha256Digest],
1085    package: &CheckpointTransferPackageV1,
1086    pinned: &ReceiptSignerAdmissionV1,
1087    verifying_key: &VerifyingKey,
1088    inputs: &ImportInputs,
1089) -> Result<(
1090    Vec<VerifiedReceiptDocumentV1>,
1091    TaskEnvelopeV1,
1092    ExecutionPlanV1,
1093    Vec<lean_ctx_protocol::AcceptedOutcomeV1>,
1094)> {
1095    let mut expected_receipts: Vec<String> = receipt_refs
1096        .iter()
1097        .map(|value| value.as_str().to_owned())
1098        .collect();
1099    expected_receipts.sort_unstable();
1100    expected_receipts.dedup();
1101    ensure!(
1102        inputs.receipt_source.digests() == expected_receipts,
1103        "transferred receipt set disagrees with the signed checkpoint"
1104    );
1105    let receipts = verify_receipts(receipt_refs, pinned, verifying_key, &|hash| {
1106        inputs.receipt_source.canonical_bytes(hash)
1107    })?;
1108    ensure!(
1109        receipts
1110            .iter()
1111            .any(|receipt| *receipt.canonical_digest() == package.lineage.current_receipt_digest),
1112        "transferred package omits its declared current receipt"
1113    );
1114    let first = receipts
1115        .first()
1116        .ok_or_else(|| anyhow::anyhow!("missing receipt"))?;
1117    let mut expected_evidence = vec![
1118        first.document().lineage.task_ref.as_str().to_owned(),
1119        first.document().lineage.plan_ref.as_str().to_owned(),
1120    ];
1121    for receipt in &receipts {
1122        if let Some(reference) = receipt.document().outcome.outcome_ref.as_ref() {
1123            expected_evidence.push(reference.as_str().to_owned());
1124        }
1125    }
1126    if package.schema_version == SOURCE_TRANSFER_SCHEMA {
1127        expected_evidence.extend(
1128            source_evidence::derive(&receipts, |hash| inputs.evidence.canonical_bytes(hash))?
1129                .references,
1130        );
1131    }
1132    expected_evidence.sort_unstable();
1133    expected_evidence.dedup();
1134    ensure!(
1135        inputs.evidence.digests() == expected_evidence,
1136        "transferred evidence set disagrees with the signed receipts"
1137    );
1138    let (task, plan) = read_task_and_plan(first, &inputs.evidence)?;
1139    let outcomes = reconstruct_outcomes(&plan, &receipts, &inputs.evidence)?;
1140    Ok((receipts, task, plan, outcomes))
1141}
1142
1143/// The receiving root in the one spelling a request project has
1144/// (`pathutil::canonicalize_secure`, without the Windows verbatim `\\?\`
1145/// prefix): the policy source view rebinds `REQUEST_PROJECT` to exactly that
1146/// spelling, and the content and source checks compare the two verbatim.
1147fn canonical_receiving_root(configured: &std::path::Path) -> Result<String> {
1148    ensure!(configured.is_absolute(), "absolute receiving root required");
1149    let root = crate::core::pathutil::canonicalize_secure(configured)?;
1150    ensure!(
1151        root.is_dir() && !crate::core::pathutil::is_broad_or_unsafe_root(&root),
1152        "unsafe receiving root"
1153    );
1154    Ok(root
1155        .to_str()
1156        .ok_or_else(|| anyhow::anyhow!("receiving root is not UTF-8"))?
1157        .to_owned())
1158}