1use 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
44pub(super) fn admit_fresh_checkpoint(checkpoint: &mut serde_json::Value) -> Result<bool> {
48 content::admit_fresh("checkpoint:", content::Document::Checkpoint, checkpoint)
49}
50
51pub(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#[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
75const 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;
86const MAX_EVIDENCE_ENTRIES: usize = 3 * MAX_CHECKPOINT_RECEIPTS + 2;
88const 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#[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#[derive(Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct HostCheckpointImportRequest {
107 schema_version: u32,
108 package_digest: Sha256Digest,
109 package: CheckpointTransferPackageV1,
110}
111
112#[derive(Clone, Deserialize, Serialize)]
114#[serde(deny_unknown_fields)]
115struct TransferEntry {
116 digest: Sha256Digest,
117 json: String,
118}
119
120#[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#[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#[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#[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#[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
211struct 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
255fn 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 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 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 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 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 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 let verifying_key = parse_verifying_key(&admission.source_public_key_hex)?;
656 let pinned = admission.source_signer.snapshot();
657 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
896struct 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
916fn 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 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 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
1055fn 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
1081fn 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
1143fn 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}