Skip to main content

riffdb_application/
campaign.rs

1use std::fmt;
2
3use riffdb_types::{
4    ApplicationExportManifestHash, ApplicationExportReceiptHash, ApplicationInstallationCampaignId,
5    ApplicationInstallationPlanHash, ApplicationInstallationReceiptHash, ApplicationLockHash,
6    ApplicationManifestHash, ApplicationPortabilityManifestHash, ApplicationReimportReceiptHash,
7    ApplicationRoleHash, ApplicationSourceHash, CapabilityId, ContractBundleHash,
8    ContractMigrationOperationId, ContractVersion, DatabaseId, GeneratedArtifactHash,
9    MigrationBundleHash, hash_application_installation_receipt,
10};
11use serde::{Deserialize, Serialize};
12
13pub use riffdb_types::MAX_APPLICATION_INSTALLATION_CAMPAIGN_STATE_BYTES;
14
15use crate::{
16    ApplicationInstallationPlan, ApplicationReimportCampaignV1, ApplicationReimportSourceV1,
17    InstallationArtifact, InstallationArtifactKind, InstallationCampaignError,
18    InstallationCampaignErrorKind, InstallationDriver, InstallationSymbol, InstalledSeedEvidence,
19    parse_hex16, parse_hex32,
20};
21
22/// Canonical schema version for terminal installation receipts.
23pub const APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V1: &str =
24    "riffdb.application-installation-receipt/v1";
25/// Canonical schema version for complete terminal installation receipts.
26pub const APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V2: &str =
27    "riffdb.application-installation-receipt/v2";
28/// Canonical terminal receipt carrying explicit reimport reconciliation identities.
29pub const APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V3: &str =
30    "riffdb.application-installation-receipt/v3";
31/// Latest supported terminal installation-receipt schema.
32pub const APPLICATION_INSTALLATION_RECEIPT_SCHEMA_CURRENT: &str =
33    APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V3;
34/// Canonical schema version for durable resumable campaign state.
35pub const APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V1: &str =
36    "riffdb.application-installation-campaign-state/v1";
37/// Canonical durable campaign state containing the reimport publication gate.
38pub const APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V2: &str =
39    "riffdb.application-installation-campaign-state/v2";
40/// Canonical durable campaign state carrying resumable reimport progress.
41pub const APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V3: &str =
42    "riffdb.application-installation-campaign-state/v3";
43/// Maximum canonical bytes in one redacted terminal receipt.
44pub const MAX_INSTALLATION_RECEIPT_BYTES: usize = 2 * 1_024 * 1_024;
45/// Compatibility alias for the shared exact campaign-state storage boundary.
46pub const MAX_INSTALLATION_CAMPAIGN_STATE_BYTES: usize =
47    MAX_APPLICATION_INSTALLATION_CAMPAIGN_STATE_BYTES;
48
49/// Closed, dependency-ordered stages in one application installation campaign.
50#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub enum InstallationStage {
52    /// Validate exact local inputs without remote mutation.
53    Preflight,
54    /// Deploy or verify the exact contract successor.
55    Contract,
56    /// Run or verify the explicitly approved migration, when present.
57    Migration,
58    /// Deploy or verify exact named-query modules.
59    QueryModules,
60    /// Deploy or verify exact reactive modules.
61    ReactiveModules,
62    /// Provision or reconcile exact application roles.
63    Roles,
64    /// Reconstitute exact portable state, or record that reimport is not required.
65    Reimport,
66    /// Rotate or verify application credentials without exposing their bytes.
67    Credentials,
68    /// Prove every required public driver against the installed identity.
69    DriverProof,
70    /// Run bounded command-based seed batches and retain only their receipts.
71    Seeds,
72    /// Seal the terminal redacted installation receipt.
73    Receipt,
74}
75
76impl InstallationStage {
77    /// Every stage in the only valid execution order.
78    pub const ALL: [Self; 11] = [
79        Self::Preflight,
80        Self::Contract,
81        Self::Migration,
82        Self::QueryModules,
83        Self::ReactiveModules,
84        Self::Roles,
85        Self::Reimport,
86        Self::Credentials,
87        Self::DriverProof,
88        Self::Seeds,
89        Self::Receipt,
90    ];
91
92    /// Stable public v1 stage label.
93    #[must_use]
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::Preflight => "preflight",
97            Self::Contract => "contract",
98            Self::Migration => "migration",
99            Self::QueryModules => "query_modules",
100            Self::ReactiveModules => "reactive_modules",
101            Self::Roles => "roles",
102            Self::Reimport => "reimport",
103            Self::Credentials => "credentials",
104            Self::DriverProof => "driver_proof",
105            Self::Seeds => "seeds",
106            Self::Receipt => "receipt",
107        }
108    }
109}
110
111/// Exact immutable identity attached to every campaign observation.
112#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113pub struct InstallationPlanIdentity(ApplicationInstallationPlanHash);
114
115impl InstallationPlanIdentity {
116    /// Wraps the compiler-derived plan hash.
117    #[must_use]
118    pub const fn new(hash: ApplicationInstallationPlanHash) -> Self {
119        Self(hash)
120    }
121
122    /// Returns the exact compiler-derived plan hash.
123    #[must_use]
124    pub const fn hash(self) -> ApplicationInstallationPlanHash {
125        self.0
126    }
127}
128
129/// Exact observed role identity after role reconciliation.
130#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
131pub struct InstalledRoleEvidence {
132    name: InstallationSymbol,
133    role_hash: ApplicationRoleHash,
134}
135
136impl InstalledRoleEvidence {
137    /// Creates exact role evidence.
138    #[must_use]
139    pub const fn new(name: InstallationSymbol, role_hash: ApplicationRoleHash) -> Self {
140        Self { name, role_hash }
141    }
142
143    /// Symbolic role name.
144    #[must_use]
145    pub const fn name(&self) -> &InstallationSymbol {
146        &self.name
147    }
148
149    /// Exact installed role identity.
150    #[must_use]
151    pub const fn role_hash(&self) -> ApplicationRoleHash {
152        self.role_hash
153    }
154}
155
156/// Exact completion evidence for the publication-gating reimport stage.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum InstalledReimportEvidence {
159    /// This ordinary installation has no portable source state.
160    NotRequired,
161    /// Exact source, mapping, and terminal reconciliation identities.
162    Reconciled(InstalledReimportReceiptEvidence),
163}
164
165/// Verified terminal identities for a completed reimport stage.
166///
167/// Fields are deliberately private: only semantic reconciliation against the
168/// exact plan, portability manifest, receipt, and target database can create
169/// this value.
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub struct InstalledReimportReceiptEvidence {
172    export_manifest_hash: ApplicationExportManifestHash,
173    export_receipt_hash: ApplicationExportReceiptHash,
174    portability_manifest_hash: ApplicationPortabilityManifestHash,
175    reimport_receipt_hash: ApplicationReimportReceiptHash,
176}
177
178/// Exact observed credential slot without credential material.
179#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
180pub struct InstalledCredentialEvidence {
181    destination: InstallationSymbol,
182    capability_id: CapabilityId,
183}
184
185impl InstalledCredentialEvidence {
186    /// Creates one slot identity observation.
187    #[must_use]
188    pub const fn new(destination: InstallationSymbol, capability_id: CapabilityId) -> Self {
189        Self {
190            destination,
191            capability_id,
192        }
193    }
194
195    /// Symbolic destination name, never a host path.
196    #[must_use]
197    pub const fn destination(&self) -> &InstallationSymbol {
198        &self.destination
199    }
200
201    /// Exact installed capability identity, never token bytes.
202    #[must_use]
203    pub const fn capability_id(&self) -> CapabilityId {
204        self.capability_id
205    }
206}
207
208/// Closed exact evidence for one idempotent campaign stage.
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub enum InstallationStageEvidence {
211    /// Exact local source/lock/manifest validation.
212    Preflight {
213        /// Author source identity.
214        source_hash: ApplicationSourceHash,
215        /// Compiler-owned lock identity.
216        lock_hash: ApplicationLockHash,
217        /// Canonical manifest identity.
218        manifest_hash: ApplicationManifestHash,
219    },
220    /// Exact active contract identity.
221    Contract {
222        /// Active version.
223        version: ContractVersion,
224        /// Active bundle hash.
225        bundle_hash: ContractBundleHash,
226    },
227    /// Exact migration completion, or explicit not-required evidence.
228    Migration {
229        /// Compiled migration identity when the plan requires one.
230        migration_hash: Option<MigrationBundleHash>,
231    },
232    /// Exact deployed named-query artifacts.
233    QueryModules(Vec<InstallationArtifact>),
234    /// Exact deployed reactive artifacts.
235    ReactiveModules(Vec<InstallationArtifact>),
236    /// Exact reconciled symbolic roles.
237    Roles(Vec<InstalledRoleEvidence>),
238    /// Exact reimport terminal evidence, or explicit not-required evidence.
239    Reimport(InstalledReimportEvidence),
240    /// Exact installed credential identities without token bytes.
241    Credentials(Vec<InstalledCredentialEvidence>),
242    /// Exact public drivers that proved the installed identity.
243    DriverProof(Vec<InstallationDriver>),
244    /// Complete per-batch seed counters with no item values.
245    Seeds(Vec<InstalledSeedEvidence>),
246    /// Terminal receipt identity sealed by the campaign state machine.
247    Receipt(ApplicationInstallationReceiptHash),
248}
249
250impl InstallationStageEvidence {
251    /// Stage represented by this evidence.
252    #[must_use]
253    pub const fn stage(&self) -> InstallationStage {
254        match self {
255            Self::Preflight { .. } => InstallationStage::Preflight,
256            Self::Contract { .. } => InstallationStage::Contract,
257            Self::Migration { .. } => InstallationStage::Migration,
258            Self::QueryModules(_) => InstallationStage::QueryModules,
259            Self::ReactiveModules(_) => InstallationStage::ReactiveModules,
260            Self::Roles(_) => InstallationStage::Roles,
261            Self::Reimport(_) => InstallationStage::Reimport,
262            Self::Credentials(_) => InstallationStage::Credentials,
263            Self::DriverProof(_) => InstallationStage::DriverProof,
264            Self::Seeds(_) => InstallationStage::Seeds,
265            Self::Receipt(_) => InstallationStage::Receipt,
266        }
267    }
268
269    /// Validates that this evidence is the exact value declared by a plan.
270    ///
271    /// This validates identity and bounded shape only. Remote contract,
272    /// module, role, and capability evidence is still observed by the server;
273    /// callers cannot use this method to authorize or assert those stages.
274    pub fn validate_for(
275        &self,
276        plan: &ApplicationInstallationPlan,
277    ) -> Result<(), InstallationCampaignError> {
278        validate_stage_evidence(plan, self)
279    }
280}
281
282/// Closed safe failure observed while executing the current stage.
283#[derive(Clone, Copy, Debug, Eq, PartialEq)]
284pub enum InstallationFailureCode {
285    /// Local source, lock, manifest, or generated artifact drift.
286    LocalArtifactMismatch,
287    /// Remote contract, module, role, or capability identity drift.
288    RemoteIdentityMismatch,
289    /// Required migration approval or exact identity is absent.
290    MigrationGateRequired,
291    /// Explicit authority widening approval is absent or stale.
292    RoleWideningApprovalRequired,
293    /// Reimport is incomplete, cancelled, or failed reconciliation.
294    ReimportPartial,
295    /// Credential destination is occupied by an unexpected identity.
296    CredentialDestinationOccupied,
297    /// One public driver could not prove the exact installed identity.
298    DriverProofFailed,
299    /// At least one seed item remains incomplete or failed.
300    SeedPartial,
301    /// Installer authority is absent or expired.
302    AuthorizationDenied,
303    /// The selected database is temporarily unavailable.
304    ServiceUnavailable,
305}
306
307/// Exact bounded next action for a partial campaign.
308#[derive(Clone, Copy, Debug, Eq, PartialEq)]
309pub enum InstallationNextAction {
310    /// Validate the exact local source, lock, manifest, and artifacts.
311    ValidateLocalArtifacts,
312    /// Deploy or verify the exact contract.
313    DeployContract,
314    /// Apply or observe the exact approved migration.
315    ApplyMigration,
316    /// Deploy or verify named-query modules.
317    DeployQueryModules,
318    /// Deploy or verify reactive modules.
319    DeployReactiveModules,
320    /// Reconcile exact application roles after reviewing any widening.
321    ReconcileRoles,
322    /// Resume exact compiler-owned reimport and terminal reconciliation.
323    ReimportApplication,
324    /// Rotate credentials only from the expected predecessor identities.
325    RotateCredentials,
326    /// Run exact public-driver identity proofs.
327    ProveDrivers,
328    /// Resume ordinary-command seed batches from their exact checkpoints.
329    RunSeeds,
330    /// Seal the redacted terminal receipt.
331    SealReceipt,
332    /// Campaign is terminal and requires no next action.
333    None,
334}
335
336impl InstallationNextAction {
337    /// Derives the one exact action for a stage.
338    #[must_use]
339    pub const fn for_stage(stage: InstallationStage) -> Self {
340        match stage {
341            InstallationStage::Preflight => Self::ValidateLocalArtifacts,
342            InstallationStage::Contract => Self::DeployContract,
343            InstallationStage::Migration => Self::ApplyMigration,
344            InstallationStage::QueryModules => Self::DeployQueryModules,
345            InstallationStage::ReactiveModules => Self::DeployReactiveModules,
346            InstallationStage::Roles => Self::ReconcileRoles,
347            InstallationStage::Reimport => Self::ReimportApplication,
348            InstallationStage::Credentials => Self::RotateCredentials,
349            InstallationStage::DriverProof => Self::ProveDrivers,
350            InstallationStage::Seeds => Self::RunSeeds,
351            InstallationStage::Receipt => Self::SealReceipt,
352        }
353    }
354}
355
356/// One bounded typed partial-stage failure.
357#[derive(Clone, Copy, Debug, Eq, PartialEq)]
358pub struct InstallationFailure {
359    stage: InstallationStage,
360    code: InstallationFailureCode,
361    next_action: InstallationNextAction,
362}
363
364impl InstallationFailure {
365    /// Current incomplete stage.
366    #[must_use]
367    pub const fn stage(self) -> InstallationStage {
368        self.stage
369    }
370
371    /// Stable safe failure code.
372    #[must_use]
373    pub const fn code(self) -> InstallationFailureCode {
374        self.code
375    }
376
377    /// Exact recovery action.
378    #[must_use]
379    pub const fn next_action(self) -> InstallationNextAction {
380        self.next_action
381    }
382}
383
384/// Public campaign phase; partial can never be confused with installed.
385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386pub enum InstallationCampaignPhase {
387    /// Campaign is ready to execute its next stage.
388    Running,
389    /// Campaign stopped with a typed failure and retained completed stages.
390    Partial,
391    /// Every stage, including receipt sealing, completed exactly.
392    Installed,
393}
394
395/// Immutable observation of one durable installation campaign.
396#[derive(Clone, Debug, Eq, PartialEq)]
397pub struct ApplicationInstallationObservation {
398    campaign_id: ApplicationInstallationCampaignId,
399    plan_hash: ApplicationInstallationPlanHash,
400    phase: InstallationCampaignPhase,
401    completed: Vec<InstallationStage>,
402    next_stage: Option<InstallationStage>,
403    next_action: InstallationNextAction,
404    failure: Option<InstallationFailure>,
405    receipt_hash: Option<ApplicationInstallationReceiptHash>,
406}
407
408impl ApplicationInstallationObservation {
409    /// Caller-stable campaign identity.
410    #[must_use]
411    pub const fn campaign_id(&self) -> ApplicationInstallationCampaignId {
412        self.campaign_id
413    }
414
415    /// Exact immutable plan identity.
416    #[must_use]
417    pub const fn plan_hash(&self) -> ApplicationInstallationPlanHash {
418        self.plan_hash
419    }
420
421    /// Current closed phase.
422    #[must_use]
423    pub const fn phase(&self) -> InstallationCampaignPhase {
424        self.phase
425    }
426
427    /// Completed stages in dependency order.
428    #[must_use]
429    pub fn completed(&self) -> &[InstallationStage] {
430        &self.completed
431    }
432
433    /// Current incomplete stage, absent only after receipt sealing.
434    #[must_use]
435    pub const fn next_stage(&self) -> Option<InstallationStage> {
436        self.next_stage
437    }
438
439    /// Exact operator recovery action.
440    #[must_use]
441    pub const fn next_action(&self) -> InstallationNextAction {
442        self.next_action
443    }
444
445    /// Typed partial failure, present only in `Partial`.
446    #[must_use]
447    pub const fn failure(&self) -> Option<InstallationFailure> {
448        self.failure
449    }
450
451    /// Terminal receipt identity, present only in `Installed`.
452    #[must_use]
453    pub const fn receipt_hash(&self) -> Option<ApplicationInstallationReceiptHash> {
454        self.receipt_hash
455    }
456}
457
458/// API-neutral deterministic state machine persisted by the shared service.
459#[derive(Clone, Eq, PartialEq)]
460pub struct ApplicationInstallationCampaign {
461    campaign_id: ApplicationInstallationCampaignId,
462    plan_hash: ApplicationInstallationPlanHash,
463    completed: Vec<InstallationStageEvidence>,
464    failure: Option<InstallationFailure>,
465    reimport: Option<ApplicationReimportCampaignV1>,
466}
467
468impl ApplicationInstallationCampaign {
469    /// Starts an empty campaign under one caller-stable identity.
470    #[must_use]
471    pub const fn start(
472        campaign_id: ApplicationInstallationCampaignId,
473        plan_hash: ApplicationInstallationPlanHash,
474    ) -> Self {
475        Self {
476            campaign_id,
477            plan_hash,
478            completed: Vec::new(),
479            failure: None,
480            reimport: None,
481        }
482    }
483
484    /// Verifies exact identity and every completed stage before resuming.
485    pub fn resume(
486        &mut self,
487        campaign_id: ApplicationInstallationCampaignId,
488        plan: &ApplicationInstallationPlan,
489    ) -> Result<ApplicationInstallationObservation, InstallationCampaignError> {
490        self.verify_identity(campaign_id, plan)?;
491        for evidence in &self.completed {
492            validate_stage_evidence(plan, evidence)?;
493        }
494        self.failure = None;
495        Ok(self.observe())
496    }
497
498    /// Records one exact idempotent stage completion in dependency order.
499    pub fn complete_stage(
500        &mut self,
501        plan: &ApplicationInstallationPlan,
502        evidence: InstallationStageEvidence,
503    ) -> Result<ApplicationInstallationObservation, InstallationCampaignError> {
504        self.verify_plan(plan)?;
505        let stage_index = InstallationStage::ALL
506            .iter()
507            .position(|stage| *stage == evidence.stage())
508            .expect("closed installation stage registry");
509        if let Some(completed) = self.completed.get(stage_index) {
510            if completed == &evidence {
511                return Ok(self.observe());
512            }
513            return Err(InstallationCampaignError::new(
514                InstallationCampaignErrorKind::EvidenceMismatch,
515            ));
516        }
517        if self.is_installed() {
518            return Err(InstallationCampaignError::new(
519                InstallationCampaignErrorKind::AlreadyTerminal,
520            ));
521        }
522        let expected = self.next_stage().ok_or_else(|| {
523            InstallationCampaignError::new(InstallationCampaignErrorKind::AlreadyTerminal)
524        })?;
525        if evidence.stage() != expected || expected == InstallationStage::Receipt {
526            return Err(InstallationCampaignError::new(
527                InstallationCampaignErrorKind::StageOutOfOrder,
528            ));
529        }
530        if expected == InstallationStage::Reimport && plan.input().reimport.is_some() {
531            return Err(InstallationCampaignError::new(
532                InstallationCampaignErrorKind::StageOutOfOrder,
533            ));
534        }
535        validate_stage_evidence(plan, &evidence)?;
536        self.completed.push(evidence);
537        self.failure = None;
538        Ok(self.observe())
539    }
540
541    /// Starts the exact nested reimport checkpoint at the sole reimport stage.
542    pub fn start_reimport(
543        &mut self,
544        plan: &ApplicationInstallationPlan,
545        source: ApplicationReimportSourceV1,
546        authority: riffdb_types::ApplicationReimportAuthorityV1,
547        scope: riffdb_types::CapabilityApplicationReimportScopeV1,
548        portability_manifest: &crate::ApplicationPortabilityManifest,
549    ) -> Result<&ApplicationReimportCampaignV1, InstallationCampaignError> {
550        self.verify_plan(plan)?;
551        if self.next_stage() != Some(InstallationStage::Reimport) || self.reimport.is_some() {
552            return Err(InstallationCampaignError::new(
553                InstallationCampaignErrorKind::StageOutOfOrder,
554            ));
555        }
556        let expected = plan.input().reimport.ok_or_else(evidence_mismatch)?;
557        if source.export_manifest_hash() != expected.export_manifest_hash()
558            || source.export_receipt_hash() != expected.export_receipt_hash()
559            || source.portability_manifest_hash() != expected.portability_manifest_hash()
560            || portability_manifest.identity() != expected.portability_manifest_hash()
561        {
562            return Err(evidence_mismatch());
563        }
564        self.reimport = Some(
565            ApplicationReimportCampaignV1::start(source, authority, scope, portability_manifest)
566                .map_err(|_| evidence_mismatch())?,
567        );
568        self.failure = None;
569        Ok(self.reimport.as_ref().expect("nested reimport just set"))
570    }
571
572    /// Current durable reimport progress, absent before a portability campaign starts.
573    #[must_use]
574    pub const fn reimport(&self) -> Option<&ApplicationReimportCampaignV1> {
575        self.reimport.as_ref()
576    }
577
578    /// Mutable reimport progress for the API-neutral service coordinator.
579    pub fn reimport_mut(&mut self) -> Option<&mut ApplicationReimportCampaignV1> {
580        self.reimport.as_mut()
581    }
582
583    /// Verifies and records terminal reimport reconciliation for this exact plan.
584    pub fn complete_reimport(
585        &mut self,
586        plan: &ApplicationInstallationPlan,
587        portability_manifest: &crate::ApplicationPortabilityManifest,
588        reimport_receipt: &crate::ApplicationReimportReceipt,
589        target_database_id: DatabaseId,
590    ) -> Result<ApplicationInstallationObservation, InstallationCampaignError> {
591        self.verify_plan(plan)?;
592        if self.next_stage() != Some(InstallationStage::Reimport) {
593            return Err(InstallationCampaignError::new(
594                InstallationCampaignErrorKind::StageOutOfOrder,
595            ));
596        }
597        let expected = plan.input().reimport.ok_or_else(evidence_mismatch)?;
598        let receipt = reimport_receipt.input();
599        let progress = self.reimport.as_ref().ok_or_else(evidence_mismatch)?;
600        if portability_manifest.identity() != expected.portability_manifest_hash()
601            || portability_manifest.input().contract_lineage != *plan.input().target.lineage()
602            || portability_manifest.input().contract_version != plan.input().contract.version()
603            || portability_manifest.input().contract_bundle_hash
604                != plan.input().contract.bundle_hash()
605            || receipt.portability_manifest_hash != expected.portability_manifest_hash()
606            || receipt.export_manifest_hash != expected.export_manifest_hash()
607            || receipt.target_database_id != target_database_id
608            || progress.phase() != crate::ApplicationReimportCampaignPhaseV1::Reconciled
609            || progress.receipt_hash() != Some(reimport_receipt.identity())
610        {
611            return Err(evidence_mismatch());
612        }
613        self.completed.push(InstallationStageEvidence::Reimport(
614            InstalledReimportEvidence::Reconciled(InstalledReimportReceiptEvidence {
615                export_manifest_hash: expected.export_manifest_hash(),
616                export_receipt_hash: expected.export_receipt_hash(),
617                portability_manifest_hash: expected.portability_manifest_hash(),
618                reimport_receipt_hash: reimport_receipt.identity(),
619            }),
620        ));
621        self.failure = None;
622        Ok(self.observe())
623    }
624
625    /// Stops the current stage with a typed partial result and exact next action.
626    pub fn record_failure(
627        &mut self,
628        plan: &ApplicationInstallationPlan,
629        code: InstallationFailureCode,
630    ) -> Result<ApplicationInstallationObservation, InstallationCampaignError> {
631        self.verify_plan(plan)?;
632        let stage = self.next_stage().ok_or_else(|| {
633            InstallationCampaignError::new(InstallationCampaignErrorKind::AlreadyTerminal)
634        })?;
635        self.failure = Some(InstallationFailure {
636            stage,
637            code,
638            next_action: InstallationNextAction::for_stage(stage),
639        });
640        Ok(self.observe())
641    }
642
643    /// Seals the terminal redacted receipt; this is the only success transition.
644    pub fn seal_receipt(
645        &mut self,
646        plan: &ApplicationInstallationPlan,
647    ) -> Result<ApplicationInstallationReceipt, InstallationCampaignError> {
648        self.verify_plan(plan)?;
649        if let Some(InstallationStageEvidence::Receipt(retained_hash)) = self.completed.last() {
650            return ApplicationInstallationReceipt::seal_matching(self, plan, *retained_hash);
651        }
652        if self.next_stage() != Some(InstallationStage::Receipt) {
653            return Err(InstallationCampaignError::new(
654                InstallationCampaignErrorKind::StageOutOfOrder,
655            ));
656        }
657        let receipt = ApplicationInstallationReceipt::seal(self, plan)?;
658        self.completed
659            .push(InstallationStageEvidence::Receipt(receipt.identity()));
660        self.failure = None;
661        Ok(receipt)
662    }
663
664    /// Current redaction-safe observation.
665    #[must_use]
666    pub fn observe(&self) -> ApplicationInstallationObservation {
667        let next_stage = self.next_stage();
668        let receipt_hash = self.completed.last().and_then(|evidence| match evidence {
669            InstallationStageEvidence::Receipt(hash) => Some(*hash),
670            _ => None,
671        });
672        let phase = if receipt_hash.is_some() {
673            InstallationCampaignPhase::Installed
674        } else if self.failure.is_some() {
675            InstallationCampaignPhase::Partial
676        } else {
677            InstallationCampaignPhase::Running
678        };
679        ApplicationInstallationObservation {
680            campaign_id: self.campaign_id,
681            plan_hash: self.plan_hash,
682            phase,
683            completed: self
684                .completed
685                .iter()
686                .map(InstallationStageEvidence::stage)
687                .collect(),
688            next_stage,
689            next_action: next_stage.map_or(InstallationNextAction::None, |stage| {
690                self.failure.map_or_else(
691                    || InstallationNextAction::for_stage(stage),
692                    InstallationFailure::next_action,
693                )
694            }),
695            failure: self.failure,
696            receipt_hash,
697        }
698    }
699
700    /// Completed exact evidence retained for resume verification.
701    #[must_use]
702    pub fn completed_evidence(&self) -> &[InstallationStageEvidence] {
703        &self.completed
704    }
705
706    /// Whether and only whether a terminal receipt has been sealed.
707    #[must_use]
708    pub fn is_installed(&self) -> bool {
709        matches!(
710            self.completed.last(),
711            Some(InstallationStageEvidence::Receipt(_))
712        )
713    }
714
715    fn verify_identity(
716        &self,
717        campaign_id: ApplicationInstallationCampaignId,
718        plan: &ApplicationInstallationPlan,
719    ) -> Result<(), InstallationCampaignError> {
720        if self.campaign_id != campaign_id {
721            return Err(InstallationCampaignError::new(
722                InstallationCampaignErrorKind::CampaignIdentityMismatch,
723            ));
724        }
725        self.verify_plan(plan)
726    }
727
728    fn verify_plan(
729        &self,
730        plan: &ApplicationInstallationPlan,
731    ) -> Result<(), InstallationCampaignError> {
732        if self.plan_hash != plan.identity() {
733            return Err(InstallationCampaignError::new(
734                InstallationCampaignErrorKind::PlanIdentityMismatch,
735            ));
736        }
737        Ok(())
738    }
739
740    fn next_stage(&self) -> Option<InstallationStage> {
741        InstallationStage::ALL.get(self.completed.len()).copied()
742    }
743}
744
745impl fmt::Debug for ApplicationInstallationCampaign {
746    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
747        formatter
748            .debug_struct("ApplicationInstallationCampaign")
749            .field("campaign_id", &self.campaign_id)
750            .field("plan_hash", &self.plan_hash)
751            .field("completed_stages", &self.completed.len())
752            .field("failure", &self.failure)
753            .finish_non_exhaustive()
754    }
755}
756
757/// One canonical durable plan-plus-campaign record.
758///
759/// The complete exact plan is retained with stage evidence so restart recovery
760/// never infers intent from whichever application files happen to be present.
761/// This value contains no bearer credentials, seed values, or host paths.
762#[derive(Clone, Eq, PartialEq)]
763pub struct ApplicationInstallationCampaignState {
764    plan: ApplicationInstallationPlan,
765    campaign: ApplicationInstallationCampaign,
766    canonical_bytes: Vec<u8>,
767}
768
769impl ApplicationInstallationCampaignState {
770    /// Captures one fully validated canonical durable state record.
771    pub fn capture(
772        campaign: &ApplicationInstallationCampaign,
773        plan: &ApplicationInstallationPlan,
774    ) -> Result<Self, InstallationCampaignError> {
775        validate_campaign_state(campaign, plan)?;
776        let dto = CampaignStateDto::from_parts(campaign, plan);
777        let canonical_bytes = encode_campaign_state_dto(&dto)?;
778        Ok(Self {
779            plan: plan.clone(),
780            campaign: campaign.clone(),
781            canonical_bytes,
782        })
783    }
784
785    /// Strictly decodes, canonicalizes, and semantically revalidates durable state.
786    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, InstallationCampaignError> {
787        if bytes.is_empty() || bytes.len() > MAX_INSTALLATION_CAMPAIGN_STATE_BYTES {
788            return Err(InstallationCampaignError::new(
789                InstallationCampaignErrorKind::LimitExceeded,
790            ));
791        }
792        let dto: CampaignStateDto = serde_json::from_slice(bytes).map_err(|_| {
793            InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
794        })?;
795        let schema = CampaignStateSchema::parse(&dto.schema)?;
796        let plan = ApplicationInstallationPlan::decode_canonical(dto.plan.as_bytes())
797            .map_err(InstallationCampaignError::from_plan_error)?;
798        let campaign = dto.to_campaign(&plan, schema)?;
799        validate_campaign_state(&campaign, &plan)?;
800        let canonical_bytes = encode_campaign_state_dto(&dto)?;
801        if canonical_bytes != bytes {
802            return Err(InstallationCampaignError::new(
803                InstallationCampaignErrorKind::NonCanonical,
804            ));
805        }
806        Ok(Self {
807            plan,
808            campaign,
809            canonical_bytes,
810        })
811    }
812
813    /// Exact immutable plan recovered with the campaign.
814    #[must_use]
815    pub const fn plan(&self) -> &ApplicationInstallationPlan {
816        &self.plan
817    }
818
819    /// Exact resumable campaign state.
820    #[must_use]
821    pub const fn campaign(&self) -> &ApplicationInstallationCampaign {
822        &self.campaign
823    }
824
825    /// Canonical durable bytes.
826    #[must_use]
827    pub fn canonical_bytes(&self) -> &[u8] {
828        &self.canonical_bytes
829    }
830
831    /// Consumes the record into its exact plan and campaign state.
832    #[must_use]
833    pub fn into_parts(self) -> (ApplicationInstallationPlan, ApplicationInstallationCampaign) {
834        (self.plan, self.campaign)
835    }
836}
837
838impl fmt::Debug for ApplicationInstallationCampaignState {
839    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
840        formatter
841            .debug_struct("ApplicationInstallationCampaignState")
842            .field("plan_hash", &self.plan.identity())
843            .field("campaign", &self.campaign)
844            .finish_non_exhaustive()
845    }
846}
847
848/// Terminal, redacted, content-addressed installation receipt.
849#[derive(Clone, Eq, PartialEq)]
850pub struct ApplicationInstallationReceipt {
851    identity: ApplicationInstallationReceiptHash,
852    canonical_bytes: Vec<u8>,
853    campaign_id: ApplicationInstallationCampaignId,
854    plan_hash: ApplicationInstallationPlanHash,
855}
856
857impl ApplicationInstallationReceipt {
858    fn seal(
859        campaign: &ApplicationInstallationCampaign,
860        plan: &ApplicationInstallationPlan,
861    ) -> Result<Self, InstallationCampaignError> {
862        let dto = ReceiptDtoV2::from_campaign(campaign, plan)?;
863        Self::from_v2_dto(dto)
864    }
865
866    fn seal_v1(
867        campaign: &ApplicationInstallationCampaign,
868        plan: &ApplicationInstallationPlan,
869    ) -> Result<Self, InstallationCampaignError> {
870        let input = plan.input();
871        let seed_items = input.seeds.iter().try_fold(0_u64, |sum, seed| {
872            sum.checked_add(seed.item_count()).ok_or_else(|| {
873                InstallationCampaignError::new(InstallationCampaignErrorKind::EvidenceMismatch)
874            })
875        })?;
876        let dto = ReceiptDtoV1 {
877            schema: APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V1.to_owned(),
878            campaign_id: hex16(campaign.campaign_id.as_bytes()),
879            plan_hash: hex32(plan.identity().as_bytes()),
880            application: input.application.as_str().to_owned(),
881            database: input.target.database().as_str().to_owned(),
882            environment: input.target.environment().as_str().to_owned(),
883            lineage: input.target.lineage().as_str().to_owned(),
884            contract_version: input.contract.version().get(),
885            contract_bundle_hash: hex32(input.contract.bundle_hash().as_bytes()),
886            source_hash: hex32(input.source_hash.as_bytes()),
887            lock_hash: hex32(input.lock_hash.as_bytes()),
888            manifest_hash: hex32(input.manifest_hash.as_bytes()),
889            artifacts: input
890                .artifacts
891                .iter()
892                .map(|artifact| ReceiptArtifactDto {
893                    kind: artifact.kind().tag().to_owned(),
894                    name: artifact.name().as_str().to_owned(),
895                    content_hash: hex32(artifact.content_hash().as_bytes()),
896                })
897                .collect(),
898            roles: input
899                .roles
900                .iter()
901                .map(|role| ReceiptRoleDto {
902                    name: role.name().as_str().to_owned(),
903                    role_hash: hex32(role.role_hash().as_bytes()),
904                })
905                .collect(),
906            drivers: input
907                .drivers
908                .iter()
909                .map(|driver| driver.tag().to_owned())
910                .collect(),
911            seed_batches: u64::try_from(input.seeds.len()).map_err(|_| {
912                InstallationCampaignError::new(InstallationCampaignErrorKind::EvidenceMismatch)
913            })?,
914            seed_items,
915            migration_hash: input
916                .migration
917                .map(|migration| hex32(migration.migration_hash().as_bytes())),
918            adapter_manifest_hash: input
919                .adapter_manifest_hash
920                .map(|hash| hex32(hash.as_bytes())),
921        };
922        Self::from_v1_dto(dto)
923    }
924
925    fn seal_matching(
926        campaign: &ApplicationInstallationCampaign,
927        plan: &ApplicationInstallationPlan,
928        expected: ApplicationInstallationReceiptHash,
929    ) -> Result<Self, InstallationCampaignError> {
930        let current = Self::seal(campaign, plan)?;
931        if current.identity() == expected {
932            return Ok(current);
933        }
934        let legacy = Self::seal_v1(campaign, plan)?;
935        if legacy.identity() == expected {
936            return Ok(legacy);
937        }
938        Err(InstallationCampaignError::new(
939            InstallationCampaignErrorKind::EvidenceMismatch,
940        ))
941    }
942
943    fn from_v1_dto(dto: ReceiptDtoV1) -> Result<Self, InstallationCampaignError> {
944        let campaign_id = parse_receipt_campaign_id(&dto.campaign_id)?;
945        let plan_hash = parse_receipt_plan_hash(&dto.plan_hash)?;
946        let mut canonical_bytes = serde_json::to_vec(&dto).map_err(|_| {
947            InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
948        })?;
949        canonical_bytes.push(b'\n');
950        if canonical_bytes.len() > MAX_INSTALLATION_RECEIPT_BYTES {
951            return Err(InstallationCampaignError::new(
952                InstallationCampaignErrorKind::LimitExceeded,
953            ));
954        }
955        Ok(Self {
956            identity: hash_application_installation_receipt(&canonical_bytes),
957            canonical_bytes,
958            campaign_id,
959            plan_hash,
960        })
961    }
962
963    fn from_v2_dto(dto: ReceiptDtoV2) -> Result<Self, InstallationCampaignError> {
964        let campaign_id = parse_receipt_campaign_id(&dto.campaign_id)?;
965        let plan_hash = parse_receipt_plan_hash(&dto.plan_hash)?;
966        let mut canonical_bytes = serde_json::to_vec(&dto).map_err(|_| {
967            InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
968        })?;
969        canonical_bytes.push(b'\n');
970        if canonical_bytes.len() > MAX_INSTALLATION_RECEIPT_BYTES {
971            return Err(InstallationCampaignError::new(
972                InstallationCampaignErrorKind::LimitExceeded,
973            ));
974        }
975        Ok(Self {
976            identity: hash_application_installation_receipt(&canonical_bytes),
977            canonical_bytes,
978            campaign_id,
979            plan_hash,
980        })
981    }
982
983    /// Strictly decodes and identity-checks canonical terminal receipt bytes.
984    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, InstallationCampaignError> {
985        if bytes.is_empty() || bytes.len() > MAX_INSTALLATION_RECEIPT_BYTES {
986            return Err(InstallationCampaignError::new(
987                InstallationCampaignErrorKind::LimitExceeded,
988            ));
989        }
990        let probe: ReceiptSchemaProbe = serde_json::from_slice(bytes).map_err(|_| {
991            InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
992        })?;
993        let receipt = match probe.schema.as_str() {
994            APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V1 => {
995                let dto: ReceiptDtoV1 = serde_json::from_slice(bytes).map_err(|_| {
996                    InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
997                })?;
998                validate_receipt_v1_dto(&dto)?;
999                Self::from_v1_dto(dto)?
1000            }
1001            APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V2 => {
1002                let dto: ReceiptDtoV2 = serde_json::from_slice(bytes).map_err(|_| {
1003                    InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
1004                })?;
1005                validate_receipt_v2_dto(&dto)?;
1006                Self::from_v2_dto(dto)?
1007            }
1008            APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V3 => {
1009                let dto: ReceiptDtoV2 = serde_json::from_slice(bytes).map_err(|_| {
1010                    InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
1011                })?;
1012                validate_receipt_v3_dto(&dto)?;
1013                Self::from_v2_dto(dto)?
1014            }
1015            _ => {
1016                return Err(InstallationCampaignError::new(
1017                    InstallationCampaignErrorKind::UnsupportedVersion,
1018                ));
1019            }
1020        };
1021        if receipt.canonical_bytes() != bytes {
1022            return Err(InstallationCampaignError::new(
1023                InstallationCampaignErrorKind::NonCanonical,
1024            ));
1025        }
1026        Ok(receipt)
1027    }
1028
1029    /// Terminal receipt identity.
1030    #[must_use]
1031    pub const fn identity(&self) -> ApplicationInstallationReceiptHash {
1032        self.identity
1033    }
1034
1035    /// Caller-stable campaign identity.
1036    #[must_use]
1037    pub const fn campaign_id(&self) -> ApplicationInstallationCampaignId {
1038        self.campaign_id
1039    }
1040
1041    /// Exact installation plan identity.
1042    #[must_use]
1043    pub const fn plan_hash(&self) -> ApplicationInstallationPlanHash {
1044        self.plan_hash
1045    }
1046
1047    /// Canonical redacted compatibility bytes.
1048    #[must_use]
1049    pub fn canonical_bytes(&self) -> &[u8] {
1050        &self.canonical_bytes
1051    }
1052}
1053
1054impl fmt::Debug for ApplicationInstallationReceipt {
1055    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1056        formatter
1057            .debug_struct("ApplicationInstallationReceipt")
1058            .field("identity", &self.identity)
1059            .field("campaign_id", &self.campaign_id)
1060            .field("plan_hash", &self.plan_hash)
1061            .finish_non_exhaustive()
1062    }
1063}
1064
1065fn validate_stage_evidence(
1066    plan: &ApplicationInstallationPlan,
1067    evidence: &InstallationStageEvidence,
1068) -> Result<(), InstallationCampaignError> {
1069    let input = plan.input();
1070    let valid = match evidence {
1071        InstallationStageEvidence::Preflight {
1072            source_hash,
1073            lock_hash,
1074            manifest_hash,
1075        } => {
1076            *source_hash == input.source_hash
1077                && *lock_hash == input.lock_hash
1078                && *manifest_hash == input.manifest_hash
1079        }
1080        InstallationStageEvidence::Contract {
1081            version,
1082            bundle_hash,
1083        } => *version == input.contract.version() && *bundle_hash == input.contract.bundle_hash(),
1084        InstallationStageEvidence::Migration { migration_hash } => {
1085            *migration_hash == input.migration.map(|migration| migration.migration_hash())
1086        }
1087        InstallationStageEvidence::QueryModules(artifacts) => {
1088            artifacts == &artifacts_for(input, InstallationArtifactKind::QueryModule)
1089        }
1090        InstallationStageEvidence::ReactiveModules(artifacts) => {
1091            artifacts == &artifacts_for(input, InstallationArtifactKind::ReactiveModule)
1092        }
1093        InstallationStageEvidence::Roles(roles) => {
1094            let expected = input
1095                .roles
1096                .iter()
1097                .map(|role| InstalledRoleEvidence::new(role.name().clone(), role.role_hash()))
1098                .collect::<Vec<_>>();
1099            roles == &expected
1100        }
1101        InstallationStageEvidence::Reimport(evidence) => match (input.reimport, evidence) {
1102            (None, InstalledReimportEvidence::NotRequired) => true,
1103            (
1104                Some(expected),
1105                InstalledReimportEvidence::Reconciled(InstalledReimportReceiptEvidence {
1106                    export_manifest_hash,
1107                    export_receipt_hash,
1108                    portability_manifest_hash,
1109                    ..
1110                }),
1111            ) => {
1112                *export_manifest_hash == expected.export_manifest_hash()
1113                    && *export_receipt_hash == expected.export_receipt_hash()
1114                    && *portability_manifest_hash == expected.portability_manifest_hash()
1115            }
1116            _ => false,
1117        },
1118        InstallationStageEvidence::Credentials(credentials) => {
1119            let expected = input
1120                .credential_destinations
1121                .iter()
1122                .map(|destination| {
1123                    InstalledCredentialEvidence::new(
1124                        destination.name().clone(),
1125                        destination.successor(),
1126                    )
1127                })
1128                .collect::<Vec<_>>();
1129            credentials == &expected
1130        }
1131        InstallationStageEvidence::DriverProof(drivers) => drivers == &input.drivers,
1132        InstallationStageEvidence::Seeds(seeds) => {
1133            seeds.len() == input.seeds.len()
1134                && seeds.iter().zip(&input.seeds).all(|(observed, expected)| {
1135                    observed.name() == expected.name()
1136                        && observed.content_hash() == expected.content_hash()
1137                        && observed.succeeded().checked_add(observed.replayed())
1138                            == Some(expected.item_count())
1139                })
1140        }
1141        InstallationStageEvidence::Receipt(_) => false,
1142    };
1143    if valid {
1144        Ok(())
1145    } else {
1146        Err(InstallationCampaignError::new(
1147            InstallationCampaignErrorKind::EvidenceMismatch,
1148        ))
1149    }
1150}
1151
1152fn artifacts_for(
1153    input: &crate::ApplicationInstallationPlanInput,
1154    kind: InstallationArtifactKind,
1155) -> Vec<InstallationArtifact> {
1156    input
1157        .artifacts
1158        .iter()
1159        .filter(|artifact| artifact.kind() == kind)
1160        .cloned()
1161        .collect()
1162}
1163
1164fn validate_campaign_state(
1165    campaign: &ApplicationInstallationCampaign,
1166    plan: &ApplicationInstallationPlan,
1167) -> Result<(), InstallationCampaignError> {
1168    campaign.verify_plan(plan)?;
1169    match (&campaign.reimport, plan.input().reimport) {
1170        (None, _) => {}
1171        (Some(progress), Some(expected)) => {
1172            if progress.source().export_manifest_hash() != expected.export_manifest_hash()
1173                || progress.source().export_receipt_hash() != expected.export_receipt_hash()
1174                || progress.source().portability_manifest_hash()
1175                    != expected.portability_manifest_hash()
1176            {
1177                return Err(evidence_mismatch());
1178            }
1179            let completed_reimport = campaign
1180                .completed
1181                .iter()
1182                .any(|evidence| matches!(evidence, InstallationStageEvidence::Reimport(_)));
1183            if completed_reimport
1184                && progress.phase() != crate::ApplicationReimportCampaignPhaseV1::Reconciled
1185            {
1186                return Err(evidence_mismatch());
1187            }
1188            if !completed_reimport && campaign.next_stage() != Some(InstallationStage::Reimport) {
1189                return Err(evidence_mismatch());
1190            }
1191        }
1192        (Some(_), None) => return Err(evidence_mismatch()),
1193    }
1194    if campaign.completed.len() > InstallationStage::ALL.len() {
1195        return Err(InstallationCampaignError::new(
1196            InstallationCampaignErrorKind::StageOutOfOrder,
1197        ));
1198    }
1199    for (index, evidence) in campaign.completed.iter().enumerate() {
1200        let expected = InstallationStage::ALL[index];
1201        if evidence.stage() != expected {
1202            return Err(InstallationCampaignError::new(
1203                InstallationCampaignErrorKind::StageOutOfOrder,
1204            ));
1205        }
1206        match evidence {
1207            InstallationStageEvidence::Receipt(hash) => {
1208                if index + 1 != InstallationStage::ALL.len() {
1209                    return Err(InstallationCampaignError::new(
1210                        InstallationCampaignErrorKind::StageOutOfOrder,
1211                    ));
1212                }
1213                let prefix = ApplicationInstallationCampaign {
1214                    campaign_id: campaign.campaign_id,
1215                    plan_hash: campaign.plan_hash,
1216                    completed: campaign.completed[..index].to_vec(),
1217                    failure: None,
1218                    reimport: campaign.reimport.clone(),
1219                };
1220                ApplicationInstallationReceipt::seal_matching(&prefix, plan, *hash)?;
1221            }
1222            _ => validate_stage_evidence(plan, evidence)?,
1223        }
1224    }
1225    match campaign.failure {
1226        None => Ok(()),
1227        Some(failure) => {
1228            let Some(stage) = campaign.next_stage() else {
1229                return Err(InstallationCampaignError::new(
1230                    InstallationCampaignErrorKind::EvidenceMismatch,
1231                ));
1232            };
1233            if failure.stage != stage
1234                || failure.next_action != InstallationNextAction::for_stage(stage)
1235            {
1236                return Err(InstallationCampaignError::new(
1237                    InstallationCampaignErrorKind::EvidenceMismatch,
1238                ));
1239            }
1240            Ok(())
1241        }
1242    }
1243}
1244
1245fn encode_campaign_state_dto(dto: &CampaignStateDto) -> Result<Vec<u8>, InstallationCampaignError> {
1246    let mut bytes = serde_json::to_vec(dto).map_err(|_| {
1247        InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding)
1248    })?;
1249    bytes.push(b'\n');
1250    if bytes.len() > MAX_INSTALLATION_CAMPAIGN_STATE_BYTES {
1251        return Err(InstallationCampaignError::new(
1252            InstallationCampaignErrorKind::LimitExceeded,
1253        ));
1254    }
1255    Ok(bytes)
1256}
1257
1258#[derive(Serialize, Deserialize)]
1259#[serde(deny_unknown_fields)]
1260struct CampaignStateDto {
1261    schema: String,
1262    campaign_id: String,
1263    plan_hash: String,
1264    plan: String,
1265    completed: Vec<CampaignEvidenceDto>,
1266    failure: Option<CampaignFailureDto>,
1267    #[serde(default, skip_serializing_if = "Option::is_none")]
1268    reimport: Option<String>,
1269}
1270
1271#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1272enum CampaignStateSchema {
1273    V1,
1274    V2,
1275    V3,
1276}
1277
1278impl CampaignStateSchema {
1279    fn parse(value: &str) -> Result<Self, InstallationCampaignError> {
1280        match value {
1281            APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V1 => Ok(Self::V1),
1282            APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V2 => Ok(Self::V2),
1283            APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V3 => Ok(Self::V3),
1284            _ => Err(InstallationCampaignError::new(
1285                InstallationCampaignErrorKind::UnsupportedVersion,
1286            )),
1287        }
1288    }
1289}
1290
1291impl CampaignStateDto {
1292    fn from_parts(
1293        campaign: &ApplicationInstallationCampaign,
1294        plan: &ApplicationInstallationPlan,
1295    ) -> Self {
1296        Self {
1297            schema: if campaign.reimport.is_some() {
1298                APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V3
1299            } else {
1300                APPLICATION_INSTALLATION_CAMPAIGN_STATE_SCHEMA_V2
1301            }
1302            .to_owned(),
1303            campaign_id: hex16(campaign.campaign_id.as_bytes()),
1304            plan_hash: hex32(campaign.plan_hash.as_bytes()),
1305            plan: String::from_utf8(plan.canonical_bytes().to_vec())
1306                .expect("canonical installation plans are JSON UTF-8"),
1307            completed: campaign
1308                .completed
1309                .iter()
1310                .map(CampaignEvidenceDto::from_evidence)
1311                .collect(),
1312            failure: campaign.failure.map(CampaignFailureDto::from_failure),
1313            reimport: campaign.reimport.as_ref().map(|progress| {
1314                String::from_utf8(
1315                    progress
1316                        .encode_canonical()
1317                        .expect("validated reimport progress is canonical"),
1318                )
1319                .expect("canonical reimport progress is JSON UTF-8")
1320            }),
1321        }
1322    }
1323
1324    fn to_campaign(
1325        &self,
1326        plan: &ApplicationInstallationPlan,
1327        schema: CampaignStateSchema,
1328    ) -> Result<ApplicationInstallationCampaign, InstallationCampaignError> {
1329        let invalid =
1330            || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
1331        let campaign_id = ApplicationInstallationCampaignId::from_bytes(
1332            parse_hex16(&self.campaign_id).map_err(InstallationCampaignError::from_plan_error)?,
1333        )
1334        .map_err(|_| invalid())?;
1335        let plan_hash = ApplicationInstallationPlanHash::from_bytes(
1336            parse_hex32(&self.plan_hash).map_err(InstallationCampaignError::from_plan_error)?,
1337        );
1338        if plan_hash != plan.identity() {
1339            return Err(InstallationCampaignError::new(
1340                InstallationCampaignErrorKind::PlanIdentityMismatch,
1341            ));
1342        }
1343        let mut completed = self
1344            .completed
1345            .iter()
1346            .map(CampaignEvidenceDto::to_evidence)
1347            .collect::<Result<Vec<_>, _>>()?;
1348        if schema == CampaignStateSchema::V1 {
1349            let role_index = completed
1350                .iter()
1351                .position(|evidence| evidence.stage() == InstallationStage::Roles);
1352            if let Some(role_index) = role_index {
1353                completed.insert(
1354                    role_index + 1,
1355                    InstallationStageEvidence::Reimport(InstalledReimportEvidence::NotRequired),
1356                );
1357            }
1358        }
1359        let reimport = self
1360            .reimport
1361            .as_ref()
1362            .map(|value| ApplicationReimportCampaignV1::decode_canonical(value.as_bytes()))
1363            .transpose()
1364            .map_err(|_| invalid())?;
1365        if (schema != CampaignStateSchema::V3 && reimport.is_some())
1366            || (schema == CampaignStateSchema::V3 && reimport.is_none())
1367        {
1368            return Err(InstallationCampaignError::new(
1369                InstallationCampaignErrorKind::UnsupportedVersion,
1370            ));
1371        }
1372        let failure = self
1373            .failure
1374            .as_ref()
1375            .map(CampaignFailureDto::to_failure)
1376            .transpose()?;
1377        Ok(ApplicationInstallationCampaign {
1378            campaign_id,
1379            plan_hash,
1380            completed,
1381            failure,
1382            reimport,
1383        })
1384    }
1385}
1386
1387#[derive(Serialize, Deserialize)]
1388#[serde(tag = "stage", rename_all = "snake_case", deny_unknown_fields)]
1389enum CampaignEvidenceDto {
1390    Preflight {
1391        source_hash: String,
1392        lock_hash: String,
1393        manifest_hash: String,
1394    },
1395    Contract {
1396        version: u64,
1397        bundle_hash: String,
1398    },
1399    Migration {
1400        migration_hash: Option<String>,
1401    },
1402    QueryModules {
1403        artifacts: Vec<CampaignArtifactDto>,
1404    },
1405    ReactiveModules {
1406        artifacts: Vec<CampaignArtifactDto>,
1407    },
1408    Roles {
1409        roles: Vec<CampaignRoleDto>,
1410    },
1411    Reimport {
1412        status: String,
1413        export_manifest_hash: Option<String>,
1414        export_receipt_hash: Option<String>,
1415        portability_manifest_hash: Option<String>,
1416        reimport_receipt_hash: Option<String>,
1417    },
1418    Credentials {
1419        credentials: Vec<CampaignCredentialDto>,
1420    },
1421    DriverProof {
1422        drivers: Vec<String>,
1423    },
1424    Seeds {
1425        seeds: Vec<CampaignSeedDto>,
1426    },
1427    Receipt {
1428        receipt_hash: String,
1429    },
1430}
1431
1432impl CampaignEvidenceDto {
1433    fn from_evidence(evidence: &InstallationStageEvidence) -> Self {
1434        match evidence {
1435            InstallationStageEvidence::Preflight {
1436                source_hash,
1437                lock_hash,
1438                manifest_hash,
1439            } => Self::Preflight {
1440                source_hash: hex32(source_hash.as_bytes()),
1441                lock_hash: hex32(lock_hash.as_bytes()),
1442                manifest_hash: hex32(manifest_hash.as_bytes()),
1443            },
1444            InstallationStageEvidence::Contract {
1445                version,
1446                bundle_hash,
1447            } => Self::Contract {
1448                version: version.get(),
1449                bundle_hash: hex32(bundle_hash.as_bytes()),
1450            },
1451            InstallationStageEvidence::Migration { migration_hash } => Self::Migration {
1452                migration_hash: migration_hash.map(|hash| hex32(hash.as_bytes())),
1453            },
1454            InstallationStageEvidence::QueryModules(artifacts) => Self::QueryModules {
1455                artifacts: artifacts.iter().map(CampaignArtifactDto::from).collect(),
1456            },
1457            InstallationStageEvidence::ReactiveModules(artifacts) => Self::ReactiveModules {
1458                artifacts: artifacts.iter().map(CampaignArtifactDto::from).collect(),
1459            },
1460            InstallationStageEvidence::Roles(roles) => Self::Roles {
1461                roles: roles.iter().map(CampaignRoleDto::from).collect(),
1462            },
1463            InstallationStageEvidence::Reimport(InstalledReimportEvidence::NotRequired) => {
1464                Self::Reimport {
1465                    status: "not_required".to_owned(),
1466                    export_manifest_hash: None,
1467                    export_receipt_hash: None,
1468                    portability_manifest_hash: None,
1469                    reimport_receipt_hash: None,
1470                }
1471            }
1472            InstallationStageEvidence::Reimport(InstalledReimportEvidence::Reconciled(
1473                InstalledReimportReceiptEvidence {
1474                    export_manifest_hash,
1475                    export_receipt_hash,
1476                    portability_manifest_hash,
1477                    reimport_receipt_hash,
1478                },
1479            )) => Self::Reimport {
1480                status: "reconciled".to_owned(),
1481                export_manifest_hash: Some(hex32(export_manifest_hash.as_bytes())),
1482                export_receipt_hash: Some(hex32(export_receipt_hash.as_bytes())),
1483                portability_manifest_hash: Some(hex32(portability_manifest_hash.as_bytes())),
1484                reimport_receipt_hash: Some(hex32(reimport_receipt_hash.as_bytes())),
1485            },
1486            InstallationStageEvidence::Credentials(credentials) => Self::Credentials {
1487                credentials: credentials
1488                    .iter()
1489                    .map(CampaignCredentialDto::from)
1490                    .collect(),
1491            },
1492            InstallationStageEvidence::DriverProof(drivers) => Self::DriverProof {
1493                drivers: drivers
1494                    .iter()
1495                    .map(|driver| driver.tag().to_owned())
1496                    .collect(),
1497            },
1498            InstallationStageEvidence::Seeds(seeds) => Self::Seeds {
1499                seeds: seeds.iter().map(CampaignSeedDto::from).collect(),
1500            },
1501            InstallationStageEvidence::Receipt(hash) => Self::Receipt {
1502                receipt_hash: hex32(hash.as_bytes()),
1503            },
1504        }
1505    }
1506
1507    fn to_evidence(&self) -> Result<InstallationStageEvidence, InstallationCampaignError> {
1508        let invalid =
1509            || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
1510        let hex =
1511            |value: &str| parse_hex32(value).map_err(InstallationCampaignError::from_plan_error);
1512        Ok(match self {
1513            Self::Preflight {
1514                source_hash,
1515                lock_hash,
1516                manifest_hash,
1517            } => InstallationStageEvidence::Preflight {
1518                source_hash: ApplicationSourceHash::from_bytes(hex(source_hash)?),
1519                lock_hash: ApplicationLockHash::from_bytes(hex(lock_hash)?),
1520                manifest_hash: ApplicationManifestHash::from_bytes(hex(manifest_hash)?),
1521            },
1522            Self::Contract {
1523                version,
1524                bundle_hash,
1525            } => InstallationStageEvidence::Contract {
1526                version: ContractVersion::new(*version).ok_or_else(invalid)?,
1527                bundle_hash: ContractBundleHash::from_bytes(hex(bundle_hash)?),
1528            },
1529            Self::Migration { migration_hash } => InstallationStageEvidence::Migration {
1530                migration_hash: migration_hash
1531                    .as_deref()
1532                    .map(hex)
1533                    .transpose()?
1534                    .map(MigrationBundleHash::from_bytes),
1535            },
1536            Self::QueryModules { artifacts } => InstallationStageEvidence::QueryModules(
1537                artifacts
1538                    .iter()
1539                    .map(CampaignArtifactDto::to_artifact)
1540                    .collect::<Result<Vec<_>, _>>()?,
1541            ),
1542            Self::ReactiveModules { artifacts } => InstallationStageEvidence::ReactiveModules(
1543                artifacts
1544                    .iter()
1545                    .map(CampaignArtifactDto::to_artifact)
1546                    .collect::<Result<Vec<_>, _>>()?,
1547            ),
1548            Self::Roles { roles } => InstallationStageEvidence::Roles(
1549                roles
1550                    .iter()
1551                    .map(CampaignRoleDto::to_role)
1552                    .collect::<Result<Vec<_>, _>>()?,
1553            ),
1554            Self::Reimport {
1555                status,
1556                export_manifest_hash,
1557                export_receipt_hash,
1558                portability_manifest_hash,
1559                reimport_receipt_hash,
1560            } => match status.as_str() {
1561                "not_required"
1562                    if export_manifest_hash.is_none()
1563                        && export_receipt_hash.is_none()
1564                        && portability_manifest_hash.is_none()
1565                        && reimport_receipt_hash.is_none() =>
1566                {
1567                    InstallationStageEvidence::Reimport(InstalledReimportEvidence::NotRequired)
1568                }
1569                "reconciled"
1570                    if export_manifest_hash.is_some()
1571                        && export_receipt_hash.is_some()
1572                        && portability_manifest_hash.is_some()
1573                        && reimport_receipt_hash.is_some() =>
1574                {
1575                    InstallationStageEvidence::Reimport(InstalledReimportEvidence::Reconciled(
1576                        InstalledReimportReceiptEvidence {
1577                            export_manifest_hash: ApplicationExportManifestHash::from_bytes(hex(
1578                                export_manifest_hash.as_deref().ok_or_else(invalid)?,
1579                            )?),
1580                            export_receipt_hash: ApplicationExportReceiptHash::from_bytes(hex(
1581                                export_receipt_hash.as_deref().ok_or_else(invalid)?,
1582                            )?),
1583                            portability_manifest_hash:
1584                                ApplicationPortabilityManifestHash::from_bytes(hex(
1585                                    portability_manifest_hash.as_deref().ok_or_else(invalid)?,
1586                                )?),
1587                            reimport_receipt_hash: ApplicationReimportReceiptHash::from_bytes(hex(
1588                                reimport_receipt_hash.as_deref().ok_or_else(invalid)?,
1589                            )?),
1590                        },
1591                    ))
1592                }
1593                _ => return Err(invalid()),
1594            },
1595            Self::Credentials { credentials } => InstallationStageEvidence::Credentials(
1596                credentials
1597                    .iter()
1598                    .map(CampaignCredentialDto::to_credential)
1599                    .collect::<Result<Vec<_>, _>>()?,
1600            ),
1601            Self::DriverProof { drivers } => InstallationStageEvidence::DriverProof(
1602                drivers
1603                    .iter()
1604                    .map(|driver| InstallationDriver::parse(driver).ok_or_else(invalid))
1605                    .collect::<Result<Vec<_>, _>>()?,
1606            ),
1607            Self::Seeds { seeds } => InstallationStageEvidence::Seeds(
1608                seeds
1609                    .iter()
1610                    .map(CampaignSeedDto::to_seed)
1611                    .collect::<Result<Vec<_>, _>>()?,
1612            ),
1613            Self::Receipt { receipt_hash } => InstallationStageEvidence::Receipt(
1614                ApplicationInstallationReceiptHash::from_bytes(hex(receipt_hash)?),
1615            ),
1616        })
1617    }
1618}
1619
1620#[derive(Serialize, Deserialize)]
1621#[serde(deny_unknown_fields)]
1622struct CampaignArtifactDto {
1623    kind: String,
1624    name: String,
1625    content_hash: String,
1626}
1627
1628impl From<&InstallationArtifact> for CampaignArtifactDto {
1629    fn from(artifact: &InstallationArtifact) -> Self {
1630        Self {
1631            kind: artifact.kind().tag().to_owned(),
1632            name: artifact.name().as_str().to_owned(),
1633            content_hash: hex32(artifact.content_hash().as_bytes()),
1634        }
1635    }
1636}
1637
1638impl CampaignArtifactDto {
1639    fn to_artifact(&self) -> Result<InstallationArtifact, InstallationCampaignError> {
1640        let invalid =
1641            || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
1642        Ok(InstallationArtifact::new(
1643            InstallationArtifactKind::parse(&self.kind).ok_or_else(invalid)?,
1644            InstallationSymbol::new(self.name.clone())
1645                .map_err(InstallationCampaignError::from_plan_error)?,
1646            GeneratedArtifactHash::from_bytes(
1647                parse_hex32(&self.content_hash)
1648                    .map_err(InstallationCampaignError::from_plan_error)?,
1649            ),
1650        ))
1651    }
1652}
1653
1654#[derive(Serialize, Deserialize)]
1655#[serde(deny_unknown_fields)]
1656struct CampaignRoleDto {
1657    name: String,
1658    role_hash: String,
1659}
1660
1661impl From<&InstalledRoleEvidence> for CampaignRoleDto {
1662    fn from(role: &InstalledRoleEvidence) -> Self {
1663        Self {
1664            name: role.name().as_str().to_owned(),
1665            role_hash: hex32(role.role_hash().as_bytes()),
1666        }
1667    }
1668}
1669
1670impl CampaignRoleDto {
1671    fn to_role(&self) -> Result<InstalledRoleEvidence, InstallationCampaignError> {
1672        Ok(InstalledRoleEvidence::new(
1673            InstallationSymbol::new(self.name.clone())
1674                .map_err(InstallationCampaignError::from_plan_error)?,
1675            ApplicationRoleHash::from_bytes(
1676                parse_hex32(&self.role_hash).map_err(InstallationCampaignError::from_plan_error)?,
1677            ),
1678        ))
1679    }
1680}
1681
1682#[derive(Serialize, Deserialize)]
1683#[serde(deny_unknown_fields)]
1684struct CampaignCredentialDto {
1685    destination: String,
1686    capability_id: String,
1687}
1688
1689impl From<&InstalledCredentialEvidence> for CampaignCredentialDto {
1690    fn from(credential: &InstalledCredentialEvidence) -> Self {
1691        Self {
1692            destination: credential.destination().as_str().to_owned(),
1693            capability_id: hex16(credential.capability_id().as_bytes()),
1694        }
1695    }
1696}
1697
1698impl CampaignCredentialDto {
1699    fn to_credential(&self) -> Result<InstalledCredentialEvidence, InstallationCampaignError> {
1700        let invalid =
1701            || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
1702        Ok(InstalledCredentialEvidence::new(
1703            InstallationSymbol::new(self.destination.clone())
1704                .map_err(InstallationCampaignError::from_plan_error)?,
1705            CapabilityId::from_bytes(
1706                parse_hex16(&self.capability_id)
1707                    .map_err(InstallationCampaignError::from_plan_error)?,
1708            )
1709            .map_err(|_| invalid())?,
1710        ))
1711    }
1712}
1713
1714#[derive(Serialize, Deserialize)]
1715#[serde(deny_unknown_fields)]
1716struct CampaignSeedDto {
1717    name: String,
1718    content_hash: String,
1719    succeeded: u64,
1720    replayed: u64,
1721}
1722
1723impl From<&InstalledSeedEvidence> for CampaignSeedDto {
1724    fn from(seed: &InstalledSeedEvidence) -> Self {
1725        Self {
1726            name: seed.name().as_str().to_owned(),
1727            content_hash: hex32(seed.content_hash().as_bytes()),
1728            succeeded: seed.succeeded(),
1729            replayed: seed.replayed(),
1730        }
1731    }
1732}
1733
1734impl CampaignSeedDto {
1735    fn to_seed(&self) -> Result<InstalledSeedEvidence, InstallationCampaignError> {
1736        Ok(InstalledSeedEvidence::new(
1737            InstallationSymbol::new(self.name.clone())
1738                .map_err(InstallationCampaignError::from_plan_error)?,
1739            GeneratedArtifactHash::from_bytes(
1740                parse_hex32(&self.content_hash)
1741                    .map_err(InstallationCampaignError::from_plan_error)?,
1742            ),
1743            self.succeeded,
1744            self.replayed,
1745        ))
1746    }
1747}
1748
1749#[derive(Serialize, Deserialize)]
1750#[serde(deny_unknown_fields)]
1751struct CampaignFailureDto {
1752    stage: String,
1753    code: String,
1754    next_action: String,
1755}
1756
1757impl CampaignFailureDto {
1758    fn from_failure(failure: InstallationFailure) -> Self {
1759        Self {
1760            stage: failure.stage().as_str().to_owned(),
1761            code: installation_failure_code_tag(failure.code()).to_owned(),
1762            next_action: installation_next_action_tag(failure.next_action()).to_owned(),
1763        }
1764    }
1765
1766    fn to_failure(&self) -> Result<InstallationFailure, InstallationCampaignError> {
1767        let invalid =
1768            || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
1769        Ok(InstallationFailure {
1770            stage: parse_installation_stage(&self.stage).ok_or_else(invalid)?,
1771            code: parse_installation_failure_code(&self.code).ok_or_else(invalid)?,
1772            next_action: parse_installation_next_action(&self.next_action).ok_or_else(invalid)?,
1773        })
1774    }
1775}
1776
1777const fn installation_failure_code_tag(code: InstallationFailureCode) -> &'static str {
1778    match code {
1779        InstallationFailureCode::LocalArtifactMismatch => "local_artifact_mismatch",
1780        InstallationFailureCode::RemoteIdentityMismatch => "remote_identity_mismatch",
1781        InstallationFailureCode::MigrationGateRequired => "migration_gate_required",
1782        InstallationFailureCode::RoleWideningApprovalRequired => "role_widening_approval_required",
1783        InstallationFailureCode::ReimportPartial => "reimport_partial",
1784        InstallationFailureCode::CredentialDestinationOccupied => "credential_destination_occupied",
1785        InstallationFailureCode::DriverProofFailed => "driver_proof_failed",
1786        InstallationFailureCode::SeedPartial => "seed_partial",
1787        InstallationFailureCode::AuthorizationDenied => "authorization_denied",
1788        InstallationFailureCode::ServiceUnavailable => "service_unavailable",
1789    }
1790}
1791
1792fn parse_installation_failure_code(value: &str) -> Option<InstallationFailureCode> {
1793    match value {
1794        "local_artifact_mismatch" => Some(InstallationFailureCode::LocalArtifactMismatch),
1795        "remote_identity_mismatch" => Some(InstallationFailureCode::RemoteIdentityMismatch),
1796        "migration_gate_required" => Some(InstallationFailureCode::MigrationGateRequired),
1797        "role_widening_approval_required" => {
1798            Some(InstallationFailureCode::RoleWideningApprovalRequired)
1799        }
1800        "reimport_partial" => Some(InstallationFailureCode::ReimportPartial),
1801        "credential_destination_occupied" => {
1802            Some(InstallationFailureCode::CredentialDestinationOccupied)
1803        }
1804        "driver_proof_failed" => Some(InstallationFailureCode::DriverProofFailed),
1805        "seed_partial" => Some(InstallationFailureCode::SeedPartial),
1806        "authorization_denied" => Some(InstallationFailureCode::AuthorizationDenied),
1807        "service_unavailable" => Some(InstallationFailureCode::ServiceUnavailable),
1808        _ => None,
1809    }
1810}
1811
1812const fn installation_next_action_tag(action: InstallationNextAction) -> &'static str {
1813    match action {
1814        InstallationNextAction::ValidateLocalArtifacts => "validate_local_artifacts",
1815        InstallationNextAction::DeployContract => "deploy_contract",
1816        InstallationNextAction::ApplyMigration => "apply_migration",
1817        InstallationNextAction::DeployQueryModules => "deploy_query_modules",
1818        InstallationNextAction::DeployReactiveModules => "deploy_reactive_modules",
1819        InstallationNextAction::ReconcileRoles => "reconcile_roles",
1820        InstallationNextAction::ReimportApplication => "reimport_application",
1821        InstallationNextAction::RotateCredentials => "rotate_credentials",
1822        InstallationNextAction::ProveDrivers => "prove_drivers",
1823        InstallationNextAction::RunSeeds => "run_seeds",
1824        InstallationNextAction::SealReceipt => "seal_receipt",
1825        InstallationNextAction::None => "none",
1826    }
1827}
1828
1829fn parse_installation_next_action(value: &str) -> Option<InstallationNextAction> {
1830    match value {
1831        "validate_local_artifacts" => Some(InstallationNextAction::ValidateLocalArtifacts),
1832        "deploy_contract" => Some(InstallationNextAction::DeployContract),
1833        "apply_migration" => Some(InstallationNextAction::ApplyMigration),
1834        "deploy_query_modules" => Some(InstallationNextAction::DeployQueryModules),
1835        "deploy_reactive_modules" => Some(InstallationNextAction::DeployReactiveModules),
1836        "reconcile_roles" => Some(InstallationNextAction::ReconcileRoles),
1837        "reimport_application" => Some(InstallationNextAction::ReimportApplication),
1838        "rotate_credentials" => Some(InstallationNextAction::RotateCredentials),
1839        "prove_drivers" => Some(InstallationNextAction::ProveDrivers),
1840        "run_seeds" => Some(InstallationNextAction::RunSeeds),
1841        "seal_receipt" => Some(InstallationNextAction::SealReceipt),
1842        "none" => Some(InstallationNextAction::None),
1843        _ => None,
1844    }
1845}
1846
1847fn parse_installation_stage(value: &str) -> Option<InstallationStage> {
1848    InstallationStage::ALL
1849        .into_iter()
1850        .find(|stage| stage.as_str() == value)
1851}
1852
1853#[derive(Deserialize)]
1854struct ReceiptSchemaProbe {
1855    schema: String,
1856}
1857
1858#[derive(Serialize, Deserialize)]
1859#[serde(deny_unknown_fields)]
1860struct ReceiptDtoV1 {
1861    schema: String,
1862    campaign_id: String,
1863    plan_hash: String,
1864    application: String,
1865    database: String,
1866    environment: String,
1867    lineage: String,
1868    contract_version: u64,
1869    contract_bundle_hash: String,
1870    source_hash: String,
1871    lock_hash: String,
1872    manifest_hash: String,
1873    artifacts: Vec<ReceiptArtifactDto>,
1874    roles: Vec<ReceiptRoleDto>,
1875    drivers: Vec<String>,
1876    seed_batches: u64,
1877    seed_items: u64,
1878    migration_hash: Option<String>,
1879    adapter_manifest_hash: Option<String>,
1880}
1881
1882#[derive(Serialize, Deserialize)]
1883#[serde(deny_unknown_fields)]
1884struct ReceiptDtoV2 {
1885    schema: String,
1886    campaign_id: String,
1887    plan_hash: String,
1888    application: String,
1889    database: String,
1890    environment: String,
1891    lineage: String,
1892    contract_version: u64,
1893    contract_bundle_hash: String,
1894    source_hash: String,
1895    lock_hash: String,
1896    manifest_hash: String,
1897    artifacts: Vec<ReceiptArtifactDto>,
1898    roles: Vec<ReceiptRoleDto>,
1899    credentials: Vec<ReceiptCredentialDto>,
1900    drivers: Vec<String>,
1901    seed_checkpoints: Vec<ReceiptSeedCheckpointDto>,
1902    migration_receipt: Option<ReceiptMigrationReferenceDto>,
1903    backup_receipt: Option<ReceiptBackupReferenceDto>,
1904    adapter_manifest_hash: Option<String>,
1905    #[serde(default, skip_serializing_if = "Option::is_none")]
1906    reimport: Option<ReceiptReimportReferenceDto>,
1907    terminal_state: String,
1908    safe_remediation: Vec<String>,
1909}
1910
1911#[derive(Serialize, Deserialize)]
1912#[serde(deny_unknown_fields)]
1913struct ReceiptArtifactDto {
1914    kind: String,
1915    name: String,
1916    content_hash: String,
1917}
1918
1919#[derive(Serialize, Deserialize)]
1920#[serde(deny_unknown_fields)]
1921struct ReceiptRoleDto {
1922    name: String,
1923    role_hash: String,
1924}
1925
1926#[derive(Serialize, Deserialize)]
1927#[serde(deny_unknown_fields)]
1928struct ReceiptCredentialDto {
1929    destination: String,
1930    role: String,
1931    capability_id: String,
1932}
1933
1934#[derive(Serialize, Deserialize)]
1935#[serde(deny_unknown_fields)]
1936struct ReceiptSeedCheckpointDto {
1937    name: String,
1938    content_hash: String,
1939    succeeded: u64,
1940    replayed: u64,
1941}
1942
1943#[derive(Serialize, Deserialize)]
1944#[serde(deny_unknown_fields)]
1945struct ReceiptMigrationReferenceDto {
1946    operation_id: String,
1947    migration_hash: String,
1948}
1949
1950#[derive(Serialize, Deserialize)]
1951#[serde(deny_unknown_fields)]
1952struct ReceiptBackupReferenceDto {
1953    migration_operation_id: String,
1954    policy: String,
1955}
1956
1957#[derive(Serialize, Deserialize)]
1958#[serde(deny_unknown_fields)]
1959struct ReceiptReimportReferenceDto {
1960    export_manifest_hash: String,
1961    export_receipt_hash: String,
1962    portability_manifest_hash: String,
1963    reimport_receipt_hash: String,
1964}
1965
1966impl ReceiptDtoV2 {
1967    fn from_campaign(
1968        campaign: &ApplicationInstallationCampaign,
1969        plan: &ApplicationInstallationPlan,
1970    ) -> Result<Self, InstallationCampaignError> {
1971        let input = plan.input();
1972        let credentials = campaign
1973            .completed
1974            .iter()
1975            .find_map(|evidence| match evidence {
1976                InstallationStageEvidence::Credentials(credentials) => Some(credentials),
1977                _ => None,
1978            })
1979            .ok_or_else(evidence_mismatch)?;
1980        let seed_checkpoints = campaign
1981            .completed
1982            .iter()
1983            .find_map(|evidence| match evidence {
1984                InstallationStageEvidence::Seeds(seeds) => Some(seeds),
1985                _ => None,
1986            })
1987            .ok_or_else(evidence_mismatch)?;
1988        let credential_receipts = credentials
1989            .iter()
1990            .map(|credential| {
1991                let destination = input
1992                    .credential_destinations
1993                    .iter()
1994                    .find(|destination| destination.name() == credential.destination())
1995                    .ok_or_else(evidence_mismatch)?;
1996                Ok(ReceiptCredentialDto {
1997                    destination: credential.destination().as_str().to_owned(),
1998                    role: destination.role().as_str().to_owned(),
1999                    capability_id: hex16(credential.capability_id().as_bytes()),
2000                })
2001            })
2002            .collect::<Result<Vec<_>, InstallationCampaignError>>()?;
2003        let operation_id = input
2004            .migration
2005            .map(|_| hex16(campaign.campaign_id.as_bytes()));
2006        let reimport = campaign
2007            .completed
2008            .iter()
2009            .find_map(|evidence| match evidence {
2010                InstallationStageEvidence::Reimport(evidence) => Some(evidence),
2011                _ => None,
2012            })
2013            .ok_or_else(evidence_mismatch)?;
2014        let reimport = match (input.reimport, reimport) {
2015            (None, InstalledReimportEvidence::NotRequired) => None,
2016            (
2017                Some(expected),
2018                InstalledReimportEvidence::Reconciled(InstalledReimportReceiptEvidence {
2019                    export_manifest_hash,
2020                    export_receipt_hash,
2021                    portability_manifest_hash,
2022                    reimport_receipt_hash,
2023                }),
2024            ) if *export_manifest_hash == expected.export_manifest_hash()
2025                && *export_receipt_hash == expected.export_receipt_hash()
2026                && *portability_manifest_hash == expected.portability_manifest_hash() =>
2027            {
2028                Some(ReceiptReimportReferenceDto {
2029                    export_manifest_hash: hex32(export_manifest_hash.as_bytes()),
2030                    export_receipt_hash: hex32(export_receipt_hash.as_bytes()),
2031                    portability_manifest_hash: hex32(portability_manifest_hash.as_bytes()),
2032                    reimport_receipt_hash: hex32(reimport_receipt_hash.as_bytes()),
2033                })
2034            }
2035            _ => return Err(evidence_mismatch()),
2036        };
2037        let schema = if reimport.is_some() {
2038            APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V3
2039        } else {
2040            APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V2
2041        };
2042        Ok(Self {
2043            schema: schema.to_owned(),
2044            campaign_id: hex16(campaign.campaign_id.as_bytes()),
2045            plan_hash: hex32(plan.identity().as_bytes()),
2046            application: input.application.as_str().to_owned(),
2047            database: input.target.database().as_str().to_owned(),
2048            environment: input.target.environment().as_str().to_owned(),
2049            lineage: input.target.lineage().as_str().to_owned(),
2050            contract_version: input.contract.version().get(),
2051            contract_bundle_hash: hex32(input.contract.bundle_hash().as_bytes()),
2052            source_hash: hex32(input.source_hash.as_bytes()),
2053            lock_hash: hex32(input.lock_hash.as_bytes()),
2054            manifest_hash: hex32(input.manifest_hash.as_bytes()),
2055            artifacts: input
2056                .artifacts
2057                .iter()
2058                .map(|artifact| ReceiptArtifactDto {
2059                    kind: artifact.kind().tag().to_owned(),
2060                    name: artifact.name().as_str().to_owned(),
2061                    content_hash: hex32(artifact.content_hash().as_bytes()),
2062                })
2063                .collect(),
2064            roles: input
2065                .roles
2066                .iter()
2067                .map(|role| ReceiptRoleDto {
2068                    name: role.name().as_str().to_owned(),
2069                    role_hash: hex32(role.role_hash().as_bytes()),
2070                })
2071                .collect(),
2072            credentials: credential_receipts,
2073            drivers: input
2074                .drivers
2075                .iter()
2076                .map(|driver| driver.tag().to_owned())
2077                .collect(),
2078            seed_checkpoints: seed_checkpoints
2079                .iter()
2080                .map(|seed| ReceiptSeedCheckpointDto {
2081                    name: seed.name().as_str().to_owned(),
2082                    content_hash: hex32(seed.content_hash().as_bytes()),
2083                    succeeded: seed.succeeded(),
2084                    replayed: seed.replayed(),
2085                })
2086                .collect(),
2087            migration_receipt: input.migration.zip(operation_id.as_ref()).map(
2088                |(migration, operation_id)| ReceiptMigrationReferenceDto {
2089                    operation_id: operation_id.clone(),
2090                    migration_hash: hex32(migration.migration_hash().as_bytes()),
2091                },
2092            ),
2093            backup_receipt: operation_id.map(|migration_operation_id| ReceiptBackupReferenceDto {
2094                migration_operation_id,
2095                policy: "required_verified".to_owned(),
2096            }),
2097            adapter_manifest_hash: input
2098                .adapter_manifest_hash
2099                .map(|hash| hex32(hash.as_bytes())),
2100            reimport,
2101            terminal_state: "installed".to_owned(),
2102            safe_remediation: Vec::new(),
2103        })
2104    }
2105}
2106
2107fn validate_receipt_v2_dto(dto: &ReceiptDtoV2) -> Result<(), InstallationCampaignError> {
2108    validate_receipt_dto(dto, APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V2, false)
2109}
2110
2111fn validate_receipt_v3_dto(dto: &ReceiptDtoV2) -> Result<(), InstallationCampaignError> {
2112    validate_receipt_dto(dto, APPLICATION_INSTALLATION_RECEIPT_SCHEMA_V3, true)
2113}
2114
2115fn validate_receipt_dto(
2116    dto: &ReceiptDtoV2,
2117    expected_schema: &str,
2118    expects_reimport: bool,
2119) -> Result<(), InstallationCampaignError> {
2120    let invalid = || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
2121    if dto.schema != expected_schema
2122        || dto.reimport.is_some() != expects_reimport
2123        || dto.terminal_state != "installed"
2124        || !dto.safe_remediation.is_empty()
2125        || dto.artifacts.is_empty()
2126        || dto.roles.is_empty()
2127        || dto.credentials.is_empty()
2128        || dto.artifacts.len() > crate::MAX_INSTALLATION_ARTIFACTS
2129        || dto.roles.len() > crate::MAX_INSTALLATION_ROLES
2130        || dto.credentials.len() > crate::MAX_CREDENTIAL_DESTINATIONS
2131        || dto.seed_checkpoints.len() > crate::MAX_INSTALLATION_SEEDS
2132        || dto.migration_receipt.is_some() != dto.backup_receipt.is_some()
2133    {
2134        return Err(invalid());
2135    }
2136    let campaign_id = parse_receipt_campaign_id(&dto.campaign_id)?;
2137    parse_receipt_plan_hash(&dto.plan_hash)?;
2138    InstallationSymbol::new(dto.application.clone())
2139        .map_err(InstallationCampaignError::from_plan_error)?;
2140    riffdb_types::DatabaseAlias::new(dto.database.clone()).map_err(|_| invalid())?;
2141    riffdb_types::Environment::new(dto.environment.clone()).map_err(|_| invalid())?;
2142    riffdb_types::ContractLineage::new(dto.lineage.clone()).map_err(|_| invalid())?;
2143    ContractVersion::new(dto.contract_version).ok_or_else(invalid)?;
2144    for hash in [
2145        &dto.contract_bundle_hash,
2146        &dto.source_hash,
2147        &dto.lock_hash,
2148        &dto.manifest_hash,
2149    ] {
2150        parse_hex32(hash).map_err(InstallationCampaignError::from_plan_error)?;
2151    }
2152    if let Some(reimport) = &dto.reimport {
2153        for hash in [
2154            &reimport.export_manifest_hash,
2155            &reimport.export_receipt_hash,
2156            &reimport.portability_manifest_hash,
2157            &reimport.reimport_receipt_hash,
2158        ] {
2159            parse_hex32(hash).map_err(InstallationCampaignError::from_plan_error)?;
2160        }
2161    }
2162    validate_receipt_artifacts(&dto.artifacts)?;
2163    validate_receipt_roles(&dto.roles)?;
2164    if !dto
2165        .artifacts
2166        .iter()
2167        .any(|artifact| artifact.kind == "manifest")
2168        || !dto
2169            .artifacts
2170            .iter()
2171            .any(|artifact| artifact.kind == "contract_bundle")
2172    {
2173        return Err(invalid());
2174    }
2175    let mut previous_credential: Option<InstallationSymbol> = None;
2176    for credential in &dto.credentials {
2177        let destination = InstallationSymbol::new(credential.destination.clone())
2178            .map_err(InstallationCampaignError::from_plan_error)?;
2179        InstallationSymbol::new(credential.role.clone())
2180            .map_err(InstallationCampaignError::from_plan_error)?;
2181        if !dto.roles.iter().any(|role| role.name == credential.role) {
2182            return Err(invalid());
2183        }
2184        CapabilityId::from_bytes(
2185            parse_hex16(&credential.capability_id)
2186                .map_err(InstallationCampaignError::from_plan_error)?,
2187        )
2188        .map_err(|_| invalid())?;
2189        if previous_credential
2190            .as_ref()
2191            .is_some_and(|previous| previous >= &destination)
2192        {
2193            return Err(invalid());
2194        }
2195        previous_credential = Some(destination);
2196    }
2197    if dto.roles.iter().any(|role| {
2198        !dto.credentials
2199            .iter()
2200            .any(|credential| credential.role == role.name)
2201    }) {
2202        return Err(invalid());
2203    }
2204    validate_receipt_drivers(&dto.drivers)?;
2205    let mut previous_seed: Option<InstallationSymbol> = None;
2206    for seed in &dto.seed_checkpoints {
2207        let name = InstallationSymbol::new(seed.name.clone())
2208            .map_err(InstallationCampaignError::from_plan_error)?;
2209        parse_hex32(&seed.content_hash).map_err(InstallationCampaignError::from_plan_error)?;
2210        let item_count = seed
2211            .succeeded
2212            .checked_add(seed.replayed)
2213            .ok_or_else(invalid)?;
2214        if item_count == 0
2215            || item_count > crate::MAX_SEED_BATCH_ITEMS
2216            || previous_seed
2217                .as_ref()
2218                .is_some_and(|previous| previous >= &name)
2219        {
2220            return Err(invalid());
2221        }
2222        previous_seed = Some(name);
2223    }
2224    if let (Some(migration), Some(backup)) = (&dto.migration_receipt, &dto.backup_receipt) {
2225        let operation_id = parse_receipt_operation_id(&migration.operation_id)?;
2226        let backup_operation_id = parse_receipt_operation_id(&backup.migration_operation_id)?;
2227        if operation_id.into_bytes() != campaign_id.into_bytes()
2228            || operation_id != backup_operation_id
2229            || backup.policy != "required_verified"
2230        {
2231            return Err(invalid());
2232        }
2233        parse_hex32(&migration.migration_hash)
2234            .map_err(InstallationCampaignError::from_plan_error)?;
2235    }
2236    if let Some(hash) = &dto.adapter_manifest_hash {
2237        parse_hex32(hash).map_err(InstallationCampaignError::from_plan_error)?;
2238    }
2239    Ok(())
2240}
2241
2242fn validate_receipt_v1_dto(dto: &ReceiptDtoV1) -> Result<(), InstallationCampaignError> {
2243    let invalid = || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
2244    ApplicationInstallationCampaignId::from_bytes(
2245        parse_hex16(&dto.campaign_id).map_err(InstallationCampaignError::from_plan_error)?,
2246    )
2247    .map_err(|_| invalid())?;
2248    parse_hex32(&dto.plan_hash).map_err(InstallationCampaignError::from_plan_error)?;
2249    InstallationSymbol::new(dto.application.clone())
2250        .map_err(InstallationCampaignError::from_plan_error)?;
2251    riffdb_types::DatabaseAlias::new(dto.database.clone()).map_err(|_| invalid())?;
2252    riffdb_types::Environment::new(dto.environment.clone()).map_err(|_| invalid())?;
2253    riffdb_types::ContractLineage::new(dto.lineage.clone()).map_err(|_| invalid())?;
2254    ContractVersion::new(dto.contract_version).ok_or_else(invalid)?;
2255    parse_hex32(&dto.contract_bundle_hash).map_err(InstallationCampaignError::from_plan_error)?;
2256    parse_hex32(&dto.source_hash).map_err(InstallationCampaignError::from_plan_error)?;
2257    parse_hex32(&dto.lock_hash).map_err(InstallationCampaignError::from_plan_error)?;
2258    parse_hex32(&dto.manifest_hash).map_err(InstallationCampaignError::from_plan_error)?;
2259    let mut previous_artifact: Option<(InstallationArtifactKind, InstallationSymbol)> = None;
2260    for artifact in &dto.artifacts {
2261        let kind = InstallationArtifactKind::parse(&artifact.kind).ok_or_else(invalid)?;
2262        let name = InstallationSymbol::new(artifact.name.clone())
2263            .map_err(InstallationCampaignError::from_plan_error)?;
2264        parse_hex32(&artifact.content_hash).map_err(InstallationCampaignError::from_plan_error)?;
2265        if previous_artifact
2266            .as_ref()
2267            .is_some_and(|previous| previous >= &(kind, name.clone()))
2268        {
2269            return Err(invalid());
2270        }
2271        previous_artifact = Some((kind, name));
2272    }
2273    let mut previous_role: Option<InstallationSymbol> = None;
2274    for role in &dto.roles {
2275        let name = InstallationSymbol::new(role.name.clone())
2276            .map_err(InstallationCampaignError::from_plan_error)?;
2277        parse_hex32(&role.role_hash).map_err(InstallationCampaignError::from_plan_error)?;
2278        if previous_role
2279            .as_ref()
2280            .is_some_and(|previous| previous >= &name)
2281        {
2282            return Err(invalid());
2283        }
2284        previous_role = Some(name);
2285    }
2286    let mut drivers = dto
2287        .drivers
2288        .iter()
2289        .map(|driver| InstallationDriver::parse(driver).ok_or_else(invalid))
2290        .collect::<Result<Vec<_>, _>>()?;
2291    let original = drivers.clone();
2292    drivers.sort();
2293    drivers.dedup();
2294    if original != drivers || dto.seed_batches > crate::MAX_INSTALLATION_SEEDS as u64 {
2295        return Err(invalid());
2296    }
2297    if let Some(hash) = &dto.migration_hash {
2298        parse_hex32(hash).map_err(InstallationCampaignError::from_plan_error)?;
2299    }
2300    if let Some(hash) = &dto.adapter_manifest_hash {
2301        parse_hex32(hash).map_err(InstallationCampaignError::from_plan_error)?;
2302    }
2303    Ok(())
2304}
2305
2306fn evidence_mismatch() -> InstallationCampaignError {
2307    InstallationCampaignError::new(InstallationCampaignErrorKind::EvidenceMismatch)
2308}
2309
2310fn parse_receipt_campaign_id(
2311    value: &str,
2312) -> Result<ApplicationInstallationCampaignId, InstallationCampaignError> {
2313    ApplicationInstallationCampaignId::from_bytes(
2314        parse_hex16(value).map_err(InstallationCampaignError::from_plan_error)?,
2315    )
2316    .map_err(|_| InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding))
2317}
2318
2319fn parse_receipt_plan_hash(
2320    value: &str,
2321) -> Result<ApplicationInstallationPlanHash, InstallationCampaignError> {
2322    Ok(ApplicationInstallationPlanHash::from_bytes(
2323        parse_hex32(value).map_err(InstallationCampaignError::from_plan_error)?,
2324    ))
2325}
2326
2327fn parse_receipt_operation_id(
2328    value: &str,
2329) -> Result<ContractMigrationOperationId, InstallationCampaignError> {
2330    ContractMigrationOperationId::from_bytes(
2331        parse_hex16(value).map_err(InstallationCampaignError::from_plan_error)?,
2332    )
2333    .map_err(|_| InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding))
2334}
2335
2336fn validate_receipt_artifacts(
2337    artifacts: &[ReceiptArtifactDto],
2338) -> Result<(), InstallationCampaignError> {
2339    let invalid = || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
2340    let mut previous: Option<(InstallationArtifactKind, InstallationSymbol)> = None;
2341    for artifact in artifacts {
2342        let kind = InstallationArtifactKind::parse(&artifact.kind).ok_or_else(invalid)?;
2343        let name = InstallationSymbol::new(artifact.name.clone())
2344            .map_err(InstallationCampaignError::from_plan_error)?;
2345        parse_hex32(&artifact.content_hash).map_err(InstallationCampaignError::from_plan_error)?;
2346        if previous
2347            .as_ref()
2348            .is_some_and(|prior| prior >= &(kind, name.clone()))
2349        {
2350            return Err(invalid());
2351        }
2352        previous = Some((kind, name));
2353    }
2354    Ok(())
2355}
2356
2357fn validate_receipt_roles(roles: &[ReceiptRoleDto]) -> Result<(), InstallationCampaignError> {
2358    let invalid = || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
2359    let mut previous: Option<InstallationSymbol> = None;
2360    for role in roles {
2361        let name = InstallationSymbol::new(role.name.clone())
2362            .map_err(InstallationCampaignError::from_plan_error)?;
2363        parse_hex32(&role.role_hash).map_err(InstallationCampaignError::from_plan_error)?;
2364        if previous.as_ref().is_some_and(|prior| prior >= &name) {
2365            return Err(invalid());
2366        }
2367        previous = Some(name);
2368    }
2369    Ok(())
2370}
2371
2372fn validate_receipt_drivers(drivers: &[String]) -> Result<(), InstallationCampaignError> {
2373    let invalid = || InstallationCampaignError::new(InstallationCampaignErrorKind::InvalidEncoding);
2374    let mut parsed = drivers
2375        .iter()
2376        .map(|driver| InstallationDriver::parse(driver).ok_or_else(invalid))
2377        .collect::<Result<Vec<_>, _>>()?;
2378    let original = parsed.clone();
2379    parsed.sort();
2380    parsed.dedup();
2381    if original != parsed {
2382        return Err(invalid());
2383    }
2384    Ok(())
2385}
2386
2387fn hex32(bytes: &[u8; 32]) -> String {
2388    crate::plan::hex32(bytes)
2389}
2390
2391fn hex16(bytes: &[u8; 16]) -> String {
2392    crate::plan::hex16(bytes)
2393}
2394
2395#[cfg(test)]
2396mod tests {
2397    use super::InstallationStage;
2398    use crate::APPLICATION_INSTALLATION_PLAN_SCHEMA_V1;
2399
2400    #[test]
2401    fn installation_stages_are_closed_and_dependency_ordered() {
2402        assert_eq!(
2403            APPLICATION_INSTALLATION_PLAN_SCHEMA_V1,
2404            "riffdb.application-installation-plan/v1"
2405        );
2406        assert_eq!(InstallationStage::ALL.len(), 11);
2407        assert_eq!(InstallationStage::ALL[0], InstallationStage::Preflight);
2408        assert_eq!(InstallationStage::ALL[6], InstallationStage::Reimport);
2409        assert_eq!(InstallationStage::ALL[10], InstallationStage::Receipt);
2410        assert_eq!(InstallationStage::ALL[10].as_str(), "receipt");
2411    }
2412}