Skip to main content

canic_host/deployment_truth/
promotion.rs

1use super::{
2    ArtifactSourceV1, BuildMaterializationEvidenceV1, BuildMaterializationInputV1,
3    BuildMaterializationResultV1, BuildRecipeIdentityV1, DEPLOYMENT_TRUTH_SCHEMA_VERSION,
4    DeploymentPlanV1, PromotionArtifactIdentityGroupV1, PromotionArtifactIdentityKindV1,
5    PromotionArtifactIdentityReportV1, PromotionArtifactLevelV1, PromotionPlanTransformEvidenceV1,
6    PromotionPlanTransformV1, PromotionReadinessStatusV1, PromotionReadinessV1,
7    RoleArtifactSourceKindV1, RoleArtifactSourceV1, RoleArtifactV1,
8    RolePromotionArtifactIdentityV1, RolePromotionInputV1, RolePromotionPlanTransformV1,
9    RolePromotionReadinessV1, SafetyFindingV1, SafetySeverityV1, stable_json_sha256_hex,
10};
11use std::collections::{BTreeMap, BTreeSet};
12use thiserror::Error as ThisError;
13
14///
15/// PromotionArtifactSourceError
16///
17#[derive(Debug, ThisError)]
18pub enum PromotionArtifactSourceError {
19    #[error("promotion artifact source is missing required field: {field}")]
20    MissingRequiredField { field: &'static str },
21    #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
22    InvalidSha256Digest { field: &'static str },
23    #[error("promotion artifact source kind {kind:?} requires a digest pin")]
24    MissingDigestPin { kind: RoleArtifactSourceKindV1 },
25    #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
26    UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
27    #[error(
28        "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
29    )]
30    MissingPreviousReceiptKind,
31}
32
33///
34/// PromotionReadinessError
35///
36#[derive(Debug, ThisError)]
37pub enum PromotionReadinessError {
38    #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
39    SchemaVersionMismatch { expected: u32, found: u32 },
40    #[error("promotion readiness is missing required field: {field}")]
41    MissingRequiredField { field: &'static str },
42    #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
43    StatusBlockerMismatch {
44        status: PromotionReadinessStatusV1,
45        blocker_count: usize,
46    },
47    #[error("promotion readiness contains duplicate role: {role}")]
48    DuplicateRole { role: String },
49    #[error("promotion readiness role {role} has inconsistent restage state")]
50    RestageStateMismatch { role: String },
51    #[error("promotion readiness finding in {field} has severity {severity:?}")]
52    FindingSeverityMismatch {
53        field: &'static str,
54        severity: SafetySeverityV1,
55    },
56    #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
57    InvalidSha256Digest { field: &'static str },
58}
59
60///
61/// PromotionPlanTransformError
62///
63#[derive(Debug, ThisError)]
64pub enum PromotionPlanTransformError {
65    #[error("promotion plan transform schema mismatch: expected {expected}, found {found}")]
66    SchemaVersionMismatch { expected: u32, found: u32 },
67    #[error("promotion plan transform is missing required field: {field}")]
68    MissingRequiredField { field: &'static str },
69    #[error("promotion readiness validation failed: {0}")]
70    Readiness(#[from] PromotionReadinessError),
71    #[error("promotion readiness is blocked with {blocker_count} blocker(s)")]
72    ReadinessBlocked { blocker_count: usize },
73    #[error("promotion target plan is missing role: {role}")]
74    TargetRoleMissing { role: String },
75    #[error("promotion transform contains duplicate role: {role}")]
76    DuplicateRole { role: String },
77    #[error("promotion transform promoted plan id mismatch: expected {expected}, found {found}")]
78    PromotedPlanIdMismatch { expected: String, found: String },
79    #[error("promotion transform role {role} is missing from promoted plan")]
80    PromotedRoleMissing { role: String },
81    #[error("promotion transform role {role} has inconsistent field {field}")]
82    RoleStateMismatch { role: String, field: &'static str },
83}
84
85///
86/// PromotionPlanTransformEvidenceError
87///
88#[derive(Debug, ThisError)]
89pub enum PromotionPlanTransformEvidenceError {
90    #[error(
91        "promotion plan transform evidence schema mismatch: expected {expected}, found {found}"
92    )]
93    SchemaVersionMismatch { expected: u32, found: u32 },
94    #[error("promotion plan transform evidence is missing required field: {field}")]
95    MissingRequiredField { field: &'static str },
96    #[error("promotion plan transform evidence has invalid transform: {0}")]
97    Transform(#[from] PromotionPlanTransformError),
98}
99
100///
101/// PromotionArtifactIdentityReportError
102///
103#[derive(Debug, ThisError)]
104pub enum PromotionArtifactIdentityReportError {
105    #[error(
106        "promotion artifact identity report schema mismatch: expected {expected}, found {found}"
107    )]
108    SchemaVersionMismatch { expected: u32, found: u32 },
109    #[error("promotion artifact identity report is missing required field: {field}")]
110    MissingRequiredField { field: &'static str },
111    #[error(
112        "promotion artifact identity report status {status:?} does not match blocker count {blocker_count}"
113    )]
114    StatusBlockerMismatch {
115        status: PromotionReadinessStatusV1,
116        blocker_count: usize,
117    },
118    #[error("promotion artifact identity report contains duplicate role: {role}")]
119    DuplicateRole { role: String },
120    #[error("promotion artifact identity report contains duplicate identity group: {identity_key}")]
121    DuplicateIdentityGroup { identity_key: String },
122    #[error("promotion artifact identity report identity group {identity_key} has no roles")]
123    EmptyIdentityGroup { identity_key: String },
124    #[error("promotion artifact identity report identity group contains unknown role: {role}")]
125    UnknownGroupedRole { role: String },
126    #[error("promotion artifact identity report groups role {role} more than once")]
127    DuplicateGroupedRole { role: String },
128    #[error("promotion artifact identity report does not group role: {role}")]
129    MissingGroupedRole { role: String },
130    #[error(
131        "promotion artifact identity report role {role} belongs to identity group {expected}, found {found}"
132    )]
133    IdentityGroupRoleMismatch {
134        role: String,
135        expected: String,
136        found: String,
137    },
138    #[error(
139        "promotion artifact identity report identity group key mismatch: expected {expected}, found {found}"
140    )]
141    IdentityGroupKeyMismatch { expected: String, found: String },
142    #[error(
143        "promotion artifact identity report field {field} must be a lowercase sha256 hex digest"
144    )]
145    InvalidSha256Digest { field: &'static str },
146    #[error("promotion artifact identity report blocker has severity {severity:?}")]
147    BlockerSeverityMismatch { severity: SafetySeverityV1 },
148}
149
150///
151/// PromotionMaterializationIdentityError
152///
153#[derive(Debug, ThisError)]
154pub enum PromotionMaterializationIdentityError {
155    #[error(
156        "promotion materialization identity schema mismatch: expected {expected}, found {found}"
157    )]
158    SchemaVersionMismatch { expected: u32, found: u32 },
159    #[error("promotion materialization identity is missing required field: {field}")]
160    MissingRequiredField { field: &'static str },
161    #[error(
162        "promotion materialization identity field {field} must be a lowercase sha256 hex digest"
163    )]
164    InvalidSha256Digest { field: &'static str },
165    #[error("promotion materialization identity field {field} is inconsistent")]
166    LinkageMismatch { field: &'static str },
167    #[error(
168        "promotion materialization identity digest mismatch for {field}: expected {expected}, found {found}"
169    )]
170    DigestMismatch {
171        field: &'static str,
172        expected: String,
173        found: String,
174    },
175}
176
177///
178/// PromotionReadinessRequest
179///
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct PromotionReadinessRequest {
182    pub readiness_id: String,
183    pub target_plan: DeploymentPlanV1,
184    pub inputs: Vec<RolePromotionInputV1>,
185}
186
187///
188/// PromotionPlanTransformRequest
189///
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct PromotionPlanTransformRequest {
192    pub promoted_plan_id: String,
193    pub target_plan: DeploymentPlanV1,
194    pub inputs: Vec<RolePromotionInputV1>,
195}
196
197///
198/// PromotionPlanTransformEvidenceRequest
199///
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct PromotionPlanTransformEvidenceRequest {
202    pub evidence_id: String,
203    pub generated_at: String,
204    pub transform: PromotionPlanTransformV1,
205}
206
207///
208/// PromotionArtifactIdentityReportRequest
209///
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct PromotionArtifactIdentityReportRequest {
212    pub report_id: String,
213    pub inputs: Vec<RolePromotionInputV1>,
214}
215
216///
217/// BuildMaterializationEvidenceRequest
218///
219#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct BuildMaterializationEvidenceRequest {
221    pub evidence_id: String,
222    pub recipe: BuildRecipeIdentityV1,
223    pub materialization_input: BuildMaterializationInputV1,
224    pub materialization_result: BuildMaterializationResultV1,
225}
226
227pub fn promoted_deployment_plan_from_inputs(
228    request: &PromotionPlanTransformRequest,
229) -> Result<DeploymentPlanV1, PromotionPlanTransformError> {
230    Ok(promoted_deployment_plan_transform_from_inputs(request)?.promoted_plan)
231}
232
233pub fn promoted_deployment_plan_transform_from_inputs(
234    request: &PromotionPlanTransformRequest,
235) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
236    ensure_transform_field("promoted_plan_id", &request.promoted_plan_id)?;
237    let readiness = promotion_readiness_from_inputs(
238        &request.promoted_plan_id,
239        &request.target_plan,
240        &request.inputs,
241    );
242    validate_promotion_readiness(&readiness)?;
243    if readiness.status == PromotionReadinessStatusV1::Blocked {
244        return Err(PromotionPlanTransformError::ReadinessBlocked {
245            blocker_count: readiness.blockers.len(),
246        });
247    }
248
249    let mut promoted_plan = request.target_plan.clone();
250    promoted_plan.plan_id.clone_from(&request.promoted_plan_id);
251    for input in &request.inputs {
252        let Some(role_artifact) = promoted_plan
253            .role_artifacts
254            .iter_mut()
255            .find(|artifact| artifact.role == input.role)
256        else {
257            return Err(PromotionPlanTransformError::TargetRoleMissing {
258                role: input.role.clone(),
259            });
260        };
261        apply_promotion_input_to_role_artifact(role_artifact, input);
262    }
263    let transform =
264        promotion_plan_transform_from_parts(&request.target_plan, promoted_plan, &request.inputs);
265    validate_promotion_plan_transform(&transform)?;
266    Ok(transform)
267}
268
269pub fn check_promotion_readiness(
270    request: &PromotionReadinessRequest,
271) -> Result<PromotionReadinessV1, PromotionReadinessError> {
272    ensure_readiness_field("readiness_id", &request.readiness_id)?;
273    let readiness = promotion_readiness_from_inputs(
274        &request.readiness_id,
275        &request.target_plan,
276        &request.inputs,
277    );
278    validate_promotion_readiness(&readiness)?;
279    Ok(readiness)
280}
281
282pub fn promotion_artifact_identity_report_from_inputs(
283    request: PromotionArtifactIdentityReportRequest,
284) -> Result<PromotionArtifactIdentityReportV1, PromotionArtifactIdentityReportError> {
285    ensure_identity_report_field("report_id", &request.report_id)?;
286    let report = promotion_artifact_identity_report(&request.report_id, &request.inputs);
287    validate_promotion_artifact_identity_report(&report)?;
288    Ok(report)
289}
290
291#[must_use]
292pub fn promotion_artifact_identity_report(
293    report_id: impl Into<String>,
294    inputs: &[RolePromotionInputV1],
295) -> PromotionArtifactIdentityReportV1 {
296    let mut roles = Vec::with_capacity(inputs.len());
297    let mut blockers = Vec::new();
298    for input in inputs {
299        if let Err(err) = validate_role_artifact_source(&input.source) {
300            blockers.push(promotion_finding(
301                "promotion_artifact_source_invalid",
302                err.to_string(),
303                SafetySeverityV1::HardFailure,
304                &input.role,
305            ));
306        }
307        if input.role != input.source.role {
308            blockers.push(promotion_finding(
309                "promotion_source_role_mismatch",
310                format!(
311                    "promotion input role {} does not match artifact source role {}",
312                    input.role, input.source.role
313                ),
314                SafetySeverityV1::HardFailure,
315                &input.role,
316            ));
317        }
318        roles.push(role_promotion_artifact_identity(input));
319    }
320
321    PromotionArtifactIdentityReportV1 {
322        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
323        report_id: report_id.into(),
324        status: if blockers.is_empty() {
325            PromotionReadinessStatusV1::Ready
326        } else {
327            PromotionReadinessStatusV1::Blocked
328        },
329        identity_groups: promotion_artifact_identity_groups(&roles),
330        roles,
331        blockers,
332    }
333}
334
335pub fn validate_promotion_artifact_identity_report(
336    report: &PromotionArtifactIdentityReportV1,
337) -> Result<(), PromotionArtifactIdentityReportError> {
338    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
339        return Err(
340            PromotionArtifactIdentityReportError::SchemaVersionMismatch {
341                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
342                found: report.schema_version,
343            },
344        );
345    }
346    ensure_identity_report_field("report_id", &report.report_id)?;
347    ensure_identity_report_status_matches_blockers(report)?;
348    ensure_unique_artifact_identity_roles(&report.roles)?;
349    for role in &report.roles {
350        validate_role_artifact_identity(role)?;
351    }
352    validate_artifact_identity_groups(&report.roles, &report.identity_groups)?;
353    validate_identity_report_blockers(&report.blockers)?;
354    Ok(())
355}
356
357pub fn promotion_plan_transform_evidence(
358    request: PromotionPlanTransformEvidenceRequest,
359) -> Result<PromotionPlanTransformEvidenceV1, PromotionPlanTransformEvidenceError> {
360    ensure_evidence_field("evidence_id", &request.evidence_id)?;
361    ensure_evidence_field("generated_at", &request.generated_at)?;
362    validate_promotion_plan_transform(&request.transform)?;
363    let evidence = PromotionPlanTransformEvidenceV1 {
364        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
365        evidence_id: request.evidence_id,
366        generated_at: request.generated_at,
367        transform: request.transform,
368    };
369    validate_promotion_plan_transform_evidence(&evidence)?;
370    Ok(evidence)
371}
372
373pub fn validate_promotion_plan_transform_evidence(
374    evidence: &PromotionPlanTransformEvidenceV1,
375) -> Result<(), PromotionPlanTransformEvidenceError> {
376    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
377        return Err(PromotionPlanTransformEvidenceError::SchemaVersionMismatch {
378            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
379            found: evidence.schema_version,
380        });
381    }
382    ensure_evidence_field("evidence_id", &evidence.evidence_id)?;
383    ensure_evidence_field("generated_at", &evidence.generated_at)?;
384    validate_promotion_plan_transform(&evidence.transform)?;
385    Ok(())
386}
387
388pub fn validate_promotion_plan_transform(
389    transform: &PromotionPlanTransformV1,
390) -> Result<(), PromotionPlanTransformError> {
391    if transform.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
392        return Err(PromotionPlanTransformError::SchemaVersionMismatch {
393            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
394            found: transform.schema_version,
395        });
396    }
397    ensure_transform_field("transform_id", &transform.transform_id)?;
398    ensure_transform_field("target_plan_id", &transform.target_plan_id)?;
399    ensure_transform_field("promoted_plan_id", &transform.promoted_plan_id)?;
400    ensure_transform_field("promoted_plan.plan_id", &transform.promoted_plan.plan_id)?;
401    if transform.promoted_plan.plan_id != transform.promoted_plan_id {
402        return Err(PromotionPlanTransformError::PromotedPlanIdMismatch {
403            expected: transform.promoted_plan_id.clone(),
404            found: transform.promoted_plan.plan_id.clone(),
405        });
406    }
407    ensure_unique_transform_roles(&transform.roles)?;
408    for role in &transform.roles {
409        validate_role_plan_transform(role, &transform.promoted_plan)?;
410    }
411    Ok(())
412}
413
414#[must_use]
415pub fn promotion_readiness_from_inputs(
416    readiness_id: impl Into<String>,
417    target_plan: &DeploymentPlanV1,
418    inputs: &[RolePromotionInputV1],
419) -> PromotionReadinessV1 {
420    let mut roles = Vec::with_capacity(inputs.len());
421    let mut blockers = Vec::new();
422    let mut warnings = Vec::new();
423
424    for input in inputs {
425        let target_artifact = target_plan
426            .role_artifacts
427            .iter()
428            .find(|artifact| artifact.role == input.role);
429        let Some(target_artifact) = target_artifact else {
430            blockers.push(promotion_finding(
431                "promotion_target_role_missing",
432                format!("target plan does not contain role {}", input.role),
433                SafetySeverityV1::HardFailure,
434                &input.role,
435            ));
436            continue;
437        };
438
439        let role_readiness = role_promotion_readiness(input, target_artifact);
440        collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
441        roles.push(role_readiness);
442    }
443
444    let status = if blockers.is_empty() {
445        PromotionReadinessStatusV1::Ready
446    } else {
447        PromotionReadinessStatusV1::Blocked
448    };
449
450    PromotionReadinessV1 {
451        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
452        readiness_id: readiness_id.into(),
453        target_plan_id: target_plan.plan_id.clone(),
454        status,
455        roles,
456        blockers,
457        warnings,
458    }
459}
460
461pub fn validate_promotion_readiness(
462    readiness: &PromotionReadinessV1,
463) -> Result<(), PromotionReadinessError> {
464    if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
465        return Err(PromotionReadinessError::SchemaVersionMismatch {
466            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
467            found: readiness.schema_version,
468        });
469    }
470    ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
471    ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
472    ensure_readiness_status_matches_blockers(readiness)?;
473    ensure_unique_readiness_roles(&readiness.roles)?;
474    for role in &readiness.roles {
475        validate_role_readiness(role)?;
476    }
477    validate_readiness_findings(
478        "blockers",
479        &readiness.blockers,
480        SafetySeverityV1::HardFailure,
481    )?;
482    validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
483    Ok(())
484}
485
486pub fn validate_role_artifact_source(
487    source: &RoleArtifactSourceV1,
488) -> Result<(), PromotionArtifactSourceError> {
489    ensure_field("role", &source.role)?;
490    ensure_locator_requirement(source)?;
491    ensure_previous_receipt_requirement(source)?;
492    ensure_digest_requirement(source)?;
493    ensure_optional_sha256(
494        "expected_wasm_sha256",
495        source.expected_wasm_sha256.as_deref(),
496    )?;
497    ensure_optional_sha256(
498        "expected_wasm_gz_sha256",
499        source.expected_wasm_gz_sha256.as_deref(),
500    )?;
501    ensure_optional_sha256(
502        "expected_candid_sha256",
503        source.expected_candid_sha256.as_deref(),
504    )?;
505    ensure_optional_sha256(
506        "expected_canonical_embedded_config_sha256",
507        source.expected_canonical_embedded_config_sha256.as_deref(),
508    )?;
509    Ok(())
510}
511
512pub fn build_materialization_evidence(
513    request: BuildMaterializationEvidenceRequest,
514) -> Result<BuildMaterializationEvidenceV1, PromotionMaterializationIdentityError> {
515    ensure_materialization_field("evidence_id", &request.evidence_id)?;
516    validate_build_recipe_identity(&request.recipe)?;
517    validate_build_materialization_input(&request.materialization_input)?;
518    validate_build_materialization_result(&request.materialization_result)?;
519    let computed_materialization_input_digest =
520        build_materialization_input_digest(&request.materialization_input);
521    let evidence = BuildMaterializationEvidenceV1 {
522        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
523        evidence_id: request.evidence_id,
524        recipe_id_matches_input: request.recipe.recipe_id
525            == request.materialization_input.build_recipe_id,
526        recipe_id_matches_result: request.recipe.recipe_id
527            == request.materialization_result.build_recipe_id,
528        materialization_input_digest_matches_result: computed_materialization_input_digest
529            == request.materialization_result.materialization_input_digest,
530        computed_materialization_input_digest,
531        recipe: request.recipe,
532        materialization_input: request.materialization_input,
533        materialization_result: request.materialization_result,
534    };
535    validate_build_materialization_evidence(&evidence)?;
536    Ok(evidence)
537}
538
539pub fn validate_build_materialization_evidence(
540    evidence: &BuildMaterializationEvidenceV1,
541) -> Result<(), PromotionMaterializationIdentityError> {
542    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
543        return Err(
544            PromotionMaterializationIdentityError::SchemaVersionMismatch {
545                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
546                found: evidence.schema_version,
547            },
548        );
549    }
550    ensure_materialization_field("evidence_id", &evidence.evidence_id)?;
551    validate_build_recipe_identity(&evidence.recipe)?;
552    validate_build_materialization_input(&evidence.materialization_input)?;
553    validate_build_materialization_result(&evidence.materialization_result)?;
554    ensure_materialization_sha256(
555        "computed_materialization_input_digest",
556        &evidence.computed_materialization_input_digest,
557    )?;
558    ensure_materialization_link(
559        "recipe_id_matches_input",
560        evidence.recipe_id_matches_input
561            == (evidence.recipe.recipe_id == evidence.materialization_input.build_recipe_id),
562    )?;
563    ensure_materialization_link("recipe_id_matches_input", evidence.recipe_id_matches_input)?;
564    ensure_materialization_link(
565        "recipe_id_matches_result",
566        evidence.recipe_id_matches_result
567            == (evidence.recipe.recipe_id == evidence.materialization_result.build_recipe_id),
568    )?;
569    ensure_materialization_link(
570        "recipe_id_matches_result",
571        evidence.recipe_id_matches_result,
572    )?;
573    let computed = build_materialization_input_digest(&evidence.materialization_input);
574    if computed != evidence.computed_materialization_input_digest {
575        return Err(PromotionMaterializationIdentityError::DigestMismatch {
576            field: "computed_materialization_input_digest",
577            expected: computed,
578            found: evidence.computed_materialization_input_digest.clone(),
579        });
580    }
581    ensure_materialization_link(
582        "materialization_input_digest_matches_result",
583        evidence.materialization_input_digest_matches_result
584            == (evidence.computed_materialization_input_digest
585                == evidence.materialization_result.materialization_input_digest),
586    )?;
587    ensure_materialization_link(
588        "materialization_input_digest_matches_result",
589        evidence.materialization_input_digest_matches_result,
590    )?;
591    Ok(())
592}
593
594#[must_use]
595pub fn build_materialization_input_digest(input: &BuildMaterializationInputV1) -> String {
596    stable_json_sha256_hex(input)
597}
598
599pub fn validate_build_recipe_identity(
600    recipe: &BuildRecipeIdentityV1,
601) -> Result<(), PromotionMaterializationIdentityError> {
602    ensure_materialization_field("recipe_id", &recipe.recipe_id)?;
603    ensure_materialization_field("source_revision", &recipe.source_revision)?;
604    ensure_materialization_field("package_or_role_selector", &recipe.package_or_role_selector)?;
605    ensure_materialization_field("cargo_profile", &recipe.cargo_profile)?;
606    ensure_materialization_sha256("cargo_features_digest", &recipe.cargo_features_digest)?;
607    ensure_materialization_sha256("cargo_lock_digest", &recipe.cargo_lock_digest)?;
608    ensure_materialization_field("rust_toolchain", &recipe.rust_toolchain)?;
609    ensure_materialization_field("builder_version", &recipe.builder_version)?;
610    ensure_materialization_field("target_triple", &recipe.target_triple)?;
611    ensure_materialization_field("linker_identity", &recipe.linker_identity)?;
612    ensure_materialization_field("deterministic_build_mode", &recipe.deterministic_build_mode)?;
613    ensure_materialization_field("wasm_opt_version", &recipe.wasm_opt_version)?;
614    ensure_materialization_field("compression_identity", &recipe.compression_identity)?;
615    Ok(())
616}
617
618pub fn validate_build_materialization_input(
619    input: &BuildMaterializationInputV1,
620) -> Result<(), PromotionMaterializationIdentityError> {
621    ensure_materialization_field("materialization_input_id", &input.materialization_input_id)?;
622    ensure_materialization_field("build_recipe_id", &input.build_recipe_id)?;
623    ensure_materialization_sha256(
624        "canonical_embedded_config_sha256",
625        &input.canonical_embedded_config_sha256,
626    )?;
627    ensure_materialization_field("network", &input.network)?;
628    ensure_materialization_field("root_trust_anchor", &input.root_trust_anchor)?;
629    ensure_materialization_field("runtime_variant", &input.runtime_variant)?;
630    Ok(())
631}
632
633pub fn validate_build_materialization_result(
634    result: &BuildMaterializationResultV1,
635) -> Result<(), PromotionMaterializationIdentityError> {
636    ensure_materialization_field(
637        "materialization_result_id",
638        &result.materialization_result_id,
639    )?;
640    ensure_materialization_field("build_recipe_id", &result.build_recipe_id)?;
641    ensure_materialization_sha256(
642        "materialization_input_digest",
643        &result.materialization_input_digest,
644    )?;
645    ensure_materialization_sha256("wasm_sha256", &result.wasm_sha256)?;
646    ensure_materialization_sha256("wasm_gz_sha256", &result.wasm_gz_sha256)?;
647    ensure_materialization_sha256("installed_module_hash", &result.installed_module_hash)?;
648    ensure_materialization_sha256("candid_sha256", &result.candid_sha256)?;
649    Ok(())
650}
651
652fn apply_promotion_input_to_role_artifact(
653    role_artifact: &mut RoleArtifactV1,
654    input: &RolePromotionInputV1,
655) {
656    match input.promotion_level {
657        PromotionArtifactLevelV1::SealedWasm => {
658            role_artifact.source = artifact_source_for_promotion_source(input.source.kind);
659            apply_promotion_source_locator(role_artifact, &input.source);
660            role_artifact
661                .wasm_sha256
662                .clone_from(&input.source.expected_wasm_sha256);
663            role_artifact
664                .wasm_gz_sha256
665                .clone_from(&input.source.expected_wasm_gz_sha256);
666            role_artifact
667                .candid_sha256
668                .clone_from(&input.source.expected_candid_sha256);
669            role_artifact
670                .canonical_embedded_config_sha256
671                .clone_from(&input.source.expected_canonical_embedded_config_sha256);
672        }
673        PromotionArtifactLevelV1::SourceBuild => {}
674    }
675}
676
677const fn artifact_source_for_promotion_source(kind: RoleArtifactSourceKindV1) -> ArtifactSourceV1 {
678    match kind {
679        RoleArtifactSourceKindV1::WorkspacePackage => ArtifactSourceV1::LocalBuild,
680        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => ArtifactSourceV1::WasmStore,
681        RoleArtifactSourceKindV1::PublishedPackage
682        | RoleArtifactSourceKindV1::LocalWasm
683        | RoleArtifactSourceKindV1::LocalWasmGz
684        | RoleArtifactSourceKindV1::PreviousReceiptArtifact => ArtifactSourceV1::External,
685    }
686}
687
688fn apply_promotion_source_locator(
689    role_artifact: &mut RoleArtifactV1,
690    source: &RoleArtifactSourceV1,
691) {
692    match source.kind {
693        RoleArtifactSourceKindV1::LocalWasm => {
694            role_artifact.wasm_path.clone_from(&source.locator);
695        }
696        RoleArtifactSourceKindV1::LocalWasmGz => {
697            role_artifact.wasm_gz_path.clone_from(&source.locator);
698        }
699        _ => {}
700    }
701}
702
703fn promotion_plan_transform_from_parts(
704    target_plan: &DeploymentPlanV1,
705    promoted_plan: DeploymentPlanV1,
706    inputs: &[RolePromotionInputV1],
707) -> PromotionPlanTransformV1 {
708    let roles = inputs
709        .iter()
710        .filter_map(|input| {
711            let before = target_plan
712                .role_artifacts
713                .iter()
714                .find(|artifact| artifact.role == input.role)?;
715            let after = promoted_plan
716                .role_artifacts
717                .iter()
718                .find(|artifact| artifact.role == input.role)?;
719            Some(role_plan_transform(input, before, after))
720        })
721        .collect();
722
723    PromotionPlanTransformV1 {
724        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
725        transform_id: format!("promotion-transform:{}", promoted_plan.plan_id),
726        target_plan_id: target_plan.plan_id.clone(),
727        promoted_plan_id: promoted_plan.plan_id.clone(),
728        promoted_plan,
729        roles,
730    }
731}
732
733fn role_plan_transform(
734    input: &RolePromotionInputV1,
735    before: &RoleArtifactV1,
736    after: &RoleArtifactV1,
737) -> RolePromotionPlanTransformV1 {
738    RolePromotionPlanTransformV1 {
739        role: input.role.clone(),
740        promotion_level: input.promotion_level,
741        source_kind: input.source.kind,
742        source_locator: input.source.locator.clone(),
743        artifact_source_before: before.source,
744        artifact_source_after: after.source,
745        wasm_sha256_before: before.wasm_sha256.clone(),
746        wasm_sha256_after: after.wasm_sha256.clone(),
747        wasm_gz_sha256_before: before.wasm_gz_sha256.clone(),
748        wasm_gz_sha256_after: after.wasm_gz_sha256.clone(),
749        candid_sha256_before: before.candid_sha256.clone(),
750        candid_sha256_after: after.candid_sha256.clone(),
751        canonical_embedded_config_sha256_before: before.canonical_embedded_config_sha256.clone(),
752        canonical_embedded_config_sha256_after: after.canonical_embedded_config_sha256.clone(),
753        artifact_identity_changed: artifact_identity_changed(before, after),
754        embedded_config_changed: before.canonical_embedded_config_sha256
755            != after.canonical_embedded_config_sha256,
756        target_materialization_preserved: input.promotion_level
757            == PromotionArtifactLevelV1::SourceBuild
758            && role_materialization_identity_matches(before, after),
759    }
760}
761
762fn artifact_identity_changed(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
763    before.source != after.source
764        || before.wasm_path != after.wasm_path
765        || before.wasm_gz_path != after.wasm_gz_path
766        || before.wasm_sha256 != after.wasm_sha256
767        || before.wasm_gz_sha256 != after.wasm_gz_sha256
768        || before.candid_path != after.candid_path
769        || before.candid_sha256 != after.candid_sha256
770}
771
772fn role_materialization_identity_matches(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
773    before.source == after.source
774        && before.wasm_path == after.wasm_path
775        && before.wasm_gz_path == after.wasm_gz_path
776        && before.wasm_sha256 == after.wasm_sha256
777        && before.wasm_gz_sha256 == after.wasm_gz_sha256
778        && before.candid_path == after.candid_path
779        && before.candid_sha256 == after.candid_sha256
780        && before.canonical_embedded_config_sha256 == after.canonical_embedded_config_sha256
781}
782
783fn role_promotion_artifact_identity(
784    input: &RolePromotionInputV1,
785) -> RolePromotionArtifactIdentityV1 {
786    let wasm_sha256 = input.source.expected_wasm_sha256.clone();
787    let wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
788    RolePromotionArtifactIdentityV1 {
789        role: input.role.clone(),
790        promotion_level: input.promotion_level,
791        source_kind: input.source.kind,
792        source_locator: input.source.locator.clone(),
793        identity_kind: promotion_artifact_identity_kind(input.promotion_level, &input.source),
794        digest_pinned: wasm_sha256.is_some() || wasm_gz_sha256.is_some(),
795        wasm_sha256,
796        wasm_gz_sha256,
797        candid_sha256: input.source.expected_candid_sha256.clone(),
798        canonical_embedded_config_sha256: input
799            .source
800            .expected_canonical_embedded_config_sha256
801            .clone(),
802    }
803}
804
805fn promotion_artifact_identity_groups(
806    roles: &[RolePromotionArtifactIdentityV1],
807) -> Vec<PromotionArtifactIdentityGroupV1> {
808    let mut groups = BTreeMap::<String, PromotionArtifactIdentityGroupV1>::new();
809    for role in roles {
810        let identity_key = artifact_identity_key_for_role(role);
811        let group = groups.entry(identity_key.clone()).or_insert_with(|| {
812            PromotionArtifactIdentityGroupV1 {
813                identity_key,
814                identity_kind: role.identity_kind,
815                roles: Vec::new(),
816                source_kinds: Vec::new(),
817                source_locators: Vec::new(),
818                digest_pinned: role.digest_pinned,
819                wasm_sha256: role.wasm_sha256.clone(),
820                wasm_gz_sha256: role.wasm_gz_sha256.clone(),
821                candid_sha256: role.candid_sha256.clone(),
822                canonical_embedded_config_sha256: role.canonical_embedded_config_sha256.clone(),
823            }
824        });
825        if !group.source_kinds.contains(&role.source_kind) {
826            group.source_kinds.push(role.source_kind);
827        }
828        if let Some(locator) = &role.source_locator
829            && !group.source_locators.contains(locator)
830        {
831            group.source_locators.push(locator.clone());
832        }
833        group.roles.push(role.role.clone());
834    }
835    groups.into_values().collect()
836}
837
838const fn promotion_artifact_identity_kind(
839    promotion_level: PromotionArtifactLevelV1,
840    source: &RoleArtifactSourceV1,
841) -> PromotionArtifactIdentityKindV1 {
842    if matches!(promotion_level, PromotionArtifactLevelV1::SourceBuild) {
843        return PromotionArtifactIdentityKindV1::SourceBuild;
844    }
845    match (
846        source.expected_wasm_sha256.is_some(),
847        source.expected_wasm_gz_sha256.is_some(),
848    ) {
849        (true, true) => PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm,
850        (true, false) => PromotionArtifactIdentityKindV1::SealedWasm,
851        (false, true) => PromotionArtifactIdentityKindV1::SealedCompressedWasm,
852        (false, false) => PromotionArtifactIdentityKindV1::Deferred,
853    }
854}
855
856fn artifact_identity_key_for_role(role: &RolePromotionArtifactIdentityV1) -> String {
857    match role.identity_kind {
858        PromotionArtifactIdentityKindV1::SealedWasm
859        | PromotionArtifactIdentityKindV1::SealedCompressedWasm
860        | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
861            role.wasm_sha256.as_deref(),
862            role.wasm_gz_sha256.as_deref(),
863            role.candid_sha256.as_deref(),
864            role.canonical_embedded_config_sha256.as_deref(),
865        ),
866        PromotionArtifactIdentityKindV1::SourceBuild => format!(
867            "source_build:source_kind={:?}:locator={}:candid={}:config={}",
868            role.source_kind,
869            optional_identity_part(role.source_locator.as_deref()),
870            optional_identity_part(role.candid_sha256.as_deref()),
871            optional_identity_part(role.canonical_embedded_config_sha256.as_deref())
872        ),
873        PromotionArtifactIdentityKindV1::Deferred => format!(
874            "deferred:source_kind={:?}:locator={}",
875            role.source_kind,
876            optional_identity_part(role.source_locator.as_deref())
877        ),
878    }
879}
880
881fn artifact_identity_key_for_group(group: &PromotionArtifactIdentityGroupV1) -> String {
882    match group.identity_kind {
883        PromotionArtifactIdentityKindV1::SealedWasm
884        | PromotionArtifactIdentityKindV1::SealedCompressedWasm
885        | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
886            group.wasm_sha256.as_deref(),
887            group.wasm_gz_sha256.as_deref(),
888            group.candid_sha256.as_deref(),
889            group.canonical_embedded_config_sha256.as_deref(),
890        ),
891        PromotionArtifactIdentityKindV1::SourceBuild => format!(
892            "source_build:source_kind={}:locator={}:candid={}:config={}",
893            source_kind_identity_part(single_group_source_kind(group)),
894            optional_identity_part(single_group_source_locator(group)),
895            optional_identity_part(group.candid_sha256.as_deref()),
896            optional_identity_part(group.canonical_embedded_config_sha256.as_deref())
897        ),
898        PromotionArtifactIdentityKindV1::Deferred => format!(
899            "deferred:source_kind={}:locator={}",
900            source_kind_identity_part(single_group_source_kind(group)),
901            optional_identity_part(single_group_source_locator(group))
902        ),
903    }
904}
905
906fn source_kind_identity_part(kind: Option<RoleArtifactSourceKindV1>) -> String {
907    kind.map_or_else(|| "not-recorded".to_string(), |kind| format!("{kind:?}"))
908}
909
910fn single_group_source_kind(
911    group: &PromotionArtifactIdentityGroupV1,
912) -> Option<RoleArtifactSourceKindV1> {
913    group.source_kinds.first().copied()
914}
915
916fn single_group_source_locator(group: &PromotionArtifactIdentityGroupV1) -> Option<&str> {
917    group.source_locators.first().map(String::as_str)
918}
919
920fn sealed_identity_key(
921    wasm_sha256: Option<&str>,
922    wasm_gz_sha256: Option<&str>,
923    candid_sha256: Option<&str>,
924    canonical_embedded_config_sha256: Option<&str>,
925) -> String {
926    format!(
927        "sealed:wasm={}:wasm_gz={}:candid={}:config={}",
928        optional_identity_part(wasm_sha256),
929        optional_identity_part(wasm_gz_sha256),
930        optional_identity_part(candid_sha256),
931        optional_identity_part(canonical_embedded_config_sha256)
932    )
933}
934
935const fn optional_identity_part(value: Option<&str>) -> &str {
936    match value {
937        Some(value) => value,
938        None => "not-recorded",
939    }
940}
941
942fn validate_role_artifact_identity(
943    role: &RolePromotionArtifactIdentityV1,
944) -> Result<(), PromotionArtifactIdentityReportError> {
945    ensure_identity_report_field("role", &role.role)?;
946    ensure_identity_optional_sha256("wasm_sha256", role.wasm_sha256.as_deref())?;
947    ensure_identity_optional_sha256("wasm_gz_sha256", role.wasm_gz_sha256.as_deref())?;
948    ensure_identity_optional_sha256("candid_sha256", role.candid_sha256.as_deref())?;
949    ensure_identity_optional_sha256(
950        "canonical_embedded_config_sha256",
951        role.canonical_embedded_config_sha256.as_deref(),
952    )?;
953    Ok(())
954}
955
956fn validate_artifact_identity_groups(
957    roles: &[RolePromotionArtifactIdentityV1],
958    groups: &[PromotionArtifactIdentityGroupV1],
959) -> Result<(), PromotionArtifactIdentityReportError> {
960    let role_names = roles
961        .iter()
962        .map(|role| role.role.as_str())
963        .collect::<BTreeSet<_>>();
964    let mut grouped_roles = BTreeSet::new();
965    let mut group_keys = BTreeSet::new();
966    for group in groups {
967        validate_artifact_identity_group(group)?;
968        if !group_keys.insert(group.identity_key.as_str()) {
969            return Err(
970                PromotionArtifactIdentityReportError::DuplicateIdentityGroup {
971                    identity_key: group.identity_key.clone(),
972                },
973            );
974        }
975        if group.roles.is_empty() {
976            return Err(PromotionArtifactIdentityReportError::EmptyIdentityGroup {
977                identity_key: group.identity_key.clone(),
978            });
979        }
980        for role in &group.roles {
981            if !role_names.contains(role.as_str()) {
982                return Err(PromotionArtifactIdentityReportError::UnknownGroupedRole {
983                    role: role.clone(),
984                });
985            }
986            if !grouped_roles.insert(role.as_str()) {
987                return Err(PromotionArtifactIdentityReportError::DuplicateGroupedRole {
988                    role: role.clone(),
989                });
990            }
991            let role_identity = roles
992                .iter()
993                .find(|candidate| candidate.role == *role)
994                .expect("known role should be present");
995            let expected = artifact_identity_key_for_role(role_identity);
996            if expected != group.identity_key {
997                return Err(
998                    PromotionArtifactIdentityReportError::IdentityGroupRoleMismatch {
999                        role: role.clone(),
1000                        expected,
1001                        found: group.identity_key.clone(),
1002                    },
1003                );
1004            }
1005        }
1006    }
1007    for role in roles {
1008        if !grouped_roles.contains(role.role.as_str()) {
1009            return Err(PromotionArtifactIdentityReportError::MissingGroupedRole {
1010                role: role.role.clone(),
1011            });
1012        }
1013    }
1014    Ok(())
1015}
1016
1017fn validate_artifact_identity_group(
1018    group: &PromotionArtifactIdentityGroupV1,
1019) -> Result<(), PromotionArtifactIdentityReportError> {
1020    ensure_identity_report_field("identity_group.identity_key", &group.identity_key)?;
1021    if group.source_kinds.is_empty() {
1022        return Err(PromotionArtifactIdentityReportError::MissingRequiredField {
1023            field: "identity_group.source_kinds",
1024        });
1025    }
1026    ensure_identity_optional_sha256("identity_group.wasm_sha256", group.wasm_sha256.as_deref())?;
1027    ensure_identity_optional_sha256(
1028        "identity_group.wasm_gz_sha256",
1029        group.wasm_gz_sha256.as_deref(),
1030    )?;
1031    ensure_identity_optional_sha256(
1032        "identity_group.candid_sha256",
1033        group.candid_sha256.as_deref(),
1034    )?;
1035    ensure_identity_optional_sha256(
1036        "identity_group.canonical_embedded_config_sha256",
1037        group.canonical_embedded_config_sha256.as_deref(),
1038    )?;
1039    let expected = artifact_identity_key_for_group(group);
1040    if expected != group.identity_key {
1041        return Err(
1042            PromotionArtifactIdentityReportError::IdentityGroupKeyMismatch {
1043                expected,
1044                found: group.identity_key.clone(),
1045            },
1046        );
1047    }
1048    Ok(())
1049}
1050
1051fn validate_role_plan_transform(
1052    role: &RolePromotionPlanTransformV1,
1053    promoted_plan: &DeploymentPlanV1,
1054) -> Result<(), PromotionPlanTransformError> {
1055    ensure_transform_field("role", &role.role)?;
1056    let Some(promoted_role) = promoted_plan
1057        .role_artifacts
1058        .iter()
1059        .find(|artifact| artifact.role == role.role)
1060    else {
1061        return Err(PromotionPlanTransformError::PromotedRoleMissing {
1062            role: role.role.clone(),
1063        });
1064    };
1065    ensure_role_matches_promoted_artifact(role, promoted_role)?;
1066    ensure_role_transform_flags_are_consistent(role)?;
1067    Ok(())
1068}
1069
1070fn ensure_role_matches_promoted_artifact(
1071    role: &RolePromotionPlanTransformV1,
1072    promoted_role: &RoleArtifactV1,
1073) -> Result<(), PromotionPlanTransformError> {
1074    ensure_role_field_matches(
1075        role,
1076        "artifact_source_after",
1077        role.artifact_source_after == promoted_role.source,
1078    )?;
1079    ensure_role_field_matches(
1080        role,
1081        "wasm_sha256_after",
1082        role.wasm_sha256_after == promoted_role.wasm_sha256,
1083    )?;
1084    ensure_role_field_matches(
1085        role,
1086        "wasm_gz_sha256_after",
1087        role.wasm_gz_sha256_after == promoted_role.wasm_gz_sha256,
1088    )?;
1089    ensure_role_field_matches(
1090        role,
1091        "candid_sha256_after",
1092        role.candid_sha256_after == promoted_role.candid_sha256,
1093    )?;
1094    ensure_role_field_matches(
1095        role,
1096        "canonical_embedded_config_sha256_after",
1097        role.canonical_embedded_config_sha256_after
1098            == promoted_role.canonical_embedded_config_sha256,
1099    )
1100}
1101
1102fn ensure_role_transform_flags_are_consistent(
1103    role: &RolePromotionPlanTransformV1,
1104) -> Result<(), PromotionPlanTransformError> {
1105    ensure_role_field_matches(
1106        role,
1107        "artifact_identity_changed",
1108        role.artifact_identity_changed == role_summary_artifact_identity_changed(role),
1109    )?;
1110    ensure_role_field_matches(
1111        role,
1112        "embedded_config_changed",
1113        role.embedded_config_changed
1114            == (role.canonical_embedded_config_sha256_before
1115                != role.canonical_embedded_config_sha256_after),
1116    )?;
1117    if role.target_materialization_preserved {
1118        ensure_role_field_matches(
1119            role,
1120            "target_materialization_preserved",
1121            role.promotion_level == PromotionArtifactLevelV1::SourceBuild
1122                && !role.artifact_identity_changed
1123                && !role.embedded_config_changed,
1124        )?;
1125    }
1126    Ok(())
1127}
1128
1129fn role_summary_artifact_identity_changed(role: &RolePromotionPlanTransformV1) -> bool {
1130    role.artifact_source_before != role.artifact_source_after
1131        || role.wasm_sha256_before != role.wasm_sha256_after
1132        || role.wasm_gz_sha256_before != role.wasm_gz_sha256_after
1133        || role.candid_sha256_before != role.candid_sha256_after
1134}
1135
1136fn ensure_role_field_matches(
1137    role: &RolePromotionPlanTransformV1,
1138    field: &'static str,
1139    matches: bool,
1140) -> Result<(), PromotionPlanTransformError> {
1141    if matches {
1142        Ok(())
1143    } else {
1144        Err(PromotionPlanTransformError::RoleStateMismatch {
1145            role: role.role.clone(),
1146            field,
1147        })
1148    }
1149}
1150
1151fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
1152    ensure_readiness_field("role", &role.role)?;
1153    ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
1154    ensure_readiness_optional_sha256(
1155        "source_wasm_gz_sha256",
1156        role.source_wasm_gz_sha256.as_deref(),
1157    )?;
1158    ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
1159    ensure_readiness_optional_sha256(
1160        "target_wasm_gz_sha256",
1161        role.target_wasm_gz_sha256.as_deref(),
1162    )?;
1163    ensure_readiness_optional_sha256(
1164        "source_canonical_embedded_config_sha256",
1165        role.source_canonical_embedded_config_sha256.as_deref(),
1166    )?;
1167    ensure_readiness_optional_sha256(
1168        "target_canonical_embedded_config_sha256",
1169        role.target_canonical_embedded_config_sha256.as_deref(),
1170    )?;
1171    if role.restage_required != (role.target_store_has_artifact == Some(false)) {
1172        return Err(PromotionReadinessError::RestageStateMismatch {
1173            role: role.role.clone(),
1174        });
1175    }
1176    Ok(())
1177}
1178
1179fn role_promotion_readiness(
1180    input: &RolePromotionInputV1,
1181    target_artifact: &RoleArtifactV1,
1182) -> RolePromotionReadinessV1 {
1183    let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
1184    let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
1185    let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
1186    let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
1187    let byte_identical_wasm =
1188        matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
1189            || {
1190                matching_optional_digest(
1191                    source_wasm_gz_sha256.as_ref(),
1192                    target_wasm_gz_sha256.as_ref(),
1193                )
1194            },
1195        );
1196    let embedded_config_identical = matching_optional_digest(
1197        input
1198            .source
1199            .expected_canonical_embedded_config_sha256
1200            .as_ref(),
1201        target_artifact.canonical_embedded_config_sha256.as_ref(),
1202    );
1203
1204    RolePromotionReadinessV1 {
1205        role: input.role.clone(),
1206        promotion_level: input.promotion_level,
1207        source_kind: input.source.kind,
1208        source_locator: input.source.locator.clone(),
1209        source_wasm_sha256,
1210        source_wasm_gz_sha256,
1211        target_wasm_sha256,
1212        target_wasm_gz_sha256,
1213        source_canonical_embedded_config_sha256: input
1214            .source
1215            .expected_canonical_embedded_config_sha256
1216            .clone(),
1217        target_canonical_embedded_config_sha256: target_artifact
1218            .canonical_embedded_config_sha256
1219            .clone(),
1220        byte_identical_wasm,
1221        embedded_config_identical,
1222        target_store_has_artifact: input.target_store_has_artifact,
1223        restage_required: input.target_store_has_artifact == Some(false),
1224    }
1225}
1226
1227fn collect_role_findings(
1228    input: &RolePromotionInputV1,
1229    readiness: &RolePromotionReadinessV1,
1230    blockers: &mut Vec<SafetyFindingV1>,
1231    warnings: &mut Vec<SafetyFindingV1>,
1232) {
1233    if let Err(err) = validate_role_artifact_source(&input.source) {
1234        blockers.push(promotion_finding(
1235            "promotion_artifact_source_invalid",
1236            err.to_string(),
1237            SafetySeverityV1::HardFailure,
1238            &input.role,
1239        ));
1240    }
1241
1242    if input.role != input.source.role {
1243        blockers.push(promotion_finding(
1244            "promotion_source_role_mismatch",
1245            format!(
1246                "promotion input role {} does not match artifact source role {}",
1247                input.role, input.source.role
1248            ),
1249            SafetySeverityV1::HardFailure,
1250            &input.role,
1251        ));
1252    }
1253
1254    if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
1255        blockers.push(promotion_finding(
1256            "promotion_wasm_digest_mismatch",
1257            "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
1258            SafetySeverityV1::HardFailure,
1259            &input.role,
1260        ));
1261    }
1262
1263    if input.require_target_embedded_config
1264        && readiness
1265            .target_canonical_embedded_config_sha256
1266            .as_deref()
1267            .is_none_or(str::is_empty)
1268    {
1269        blockers.push(promotion_finding(
1270            "promotion_target_embedded_config_missing",
1271            "promotion requires target canonical embedded config but target plan has no digest",
1272            SafetySeverityV1::HardFailure,
1273            &input.role,
1274        ));
1275    }
1276
1277    if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
1278        && readiness.embedded_config_identical != Some(true)
1279    {
1280        blockers.push(promotion_finding(
1281            "promotion_sealed_wasm_embedded_config_mismatch",
1282            "sealed wasm promotion requires embedded config identity to be acceptable for the target",
1283            SafetySeverityV1::HardFailure,
1284            &input.role,
1285        ));
1286    }
1287
1288    if readiness.restage_required {
1289        warnings.push(promotion_finding(
1290            "promotion_target_store_restage_required",
1291            "target artifact store does not already contain the artifact; restaging is required",
1292            SafetySeverityV1::Warning,
1293            &input.role,
1294        ));
1295    }
1296}
1297
1298fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
1299    match (left.map(String::as_str), right.map(String::as_str)) {
1300        (Some(left), Some(right)) => Some(left == right),
1301        _ => None,
1302    }
1303}
1304
1305fn promotion_finding(
1306    code: impl Into<String>,
1307    message: impl Into<String>,
1308    severity: SafetySeverityV1,
1309    role: &str,
1310) -> SafetyFindingV1 {
1311    SafetyFindingV1 {
1312        code: code.into(),
1313        message: message.into(),
1314        severity,
1315        subject: Some(role.to_string()),
1316    }
1317}
1318
1319fn ensure_locator_requirement(
1320    source: &RoleArtifactSourceV1,
1321) -> Result<(), PromotionArtifactSourceError> {
1322    match source.kind {
1323        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
1324        _ => ensure_option_field("locator", source.locator.as_deref()),
1325    }
1326}
1327
1328const fn ensure_previous_receipt_requirement(
1329    source: &RoleArtifactSourceV1,
1330) -> Result<(), PromotionArtifactSourceError> {
1331    match (source.kind, source.previous_receipt_kind) {
1332        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
1333        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
1334            Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
1335        }
1336        (_, Some(_)) => {
1337            Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
1338        }
1339        (_, None) => Ok(()),
1340    }
1341}
1342
1343const fn ensure_digest_requirement(
1344    source: &RoleArtifactSourceV1,
1345) -> Result<(), PromotionArtifactSourceError> {
1346    let has_digest =
1347        source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
1348    match source.kind {
1349        RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
1350            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
1351        }
1352        RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
1353            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
1354        }
1355        RoleArtifactSourceKindV1::PublishedPackage
1356        | RoleArtifactSourceKindV1::PreviousReceiptArtifact
1357            if !has_digest =>
1358        {
1359            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
1360        }
1361        _ => Ok(()),
1362    }
1363}
1364
1365fn ensure_option_field(
1366    field: &'static str,
1367    value: Option<&str>,
1368) -> Result<(), PromotionArtifactSourceError> {
1369    match value {
1370        Some(value) => ensure_field(field, value),
1371        None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
1372    }
1373}
1374
1375fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
1376    if value.trim().is_empty() {
1377        return Err(PromotionArtifactSourceError::MissingRequiredField { field });
1378    }
1379    Ok(())
1380}
1381
1382fn ensure_optional_sha256(
1383    field: &'static str,
1384    value: Option<&str>,
1385) -> Result<(), PromotionArtifactSourceError> {
1386    let Some(value) = value else {
1387        return Ok(());
1388    };
1389    if is_lower_hex_sha256(value) {
1390        Ok(())
1391    } else {
1392        Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
1393    }
1394}
1395
1396fn is_lower_hex_sha256(value: &str) -> bool {
1397    value.len() == 64
1398        && value
1399            .bytes()
1400            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1401}
1402
1403const fn ensure_readiness_status_matches_blockers(
1404    readiness: &PromotionReadinessV1,
1405) -> Result<(), PromotionReadinessError> {
1406    match (readiness.status, readiness.blockers.is_empty()) {
1407        (PromotionReadinessStatusV1::Ready, false)
1408        | (PromotionReadinessStatusV1::Blocked, true) => {
1409            Err(PromotionReadinessError::StatusBlockerMismatch {
1410                status: readiness.status,
1411                blocker_count: readiness.blockers.len(),
1412            })
1413        }
1414        _ => Ok(()),
1415    }
1416}
1417
1418fn ensure_unique_readiness_roles(
1419    roles: &[RolePromotionReadinessV1],
1420) -> Result<(), PromotionReadinessError> {
1421    let mut seen = std::collections::BTreeSet::new();
1422    for role in roles {
1423        if !seen.insert(role.role.as_str()) {
1424            return Err(PromotionReadinessError::DuplicateRole {
1425                role: role.role.clone(),
1426            });
1427        }
1428    }
1429    Ok(())
1430}
1431
1432fn ensure_unique_transform_roles(
1433    roles: &[RolePromotionPlanTransformV1],
1434) -> Result<(), PromotionPlanTransformError> {
1435    let mut seen = std::collections::BTreeSet::new();
1436    for role in roles {
1437        if !seen.insert(role.role.as_str()) {
1438            return Err(PromotionPlanTransformError::DuplicateRole {
1439                role: role.role.clone(),
1440            });
1441        }
1442    }
1443    Ok(())
1444}
1445
1446const fn ensure_identity_report_status_matches_blockers(
1447    report: &PromotionArtifactIdentityReportV1,
1448) -> Result<(), PromotionArtifactIdentityReportError> {
1449    match (report.status, report.blockers.is_empty()) {
1450        (PromotionReadinessStatusV1::Ready, false)
1451        | (PromotionReadinessStatusV1::Blocked, true) => Err(
1452            PromotionArtifactIdentityReportError::StatusBlockerMismatch {
1453                status: report.status,
1454                blocker_count: report.blockers.len(),
1455            },
1456        ),
1457        _ => Ok(()),
1458    }
1459}
1460
1461fn ensure_unique_artifact_identity_roles(
1462    roles: &[RolePromotionArtifactIdentityV1],
1463) -> Result<(), PromotionArtifactIdentityReportError> {
1464    let mut seen = std::collections::BTreeSet::new();
1465    for role in roles {
1466        if !seen.insert(role.role.as_str()) {
1467            return Err(PromotionArtifactIdentityReportError::DuplicateRole {
1468                role: role.role.clone(),
1469            });
1470        }
1471    }
1472    Ok(())
1473}
1474
1475fn validate_identity_report_blockers(
1476    blockers: &[SafetyFindingV1],
1477) -> Result<(), PromotionArtifactIdentityReportError> {
1478    for blocker in blockers {
1479        ensure_identity_report_field("blocker.code", &blocker.code)?;
1480        ensure_identity_report_field("blocker.message", &blocker.message)?;
1481        if blocker.severity != SafetySeverityV1::HardFailure {
1482            return Err(
1483                PromotionArtifactIdentityReportError::BlockerSeverityMismatch {
1484                    severity: blocker.severity,
1485                },
1486            );
1487        }
1488    }
1489    Ok(())
1490}
1491
1492fn validate_readiness_findings(
1493    field: &'static str,
1494    findings: &[SafetyFindingV1],
1495    expected_severity: SafetySeverityV1,
1496) -> Result<(), PromotionReadinessError> {
1497    for finding in findings {
1498        ensure_readiness_field("finding.code", &finding.code)?;
1499        ensure_readiness_field("finding.message", &finding.message)?;
1500        if finding.severity != expected_severity {
1501            return Err(PromotionReadinessError::FindingSeverityMismatch {
1502                field,
1503                severity: finding.severity,
1504            });
1505        }
1506    }
1507    Ok(())
1508}
1509
1510fn ensure_identity_report_field(
1511    field: &'static str,
1512    value: &str,
1513) -> Result<(), PromotionArtifactIdentityReportError> {
1514    if value.trim().is_empty() {
1515        return Err(PromotionArtifactIdentityReportError::MissingRequiredField { field });
1516    }
1517    Ok(())
1518}
1519
1520fn ensure_identity_optional_sha256(
1521    field: &'static str,
1522    value: Option<&str>,
1523) -> Result<(), PromotionArtifactIdentityReportError> {
1524    let Some(value) = value else {
1525        return Ok(());
1526    };
1527    if is_lower_hex_sha256(value) {
1528        Ok(())
1529    } else {
1530        Err(PromotionArtifactIdentityReportError::InvalidSha256Digest { field })
1531    }
1532}
1533
1534fn ensure_materialization_field(
1535    field: &'static str,
1536    value: &str,
1537) -> Result<(), PromotionMaterializationIdentityError> {
1538    if value.trim().is_empty() {
1539        return Err(PromotionMaterializationIdentityError::MissingRequiredField { field });
1540    }
1541    Ok(())
1542}
1543
1544fn ensure_materialization_sha256(
1545    field: &'static str,
1546    value: &str,
1547) -> Result<(), PromotionMaterializationIdentityError> {
1548    ensure_materialization_field(field, value)?;
1549    if is_lower_hex_sha256(value) {
1550        Ok(())
1551    } else {
1552        Err(PromotionMaterializationIdentityError::InvalidSha256Digest { field })
1553    }
1554}
1555
1556const fn ensure_materialization_link(
1557    field: &'static str,
1558    valid: bool,
1559) -> Result<(), PromotionMaterializationIdentityError> {
1560    if valid {
1561        Ok(())
1562    } else {
1563        Err(PromotionMaterializationIdentityError::LinkageMismatch { field })
1564    }
1565}
1566
1567fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
1568    if value.trim().is_empty() {
1569        return Err(PromotionReadinessError::MissingRequiredField { field });
1570    }
1571    Ok(())
1572}
1573
1574fn ensure_readiness_optional_sha256(
1575    field: &'static str,
1576    value: Option<&str>,
1577) -> Result<(), PromotionReadinessError> {
1578    let Some(value) = value else {
1579        return Ok(());
1580    };
1581    if is_lower_hex_sha256(value) {
1582        Ok(())
1583    } else {
1584        Err(PromotionReadinessError::InvalidSha256Digest { field })
1585    }
1586}
1587
1588fn ensure_transform_field(
1589    field: &'static str,
1590    value: &str,
1591) -> Result<(), PromotionPlanTransformError> {
1592    if value.trim().is_empty() {
1593        return Err(PromotionPlanTransformError::MissingRequiredField { field });
1594    }
1595    Ok(())
1596}
1597
1598fn ensure_evidence_field(
1599    field: &'static str,
1600    value: &str,
1601) -> Result<(), PromotionPlanTransformEvidenceError> {
1602    if value.trim().is_empty() {
1603        return Err(PromotionPlanTransformEvidenceError::MissingRequiredField { field });
1604    }
1605    Ok(())
1606}