Skip to main content

canic_host/deployment_truth/
promotion.rs

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