Skip to main content

a3s_box_runtime/oci/build/
receipt.rs

1//! Durable lifecycle records and terminal receipts for native OCI builds.
2//!
3//! The receipt journal is the sole build-operation state machine. It binds one
4//! caller operation and immutable source digest to the native engine, its
5//! operation-owned workspace, and the exact output already owned by
6//! [`ImageStore`]. The legacy pending intent and successful receipt schemas are
7//! retained as compatible states in this same journal rather than introducing
8//! a second supervisor store.
9
10use a3s_box_core::platform::Platform;
11use a3s_box_core::OperationId;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17use super::cache::{
18    inspect_build_cache_artifact, BuildCacheExportIdentity, BuildCacheReceipt, RecordedBuildCache,
19};
20use super::output::inspect_stored_build_output;
21use super::{BuildCachePolicy, BuildOutputDescriptor, BuildResult, OCI_IMAGE_MANIFEST_MEDIA_TYPE};
22use crate::oci::image::canonical_sha256_digest_hex;
23use crate::oci::ImageStore;
24
25const MAX_OPERATION_ID_BYTES: usize = 255;
26const MAX_RECEIPT_BYTES: u64 = 64 * 1024;
27const MAX_TERMINAL_MESSAGE_BYTES: usize = 4 * 1024;
28const RECEIPT_DIRECTORY: &str = "build-receipts";
29
30mod journal;
31
32pub(super) use journal::{BuildExecutionLease, BuildOperationJournal, LockedBuildOperation};
33
34/// Immutable caller identity for one recoverable build output.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct BuildOperationIdentity {
37    operation_id: OperationId,
38    source_digest: String,
39    output_reference: String,
40}
41
42impl BuildOperationIdentity {
43    /// Validate one bounded operation ID and canonical source Artifact digest.
44    pub fn new(
45        operation_id: OperationId,
46        source_digest: impl Into<String>,
47    ) -> Result<Self, BuildReceiptError> {
48        if operation_id.as_str().len() > MAX_OPERATION_ID_BYTES
49            || operation_id
50                .as_str()
51                .bytes()
52                .any(|byte| byte.is_ascii_control())
53        {
54            return Err(BuildReceiptError::InvalidIdentity {
55                field: "operation_id",
56                reason: "must contain at most 255 non-control UTF-8 bytes",
57            });
58        }
59        let source_digest = source_digest.into();
60        if canonical_sha256_digest_hex(&source_digest).is_err() {
61            return Err(BuildReceiptError::InvalidIdentity {
62                field: "source_digest",
63                reason: "must be canonical sha256:<64 lowercase hex>",
64            });
65        }
66        let operation_key = operation_key(&operation_id);
67        Ok(Self {
68            operation_id,
69            source_digest,
70            output_reference: format!("a3s-box/build-operation:{operation_key}"),
71        })
72    }
73
74    /// Caller-owned idempotency identity.
75    pub fn operation_id(&self) -> &OperationId {
76        &self.operation_id
77    }
78
79    /// Immutable source Artifact content identity.
80    pub fn source_digest(&self) -> &str {
81        &self.source_digest
82    }
83
84    /// Box-internal image reference derived from the operation identity.
85    pub fn output_reference(&self) -> &str {
86        &self.output_reference
87    }
88}
89
90/// Legacy pre-supervision intent accepted as a state in the same journal.
91///
92/// New starts write [`SupervisedBuildOperation`] directly. This schema remains
93/// readable so an upgrade can adopt a committed output or migrate an abandoned
94/// intent without a second compatibility store.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub(super) struct PendingBuildOperation {
98    schema: String,
99    operation_id: OperationId,
100    source_digest: String,
101    plan_digest: String,
102    output_reference: String,
103}
104
105impl PendingBuildOperation {
106    const SCHEMA: &'static str = "a3s.box.build-output-intent.v1";
107
108    #[cfg_attr(not(test), allow(dead_code))]
109    pub(super) fn new(
110        identity: &BuildOperationIdentity,
111        plan_digest: String,
112    ) -> Result<Self, BuildReceiptError> {
113        let pending = Self {
114            schema: Self::SCHEMA.to_string(),
115            operation_id: identity.operation_id.clone(),
116            source_digest: identity.source_digest.clone(),
117            plan_digest,
118            output_reference: identity.output_reference.clone(),
119        };
120        pending.require_identity(identity, &pending.plan_digest)?;
121        Ok(pending)
122    }
123
124    fn validate(&self) -> Result<(), BuildReceiptError> {
125        if self.schema != Self::SCHEMA
126            || !valid_operation_id(&self.operation_id)
127            || canonical_sha256_digest_hex(&self.source_digest).is_err()
128            || canonical_sha256_digest_hex(&self.plan_digest).is_err()
129            || self.output_reference
130                != format!(
131                    "a3s-box/build-operation:{}",
132                    operation_key(&self.operation_id)
133                )
134        {
135            return Err(BuildReceiptError::InvalidReceipt {
136                operation_id: self.operation_id.to_string(),
137                message: "pending build intent violates its closed identity".to_string(),
138            });
139        }
140        Ok(())
141    }
142
143    pub(super) fn require_identity(
144        &self,
145        identity: &BuildOperationIdentity,
146        plan_digest: &str,
147    ) -> Result<(), BuildReceiptError> {
148        self.validate()?;
149        if self.operation_id != identity.operation_id
150            || self.source_digest != identity.source_digest
151            || self.plan_digest != plan_digest
152            || self.output_reference != identity.output_reference
153        {
154            return Err(BuildReceiptError::Conflict {
155                operation_id: identity.operation_id.to_string(),
156                message: "the pending source, plan, or output identity differs".to_string(),
157            });
158        }
159        Ok(())
160    }
161
162    fn matches_receipt(&self, receipt: &BuildOutputReceipt) -> bool {
163        self.operation_id == receipt.operation_id
164            && self.source_digest == receipt.source_digest
165            && self.plan_digest == receipt.plan_digest
166            && self.output_reference == receipt.output.reference
167            && receipt.schema == BuildOutputReceipt::LEGACY_SCHEMA
168            && receipt.cache.is_none()
169    }
170}
171
172/// Stable host-process identity persisted in the one operation journal.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", deny_unknown_fields)]
175pub(super) struct BuildProcessIdentity {
176    pub(super) pid: u32,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub(super) start_time: Option<u64>,
179}
180
181impl BuildProcessIdentity {
182    pub(super) fn current() -> Self {
183        let pid = std::process::id();
184        Self {
185            pid,
186            start_time: crate::process::pid_start_time(pid),
187        }
188    }
189
190    fn validate(self, operation_id: &OperationId) -> Result<(), BuildReceiptError> {
191        if self.pid == 0 {
192            return Err(BuildReceiptError::InvalidReceipt {
193                operation_id: operation_id.to_string(),
194                message: "persisted build process has PID zero".to_string(),
195            });
196        }
197        Ok(())
198    }
199}
200
201/// Non-success phases represented by the authoritative operation record.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub(super) enum PersistedBuildPhase {
205    Running,
206    Cancelling,
207    Cancelled,
208    Failed,
209}
210
211/// Supervised lifecycle state stored in the existing receipt journal.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub(super) struct SupervisedBuildOperation {
215    schema: String,
216    operation_id: OperationId,
217    source_digest: String,
218    plan_digest: String,
219    output_reference: String,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    cache_policy: Option<BuildCachePolicy>,
222    pub(super) phase: PersistedBuildPhase,
223    owner: BuildProcessIdentity,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub(super) run_process: Option<BuildProcessIdentity>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    message: Option<String>,
228    started_at: DateTime<Utc>,
229    updated_at: DateTime<Utc>,
230}
231
232impl SupervisedBuildOperation {
233    pub(super) const SCHEMA: &'static str = "a3s.box.build-operation.v2";
234    const LEGACY_SCHEMA: &'static str = "a3s.box.build-operation.v1";
235
236    pub(super) fn new(
237        identity: &BuildOperationIdentity,
238        plan_digest: String,
239        cache_policy: BuildCachePolicy,
240    ) -> Result<Self, BuildReceiptError> {
241        let now = Utc::now();
242        let operation = Self {
243            schema: Self::SCHEMA.to_string(),
244            operation_id: identity.operation_id.clone(),
245            source_digest: identity.source_digest.clone(),
246            plan_digest,
247            output_reference: identity.output_reference.clone(),
248            cache_policy: Some(cache_policy),
249            phase: PersistedBuildPhase::Running,
250            owner: BuildProcessIdentity::current(),
251            run_process: None,
252            message: None,
253            started_at: now,
254            updated_at: now,
255        };
256        operation.validate()?;
257        operation.require_identity(identity, &operation.plan_digest, cache_policy)?;
258        Ok(operation)
259    }
260
261    pub(super) fn from_pending(
262        pending: &PendingBuildOperation,
263        identity: &BuildOperationIdentity,
264        plan_digest: &str,
265        cache_policy: BuildCachePolicy,
266    ) -> Result<Self, BuildReceiptError> {
267        pending.require_identity(identity, plan_digest)?;
268        Self::new(identity, plan_digest.to_string(), cache_policy)
269    }
270
271    fn validate(&self) -> Result<(), BuildReceiptError> {
272        let schema_is_valid = (self.schema == Self::SCHEMA && self.cache_policy.is_some())
273            || (self.schema == Self::LEGACY_SCHEMA && self.cache_policy.is_none());
274        if !schema_is_valid
275            || !valid_operation_id(&self.operation_id)
276            || canonical_sha256_digest_hex(&self.source_digest).is_err()
277            || canonical_sha256_digest_hex(&self.plan_digest).is_err()
278            || self.output_reference
279                != format!(
280                    "a3s-box/build-operation:{}",
281                    operation_key(&self.operation_id)
282                )
283            || self.updated_at < self.started_at
284        {
285            return Err(BuildReceiptError::InvalidReceipt {
286                operation_id: self.operation_id.to_string(),
287                message: "supervised build operation violates its closed identity".to_string(),
288            });
289        }
290        self.owner.validate(&self.operation_id)?;
291        if let Some(process) = self.run_process {
292            process.validate(&self.operation_id)?;
293        }
294        let terminal = matches!(
295            self.phase,
296            PersistedBuildPhase::Cancelled | PersistedBuildPhase::Failed
297        );
298        if terminal != self.message.is_some()
299            || self.message.as_ref().is_some_and(|message| {
300                message.is_empty() || message.len() > MAX_TERMINAL_MESSAGE_BYTES
301            })
302            || (terminal && self.run_process.is_some())
303        {
304            return Err(BuildReceiptError::InvalidReceipt {
305                operation_id: self.operation_id.to_string(),
306                message: "supervised build phase fields are inconsistent".to_string(),
307            });
308        }
309        Ok(())
310    }
311
312    pub(super) fn require_identity(
313        &self,
314        identity: &BuildOperationIdentity,
315        plan_digest: &str,
316        cache_policy: BuildCachePolicy,
317    ) -> Result<(), BuildReceiptError> {
318        self.validate()?;
319        if self.operation_id != identity.operation_id
320            || self.source_digest != identity.source_digest
321            || self.plan_digest != plan_digest
322            || self.output_reference != identity.output_reference
323            || self
324                .cache_policy
325                .is_some_and(|persisted| persisted != cache_policy)
326        {
327            return Err(BuildReceiptError::Conflict {
328                operation_id: identity.operation_id.to_string(),
329                message: "the supervised source, plan, or output identity differs".to_string(),
330            });
331        }
332        Ok(())
333    }
334
335    pub(super) const fn cache_policy(&self) -> Option<BuildCachePolicy> {
336        self.cache_policy
337    }
338
339    pub(super) fn request_cancellation(&mut self) -> bool {
340        if self.phase != PersistedBuildPhase::Running {
341            return false;
342        }
343        self.phase = PersistedBuildPhase::Cancelling;
344        self.updated_at = Utc::now();
345        true
346    }
347
348    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
349    pub(super) fn set_run_process(
350        &mut self,
351        process: Option<BuildProcessIdentity>,
352    ) -> Result<(), BuildReceiptError> {
353        if matches!(
354            self.phase,
355            PersistedBuildPhase::Cancelled | PersistedBuildPhase::Failed
356        ) {
357            return Err(BuildReceiptError::Conflict {
358                operation_id: self.operation_id.to_string(),
359                message: "a terminal build cannot own a RUN process".to_string(),
360            });
361        }
362        self.run_process = process;
363        self.updated_at = Utc::now();
364        self.validate()
365    }
366
367    pub(super) fn finish(&mut self, phase: PersistedBuildPhase, message: String) {
368        debug_assert!(matches!(
369            phase,
370            PersistedBuildPhase::Cancelled | PersistedBuildPhase::Failed
371        ));
372        self.phase = phase;
373        self.run_process = None;
374        self.message = Some(bounded_terminal_message(message));
375        self.updated_at = Utc::now();
376    }
377
378    pub(super) fn terminal_message(&self) -> Option<&str> {
379        self.message.as_deref()
380    }
381
382    pub(super) fn operation_id(&self) -> &OperationId {
383        &self.operation_id
384    }
385
386    fn matches_receipt(&self, receipt: &BuildOutputReceipt) -> bool {
387        self.operation_id == receipt.operation_id
388            && self.source_digest == receipt.source_digest
389            && self.plan_digest == receipt.plan_digest
390            && self.output_reference == receipt.output.reference
391            && match self.cache_policy {
392                Some(policy) => {
393                    receipt.schema == BuildOutputReceipt::SCHEMA
394                        && receipt.matches_cache_policy(policy)
395                }
396                None => {
397                    receipt.schema == BuildOutputReceipt::LEGACY_SCHEMA && receipt.cache.is_none()
398                }
399            }
400    }
401}
402
403/// Strict on-disk state for one build operation.
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(untagged)]
406pub(super) enum PersistedBuildOperation {
407    Supervised(SupervisedBuildOperation),
408    Pending(PendingBuildOperation),
409    Succeeded(Box<BuildOutputReceipt>),
410}
411
412impl PersistedBuildOperation {
413    fn validate(&self) -> Result<(), BuildReceiptError> {
414        match self {
415            Self::Supervised(operation) => operation.validate(),
416            Self::Pending(pending) => pending.validate(),
417            Self::Succeeded(receipt) => receipt.validate(),
418        }
419    }
420
421    fn operation_id(&self) -> &OperationId {
422        match self {
423            Self::Supervised(operation) => &operation.operation_id,
424            Self::Pending(pending) => &pending.operation_id,
425            Self::Succeeded(receipt) => &receipt.operation_id,
426        }
427    }
428}
429
430/// Persisted, path-independent description of one native OCI output.
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase", deny_unknown_fields)]
433pub struct BuildReceiptOutput {
434    /// Operation-specific internal ImageStore reference.
435    pub reference: String,
436    /// Exact root OCI manifest descriptor.
437    pub descriptor: BuildOutputDescriptor,
438    /// Exact single-platform output.
439    pub platform: Platform,
440    /// Bytes occupied by the durable ImageStore layout.
441    pub content_bytes: u64,
442    /// Manifest layer count.
443    pub layer_count: u64,
444    /// Content-addressed blob count.
445    pub blob_count: u64,
446    /// Canonical digest of the sorted digest-and-size blob inventory.
447    pub blob_inventory_digest: String,
448}
449
450/// Durable terminal receipt for a successful native build.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase", deny_unknown_fields)]
453pub struct BuildOutputReceipt {
454    /// Exact receipt schema.
455    pub schema: String,
456    /// Caller-owned idempotency identity.
457    pub operation_id: OperationId,
458    /// Immutable source Artifact content identity.
459    pub source_digest: String,
460    /// Canonical A3S ACL build-plan identity.
461    pub plan_digest: String,
462    /// Path-independent native OCI output evidence.
463    pub output: BuildReceiptOutput,
464    /// Portable native cache evidence, present for new content-addressed plans.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub cache: Option<BuildCacheReceipt>,
467}
468
469impl BuildOutputReceipt {
470    /// Current closed receipt schema.
471    pub const SCHEMA: &'static str = "a3s.box.build-output-receipt.v2";
472    const LEGACY_SCHEMA: &'static str = "a3s.box.build-output-receipt.v1";
473
474    pub(super) fn from_result(
475        identity: &BuildOperationIdentity,
476        plan_digest: String,
477        result: &BuildResult,
478        cache_policy: BuildCachePolicy,
479        cache: Option<&RecordedBuildCache>,
480    ) -> Result<Self, BuildReceiptError> {
481        let layer_count =
482            u64::try_from(result.layer_count).map_err(|_| BuildReceiptError::OutputInvalid {
483                operation_id: identity.operation_id.to_string(),
484                message: "layer count exceeds the durable receipt range".to_string(),
485            })?;
486        let blob_count =
487            u64::try_from(result.blob_count).map_err(|_| BuildReceiptError::OutputInvalid {
488                operation_id: identity.operation_id.to_string(),
489                message: "blob count exceeds the durable receipt range".to_string(),
490            })?;
491        let receipt = Self {
492            schema: Self::SCHEMA.to_string(),
493            operation_id: identity.operation_id.clone(),
494            source_digest: identity.source_digest.clone(),
495            plan_digest,
496            output: BuildReceiptOutput {
497                reference: result.reference.clone(),
498                descriptor: result.descriptor.clone(),
499                platform: result.platform.clone(),
500                content_bytes: result.content_bytes(),
501                layer_count,
502                blob_count,
503                blob_inventory_digest: result.blob_inventory_digest.clone(),
504            },
505            cache: cache.map(|cache| cache.receipt.clone()),
506        };
507        receipt.validate()?;
508        receipt.require_identity(identity, &receipt.plan_digest, cache_policy)?;
509        Ok(receipt)
510    }
511
512    pub(super) fn from_legacy_result(
513        identity: &BuildOperationIdentity,
514        plan_digest: String,
515        result: &BuildResult,
516    ) -> Result<Self, BuildReceiptError> {
517        let mut receipt = Self::from_result(
518            identity,
519            plan_digest,
520            result,
521            BuildCachePolicy::Disabled,
522            None,
523        )?;
524        receipt.schema = Self::LEGACY_SCHEMA.to_string();
525        receipt.validate()?;
526        Ok(receipt)
527    }
528
529    fn validate(&self) -> Result<(), BuildReceiptError> {
530        let operation_id = self.operation_id.to_string();
531        if self.schema != Self::SCHEMA && self.schema != Self::LEGACY_SCHEMA {
532            return Err(BuildReceiptError::InvalidReceipt {
533                operation_id,
534                message: format!("unsupported schema {:?}", self.schema),
535            });
536        }
537        if self.schema == Self::LEGACY_SCHEMA && self.cache.is_some() {
538            return Err(BuildReceiptError::InvalidReceipt {
539                operation_id,
540                message: "legacy output receipt cannot contain cache evidence".to_string(),
541            });
542        }
543        if !valid_operation_id(&self.operation_id) {
544            return Err(BuildReceiptError::InvalidReceipt {
545                operation_id,
546                message: "operation ID is outside the closed receipt bounds".to_string(),
547            });
548        }
549        for (field, digest) in [
550            ("source digest", self.source_digest.as_str()),
551            ("plan digest", self.plan_digest.as_str()),
552            ("output digest", self.output.descriptor.digest.as_str()),
553            (
554                "blob inventory digest",
555                self.output.blob_inventory_digest.as_str(),
556            ),
557        ] {
558            if canonical_sha256_digest_hex(digest).is_err() {
559                return Err(BuildReceiptError::InvalidReceipt {
560                    operation_id,
561                    message: format!("{field} is not canonical SHA-256"),
562                });
563            }
564        }
565        if self.output.reference
566            != format!(
567                "a3s-box/build-operation:{}",
568                operation_key(&self.operation_id)
569            )
570        {
571            return Err(BuildReceiptError::InvalidReceipt {
572                operation_id,
573                message: "output reference is not derived from the operation identity".to_string(),
574            });
575        }
576        if self.output.descriptor.media_type != OCI_IMAGE_MANIFEST_MEDIA_TYPE
577            || self.output.descriptor.size == 0
578            || self.output.content_bytes < self.output.descriptor.size
579            || self.output.blob_count < 2
580        {
581            return Err(BuildReceiptError::InvalidReceipt {
582                operation_id,
583                message: "output descriptor or content counts are invalid".to_string(),
584            });
585        }
586        if self.output.platform.os != "linux" || self.output.platform.architecture.trim().is_empty()
587        {
588            return Err(BuildReceiptError::InvalidReceipt {
589                operation_id,
590                message: "output platform is outside the native build contract".to_string(),
591            });
592        }
593        if let Some(cache) = &self.cache {
594            cache
595                .validate()
596                .map_err(|error| BuildReceiptError::CacheInvalid {
597                    operation_id: self.operation_id.to_string(),
598                    message: error.to_string(),
599                })?;
600            if cache.source_digest != self.source_digest
601                || cache.plan_digest != self.plan_digest
602                || cache.platform != self.output.platform
603            {
604                return Err(BuildReceiptError::InvalidReceipt {
605                    operation_id: self.operation_id.to_string(),
606                    message: "cache and image receipts have different immutable intent".to_string(),
607                });
608            }
609        }
610        Ok(())
611    }
612
613    pub(super) fn require_identity(
614        &self,
615        identity: &BuildOperationIdentity,
616        plan_digest: &str,
617        cache_policy: BuildCachePolicy,
618    ) -> Result<(), BuildReceiptError> {
619        self.validate()?;
620        if self.operation_id != identity.operation_id
621            || self.source_digest != identity.source_digest
622            || self.plan_digest != plan_digest
623            || self.output.reference != identity.output_reference
624        {
625            return Err(BuildReceiptError::Conflict {
626                operation_id: identity.operation_id.to_string(),
627                message: "the persisted source, plan, or output identity differs".to_string(),
628            });
629        }
630        if self.schema == Self::SCHEMA && !self.matches_cache_policy(cache_policy) {
631            return Err(BuildReceiptError::InvalidReceipt {
632                operation_id: identity.operation_id.to_string(),
633                message: "cache evidence differs from the admitted build-plan policy".to_string(),
634            });
635        }
636        Ok(())
637    }
638
639    fn matches_cache_policy(&self, cache_policy: BuildCachePolicy) -> bool {
640        match cache_policy {
641            BuildCachePolicy::ContentAddressed => self.cache.is_some(),
642            BuildCachePolicy::Disabled => self.cache.is_none(),
643        }
644    }
645
646    pub(super) async fn resolve(
647        &self,
648        store: &ImageStore,
649    ) -> Result<BuildResult, BuildReceiptError> {
650        self.validate()?;
651        let actual = inspect_stored_output(&self.operation_id, &self.output.reference, store)
652            .await?
653            .ok_or_else(|| BuildReceiptError::OutputMissing {
654                operation_id: self.operation_id.to_string(),
655                reference: self.output.reference.clone(),
656            })?;
657        if actual.descriptor != self.output.descriptor
658            || actual.platform != self.output.platform
659            || actual.content_bytes() != self.output.content_bytes
660            || u64::try_from(actual.layer_count).ok() != Some(self.output.layer_count)
661            || u64::try_from(actual.blob_count).ok() != Some(self.output.blob_count)
662            || actual.blob_inventory_digest != self.output.blob_inventory_digest
663        {
664            return Err(BuildReceiptError::OutputInvalid {
665                operation_id: self.operation_id.to_string(),
666                message: "revalidated ImageStore output differs from the receipt".to_string(),
667            });
668        }
669        Ok(actual)
670    }
671
672    pub(super) async fn resolve_cache(
673        &self,
674        layout_directory: &std::path::Path,
675    ) -> Result<Option<RecordedBuildCache>, BuildReceiptError> {
676        let Some(expected) = self.cache.clone() else {
677            return Ok(None);
678        };
679        let identity = BuildCacheExportIdentity::new(
680            self.source_digest.clone(),
681            self.plan_digest.clone(),
682            expected.platform.clone(),
683        )
684        .map_err(|error| BuildReceiptError::CacheInvalid {
685            operation_id: self.operation_id.to_string(),
686            message: error.to_string(),
687        })?;
688        let root = layout_directory.to_path_buf();
689        let operation = self.operation_id.to_string();
690        tokio::task::spawn_blocking(move || {
691            inspect_build_cache_artifact(&root, &identity, Some(&expected))
692        })
693        .await
694        .map_err(|error| BuildReceiptError::Task {
695            operation_id: self.operation_id.to_string(),
696            message: format!("cache receipt validation task failed: {error}"),
697        })?
698        .map(Some)
699        .map_err(|error| BuildReceiptError::CacheInvalid {
700            operation_id: operation,
701            message: error.to_string(),
702        })
703    }
704}
705
706pub(super) async fn inspect_stored_output(
707    operation_id: &OperationId,
708    reference: &str,
709    store: &ImageStore,
710) -> Result<Option<BuildResult>, BuildReceiptError> {
711    let Some(stored) =
712        store
713            .get_checked(reference)
714            .await
715            .map_err(|error| BuildReceiptError::OutputInvalid {
716                operation_id: operation_id.to_string(),
717                message: format!("failed to read the authoritative ImageStore index: {error}"),
718            })?
719    else {
720        return Ok(None);
721    };
722    let reference = reference.to_string();
723    let store_root = store.store_dir().to_path_buf();
724    let output = tokio::task::spawn_blocking(move || {
725        inspect_stored_build_output(&reference, stored, &store_root)
726    })
727    .await
728    .map_err(|error| BuildReceiptError::Task {
729        operation_id: operation_id.to_string(),
730        message: format!("OCI receipt validation task failed: {error}"),
731    })?
732    .map_err(|error| BuildReceiptError::OutputInvalid {
733        operation_id: operation_id.to_string(),
734        message: error.to_string(),
735    })?;
736    Ok(Some(output))
737}
738
739/// A successful recorded execution or exact durable replay.
740#[derive(Debug)]
741pub struct RecordedBuildResult {
742    /// Stable path-independent terminal receipt.
743    pub receipt: BuildOutputReceipt,
744    /// Revalidated store-owned OCI output.
745    pub output: BuildResult,
746    /// Revalidated operation-owned portable cache artifact.
747    pub cache: Option<RecordedBuildCache>,
748    /// Whether this call replayed an existing terminal receipt.
749    pub replayed: bool,
750}
751
752/// Typed observation of the one durable build-operation state machine.
753#[derive(Debug)]
754pub enum RecordedBuildStatus {
755    /// The native engine owns the operation execution lease.
756    Running,
757    /// Cancellation is durable and the native engine is fencing current work.
758    Cancelling,
759    /// Cancellation completed and the operation-owned workspace was reclaimed.
760    Cancelled { message: String },
761    /// Execution failed and the operation-owned workspace was reclaimed.
762    Failed { message: String },
763    /// The exact ImageStore output and successful receipt were revalidated.
764    Succeeded(Box<RecordedBuildResult>),
765}
766
767/// Idempotent outcome from requesting cancellation.
768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
769pub enum BuildCancellationOutcome {
770    /// No operation record exists.
771    NotFound,
772    /// The live build accepted a new durable cancellation request.
773    Requested,
774    /// A cancellation request was already durable and remains in progress.
775    AlreadyRequested,
776    /// The operation was already durably cancelled.
777    AlreadyCancelled,
778    /// The operation had already reached success or failure.
779    AlreadyTerminal,
780}
781
782/// Fail-closed receipt identity, persistence, conflict, and output errors.
783#[derive(Debug, Error)]
784pub enum BuildReceiptError {
785    /// Caller identity is outside the closed contract.
786    #[error("Box build receipt identity field {field} {reason}")]
787    InvalidIdentity {
788        field: &'static str,
789        reason: &'static str,
790    },
791    /// Receipt storage could not be used safely.
792    #[error("Box build receipt store is unsafe: {message}")]
793    UnsafeStore { message: String },
794    /// Receipt persistence failed.
795    #[error("Box build receipt store I/O failed: {message}: {source}")]
796    StoreIo {
797        message: String,
798        #[source]
799        source: std::io::Error,
800    },
801    /// Blocking persistence task failed.
802    #[error("Box build receipt task failed for {operation_id}: {message}")]
803    Task {
804        operation_id: String,
805        message: String,
806    },
807    /// Existing receipt bytes or fields violate the closed schema.
808    #[error("Box build receipt is invalid for {operation_id}: {message}")]
809    InvalidReceipt {
810        operation_id: String,
811        message: String,
812    },
813    /// One operation ID was reused for different immutable intent.
814    #[error("Box build receipt conflict for {operation_id}: {message}")]
815    Conflict {
816        operation_id: String,
817        message: String,
818    },
819    /// Receipt exists but its ImageStore reference is absent.
820    #[error("Box build output is missing for {operation_id}: ImageStore reference {reference}")]
821    OutputMissing {
822        operation_id: String,
823        reference: String,
824    },
825    /// Persisted output no longer proves the receipt.
826    #[error("Box build output is invalid for {operation_id}: {message}")]
827    OutputInvalid {
828        operation_id: String,
829        message: String,
830    },
831    /// Persisted cache evidence or its operation-owned OCI artifact is invalid.
832    #[error("Box build cache is invalid for {operation_id}: {message}")]
833    CacheInvalid {
834        operation_id: String,
835        message: String,
836    },
837}
838
839fn operation_key(operation_id: &OperationId) -> String {
840    format!("{:x}", Sha256::digest(operation_id.as_str().as_bytes()))
841}
842
843fn valid_operation_id(operation_id: &OperationId) -> bool {
844    operation_id.as_str().len() <= MAX_OPERATION_ID_BYTES
845        && !operation_id
846            .as_str()
847            .bytes()
848            .any(|byte| byte.is_ascii_control())
849}
850
851fn bounded_terminal_message(mut message: String) -> String {
852    if message.is_empty() {
853        return "build operation ended without an error message".to_string();
854    }
855    if message.len() <= MAX_TERMINAL_MESSAGE_BYTES {
856        return message;
857    }
858    let mut boundary = MAX_TERMINAL_MESSAGE_BYTES;
859    while !message.is_char_boundary(boundary) {
860        boundary -= 1;
861    }
862    message.truncate(boundary);
863    message
864}
865
866#[cfg(test)]
867mod tests;