Skip to main content

canic_host/deployment_truth/
promotion.rs

1use super::{
2    ArtifactSourceV1, DEPLOYMENT_TRUTH_SCHEMA_VERSION, DeploymentPlanV1, PromotionArtifactLevelV1,
3    PromotionPlanTransformEvidenceV1, PromotionPlanTransformV1, PromotionReadinessStatusV1,
4    PromotionReadinessV1, RoleArtifactSourceKindV1, RoleArtifactSourceV1, RoleArtifactV1,
5    RolePromotionInputV1, RolePromotionPlanTransformV1, RolePromotionReadinessV1, SafetyFindingV1,
6    SafetySeverityV1,
7};
8use thiserror::Error as ThisError;
9
10///
11/// PromotionArtifactSourceError
12///
13#[derive(Debug, ThisError)]
14pub enum PromotionArtifactSourceError {
15    #[error("promotion artifact source is missing required field: {field}")]
16    MissingRequiredField { field: &'static str },
17    #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
18    InvalidSha256Digest { field: &'static str },
19    #[error("promotion artifact source kind {kind:?} requires a digest pin")]
20    MissingDigestPin { kind: RoleArtifactSourceKindV1 },
21    #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
22    UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
23    #[error(
24        "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
25    )]
26    MissingPreviousReceiptKind,
27}
28
29///
30/// PromotionReadinessError
31///
32#[derive(Debug, ThisError)]
33pub enum PromotionReadinessError {
34    #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
35    SchemaVersionMismatch { expected: u32, found: u32 },
36    #[error("promotion readiness is missing required field: {field}")]
37    MissingRequiredField { field: &'static str },
38    #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
39    StatusBlockerMismatch {
40        status: PromotionReadinessStatusV1,
41        blocker_count: usize,
42    },
43    #[error("promotion readiness contains duplicate role: {role}")]
44    DuplicateRole { role: String },
45    #[error("promotion readiness role {role} has inconsistent restage state")]
46    RestageStateMismatch { role: String },
47    #[error("promotion readiness finding in {field} has severity {severity:?}")]
48    FindingSeverityMismatch {
49        field: &'static str,
50        severity: SafetySeverityV1,
51    },
52    #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
53    InvalidSha256Digest { field: &'static str },
54}
55
56///
57/// PromotionPlanTransformError
58///
59#[derive(Debug, ThisError)]
60pub enum PromotionPlanTransformError {
61    #[error("promotion plan transform schema mismatch: expected {expected}, found {found}")]
62    SchemaVersionMismatch { expected: u32, found: u32 },
63    #[error("promotion plan transform is missing required field: {field}")]
64    MissingRequiredField { field: &'static str },
65    #[error("promotion readiness validation failed: {0}")]
66    Readiness(#[from] PromotionReadinessError),
67    #[error("promotion readiness is blocked with {blocker_count} blocker(s)")]
68    ReadinessBlocked { blocker_count: usize },
69    #[error("promotion target plan is missing role: {role}")]
70    TargetRoleMissing { role: String },
71    #[error("promotion transform contains duplicate role: {role}")]
72    DuplicateRole { role: String },
73    #[error("promotion transform promoted plan id mismatch: expected {expected}, found {found}")]
74    PromotedPlanIdMismatch { expected: String, found: String },
75    #[error("promotion transform role {role} is missing from promoted plan")]
76    PromotedRoleMissing { role: String },
77    #[error("promotion transform role {role} has inconsistent field {field}")]
78    RoleStateMismatch { role: String, field: &'static str },
79}
80
81///
82/// PromotionPlanTransformEvidenceError
83///
84#[derive(Debug, ThisError)]
85pub enum PromotionPlanTransformEvidenceError {
86    #[error(
87        "promotion plan transform evidence schema mismatch: expected {expected}, found {found}"
88    )]
89    SchemaVersionMismatch { expected: u32, found: u32 },
90    #[error("promotion plan transform evidence is missing required field: {field}")]
91    MissingRequiredField { field: &'static str },
92    #[error("promotion plan transform evidence has invalid transform: {0}")]
93    Transform(#[from] PromotionPlanTransformError),
94}
95
96///
97/// PromotionReadinessRequest
98///
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct PromotionReadinessRequest {
101    pub readiness_id: String,
102    pub target_plan: DeploymentPlanV1,
103    pub inputs: Vec<RolePromotionInputV1>,
104}
105
106///
107/// PromotionPlanTransformRequest
108///
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct PromotionPlanTransformRequest {
111    pub promoted_plan_id: String,
112    pub target_plan: DeploymentPlanV1,
113    pub inputs: Vec<RolePromotionInputV1>,
114}
115
116///
117/// PromotionPlanTransformEvidenceRequest
118///
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct PromotionPlanTransformEvidenceRequest {
121    pub evidence_id: String,
122    pub generated_at: String,
123    pub transform: PromotionPlanTransformV1,
124}
125
126pub fn promoted_deployment_plan_from_inputs(
127    request: &PromotionPlanTransformRequest,
128) -> Result<DeploymentPlanV1, PromotionPlanTransformError> {
129    Ok(promoted_deployment_plan_transform_from_inputs(request)?.promoted_plan)
130}
131
132pub fn promoted_deployment_plan_transform_from_inputs(
133    request: &PromotionPlanTransformRequest,
134) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
135    ensure_transform_field("promoted_plan_id", &request.promoted_plan_id)?;
136    let readiness = promotion_readiness_from_inputs(
137        &request.promoted_plan_id,
138        &request.target_plan,
139        &request.inputs,
140    );
141    validate_promotion_readiness(&readiness)?;
142    if readiness.status == PromotionReadinessStatusV1::Blocked {
143        return Err(PromotionPlanTransformError::ReadinessBlocked {
144            blocker_count: readiness.blockers.len(),
145        });
146    }
147
148    let mut promoted_plan = request.target_plan.clone();
149    promoted_plan.plan_id.clone_from(&request.promoted_plan_id);
150    for input in &request.inputs {
151        let Some(role_artifact) = promoted_plan
152            .role_artifacts
153            .iter_mut()
154            .find(|artifact| artifact.role == input.role)
155        else {
156            return Err(PromotionPlanTransformError::TargetRoleMissing {
157                role: input.role.clone(),
158            });
159        };
160        apply_promotion_input_to_role_artifact(role_artifact, input);
161    }
162    let transform =
163        promotion_plan_transform_from_parts(&request.target_plan, promoted_plan, &request.inputs);
164    validate_promotion_plan_transform(&transform)?;
165    Ok(transform)
166}
167
168pub fn check_promotion_readiness(
169    request: &PromotionReadinessRequest,
170) -> Result<PromotionReadinessV1, PromotionReadinessError> {
171    ensure_readiness_field("readiness_id", &request.readiness_id)?;
172    let readiness = promotion_readiness_from_inputs(
173        &request.readiness_id,
174        &request.target_plan,
175        &request.inputs,
176    );
177    validate_promotion_readiness(&readiness)?;
178    Ok(readiness)
179}
180
181pub fn promotion_plan_transform_evidence(
182    request: PromotionPlanTransformEvidenceRequest,
183) -> Result<PromotionPlanTransformEvidenceV1, PromotionPlanTransformEvidenceError> {
184    ensure_evidence_field("evidence_id", &request.evidence_id)?;
185    ensure_evidence_field("generated_at", &request.generated_at)?;
186    validate_promotion_plan_transform(&request.transform)?;
187    let evidence = PromotionPlanTransformEvidenceV1 {
188        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
189        evidence_id: request.evidence_id,
190        generated_at: request.generated_at,
191        transform: request.transform,
192    };
193    validate_promotion_plan_transform_evidence(&evidence)?;
194    Ok(evidence)
195}
196
197pub fn validate_promotion_plan_transform_evidence(
198    evidence: &PromotionPlanTransformEvidenceV1,
199) -> Result<(), PromotionPlanTransformEvidenceError> {
200    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
201        return Err(PromotionPlanTransformEvidenceError::SchemaVersionMismatch {
202            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
203            found: evidence.schema_version,
204        });
205    }
206    ensure_evidence_field("evidence_id", &evidence.evidence_id)?;
207    ensure_evidence_field("generated_at", &evidence.generated_at)?;
208    validate_promotion_plan_transform(&evidence.transform)?;
209    Ok(())
210}
211
212pub fn validate_promotion_plan_transform(
213    transform: &PromotionPlanTransformV1,
214) -> Result<(), PromotionPlanTransformError> {
215    if transform.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
216        return Err(PromotionPlanTransformError::SchemaVersionMismatch {
217            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
218            found: transform.schema_version,
219        });
220    }
221    ensure_transform_field("transform_id", &transform.transform_id)?;
222    ensure_transform_field("target_plan_id", &transform.target_plan_id)?;
223    ensure_transform_field("promoted_plan_id", &transform.promoted_plan_id)?;
224    ensure_transform_field("promoted_plan.plan_id", &transform.promoted_plan.plan_id)?;
225    if transform.promoted_plan.plan_id != transform.promoted_plan_id {
226        return Err(PromotionPlanTransformError::PromotedPlanIdMismatch {
227            expected: transform.promoted_plan_id.clone(),
228            found: transform.promoted_plan.plan_id.clone(),
229        });
230    }
231    ensure_unique_transform_roles(&transform.roles)?;
232    for role in &transform.roles {
233        validate_role_plan_transform(role, &transform.promoted_plan)?;
234    }
235    Ok(())
236}
237
238#[must_use]
239pub fn promotion_readiness_from_inputs(
240    readiness_id: impl Into<String>,
241    target_plan: &DeploymentPlanV1,
242    inputs: &[RolePromotionInputV1],
243) -> PromotionReadinessV1 {
244    let mut roles = Vec::with_capacity(inputs.len());
245    let mut blockers = Vec::new();
246    let mut warnings = Vec::new();
247
248    for input in inputs {
249        let target_artifact = target_plan
250            .role_artifacts
251            .iter()
252            .find(|artifact| artifact.role == input.role);
253        let Some(target_artifact) = target_artifact else {
254            blockers.push(promotion_finding(
255                "promotion_target_role_missing",
256                format!("target plan does not contain role {}", input.role),
257                SafetySeverityV1::HardFailure,
258                &input.role,
259            ));
260            continue;
261        };
262
263        let role_readiness = role_promotion_readiness(input, target_artifact);
264        collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
265        roles.push(role_readiness);
266    }
267
268    let status = if blockers.is_empty() {
269        PromotionReadinessStatusV1::Ready
270    } else {
271        PromotionReadinessStatusV1::Blocked
272    };
273
274    PromotionReadinessV1 {
275        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
276        readiness_id: readiness_id.into(),
277        target_plan_id: target_plan.plan_id.clone(),
278        status,
279        roles,
280        blockers,
281        warnings,
282    }
283}
284
285pub fn validate_promotion_readiness(
286    readiness: &PromotionReadinessV1,
287) -> Result<(), PromotionReadinessError> {
288    if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
289        return Err(PromotionReadinessError::SchemaVersionMismatch {
290            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
291            found: readiness.schema_version,
292        });
293    }
294    ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
295    ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
296    ensure_readiness_status_matches_blockers(readiness)?;
297    ensure_unique_readiness_roles(&readiness.roles)?;
298    for role in &readiness.roles {
299        validate_role_readiness(role)?;
300    }
301    validate_readiness_findings(
302        "blockers",
303        &readiness.blockers,
304        SafetySeverityV1::HardFailure,
305    )?;
306    validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
307    Ok(())
308}
309
310pub fn validate_role_artifact_source(
311    source: &RoleArtifactSourceV1,
312) -> Result<(), PromotionArtifactSourceError> {
313    ensure_field("role", &source.role)?;
314    ensure_locator_requirement(source)?;
315    ensure_previous_receipt_requirement(source)?;
316    ensure_digest_requirement(source)?;
317    ensure_optional_sha256(
318        "expected_wasm_sha256",
319        source.expected_wasm_sha256.as_deref(),
320    )?;
321    ensure_optional_sha256(
322        "expected_wasm_gz_sha256",
323        source.expected_wasm_gz_sha256.as_deref(),
324    )?;
325    ensure_optional_sha256(
326        "expected_candid_sha256",
327        source.expected_candid_sha256.as_deref(),
328    )?;
329    ensure_optional_sha256(
330        "expected_canonical_embedded_config_sha256",
331        source.expected_canonical_embedded_config_sha256.as_deref(),
332    )?;
333    Ok(())
334}
335
336fn apply_promotion_input_to_role_artifact(
337    role_artifact: &mut RoleArtifactV1,
338    input: &RolePromotionInputV1,
339) {
340    match input.promotion_level {
341        PromotionArtifactLevelV1::SealedWasm => {
342            role_artifact.source = artifact_source_for_promotion_source(input.source.kind);
343            apply_promotion_source_locator(role_artifact, &input.source);
344            role_artifact
345                .wasm_sha256
346                .clone_from(&input.source.expected_wasm_sha256);
347            role_artifact
348                .wasm_gz_sha256
349                .clone_from(&input.source.expected_wasm_gz_sha256);
350            role_artifact
351                .candid_sha256
352                .clone_from(&input.source.expected_candid_sha256);
353            role_artifact
354                .canonical_embedded_config_sha256
355                .clone_from(&input.source.expected_canonical_embedded_config_sha256);
356        }
357        PromotionArtifactLevelV1::SourceBuild => {}
358    }
359}
360
361const fn artifact_source_for_promotion_source(kind: RoleArtifactSourceKindV1) -> ArtifactSourceV1 {
362    match kind {
363        RoleArtifactSourceKindV1::WorkspacePackage => ArtifactSourceV1::LocalBuild,
364        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => ArtifactSourceV1::WasmStore,
365        RoleArtifactSourceKindV1::PublishedPackage
366        | RoleArtifactSourceKindV1::LocalWasm
367        | RoleArtifactSourceKindV1::LocalWasmGz
368        | RoleArtifactSourceKindV1::PreviousReceiptArtifact => ArtifactSourceV1::External,
369    }
370}
371
372fn apply_promotion_source_locator(
373    role_artifact: &mut RoleArtifactV1,
374    source: &RoleArtifactSourceV1,
375) {
376    match source.kind {
377        RoleArtifactSourceKindV1::LocalWasm => {
378            role_artifact.wasm_path.clone_from(&source.locator);
379        }
380        RoleArtifactSourceKindV1::LocalWasmGz => {
381            role_artifact.wasm_gz_path.clone_from(&source.locator);
382        }
383        _ => {}
384    }
385}
386
387fn promotion_plan_transform_from_parts(
388    target_plan: &DeploymentPlanV1,
389    promoted_plan: DeploymentPlanV1,
390    inputs: &[RolePromotionInputV1],
391) -> PromotionPlanTransformV1 {
392    let roles = inputs
393        .iter()
394        .filter_map(|input| {
395            let before = target_plan
396                .role_artifacts
397                .iter()
398                .find(|artifact| artifact.role == input.role)?;
399            let after = promoted_plan
400                .role_artifacts
401                .iter()
402                .find(|artifact| artifact.role == input.role)?;
403            Some(role_plan_transform(input, before, after))
404        })
405        .collect();
406
407    PromotionPlanTransformV1 {
408        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
409        transform_id: format!("promotion-transform:{}", promoted_plan.plan_id),
410        target_plan_id: target_plan.plan_id.clone(),
411        promoted_plan_id: promoted_plan.plan_id.clone(),
412        promoted_plan,
413        roles,
414    }
415}
416
417fn role_plan_transform(
418    input: &RolePromotionInputV1,
419    before: &RoleArtifactV1,
420    after: &RoleArtifactV1,
421) -> RolePromotionPlanTransformV1 {
422    RolePromotionPlanTransformV1 {
423        role: input.role.clone(),
424        promotion_level: input.promotion_level,
425        source_kind: input.source.kind,
426        source_locator: input.source.locator.clone(),
427        artifact_source_before: before.source,
428        artifact_source_after: after.source,
429        wasm_sha256_before: before.wasm_sha256.clone(),
430        wasm_sha256_after: after.wasm_sha256.clone(),
431        wasm_gz_sha256_before: before.wasm_gz_sha256.clone(),
432        wasm_gz_sha256_after: after.wasm_gz_sha256.clone(),
433        candid_sha256_before: before.candid_sha256.clone(),
434        candid_sha256_after: after.candid_sha256.clone(),
435        canonical_embedded_config_sha256_before: before.canonical_embedded_config_sha256.clone(),
436        canonical_embedded_config_sha256_after: after.canonical_embedded_config_sha256.clone(),
437        artifact_identity_changed: artifact_identity_changed(before, after),
438        embedded_config_changed: before.canonical_embedded_config_sha256
439            != after.canonical_embedded_config_sha256,
440        target_materialization_preserved: input.promotion_level
441            == PromotionArtifactLevelV1::SourceBuild
442            && role_materialization_identity_matches(before, after),
443    }
444}
445
446fn artifact_identity_changed(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
447    before.source != after.source
448        || before.wasm_path != after.wasm_path
449        || before.wasm_gz_path != after.wasm_gz_path
450        || before.wasm_sha256 != after.wasm_sha256
451        || before.wasm_gz_sha256 != after.wasm_gz_sha256
452        || before.candid_path != after.candid_path
453        || before.candid_sha256 != after.candid_sha256
454}
455
456fn role_materialization_identity_matches(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
457    before.source == after.source
458        && before.wasm_path == after.wasm_path
459        && before.wasm_gz_path == after.wasm_gz_path
460        && before.wasm_sha256 == after.wasm_sha256
461        && before.wasm_gz_sha256 == after.wasm_gz_sha256
462        && before.candid_path == after.candid_path
463        && before.candid_sha256 == after.candid_sha256
464        && before.canonical_embedded_config_sha256 == after.canonical_embedded_config_sha256
465}
466
467fn validate_role_plan_transform(
468    role: &RolePromotionPlanTransformV1,
469    promoted_plan: &DeploymentPlanV1,
470) -> Result<(), PromotionPlanTransformError> {
471    ensure_transform_field("role", &role.role)?;
472    let Some(promoted_role) = promoted_plan
473        .role_artifacts
474        .iter()
475        .find(|artifact| artifact.role == role.role)
476    else {
477        return Err(PromotionPlanTransformError::PromotedRoleMissing {
478            role: role.role.clone(),
479        });
480    };
481    ensure_role_matches_promoted_artifact(role, promoted_role)?;
482    ensure_role_transform_flags_are_consistent(role)?;
483    Ok(())
484}
485
486fn ensure_role_matches_promoted_artifact(
487    role: &RolePromotionPlanTransformV1,
488    promoted_role: &RoleArtifactV1,
489) -> Result<(), PromotionPlanTransformError> {
490    ensure_role_field_matches(
491        role,
492        "artifact_source_after",
493        role.artifact_source_after == promoted_role.source,
494    )?;
495    ensure_role_field_matches(
496        role,
497        "wasm_sha256_after",
498        role.wasm_sha256_after == promoted_role.wasm_sha256,
499    )?;
500    ensure_role_field_matches(
501        role,
502        "wasm_gz_sha256_after",
503        role.wasm_gz_sha256_after == promoted_role.wasm_gz_sha256,
504    )?;
505    ensure_role_field_matches(
506        role,
507        "candid_sha256_after",
508        role.candid_sha256_after == promoted_role.candid_sha256,
509    )?;
510    ensure_role_field_matches(
511        role,
512        "canonical_embedded_config_sha256_after",
513        role.canonical_embedded_config_sha256_after
514            == promoted_role.canonical_embedded_config_sha256,
515    )
516}
517
518fn ensure_role_transform_flags_are_consistent(
519    role: &RolePromotionPlanTransformV1,
520) -> Result<(), PromotionPlanTransformError> {
521    ensure_role_field_matches(
522        role,
523        "artifact_identity_changed",
524        role.artifact_identity_changed == role_summary_artifact_identity_changed(role),
525    )?;
526    ensure_role_field_matches(
527        role,
528        "embedded_config_changed",
529        role.embedded_config_changed
530            == (role.canonical_embedded_config_sha256_before
531                != role.canonical_embedded_config_sha256_after),
532    )?;
533    if role.target_materialization_preserved {
534        ensure_role_field_matches(
535            role,
536            "target_materialization_preserved",
537            role.promotion_level == PromotionArtifactLevelV1::SourceBuild
538                && !role.artifact_identity_changed
539                && !role.embedded_config_changed,
540        )?;
541    }
542    Ok(())
543}
544
545fn role_summary_artifact_identity_changed(role: &RolePromotionPlanTransformV1) -> bool {
546    role.artifact_source_before != role.artifact_source_after
547        || role.wasm_sha256_before != role.wasm_sha256_after
548        || role.wasm_gz_sha256_before != role.wasm_gz_sha256_after
549        || role.candid_sha256_before != role.candid_sha256_after
550}
551
552fn ensure_role_field_matches(
553    role: &RolePromotionPlanTransformV1,
554    field: &'static str,
555    matches: bool,
556) -> Result<(), PromotionPlanTransformError> {
557    if matches {
558        Ok(())
559    } else {
560        Err(PromotionPlanTransformError::RoleStateMismatch {
561            role: role.role.clone(),
562            field,
563        })
564    }
565}
566
567fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
568    ensure_readiness_field("role", &role.role)?;
569    ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
570    ensure_readiness_optional_sha256(
571        "source_wasm_gz_sha256",
572        role.source_wasm_gz_sha256.as_deref(),
573    )?;
574    ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
575    ensure_readiness_optional_sha256(
576        "target_wasm_gz_sha256",
577        role.target_wasm_gz_sha256.as_deref(),
578    )?;
579    ensure_readiness_optional_sha256(
580        "source_canonical_embedded_config_sha256",
581        role.source_canonical_embedded_config_sha256.as_deref(),
582    )?;
583    ensure_readiness_optional_sha256(
584        "target_canonical_embedded_config_sha256",
585        role.target_canonical_embedded_config_sha256.as_deref(),
586    )?;
587    if role.restage_required != (role.target_store_has_artifact == Some(false)) {
588        return Err(PromotionReadinessError::RestageStateMismatch {
589            role: role.role.clone(),
590        });
591    }
592    Ok(())
593}
594
595fn role_promotion_readiness(
596    input: &RolePromotionInputV1,
597    target_artifact: &RoleArtifactV1,
598) -> RolePromotionReadinessV1 {
599    let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
600    let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
601    let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
602    let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
603    let byte_identical_wasm =
604        matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
605            || {
606                matching_optional_digest(
607                    source_wasm_gz_sha256.as_ref(),
608                    target_wasm_gz_sha256.as_ref(),
609                )
610            },
611        );
612    let embedded_config_identical = matching_optional_digest(
613        input
614            .source
615            .expected_canonical_embedded_config_sha256
616            .as_ref(),
617        target_artifact.canonical_embedded_config_sha256.as_ref(),
618    );
619
620    RolePromotionReadinessV1 {
621        role: input.role.clone(),
622        promotion_level: input.promotion_level,
623        source_kind: input.source.kind,
624        source_locator: input.source.locator.clone(),
625        source_wasm_sha256,
626        source_wasm_gz_sha256,
627        target_wasm_sha256,
628        target_wasm_gz_sha256,
629        source_canonical_embedded_config_sha256: input
630            .source
631            .expected_canonical_embedded_config_sha256
632            .clone(),
633        target_canonical_embedded_config_sha256: target_artifact
634            .canonical_embedded_config_sha256
635            .clone(),
636        byte_identical_wasm,
637        embedded_config_identical,
638        target_store_has_artifact: input.target_store_has_artifact,
639        restage_required: input.target_store_has_artifact == Some(false),
640    }
641}
642
643fn collect_role_findings(
644    input: &RolePromotionInputV1,
645    readiness: &RolePromotionReadinessV1,
646    blockers: &mut Vec<SafetyFindingV1>,
647    warnings: &mut Vec<SafetyFindingV1>,
648) {
649    if let Err(err) = validate_role_artifact_source(&input.source) {
650        blockers.push(promotion_finding(
651            "promotion_artifact_source_invalid",
652            err.to_string(),
653            SafetySeverityV1::HardFailure,
654            &input.role,
655        ));
656    }
657
658    if input.role != input.source.role {
659        blockers.push(promotion_finding(
660            "promotion_source_role_mismatch",
661            format!(
662                "promotion input role {} does not match artifact source role {}",
663                input.role, input.source.role
664            ),
665            SafetySeverityV1::HardFailure,
666            &input.role,
667        ));
668    }
669
670    if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
671        blockers.push(promotion_finding(
672            "promotion_wasm_digest_mismatch",
673            "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
674            SafetySeverityV1::HardFailure,
675            &input.role,
676        ));
677    }
678
679    if input.require_target_embedded_config
680        && readiness
681            .target_canonical_embedded_config_sha256
682            .as_deref()
683            .is_none_or(str::is_empty)
684    {
685        blockers.push(promotion_finding(
686            "promotion_target_embedded_config_missing",
687            "promotion requires target canonical embedded config but target plan has no digest",
688            SafetySeverityV1::HardFailure,
689            &input.role,
690        ));
691    }
692
693    if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
694        && readiness.embedded_config_identical != Some(true)
695    {
696        blockers.push(promotion_finding(
697            "promotion_sealed_wasm_embedded_config_mismatch",
698            "sealed wasm promotion requires embedded config identity to be acceptable for the target",
699            SafetySeverityV1::HardFailure,
700            &input.role,
701        ));
702    }
703
704    if readiness.restage_required {
705        warnings.push(promotion_finding(
706            "promotion_target_store_restage_required",
707            "target artifact store does not already contain the artifact; restaging is required",
708            SafetySeverityV1::Warning,
709            &input.role,
710        ));
711    }
712}
713
714fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
715    match (left.map(String::as_str), right.map(String::as_str)) {
716        (Some(left), Some(right)) => Some(left == right),
717        _ => None,
718    }
719}
720
721fn promotion_finding(
722    code: impl Into<String>,
723    message: impl Into<String>,
724    severity: SafetySeverityV1,
725    role: &str,
726) -> SafetyFindingV1 {
727    SafetyFindingV1 {
728        code: code.into(),
729        message: message.into(),
730        severity,
731        subject: Some(role.to_string()),
732    }
733}
734
735fn ensure_locator_requirement(
736    source: &RoleArtifactSourceV1,
737) -> Result<(), PromotionArtifactSourceError> {
738    match source.kind {
739        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
740        _ => ensure_option_field("locator", source.locator.as_deref()),
741    }
742}
743
744const fn ensure_previous_receipt_requirement(
745    source: &RoleArtifactSourceV1,
746) -> Result<(), PromotionArtifactSourceError> {
747    match (source.kind, source.previous_receipt_kind) {
748        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
749        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
750            Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
751        }
752        (_, Some(_)) => {
753            Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
754        }
755        (_, None) => Ok(()),
756    }
757}
758
759const fn ensure_digest_requirement(
760    source: &RoleArtifactSourceV1,
761) -> Result<(), PromotionArtifactSourceError> {
762    let has_digest =
763        source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
764    match source.kind {
765        RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
766            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
767        }
768        RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
769            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
770        }
771        RoleArtifactSourceKindV1::PublishedPackage
772        | RoleArtifactSourceKindV1::PreviousReceiptArtifact
773            if !has_digest =>
774        {
775            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
776        }
777        _ => Ok(()),
778    }
779}
780
781fn ensure_option_field(
782    field: &'static str,
783    value: Option<&str>,
784) -> Result<(), PromotionArtifactSourceError> {
785    match value {
786        Some(value) => ensure_field(field, value),
787        None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
788    }
789}
790
791fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
792    if value.trim().is_empty() {
793        return Err(PromotionArtifactSourceError::MissingRequiredField { field });
794    }
795    Ok(())
796}
797
798fn ensure_optional_sha256(
799    field: &'static str,
800    value: Option<&str>,
801) -> Result<(), PromotionArtifactSourceError> {
802    let Some(value) = value else {
803        return Ok(());
804    };
805    if is_lower_hex_sha256(value) {
806        Ok(())
807    } else {
808        Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
809    }
810}
811
812fn is_lower_hex_sha256(value: &str) -> bool {
813    value.len() == 64
814        && value
815            .bytes()
816            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
817}
818
819const fn ensure_readiness_status_matches_blockers(
820    readiness: &PromotionReadinessV1,
821) -> Result<(), PromotionReadinessError> {
822    match (readiness.status, readiness.blockers.is_empty()) {
823        (PromotionReadinessStatusV1::Ready, false)
824        | (PromotionReadinessStatusV1::Blocked, true) => {
825            Err(PromotionReadinessError::StatusBlockerMismatch {
826                status: readiness.status,
827                blocker_count: readiness.blockers.len(),
828            })
829        }
830        _ => Ok(()),
831    }
832}
833
834fn ensure_unique_readiness_roles(
835    roles: &[RolePromotionReadinessV1],
836) -> Result<(), PromotionReadinessError> {
837    let mut seen = std::collections::BTreeSet::new();
838    for role in roles {
839        if !seen.insert(role.role.as_str()) {
840            return Err(PromotionReadinessError::DuplicateRole {
841                role: role.role.clone(),
842            });
843        }
844    }
845    Ok(())
846}
847
848fn ensure_unique_transform_roles(
849    roles: &[RolePromotionPlanTransformV1],
850) -> Result<(), PromotionPlanTransformError> {
851    let mut seen = std::collections::BTreeSet::new();
852    for role in roles {
853        if !seen.insert(role.role.as_str()) {
854            return Err(PromotionPlanTransformError::DuplicateRole {
855                role: role.role.clone(),
856            });
857        }
858    }
859    Ok(())
860}
861
862fn validate_readiness_findings(
863    field: &'static str,
864    findings: &[SafetyFindingV1],
865    expected_severity: SafetySeverityV1,
866) -> Result<(), PromotionReadinessError> {
867    for finding in findings {
868        ensure_readiness_field("finding.code", &finding.code)?;
869        ensure_readiness_field("finding.message", &finding.message)?;
870        if finding.severity != expected_severity {
871            return Err(PromotionReadinessError::FindingSeverityMismatch {
872                field,
873                severity: finding.severity,
874            });
875        }
876    }
877    Ok(())
878}
879
880fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
881    if value.trim().is_empty() {
882        return Err(PromotionReadinessError::MissingRequiredField { field });
883    }
884    Ok(())
885}
886
887fn ensure_readiness_optional_sha256(
888    field: &'static str,
889    value: Option<&str>,
890) -> Result<(), PromotionReadinessError> {
891    let Some(value) = value else {
892        return Ok(());
893    };
894    if is_lower_hex_sha256(value) {
895        Ok(())
896    } else {
897        Err(PromotionReadinessError::InvalidSha256Digest { field })
898    }
899}
900
901fn ensure_transform_field(
902    field: &'static str,
903    value: &str,
904) -> Result<(), PromotionPlanTransformError> {
905    if value.trim().is_empty() {
906        return Err(PromotionPlanTransformError::MissingRequiredField { field });
907    }
908    Ok(())
909}
910
911fn ensure_evidence_field(
912    field: &'static str,
913    value: &str,
914) -> Result<(), PromotionPlanTransformEvidenceError> {
915    if value.trim().is_empty() {
916        return Err(PromotionPlanTransformEvidenceError::MissingRequiredField { field });
917    }
918    Ok(())
919}