Skip to main content

canic_host/deployment_truth/
promotion.rs

1use super::executor::{
2    DeploymentExecutionPreflightError, validate_deployment_execution_preflight,
3    validate_deployment_execution_preflight_for_check,
4};
5use super::{
6    ArtifactPromotionPlanV1, ArtifactSourceV1, BuildMaterializationEvidenceV1,
7    BuildMaterializationInputV1, BuildMaterializationResultV1, BuildRecipeIdentityV1,
8    DEPLOYMENT_TRUTH_SCHEMA_VERSION, DeploymentCheckV1, DeploymentExecutionPreflightStatusV1,
9    DeploymentExecutionPreflightV1, DeploymentPlanV1, PromotionArtifactIdentityGroupV1,
10    PromotionArtifactIdentityKindV1, PromotionArtifactIdentityReportV1, PromotionArtifactLevelV1,
11    PromotionPlanTransformEvidenceV1, PromotionPlanTransformV1, PromotionPolicyCheckV1,
12    PromotionPolicyClaimV1, PromotionPolicyRequirementV1, PromotionReadinessStatusV1,
13    PromotionReadinessV1, PromotionTargetExecutionLineageV1, RoleArtifactSourceKindV1,
14    RoleArtifactSourceV1, RoleArtifactV1, RolePromotionArtifactIdentityV1, RolePromotionInputV1,
15    RolePromotionMaterializationLinkV1, RolePromotionPlanTransformV1,
16    RolePromotionPolicyDecisionV1, RolePromotionPolicyV1, RolePromotionReadinessV1,
17    SafetyFindingV1, SafetySeverityV1, stable_json_sha256_hex,
18};
19use serde::Serialize;
20use std::collections::{BTreeMap, BTreeSet};
21use thiserror::Error as ThisError;
22
23///
24/// PromotionArtifactSourceError
25///
26#[derive(Debug, ThisError)]
27pub enum PromotionArtifactSourceError {
28    #[error("promotion artifact source is missing required field: {field}")]
29    MissingRequiredField { field: &'static str },
30    #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
31    InvalidSha256Digest { field: &'static str },
32    #[error("promotion artifact source kind {kind:?} requires a digest pin")]
33    MissingDigestPin { kind: RoleArtifactSourceKindV1 },
34    #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
35    UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
36    #[error(
37        "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
38    )]
39    MissingPreviousReceiptKind,
40    #[error(
41        "promotion artifact source kind PreviousReceiptArtifact requires a source receipt lineage digest"
42    )]
43    MissingPreviousReceiptLineageDigest,
44    #[error("promotion artifact source kind {kind:?} cannot carry source receipt lineage digest")]
45    UnexpectedPreviousReceiptLineageDigest { kind: RoleArtifactSourceKindV1 },
46}
47
48///
49/// PromotionReadinessError
50///
51#[derive(Debug, ThisError)]
52pub enum PromotionReadinessError {
53    #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
54    SchemaVersionMismatch { expected: u32, found: u32 },
55    #[error("promotion readiness is missing required field: {field}")]
56    MissingRequiredField { field: &'static str },
57    #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
58    StatusBlockerMismatch {
59        status: PromotionReadinessStatusV1,
60        blocker_count: usize,
61    },
62    #[error("promotion readiness contains duplicate role: {role}")]
63    DuplicateRole { role: String },
64    #[error("promotion readiness role {role} has inconsistent restage state")]
65    RestageStateMismatch { role: String },
66    #[error("promotion readiness finding in {field} has severity {severity:?}")]
67    FindingSeverityMismatch {
68        field: &'static str,
69        severity: SafetySeverityV1,
70    },
71    #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
72    InvalidSha256Digest { field: &'static str },
73}
74
75///
76/// PromotionPlanTransformError
77///
78#[derive(Debug, ThisError)]
79pub enum PromotionPlanTransformError {
80    #[error("promotion plan transform schema mismatch: expected {expected}, found {found}")]
81    SchemaVersionMismatch { expected: u32, found: u32 },
82    #[error("promotion plan transform is missing required field: {field}")]
83    MissingRequiredField { field: &'static str },
84    #[error("promotion readiness validation failed: {0}")]
85    Readiness(#[from] PromotionReadinessError),
86    #[error("promotion readiness is blocked with {blocker_count} blocker(s)")]
87    ReadinessBlocked { blocker_count: usize },
88    #[error("promotion target plan is missing role: {role}")]
89    TargetRoleMissing { role: String },
90    #[error("promotion transform contains duplicate source/build materialization for role: {role}")]
91    DuplicateMaterializationRole { role: String },
92    #[error(
93        "promotion transform is missing source/build materialization evidence for role: {role}"
94    )]
95    MaterializationRoleMissing { role: String },
96    #[error(
97        "promotion transform contains unexpected source/build materialization for role: {role}"
98    )]
99    UnexpectedMaterializationRole { role: String },
100    #[error("promotion materialization evidence is invalid: {0}")]
101    Materialization(#[from] PromotionMaterializationIdentityError),
102    #[error("promotion transform contains duplicate role: {role}")]
103    DuplicateRole { role: String },
104    #[error("promotion transform promoted plan id mismatch: expected {expected}, found {found}")]
105    PromotedPlanIdMismatch { expected: String, found: String },
106    #[error("promotion transform role {role} is missing from promoted plan")]
107    PromotedRoleMissing { role: String },
108    #[error("promotion transform role {role} has inconsistent field {field}")]
109    RoleStateMismatch { role: String, field: &'static str },
110}
111
112///
113/// PromotionPlanTransformEvidenceError
114///
115#[derive(Debug, ThisError)]
116pub enum PromotionPlanTransformEvidenceError {
117    #[error(
118        "promotion plan transform evidence schema mismatch: expected {expected}, found {found}"
119    )]
120    SchemaVersionMismatch { expected: u32, found: u32 },
121    #[error("promotion plan transform evidence is missing required field: {field}")]
122    MissingRequiredField { field: &'static str },
123    #[error("promotion plan transform evidence has invalid transform: {0}")]
124    Transform(#[from] PromotionPlanTransformError),
125}
126
127///
128/// ArtifactPromotionPlanError
129///
130#[derive(Debug, ThisError)]
131pub enum ArtifactPromotionPlanError {
132    #[error("artifact promotion plan schema mismatch: expected {expected}, found {found}")]
133    SchemaVersionMismatch { expected: u32, found: u32 },
134    #[error("artifact promotion plan is missing required field: {field}")]
135    MissingRequiredField { field: &'static str },
136    #[error(
137        "artifact promotion plan status {status:?} does not match blocker count {blocker_count}"
138    )]
139    StatusBlockerMismatch {
140        status: PromotionReadinessStatusV1,
141        blocker_count: usize,
142    },
143    #[error("artifact promotion plan field {field} is inconsistent")]
144    LinkageMismatch { field: &'static str },
145    #[error("artifact promotion plan readiness is invalid: {0}")]
146    Readiness(#[from] PromotionReadinessError),
147    #[error("artifact promotion plan artifact identity report is invalid: {0}")]
148    ArtifactIdentityReport(#[from] PromotionArtifactIdentityReportError),
149    #[error("artifact promotion plan transform is invalid: {0}")]
150    Transform(#[from] PromotionPlanTransformError),
151    #[error("artifact promotion plan target execution lineage is invalid: {0}")]
152    TargetExecutionLineage(#[from] PromotionTargetExecutionLineageError),
153    #[error(
154        "artifact promotion plan requires target execution lineage for deployment check validation"
155    )]
156    MissingTargetExecutionLineage,
157    #[error("artifact promotion plan target deployment check is invalid: {0}")]
158    TargetCheck(#[source] DeploymentExecutionPreflightError),
159}
160
161///
162/// PromotionTargetExecutionLineageError
163///
164#[derive(Debug, ThisError)]
165pub enum PromotionTargetExecutionLineageError {
166    #[error(
167        "promotion target execution lineage schema mismatch: expected {expected}, found {found}"
168    )]
169    SchemaVersionMismatch { expected: u32, found: u32 },
170    #[error("promotion target execution lineage is missing required field: {field}")]
171    MissingRequiredField { field: &'static str },
172    #[error(
173        "promotion target execution lineage field {field} must be a lowercase sha256 hex digest"
174    )]
175    InvalidSha256Digest { field: &'static str },
176    #[error("promotion target execution lineage has invalid transform: {0}")]
177    Transform(#[from] PromotionPlanTransformError),
178    #[error("promotion target execution lineage has invalid execution preflight: {0}")]
179    Preflight(#[from] DeploymentExecutionPreflightError),
180    #[error("promotion target execution lineage field {field} is inconsistent")]
181    LinkageMismatch { field: &'static str },
182    #[error("promotion target execution lineage must not claim execution occurred")]
183    ExecutionAttempted,
184}
185
186///
187/// PromotionArtifactIdentityReportError
188///
189#[derive(Debug, ThisError)]
190pub enum PromotionArtifactIdentityReportError {
191    #[error(
192        "promotion artifact identity report schema mismatch: expected {expected}, found {found}"
193    )]
194    SchemaVersionMismatch { expected: u32, found: u32 },
195    #[error("promotion artifact identity report is missing required field: {field}")]
196    MissingRequiredField { field: &'static str },
197    #[error(
198        "promotion artifact identity report status {status:?} does not match blocker count {blocker_count}"
199    )]
200    StatusBlockerMismatch {
201        status: PromotionReadinessStatusV1,
202        blocker_count: usize,
203    },
204    #[error("promotion artifact identity report contains duplicate role: {role}")]
205    DuplicateRole { role: String },
206    #[error("promotion artifact identity report contains duplicate identity group: {identity_key}")]
207    DuplicateIdentityGroup { identity_key: String },
208    #[error("promotion artifact identity report identity group {identity_key} has no roles")]
209    EmptyIdentityGroup { identity_key: String },
210    #[error("promotion artifact identity report identity group contains unknown role: {role}")]
211    UnknownGroupedRole { role: String },
212    #[error("promotion artifact identity report groups role {role} more than once")]
213    DuplicateGroupedRole { role: String },
214    #[error("promotion artifact identity report does not group role: {role}")]
215    MissingGroupedRole { role: String },
216    #[error(
217        "promotion artifact identity report role {role} belongs to identity group {expected}, found {found}"
218    )]
219    IdentityGroupRoleMismatch {
220        role: String,
221        expected: String,
222        found: String,
223    },
224    #[error(
225        "promotion artifact identity report identity group key mismatch: expected {expected}, found {found}"
226    )]
227    IdentityGroupKeyMismatch { expected: String, found: String },
228    #[error(
229        "promotion artifact identity report field {field} must be a lowercase sha256 hex digest"
230    )]
231    InvalidSha256Digest { field: &'static str },
232    #[error("promotion artifact identity report blocker has severity {severity:?}")]
233    BlockerSeverityMismatch { severity: SafetySeverityV1 },
234}
235
236///
237/// PromotionMaterializationIdentityError
238///
239#[derive(Debug, ThisError)]
240pub enum PromotionMaterializationIdentityError {
241    #[error(
242        "promotion materialization identity schema mismatch: expected {expected}, found {found}"
243    )]
244    SchemaVersionMismatch { expected: u32, found: u32 },
245    #[error("promotion materialization identity is missing required field: {field}")]
246    MissingRequiredField { field: &'static str },
247    #[error(
248        "promotion materialization identity field {field} must be a lowercase sha256 hex digest"
249    )]
250    InvalidSha256Digest { field: &'static str },
251    #[error("promotion materialization identity field {field} is inconsistent")]
252    LinkageMismatch { field: &'static str },
253    #[error(
254        "promotion materialization identity digest mismatch for {field}: expected {expected}, found {found}"
255    )]
256    DigestMismatch {
257        field: &'static str,
258        expected: String,
259        found: String,
260    },
261}
262
263///
264/// PromotionPolicyCheckError
265///
266#[derive(Debug, ThisError)]
267pub enum PromotionPolicyCheckError {
268    #[error("promotion policy check schema mismatch: expected {expected}, found {found}")]
269    SchemaVersionMismatch { expected: u32, found: u32 },
270    #[error("promotion policy check is missing required field: {field}")]
271    MissingRequiredField { field: &'static str },
272    #[error(
273        "promotion policy check status {status:?} does not match blocker count {blocker_count}"
274    )]
275    StatusBlockerMismatch {
276        status: PromotionReadinessStatusV1,
277        blocker_count: usize,
278    },
279    #[error("promotion policy check contains duplicate role: {role}")]
280    DuplicateRole { role: String },
281    #[error("promotion policy for role {role} has duplicate allowed level {level:?}")]
282    DuplicateAllowedLevel {
283        role: String,
284        level: PromotionArtifactLevelV1,
285    },
286    #[error("promotion policy for role {role} has no allowed promotion levels")]
287    EmptyAllowedLevels { role: String },
288    #[error("promotion policy decision for role {role} has inconsistent field {field}")]
289    DecisionMismatch { role: String, field: &'static str },
290    #[error("promotion policy check blocker has severity {severity:?}")]
291    BlockerSeverityMismatch { severity: SafetySeverityV1 },
292}
293
294///
295/// PromotionReadinessRequest
296///
297#[derive(Clone, Debug, Eq, PartialEq)]
298pub struct PromotionReadinessRequest {
299    pub readiness_id: String,
300    pub target_plan: DeploymentPlanV1,
301    pub inputs: Vec<RolePromotionInputV1>,
302}
303
304///
305/// PromotionReadinessWithPolicyRequest
306///
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub struct PromotionReadinessWithPolicyRequest {
309    pub readiness_id: String,
310    pub target_plan: DeploymentPlanV1,
311    pub inputs: Vec<RolePromotionInputV1>,
312    pub policies: Vec<RolePromotionPolicyV1>,
313}
314
315///
316/// PromotionPlanTransformRequest
317///
318#[derive(Clone, Debug, Eq, PartialEq)]
319pub struct PromotionPlanTransformRequest {
320    pub promoted_plan_id: String,
321    pub target_plan: DeploymentPlanV1,
322    pub inputs: Vec<RolePromotionInputV1>,
323}
324
325///
326/// PromotionPlanTransformWithMaterializationRequest
327///
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub struct PromotionPlanTransformWithMaterializationRequest {
330    pub promoted_plan_id: String,
331    pub target_plan: DeploymentPlanV1,
332    pub inputs: Vec<RolePromotionInputV1>,
333    pub materialization_evidence: Vec<BuildMaterializationEvidenceV1>,
334}
335
336///
337/// PromotionPlanTransformEvidenceRequest
338///
339#[derive(Clone, Debug, Eq, PartialEq)]
340pub struct PromotionPlanTransformEvidenceRequest {
341    pub evidence_id: String,
342    pub generated_at: String,
343    pub transform: PromotionPlanTransformV1,
344}
345
346///
347/// ArtifactPromotionPlanRequest
348///
349#[derive(Clone, Debug, Eq, PartialEq)]
350pub struct ArtifactPromotionPlanRequest {
351    pub plan_id: String,
352    pub generated_at: String,
353    pub readiness: PromotionReadinessV1,
354    pub artifact_identity_report: PromotionArtifactIdentityReportV1,
355    pub transform: PromotionPlanTransformV1,
356    pub target_execution_lineage: Option<PromotionTargetExecutionLineageV1>,
357}
358
359///
360/// PromotionTargetExecutionLineageRequest
361///
362#[derive(Clone, Debug, Eq, PartialEq)]
363pub struct PromotionTargetExecutionLineageRequest {
364    pub lineage_id: String,
365    pub generated_at: String,
366    pub transform: PromotionPlanTransformV1,
367    pub execution_preflight: DeploymentExecutionPreflightV1,
368}
369
370///
371/// PromotionArtifactIdentityReportRequest
372///
373#[derive(Clone, Debug, Eq, PartialEq)]
374pub struct PromotionArtifactIdentityReportRequest {
375    pub report_id: String,
376    pub inputs: Vec<RolePromotionInputV1>,
377}
378
379///
380/// BuildMaterializationEvidenceRequest
381///
382#[derive(Clone, Debug, Eq, PartialEq)]
383pub struct BuildMaterializationEvidenceRequest {
384    pub evidence_id: String,
385    pub recipe: BuildRecipeIdentityV1,
386    pub materialization_input: BuildMaterializationInputV1,
387    pub materialization_result: BuildMaterializationResultV1,
388}
389
390///
391/// PromotionPolicyCheckRequest
392///
393#[derive(Clone, Debug, Eq, PartialEq)]
394pub struct PromotionPolicyCheckRequest {
395    pub check_id: String,
396    pub inputs: Vec<RolePromotionInputV1>,
397    pub policies: Vec<RolePromotionPolicyV1>,
398}
399
400#[derive(Serialize)]
401struct PromotionPlanLineageInput<'a> {
402    target_plan_id: &'a str,
403    promoted_plan_id: &'a str,
404    promoted_plan: &'a DeploymentPlanV1,
405    roles: &'a [RolePromotionPlanTransformV1],
406}
407
408#[derive(Serialize)]
409struct PromotionTargetExecutionLineageInput<'a> {
410    promotion_plan_lineage_digest: &'a str,
411    promoted_plan_id: &'a str,
412    preflight_plan_id: &'a str,
413    preflight_safety_report_id: &'a str,
414    preflight_authority_plan_id: &'a str,
415    preflight_backend: &'a super::DeploymentExecutorBackendV1,
416    preflight_status: DeploymentExecutionPreflightStatusV1,
417    planned_phases: &'a [String],
418    required_capabilities: &'a [super::DeploymentExecutorCapabilityV1],
419    missing_capabilities: &'a [super::DeploymentExecutorCapabilityV1],
420    execution_attempted: bool,
421}
422
423pub fn promoted_deployment_plan_from_inputs(
424    request: &PromotionPlanTransformRequest,
425) -> Result<DeploymentPlanV1, PromotionPlanTransformError> {
426    Ok(promoted_deployment_plan_transform_from_inputs(request)?.promoted_plan)
427}
428
429pub fn promoted_deployment_plan_transform_from_inputs(
430    request: &PromotionPlanTransformRequest,
431) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
432    ensure_transform_field("promoted_plan_id", &request.promoted_plan_id)?;
433    let readiness = promotion_readiness_from_inputs(
434        &request.promoted_plan_id,
435        &request.target_plan,
436        &request.inputs,
437    );
438    validate_promotion_readiness(&readiness)?;
439    if readiness.status == PromotionReadinessStatusV1::Blocked {
440        return Err(PromotionPlanTransformError::ReadinessBlocked {
441            blocker_count: readiness.blockers.len(),
442        });
443    }
444
445    let mut promoted_plan = request.target_plan.clone();
446    promoted_plan.plan_id.clone_from(&request.promoted_plan_id);
447    for input in &request.inputs {
448        let Some(role_artifact) = promoted_plan
449            .role_artifacts
450            .iter_mut()
451            .find(|artifact| artifact.role == input.role)
452        else {
453            return Err(PromotionPlanTransformError::TargetRoleMissing {
454                role: input.role.clone(),
455            });
456        };
457        apply_promotion_input_to_role_artifact(role_artifact, input);
458    }
459    let transform =
460        promotion_plan_transform_from_parts(&request.target_plan, promoted_plan, &request.inputs);
461    validate_promotion_plan_transform(&transform)?;
462    Ok(transform)
463}
464
465pub fn promoted_deployment_plan_transform_from_inputs_with_materialization(
466    request: &PromotionPlanTransformWithMaterializationRequest,
467) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
468    let base_request = PromotionPlanTransformRequest {
469        promoted_plan_id: request.promoted_plan_id.clone(),
470        target_plan: request.target_plan.clone(),
471        inputs: request.inputs.clone(),
472    };
473    let mut transform = promoted_deployment_plan_transform_from_inputs(&base_request)?;
474    attach_source_build_materialization(
475        &mut transform,
476        &request.inputs,
477        &request.materialization_evidence,
478    )?;
479    refresh_promotion_plan_lineage_digest(&mut transform);
480    validate_promotion_plan_transform(&transform)?;
481    Ok(transform)
482}
483
484pub fn check_promotion_readiness(
485    request: &PromotionReadinessRequest,
486) -> Result<PromotionReadinessV1, PromotionReadinessError> {
487    ensure_readiness_field("readiness_id", &request.readiness_id)?;
488    let readiness = promotion_readiness_from_inputs(
489        &request.readiness_id,
490        &request.target_plan,
491        &request.inputs,
492    );
493    validate_promotion_readiness(&readiness)?;
494    Ok(readiness)
495}
496
497pub fn check_promotion_readiness_with_policy(
498    request: &PromotionReadinessWithPolicyRequest,
499) -> Result<PromotionReadinessV1, PromotionReadinessError> {
500    ensure_readiness_field("readiness_id", &request.readiness_id)?;
501    let readiness = promotion_readiness_from_inputs_with_policy(
502        &request.readiness_id,
503        &request.target_plan,
504        &request.inputs,
505        &request.policies,
506    );
507    validate_promotion_readiness(&readiness)?;
508    Ok(readiness)
509}
510
511pub fn check_promotion_policy(
512    request: PromotionPolicyCheckRequest,
513) -> Result<PromotionPolicyCheckV1, PromotionPolicyCheckError> {
514    ensure_policy_field("check_id", &request.check_id)?;
515    let check =
516        promotion_policy_check_from_inputs(&request.check_id, &request.inputs, &request.policies);
517    validate_promotion_policy_check(&check)?;
518    Ok(check)
519}
520
521#[must_use]
522pub fn promotion_policy_check_from_inputs(
523    check_id: impl Into<String>,
524    inputs: &[RolePromotionInputV1],
525    policies: &[RolePromotionPolicyV1],
526) -> PromotionPolicyCheckV1 {
527    let mut roles = Vec::with_capacity(inputs.len());
528    let mut blockers = Vec::new();
529    let mut seen_policy_roles = BTreeSet::new();
530    for policy in policies {
531        if !seen_policy_roles.insert(policy.role.as_str()) {
532            blockers.push(promotion_finding(
533                "promotion_policy_duplicate",
534                format!("multiple promotion policies exist for role {}", policy.role),
535                SafetySeverityV1::HardFailure,
536                &policy.role,
537            ));
538        }
539        if let Err(err) = validate_role_promotion_policy(policy) {
540            blockers.push(promotion_finding(
541                "promotion_policy_invalid",
542                err.to_string(),
543                SafetySeverityV1::HardFailure,
544                &policy.role,
545            ));
546        }
547    }
548    for input in inputs {
549        let matching_policies = policies
550            .iter()
551            .filter(|policy| policy.role == input.role)
552            .collect::<Vec<_>>();
553        match matching_policies.as_slice() {
554            [] => {
555                blockers.push(promotion_finding(
556                    "promotion_policy_missing",
557                    format!("no promotion policy exists for role {}", input.role),
558                    SafetySeverityV1::HardFailure,
559                    &input.role,
560                ));
561            }
562            [policy] => {
563                let decision = role_promotion_policy_decision(input, policy);
564                collect_policy_findings(&decision, &mut blockers);
565                roles.push(decision);
566            }
567            _ => blockers.push(promotion_finding(
568                "promotion_policy_duplicate",
569                format!("multiple promotion policies exist for role {}", input.role),
570                SafetySeverityV1::HardFailure,
571                &input.role,
572            )),
573        }
574    }
575
576    PromotionPolicyCheckV1 {
577        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
578        check_id: check_id.into(),
579        status: if blockers.is_empty() {
580            PromotionReadinessStatusV1::Ready
581        } else {
582            PromotionReadinessStatusV1::Blocked
583        },
584        roles,
585        blockers,
586    }
587}
588
589pub fn validate_promotion_policy_check(
590    check: &PromotionPolicyCheckV1,
591) -> Result<(), PromotionPolicyCheckError> {
592    if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
593        return Err(PromotionPolicyCheckError::SchemaVersionMismatch {
594            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
595            found: check.schema_version,
596        });
597    }
598    ensure_policy_field("check_id", &check.check_id)?;
599    ensure_policy_status_matches_blockers(check)?;
600    ensure_unique_policy_decision_roles(&check.roles)?;
601    for role in &check.roles {
602        validate_role_promotion_policy_decision(role)?;
603    }
604    validate_policy_blockers(&check.blockers)?;
605    Ok(())
606}
607
608pub fn promotion_artifact_identity_report_from_inputs(
609    request: PromotionArtifactIdentityReportRequest,
610) -> Result<PromotionArtifactIdentityReportV1, PromotionArtifactIdentityReportError> {
611    ensure_identity_report_field("report_id", &request.report_id)?;
612    let report = promotion_artifact_identity_report(&request.report_id, &request.inputs);
613    validate_promotion_artifact_identity_report(&report)?;
614    Ok(report)
615}
616
617#[must_use]
618pub fn promotion_artifact_identity_report(
619    report_id: impl Into<String>,
620    inputs: &[RolePromotionInputV1],
621) -> PromotionArtifactIdentityReportV1 {
622    let mut roles = Vec::with_capacity(inputs.len());
623    let mut blockers = Vec::new();
624    for input in inputs {
625        if let Err(err) = validate_role_artifact_source(&input.source) {
626            blockers.push(promotion_finding(
627                "promotion_artifact_source_invalid",
628                err.to_string(),
629                SafetySeverityV1::HardFailure,
630                &input.role,
631            ));
632        }
633        if input.role != input.source.role {
634            blockers.push(promotion_finding(
635                "promotion_source_role_mismatch",
636                format!(
637                    "promotion input role {} does not match artifact source role {}",
638                    input.role, input.source.role
639                ),
640                SafetySeverityV1::HardFailure,
641                &input.role,
642            ));
643        }
644        roles.push(role_promotion_artifact_identity(input));
645    }
646
647    PromotionArtifactIdentityReportV1 {
648        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
649        report_id: report_id.into(),
650        status: if blockers.is_empty() {
651            PromotionReadinessStatusV1::Ready
652        } else {
653            PromotionReadinessStatusV1::Blocked
654        },
655        identity_groups: promotion_artifact_identity_groups(&roles),
656        roles,
657        blockers,
658    }
659}
660
661pub fn validate_promotion_artifact_identity_report(
662    report: &PromotionArtifactIdentityReportV1,
663) -> Result<(), PromotionArtifactIdentityReportError> {
664    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
665        return Err(
666            PromotionArtifactIdentityReportError::SchemaVersionMismatch {
667                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
668                found: report.schema_version,
669            },
670        );
671    }
672    ensure_identity_report_field("report_id", &report.report_id)?;
673    ensure_identity_report_status_matches_blockers(report)?;
674    ensure_unique_artifact_identity_roles(&report.roles)?;
675    for role in &report.roles {
676        validate_role_artifact_identity(role)?;
677    }
678    validate_artifact_identity_groups(&report.roles, &report.identity_groups)?;
679    validate_identity_report_blockers(&report.blockers)?;
680    Ok(())
681}
682
683pub fn promotion_plan_transform_evidence(
684    request: PromotionPlanTransformEvidenceRequest,
685) -> Result<PromotionPlanTransformEvidenceV1, PromotionPlanTransformEvidenceError> {
686    ensure_evidence_field("evidence_id", &request.evidence_id)?;
687    ensure_evidence_field("generated_at", &request.generated_at)?;
688    validate_promotion_plan_transform(&request.transform)?;
689    let evidence = PromotionPlanTransformEvidenceV1 {
690        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
691        evidence_id: request.evidence_id,
692        generated_at: request.generated_at,
693        transform: request.transform,
694    };
695    validate_promotion_plan_transform_evidence(&evidence)?;
696    Ok(evidence)
697}
698
699pub fn artifact_promotion_plan(
700    request: ArtifactPromotionPlanRequest,
701) -> Result<ArtifactPromotionPlanV1, ArtifactPromotionPlanError> {
702    ensure_artifact_promotion_plan_field("plan_id", &request.plan_id)?;
703    ensure_artifact_promotion_plan_field("generated_at", &request.generated_at)?;
704    validate_promotion_readiness(&request.readiness)?;
705    validate_promotion_artifact_identity_report(&request.artifact_identity_report)?;
706    validate_promotion_plan_transform(&request.transform)?;
707    if let Some(lineage) = &request.target_execution_lineage {
708        validate_promotion_target_execution_lineage(lineage)?;
709    }
710
711    let blockers =
712        artifact_promotion_plan_blockers(&request.readiness, &request.artifact_identity_report);
713    let status = if blockers.is_empty() {
714        PromotionReadinessStatusV1::Ready
715    } else {
716        PromotionReadinessStatusV1::Blocked
717    };
718    let plan = ArtifactPromotionPlanV1 {
719        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
720        plan_id: request.plan_id,
721        generated_at: request.generated_at,
722        status,
723        target_plan_id: request.transform.target_plan_id.clone(),
724        promoted_plan_id: request.transform.promoted_plan_id.clone(),
725        promotion_plan_lineage_digest: request.transform.promotion_plan_lineage_digest.clone(),
726        readiness: request.readiness,
727        artifact_identity_report: request.artifact_identity_report,
728        transform: request.transform,
729        target_execution_lineage: request.target_execution_lineage,
730        blockers,
731    };
732    validate_artifact_promotion_plan(&plan)?;
733    Ok(plan)
734}
735
736pub fn promotion_target_execution_lineage(
737    request: PromotionTargetExecutionLineageRequest,
738) -> Result<PromotionTargetExecutionLineageV1, PromotionTargetExecutionLineageError> {
739    ensure_target_execution_lineage_field("lineage_id", &request.lineage_id)?;
740    ensure_target_execution_lineage_field("generated_at", &request.generated_at)?;
741    validate_promotion_plan_transform(&request.transform)?;
742    validate_deployment_execution_preflight(&request.execution_preflight)?;
743
744    let target_execution_lineage_digest = promotion_target_execution_lineage_digest(
745        &request.transform,
746        &request.execution_preflight,
747        false,
748    );
749    let lineage = PromotionTargetExecutionLineageV1 {
750        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
751        lineage_id: request.lineage_id,
752        generated_at: request.generated_at,
753        target_execution_lineage_digest,
754        transform: request.transform,
755        execution_preflight: request.execution_preflight,
756        execution_attempted: false,
757    };
758    validate_promotion_target_execution_lineage(&lineage)?;
759    Ok(lineage)
760}
761
762pub fn validate_artifact_promotion_plan(
763    plan: &ArtifactPromotionPlanV1,
764) -> Result<(), ArtifactPromotionPlanError> {
765    if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
766        return Err(ArtifactPromotionPlanError::SchemaVersionMismatch {
767            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
768            found: plan.schema_version,
769        });
770    }
771    ensure_artifact_promotion_plan_field("plan_id", &plan.plan_id)?;
772    ensure_artifact_promotion_plan_field("generated_at", &plan.generated_at)?;
773    ensure_artifact_promotion_plan_field("target_plan_id", &plan.target_plan_id)?;
774    ensure_artifact_promotion_plan_field("promoted_plan_id", &plan.promoted_plan_id)?;
775    ensure_artifact_promotion_plan_field(
776        "promotion_plan_lineage_digest",
777        &plan.promotion_plan_lineage_digest,
778    )?;
779    ensure_artifact_promotion_status_matches_blockers(plan)?;
780    validate_promotion_readiness(&plan.readiness)?;
781    validate_promotion_artifact_identity_report(&plan.artifact_identity_report)?;
782    validate_promotion_plan_transform(&plan.transform)?;
783    ensure_artifact_promotion_plan_linkage(plan)?;
784    if let Some(lineage) = &plan.target_execution_lineage {
785        validate_promotion_target_execution_lineage(lineage)?;
786        if lineage.transform != plan.transform {
787            return Err(ArtifactPromotionPlanError::LinkageMismatch {
788                field: "target_execution_lineage.transform",
789            });
790        }
791    }
792    Ok(())
793}
794
795pub fn validate_artifact_promotion_plan_for_check(
796    plan: &ArtifactPromotionPlanV1,
797    target_check: &DeploymentCheckV1,
798) -> Result<(), ArtifactPromotionPlanError> {
799    validate_artifact_promotion_plan(plan)?;
800    if target_check.plan != plan.transform.promoted_plan {
801        return Err(ArtifactPromotionPlanError::LinkageMismatch {
802            field: "target_check.plan",
803        });
804    }
805    let Some(lineage) = &plan.target_execution_lineage else {
806        return Err(ArtifactPromotionPlanError::MissingTargetExecutionLineage);
807    };
808    validate_deployment_execution_preflight_for_check(target_check, &lineage.execution_preflight)
809        .map_err(ArtifactPromotionPlanError::TargetCheck)?;
810    Ok(())
811}
812
813pub fn validate_promotion_plan_transform_evidence(
814    evidence: &PromotionPlanTransformEvidenceV1,
815) -> Result<(), PromotionPlanTransformEvidenceError> {
816    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
817        return Err(PromotionPlanTransformEvidenceError::SchemaVersionMismatch {
818            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
819            found: evidence.schema_version,
820        });
821    }
822    ensure_evidence_field("evidence_id", &evidence.evidence_id)?;
823    ensure_evidence_field("generated_at", &evidence.generated_at)?;
824    validate_promotion_plan_transform(&evidence.transform)?;
825    Ok(())
826}
827
828pub fn validate_promotion_target_execution_lineage(
829    lineage: &PromotionTargetExecutionLineageV1,
830) -> Result<(), PromotionTargetExecutionLineageError> {
831    if lineage.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
832        return Err(
833            PromotionTargetExecutionLineageError::SchemaVersionMismatch {
834                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
835                found: lineage.schema_version,
836            },
837        );
838    }
839    ensure_target_execution_lineage_field("lineage_id", &lineage.lineage_id)?;
840    ensure_target_execution_lineage_field("generated_at", &lineage.generated_at)?;
841    ensure_target_execution_lineage_sha256(
842        "target_execution_lineage_digest",
843        &lineage.target_execution_lineage_digest,
844    )?;
845    validate_promotion_plan_transform(&lineage.transform)?;
846    validate_deployment_execution_preflight(&lineage.execution_preflight)?;
847    if lineage.execution_attempted {
848        return Err(PromotionTargetExecutionLineageError::ExecutionAttempted);
849    }
850    if lineage.execution_preflight.plan_id != lineage.transform.promoted_plan_id {
851        return Err(PromotionTargetExecutionLineageError::LinkageMismatch {
852            field: "execution_preflight.plan_id",
853        });
854    }
855    let expected = promotion_target_execution_lineage_digest(
856        &lineage.transform,
857        &lineage.execution_preflight,
858        lineage.execution_attempted,
859    );
860    if expected != lineage.target_execution_lineage_digest {
861        return Err(PromotionTargetExecutionLineageError::LinkageMismatch {
862            field: "target_execution_lineage_digest",
863        });
864    }
865    Ok(())
866}
867
868pub fn validate_promotion_plan_transform(
869    transform: &PromotionPlanTransformV1,
870) -> Result<(), PromotionPlanTransformError> {
871    if transform.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
872        return Err(PromotionPlanTransformError::SchemaVersionMismatch {
873            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
874            found: transform.schema_version,
875        });
876    }
877    ensure_transform_field("transform_id", &transform.transform_id)?;
878    ensure_transform_field("target_plan_id", &transform.target_plan_id)?;
879    ensure_transform_field("promoted_plan_id", &transform.promoted_plan_id)?;
880    ensure_transform_field(
881        "promotion_plan_lineage_digest",
882        &transform.promotion_plan_lineage_digest,
883    )?;
884    ensure_transform_field("promoted_plan.plan_id", &transform.promoted_plan.plan_id)?;
885    if transform.promoted_plan.plan_id != transform.promoted_plan_id {
886        return Err(PromotionPlanTransformError::PromotedPlanIdMismatch {
887            expected: transform.promoted_plan_id.clone(),
888            found: transform.promoted_plan.plan_id.clone(),
889        });
890    }
891    ensure_unique_transform_roles(&transform.roles)?;
892    for role in &transform.roles {
893        validate_role_plan_transform(role, &transform.promoted_plan)?;
894    }
895    let expected = promotion_plan_lineage_digest(
896        &transform.target_plan_id,
897        &transform.promoted_plan_id,
898        &transform.promoted_plan,
899        &transform.roles,
900    );
901    if expected != transform.promotion_plan_lineage_digest {
902        return Err(PromotionPlanTransformError::RoleStateMismatch {
903            role: "promotion_plan_lineage".to_string(),
904            field: "promotion_plan_lineage_digest",
905        });
906    }
907    Ok(())
908}
909
910#[must_use]
911pub fn promotion_readiness_from_inputs(
912    readiness_id: impl Into<String>,
913    target_plan: &DeploymentPlanV1,
914    inputs: &[RolePromotionInputV1],
915) -> PromotionReadinessV1 {
916    let mut roles = Vec::with_capacity(inputs.len());
917    let mut blockers = Vec::new();
918    let mut warnings = Vec::new();
919
920    for input in inputs {
921        let target_artifact = target_plan
922            .role_artifacts
923            .iter()
924            .find(|artifact| artifact.role == input.role);
925        let Some(target_artifact) = target_artifact else {
926            blockers.push(promotion_finding(
927                "promotion_target_role_missing",
928                format!("target plan does not contain role {}", input.role),
929                SafetySeverityV1::HardFailure,
930                &input.role,
931            ));
932            continue;
933        };
934
935        let role_readiness = role_promotion_readiness(input, target_artifact);
936        collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
937        roles.push(role_readiness);
938    }
939
940    let status = if blockers.is_empty() {
941        PromotionReadinessStatusV1::Ready
942    } else {
943        PromotionReadinessStatusV1::Blocked
944    };
945
946    PromotionReadinessV1 {
947        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
948        readiness_id: readiness_id.into(),
949        target_plan_id: target_plan.plan_id.clone(),
950        status,
951        roles,
952        blockers,
953        warnings,
954    }
955}
956
957#[must_use]
958pub fn promotion_readiness_from_inputs_with_policy(
959    readiness_id: impl Into<String>,
960    target_plan: &DeploymentPlanV1,
961    inputs: &[RolePromotionInputV1],
962    policies: &[RolePromotionPolicyV1],
963) -> PromotionReadinessV1 {
964    let readiness_id = readiness_id.into();
965    let policy_check =
966        promotion_policy_check_from_inputs(format!("{readiness_id}:policy"), inputs, policies);
967    let mut readiness = promotion_readiness_from_inputs(readiness_id, target_plan, inputs);
968    readiness.blockers.extend(policy_check.blockers);
969    readiness.status = if readiness.blockers.is_empty() {
970        PromotionReadinessStatusV1::Ready
971    } else {
972        PromotionReadinessStatusV1::Blocked
973    };
974    readiness
975}
976
977pub fn validate_promotion_readiness(
978    readiness: &PromotionReadinessV1,
979) -> Result<(), PromotionReadinessError> {
980    if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
981        return Err(PromotionReadinessError::SchemaVersionMismatch {
982            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
983            found: readiness.schema_version,
984        });
985    }
986    ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
987    ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
988    ensure_readiness_status_matches_blockers(readiness)?;
989    ensure_unique_readiness_roles(&readiness.roles)?;
990    for role in &readiness.roles {
991        validate_role_readiness(role)?;
992    }
993    validate_readiness_findings(
994        "blockers",
995        &readiness.blockers,
996        SafetySeverityV1::HardFailure,
997    )?;
998    validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
999    Ok(())
1000}
1001
1002pub fn validate_role_artifact_source(
1003    source: &RoleArtifactSourceV1,
1004) -> Result<(), PromotionArtifactSourceError> {
1005    ensure_field("role", &source.role)?;
1006    ensure_locator_requirement(source)?;
1007    ensure_previous_receipt_requirement(source)?;
1008    ensure_digest_requirement(source)?;
1009    ensure_previous_receipt_lineage_digest_requirement(source)?;
1010    ensure_optional_sha256(
1011        "expected_wasm_sha256",
1012        source.expected_wasm_sha256.as_deref(),
1013    )?;
1014    ensure_optional_sha256(
1015        "expected_wasm_gz_sha256",
1016        source.expected_wasm_gz_sha256.as_deref(),
1017    )?;
1018    ensure_optional_sha256(
1019        "expected_candid_sha256",
1020        source.expected_candid_sha256.as_deref(),
1021    )?;
1022    ensure_optional_sha256(
1023        "expected_canonical_embedded_config_sha256",
1024        source.expected_canonical_embedded_config_sha256.as_deref(),
1025    )?;
1026    ensure_optional_sha256(
1027        "previous_receipt_lineage_digest",
1028        source.previous_receipt_lineage_digest.as_deref(),
1029    )?;
1030    Ok(())
1031}
1032
1033pub fn validate_role_promotion_policy(
1034    policy: &RolePromotionPolicyV1,
1035) -> Result<(), PromotionPolicyCheckError> {
1036    ensure_policy_field("role", &policy.role)?;
1037    if policy.allowed_promotion_levels.is_empty() {
1038        return Err(PromotionPolicyCheckError::EmptyAllowedLevels {
1039            role: policy.role.clone(),
1040        });
1041    }
1042    let mut seen = BTreeSet::new();
1043    for level in &policy.allowed_promotion_levels {
1044        if !seen.insert(*level) {
1045            return Err(PromotionPolicyCheckError::DuplicateAllowedLevel {
1046                role: policy.role.clone(),
1047                level: *level,
1048            });
1049        }
1050    }
1051    let mut seen_requirements = BTreeSet::new();
1052    for requirement in &policy.requirements {
1053        if !seen_requirements.insert(*requirement) {
1054            return Err(PromotionPolicyCheckError::DecisionMismatch {
1055                role: policy.role.clone(),
1056                field: "requirements",
1057            });
1058        }
1059    }
1060    if policy
1061        .requirements
1062        .contains(&PromotionPolicyRequirementV1::SealedBytes)
1063        && policy
1064            .allowed_promotion_levels
1065            .iter()
1066            .any(|level| *level != PromotionArtifactLevelV1::SealedWasm)
1067    {
1068        return Err(PromotionPolicyCheckError::DecisionMismatch {
1069            role: policy.role.clone(),
1070            field: "sealed_bytes",
1071        });
1072    }
1073    Ok(())
1074}
1075
1076pub fn build_materialization_evidence(
1077    request: BuildMaterializationEvidenceRequest,
1078) -> Result<BuildMaterializationEvidenceV1, PromotionMaterializationIdentityError> {
1079    ensure_materialization_field("evidence_id", &request.evidence_id)?;
1080    validate_build_recipe_identity(&request.recipe)?;
1081    validate_build_materialization_input(&request.materialization_input)?;
1082    validate_build_materialization_result(&request.materialization_result)?;
1083    let computed_materialization_input_digest =
1084        build_materialization_input_digest(&request.materialization_input);
1085    let evidence = BuildMaterializationEvidenceV1 {
1086        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1087        evidence_id: request.evidence_id,
1088        recipe_id_matches_input: request.recipe.recipe_id
1089            == request.materialization_input.build_recipe_id,
1090        recipe_id_matches_result: request.recipe.recipe_id
1091            == request.materialization_result.build_recipe_id,
1092        materialization_input_digest_matches_result: computed_materialization_input_digest
1093            == request.materialization_result.materialization_input_digest,
1094        computed_materialization_input_digest,
1095        recipe: request.recipe,
1096        materialization_input: request.materialization_input,
1097        materialization_result: request.materialization_result,
1098    };
1099    validate_build_materialization_evidence(&evidence)?;
1100    Ok(evidence)
1101}
1102
1103pub fn validate_build_materialization_evidence(
1104    evidence: &BuildMaterializationEvidenceV1,
1105) -> Result<(), PromotionMaterializationIdentityError> {
1106    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1107        return Err(
1108            PromotionMaterializationIdentityError::SchemaVersionMismatch {
1109                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1110                found: evidence.schema_version,
1111            },
1112        );
1113    }
1114    ensure_materialization_field("evidence_id", &evidence.evidence_id)?;
1115    validate_build_recipe_identity(&evidence.recipe)?;
1116    validate_build_materialization_input(&evidence.materialization_input)?;
1117    validate_build_materialization_result(&evidence.materialization_result)?;
1118    ensure_materialization_sha256(
1119        "computed_materialization_input_digest",
1120        &evidence.computed_materialization_input_digest,
1121    )?;
1122    ensure_materialization_link(
1123        "recipe_id_matches_input",
1124        evidence.recipe_id_matches_input
1125            == (evidence.recipe.recipe_id == evidence.materialization_input.build_recipe_id),
1126    )?;
1127    ensure_materialization_link("recipe_id_matches_input", evidence.recipe_id_matches_input)?;
1128    ensure_materialization_link(
1129        "recipe_id_matches_result",
1130        evidence.recipe_id_matches_result
1131            == (evidence.recipe.recipe_id == evidence.materialization_result.build_recipe_id),
1132    )?;
1133    ensure_materialization_link(
1134        "recipe_id_matches_result",
1135        evidence.recipe_id_matches_result,
1136    )?;
1137    let computed = build_materialization_input_digest(&evidence.materialization_input);
1138    if computed != evidence.computed_materialization_input_digest {
1139        return Err(PromotionMaterializationIdentityError::DigestMismatch {
1140            field: "computed_materialization_input_digest",
1141            expected: computed,
1142            found: evidence.computed_materialization_input_digest.clone(),
1143        });
1144    }
1145    ensure_materialization_link(
1146        "materialization_input_digest_matches_result",
1147        evidence.materialization_input_digest_matches_result
1148            == (evidence.computed_materialization_input_digest
1149                == evidence.materialization_result.materialization_input_digest),
1150    )?;
1151    ensure_materialization_link(
1152        "materialization_input_digest_matches_result",
1153        evidence.materialization_input_digest_matches_result,
1154    )?;
1155    Ok(())
1156}
1157
1158#[must_use]
1159pub fn build_materialization_input_digest(input: &BuildMaterializationInputV1) -> String {
1160    stable_json_sha256_hex(input)
1161}
1162
1163pub fn validate_build_recipe_identity(
1164    recipe: &BuildRecipeIdentityV1,
1165) -> Result<(), PromotionMaterializationIdentityError> {
1166    ensure_materialization_field("recipe_id", &recipe.recipe_id)?;
1167    ensure_materialization_field("source_revision", &recipe.source_revision)?;
1168    ensure_materialization_field("package_or_role_selector", &recipe.package_or_role_selector)?;
1169    ensure_materialization_field("cargo_profile", &recipe.cargo_profile)?;
1170    ensure_materialization_sha256("cargo_features_digest", &recipe.cargo_features_digest)?;
1171    ensure_materialization_sha256("cargo_lock_digest", &recipe.cargo_lock_digest)?;
1172    ensure_materialization_field("rust_toolchain", &recipe.rust_toolchain)?;
1173    ensure_materialization_field("builder_version", &recipe.builder_version)?;
1174    ensure_materialization_field("target_triple", &recipe.target_triple)?;
1175    ensure_materialization_field("linker_identity", &recipe.linker_identity)?;
1176    ensure_materialization_field("deterministic_build_mode", &recipe.deterministic_build_mode)?;
1177    ensure_materialization_field("wasm_opt_version", &recipe.wasm_opt_version)?;
1178    ensure_materialization_field("compression_identity", &recipe.compression_identity)?;
1179    Ok(())
1180}
1181
1182pub fn validate_build_materialization_input(
1183    input: &BuildMaterializationInputV1,
1184) -> Result<(), PromotionMaterializationIdentityError> {
1185    ensure_materialization_field("materialization_input_id", &input.materialization_input_id)?;
1186    ensure_materialization_field("build_recipe_id", &input.build_recipe_id)?;
1187    ensure_materialization_sha256(
1188        "canonical_embedded_config_sha256",
1189        &input.canonical_embedded_config_sha256,
1190    )?;
1191    ensure_materialization_field("network", &input.network)?;
1192    ensure_materialization_field("root_trust_anchor", &input.root_trust_anchor)?;
1193    ensure_materialization_field("runtime_variant", &input.runtime_variant)?;
1194    Ok(())
1195}
1196
1197pub fn validate_build_materialization_result(
1198    result: &BuildMaterializationResultV1,
1199) -> Result<(), PromotionMaterializationIdentityError> {
1200    ensure_materialization_field(
1201        "materialization_result_id",
1202        &result.materialization_result_id,
1203    )?;
1204    ensure_materialization_field("build_recipe_id", &result.build_recipe_id)?;
1205    ensure_materialization_sha256(
1206        "materialization_input_digest",
1207        &result.materialization_input_digest,
1208    )?;
1209    ensure_materialization_sha256("wasm_sha256", &result.wasm_sha256)?;
1210    ensure_materialization_sha256("wasm_gz_sha256", &result.wasm_gz_sha256)?;
1211    ensure_materialization_sha256("installed_module_hash", &result.installed_module_hash)?;
1212    ensure_materialization_sha256("candid_sha256", &result.candid_sha256)?;
1213    Ok(())
1214}
1215
1216fn apply_promotion_input_to_role_artifact(
1217    role_artifact: &mut RoleArtifactV1,
1218    input: &RolePromotionInputV1,
1219) {
1220    match input.promotion_level {
1221        PromotionArtifactLevelV1::SealedWasm => {
1222            role_artifact.source = artifact_source_for_promotion_source(input.source.kind);
1223            apply_promotion_source_locator(role_artifact, &input.source);
1224            role_artifact
1225                .wasm_sha256
1226                .clone_from(&input.source.expected_wasm_sha256);
1227            role_artifact
1228                .wasm_gz_sha256
1229                .clone_from(&input.source.expected_wasm_gz_sha256);
1230            role_artifact
1231                .candid_sha256
1232                .clone_from(&input.source.expected_candid_sha256);
1233            role_artifact
1234                .canonical_embedded_config_sha256
1235                .clone_from(&input.source.expected_canonical_embedded_config_sha256);
1236        }
1237        PromotionArtifactLevelV1::SourceBuild => {}
1238    }
1239}
1240
1241const fn artifact_source_for_promotion_source(kind: RoleArtifactSourceKindV1) -> ArtifactSourceV1 {
1242    match kind {
1243        RoleArtifactSourceKindV1::WorkspacePackage => ArtifactSourceV1::LocalBuild,
1244        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => ArtifactSourceV1::WasmStore,
1245        RoleArtifactSourceKindV1::PublishedPackage
1246        | RoleArtifactSourceKindV1::LocalWasm
1247        | RoleArtifactSourceKindV1::LocalWasmGz
1248        | RoleArtifactSourceKindV1::PreviousReceiptArtifact => ArtifactSourceV1::External,
1249    }
1250}
1251
1252fn apply_promotion_source_locator(
1253    role_artifact: &mut RoleArtifactV1,
1254    source: &RoleArtifactSourceV1,
1255) {
1256    match source.kind {
1257        RoleArtifactSourceKindV1::LocalWasm => {
1258            role_artifact.wasm_path.clone_from(&source.locator);
1259        }
1260        RoleArtifactSourceKindV1::LocalWasmGz => {
1261            role_artifact.wasm_gz_path.clone_from(&source.locator);
1262        }
1263        _ => {}
1264    }
1265}
1266
1267fn promotion_plan_transform_from_parts(
1268    target_plan: &DeploymentPlanV1,
1269    promoted_plan: DeploymentPlanV1,
1270    inputs: &[RolePromotionInputV1],
1271) -> PromotionPlanTransformV1 {
1272    let roles = inputs
1273        .iter()
1274        .filter_map(|input| {
1275            let before = target_plan
1276                .role_artifacts
1277                .iter()
1278                .find(|artifact| artifact.role == input.role)?;
1279            let after = promoted_plan
1280                .role_artifacts
1281                .iter()
1282                .find(|artifact| artifact.role == input.role)?;
1283            Some(role_plan_transform(input, before, after))
1284        })
1285        .collect::<Vec<_>>();
1286    let promotion_plan_lineage_digest = promotion_plan_lineage_digest(
1287        &target_plan.plan_id,
1288        &promoted_plan.plan_id,
1289        &promoted_plan,
1290        &roles,
1291    );
1292
1293    PromotionPlanTransformV1 {
1294        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1295        transform_id: format!("promotion-transform:{}", promoted_plan.plan_id),
1296        target_plan_id: target_plan.plan_id.clone(),
1297        promoted_plan_id: promoted_plan.plan_id.clone(),
1298        promotion_plan_lineage_digest,
1299        promoted_plan,
1300        roles,
1301    }
1302}
1303
1304#[must_use]
1305pub fn promotion_plan_lineage_digest(
1306    target_plan_id: &str,
1307    promoted_plan_id: &str,
1308    promoted_plan: &DeploymentPlanV1,
1309    roles: &[RolePromotionPlanTransformV1],
1310) -> String {
1311    stable_json_sha256_hex(&PromotionPlanLineageInput {
1312        target_plan_id,
1313        promoted_plan_id,
1314        promoted_plan,
1315        roles,
1316    })
1317}
1318
1319#[must_use]
1320pub fn promotion_target_execution_lineage_digest(
1321    transform: &PromotionPlanTransformV1,
1322    preflight: &DeploymentExecutionPreflightV1,
1323    execution_attempted: bool,
1324) -> String {
1325    stable_json_sha256_hex(&PromotionTargetExecutionLineageInput {
1326        promotion_plan_lineage_digest: &transform.promotion_plan_lineage_digest,
1327        promoted_plan_id: &transform.promoted_plan_id,
1328        preflight_plan_id: &preflight.plan_id,
1329        preflight_safety_report_id: &preflight.safety_report_id,
1330        preflight_authority_plan_id: &preflight.authority_plan_id,
1331        preflight_backend: &preflight.backend,
1332        preflight_status: preflight.status,
1333        planned_phases: &preflight.planned_phases,
1334        required_capabilities: &preflight.required_capabilities,
1335        missing_capabilities: &preflight.missing_capabilities,
1336        execution_attempted,
1337    })
1338}
1339
1340fn role_plan_transform(
1341    input: &RolePromotionInputV1,
1342    before: &RoleArtifactV1,
1343    after: &RoleArtifactV1,
1344) -> RolePromotionPlanTransformV1 {
1345    RolePromotionPlanTransformV1 {
1346        role: input.role.clone(),
1347        promotion_level: input.promotion_level,
1348        source_kind: input.source.kind,
1349        source_locator: input.source.locator.clone(),
1350        artifact_source_before: before.source,
1351        artifact_source_after: after.source,
1352        wasm_sha256_before: before.wasm_sha256.clone(),
1353        wasm_sha256_after: after.wasm_sha256.clone(),
1354        wasm_gz_sha256_before: before.wasm_gz_sha256.clone(),
1355        wasm_gz_sha256_after: after.wasm_gz_sha256.clone(),
1356        candid_sha256_before: before.candid_sha256.clone(),
1357        candid_sha256_after: after.candid_sha256.clone(),
1358        canonical_embedded_config_sha256_before: before.canonical_embedded_config_sha256.clone(),
1359        canonical_embedded_config_sha256_after: after.canonical_embedded_config_sha256.clone(),
1360        artifact_identity_changed: artifact_identity_changed(before, after),
1361        embedded_config_changed: before.canonical_embedded_config_sha256
1362            != after.canonical_embedded_config_sha256,
1363        target_materialization_preserved: input.promotion_level
1364            == PromotionArtifactLevelV1::SourceBuild
1365            && role_materialization_identity_matches(before, after),
1366        source_build_materialization: None,
1367    }
1368}
1369
1370fn attach_source_build_materialization(
1371    transform: &mut PromotionPlanTransformV1,
1372    inputs: &[RolePromotionInputV1],
1373    evidence: &[BuildMaterializationEvidenceV1],
1374) -> Result<(), PromotionPlanTransformError> {
1375    let input_roles = inputs
1376        .iter()
1377        .map(|input| input.role.as_str())
1378        .collect::<BTreeSet<_>>();
1379    let mut links = BTreeMap::new();
1380    for item in evidence {
1381        validate_build_materialization_evidence(item)?;
1382        let role = item.recipe.package_or_role_selector.as_str();
1383        if !input_roles.contains(role) {
1384            return Err(PromotionPlanTransformError::UnexpectedMaterializationRole {
1385                role: role.to_string(),
1386            });
1387        }
1388        if links
1389            .insert(role.to_string(), materialization_link_from_evidence(item))
1390            .is_some()
1391        {
1392            return Err(PromotionPlanTransformError::DuplicateMaterializationRole {
1393                role: role.to_string(),
1394            });
1395        }
1396    }
1397
1398    for role in &mut transform.roles {
1399        match role.promotion_level {
1400            PromotionArtifactLevelV1::SourceBuild => {
1401                let Some(link) = links.remove(&role.role) else {
1402                    return Err(PromotionPlanTransformError::MaterializationRoleMissing {
1403                        role: role.role.clone(),
1404                    });
1405                };
1406                role.source_build_materialization = Some(link);
1407            }
1408            PromotionArtifactLevelV1::SealedWasm => {
1409                if links.remove(&role.role).is_some() {
1410                    return Err(PromotionPlanTransformError::UnexpectedMaterializationRole {
1411                        role: role.role.clone(),
1412                    });
1413                }
1414            }
1415        }
1416    }
1417
1418    if let Some(role) = links.keys().next() {
1419        return Err(PromotionPlanTransformError::UnexpectedMaterializationRole {
1420            role: role.clone(),
1421        });
1422    }
1423    Ok(())
1424}
1425
1426fn materialization_link_from_evidence(
1427    evidence: &BuildMaterializationEvidenceV1,
1428) -> RolePromotionMaterializationLinkV1 {
1429    RolePromotionMaterializationLinkV1 {
1430        role: evidence.recipe.package_or_role_selector.clone(),
1431        evidence_id: evidence.evidence_id.clone(),
1432        recipe_id: evidence.recipe.recipe_id.clone(),
1433        materialization_input_id: evidence
1434            .materialization_input
1435            .materialization_input_id
1436            .clone(),
1437        materialization_result_id: evidence
1438            .materialization_result
1439            .materialization_result_id
1440            .clone(),
1441        materialization_input_digest: evidence.computed_materialization_input_digest.clone(),
1442        wasm_sha256: evidence.materialization_result.wasm_sha256.clone(),
1443        wasm_gz_sha256: evidence.materialization_result.wasm_gz_sha256.clone(),
1444        installed_module_hash: evidence
1445            .materialization_result
1446            .installed_module_hash
1447            .clone(),
1448        candid_sha256: evidence.materialization_result.candid_sha256.clone(),
1449    }
1450}
1451
1452fn artifact_promotion_plan_blockers(
1453    readiness: &PromotionReadinessV1,
1454    artifact_identity_report: &PromotionArtifactIdentityReportV1,
1455) -> Vec<SafetyFindingV1> {
1456    let mut blockers =
1457        Vec::with_capacity(readiness.blockers.len() + artifact_identity_report.blockers.len());
1458    blockers.extend(readiness.blockers.clone());
1459    blockers.extend(artifact_identity_report.blockers.clone());
1460    blockers
1461}
1462
1463fn refresh_promotion_plan_lineage_digest(transform: &mut PromotionPlanTransformV1) {
1464    transform.promotion_plan_lineage_digest = promotion_plan_lineage_digest(
1465        &transform.target_plan_id,
1466        &transform.promoted_plan_id,
1467        &transform.promoted_plan,
1468        &transform.roles,
1469    );
1470}
1471
1472fn artifact_identity_changed(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
1473    before.source != after.source
1474        || before.wasm_path != after.wasm_path
1475        || before.wasm_gz_path != after.wasm_gz_path
1476        || before.wasm_sha256 != after.wasm_sha256
1477        || before.wasm_gz_sha256 != after.wasm_gz_sha256
1478        || before.candid_path != after.candid_path
1479        || before.candid_sha256 != after.candid_sha256
1480}
1481
1482fn role_materialization_identity_matches(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
1483    before.source == after.source
1484        && before.wasm_path == after.wasm_path
1485        && before.wasm_gz_path == after.wasm_gz_path
1486        && before.wasm_sha256 == after.wasm_sha256
1487        && before.wasm_gz_sha256 == after.wasm_gz_sha256
1488        && before.candid_path == after.candid_path
1489        && before.candid_sha256 == after.candid_sha256
1490        && before.canonical_embedded_config_sha256 == after.canonical_embedded_config_sha256
1491}
1492
1493fn role_promotion_artifact_identity(
1494    input: &RolePromotionInputV1,
1495) -> RolePromotionArtifactIdentityV1 {
1496    let wasm_sha256 = input.source.expected_wasm_sha256.clone();
1497    let wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
1498    RolePromotionArtifactIdentityV1 {
1499        role: input.role.clone(),
1500        promotion_level: input.promotion_level,
1501        source_kind: input.source.kind,
1502        source_locator: input.source.locator.clone(),
1503        identity_kind: promotion_artifact_identity_kind(input.promotion_level, &input.source),
1504        digest_pinned: wasm_sha256.is_some() || wasm_gz_sha256.is_some(),
1505        wasm_sha256,
1506        wasm_gz_sha256,
1507        candid_sha256: input.source.expected_candid_sha256.clone(),
1508        canonical_embedded_config_sha256: input
1509            .source
1510            .expected_canonical_embedded_config_sha256
1511            .clone(),
1512    }
1513}
1514
1515fn role_promotion_policy_decision(
1516    input: &RolePromotionInputV1,
1517    policy: &RolePromotionPolicyV1,
1518) -> RolePromotionPolicyDecisionV1 {
1519    let level_allowed = policy
1520        .allowed_promotion_levels
1521        .contains(&input.promotion_level);
1522    let claims = promotion_policy_claims_for_input(input);
1523    let policy_satisfied = level_allowed
1524        && (!policy
1525            .requirements
1526            .contains(&PromotionPolicyRequirementV1::SealedBytes)
1527            || input.promotion_level == PromotionArtifactLevelV1::SealedWasm)
1528        && (!policy
1529            .requirements
1530            .contains(&PromotionPolicyRequirementV1::ByteIdenticalWasm)
1531            || claims.contains(&PromotionPolicyClaimV1::ByteIdenticalWasm))
1532        && (!policy
1533            .requirements
1534            .contains(&PromotionPolicyRequirementV1::TargetConfigDigest)
1535            || claims.contains(&PromotionPolicyClaimV1::TargetConfigDigest));
1536    RolePromotionPolicyDecisionV1 {
1537        role: input.role.clone(),
1538        requested_promotion_level: input.promotion_level,
1539        allowed_promotion_levels: policy.allowed_promotion_levels.clone(),
1540        requirements: policy.requirements.clone(),
1541        claims,
1542        level_allowed,
1543        policy_satisfied,
1544    }
1545}
1546
1547fn promotion_policy_claims_for_input(input: &RolePromotionInputV1) -> Vec<PromotionPolicyClaimV1> {
1548    let mut claims = Vec::with_capacity(2);
1549    if input.require_byte_identical_wasm {
1550        claims.push(PromotionPolicyClaimV1::ByteIdenticalWasm);
1551    }
1552    if input.require_target_embedded_config {
1553        claims.push(PromotionPolicyClaimV1::TargetConfigDigest);
1554    }
1555    claims
1556}
1557
1558fn collect_policy_findings(
1559    decision: &RolePromotionPolicyDecisionV1,
1560    blockers: &mut Vec<SafetyFindingV1>,
1561) {
1562    if !decision.level_allowed {
1563        blockers.push(promotion_finding(
1564            "promotion_policy_level_not_allowed",
1565            format!(
1566                "role {} cannot use promotion level {:?}",
1567                decision.role, decision.requested_promotion_level
1568            ),
1569            SafetySeverityV1::HardFailure,
1570            &decision.role,
1571        ));
1572    }
1573    if decision
1574        .requirements
1575        .contains(&PromotionPolicyRequirementV1::SealedBytes)
1576        && decision.requested_promotion_level != PromotionArtifactLevelV1::SealedWasm
1577    {
1578        blockers.push(promotion_finding(
1579            "promotion_policy_must_use_sealed_bytes",
1580            format!("role {} must use sealed bytes", decision.role),
1581            SafetySeverityV1::HardFailure,
1582            &decision.role,
1583        ));
1584    }
1585    if decision
1586        .requirements
1587        .contains(&PromotionPolicyRequirementV1::ByteIdenticalWasm)
1588        && !decision
1589            .claims
1590            .contains(&PromotionPolicyClaimV1::ByteIdenticalWasm)
1591    {
1592        blockers.push(promotion_finding(
1593            "promotion_policy_byte_identity_required",
1594            format!("role {} requires byte-identical wasm", decision.role),
1595            SafetySeverityV1::HardFailure,
1596            &decision.role,
1597        ));
1598    }
1599    if decision
1600        .requirements
1601        .contains(&PromotionPolicyRequirementV1::TargetConfigDigest)
1602        && !decision
1603            .claims
1604            .contains(&PromotionPolicyClaimV1::TargetConfigDigest)
1605    {
1606        blockers.push(promotion_finding(
1607            "promotion_policy_target_config_digest_required",
1608            format!("role {} requires target config digest", decision.role),
1609            SafetySeverityV1::HardFailure,
1610            &decision.role,
1611        ));
1612    }
1613}
1614
1615fn promotion_artifact_identity_groups(
1616    roles: &[RolePromotionArtifactIdentityV1],
1617) -> Vec<PromotionArtifactIdentityGroupV1> {
1618    let mut groups = BTreeMap::<String, PromotionArtifactIdentityGroupV1>::new();
1619    for role in roles {
1620        let identity_key = artifact_identity_key_for_role(role);
1621        let group = groups.entry(identity_key.clone()).or_insert_with(|| {
1622            PromotionArtifactIdentityGroupV1 {
1623                identity_key,
1624                identity_kind: role.identity_kind,
1625                roles: Vec::new(),
1626                source_kinds: Vec::new(),
1627                source_locators: Vec::new(),
1628                digest_pinned: role.digest_pinned,
1629                wasm_sha256: role.wasm_sha256.clone(),
1630                wasm_gz_sha256: role.wasm_gz_sha256.clone(),
1631                candid_sha256: role.candid_sha256.clone(),
1632                canonical_embedded_config_sha256: role.canonical_embedded_config_sha256.clone(),
1633            }
1634        });
1635        if !group.source_kinds.contains(&role.source_kind) {
1636            group.source_kinds.push(role.source_kind);
1637        }
1638        if let Some(locator) = &role.source_locator
1639            && !group.source_locators.contains(locator)
1640        {
1641            group.source_locators.push(locator.clone());
1642        }
1643        group.roles.push(role.role.clone());
1644    }
1645    groups.into_values().collect()
1646}
1647
1648const fn promotion_artifact_identity_kind(
1649    promotion_level: PromotionArtifactLevelV1,
1650    source: &RoleArtifactSourceV1,
1651) -> PromotionArtifactIdentityKindV1 {
1652    if matches!(promotion_level, PromotionArtifactLevelV1::SourceBuild) {
1653        return PromotionArtifactIdentityKindV1::SourceBuild;
1654    }
1655    match (
1656        source.expected_wasm_sha256.is_some(),
1657        source.expected_wasm_gz_sha256.is_some(),
1658    ) {
1659        (true, true) => PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm,
1660        (true, false) => PromotionArtifactIdentityKindV1::SealedWasm,
1661        (false, true) => PromotionArtifactIdentityKindV1::SealedCompressedWasm,
1662        (false, false) => PromotionArtifactIdentityKindV1::Deferred,
1663    }
1664}
1665
1666fn artifact_identity_key_for_role(role: &RolePromotionArtifactIdentityV1) -> String {
1667    match role.identity_kind {
1668        PromotionArtifactIdentityKindV1::SealedWasm
1669        | PromotionArtifactIdentityKindV1::SealedCompressedWasm
1670        | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
1671            role.wasm_sha256.as_deref(),
1672            role.wasm_gz_sha256.as_deref(),
1673            role.candid_sha256.as_deref(),
1674            role.canonical_embedded_config_sha256.as_deref(),
1675        ),
1676        PromotionArtifactIdentityKindV1::SourceBuild => format!(
1677            "source_build:source_kind={:?}:locator={}:candid={}:config={}",
1678            role.source_kind,
1679            optional_identity_part(role.source_locator.as_deref()),
1680            optional_identity_part(role.candid_sha256.as_deref()),
1681            optional_identity_part(role.canonical_embedded_config_sha256.as_deref())
1682        ),
1683        PromotionArtifactIdentityKindV1::Deferred => format!(
1684            "deferred:source_kind={:?}:locator={}",
1685            role.source_kind,
1686            optional_identity_part(role.source_locator.as_deref())
1687        ),
1688    }
1689}
1690
1691fn artifact_identity_key_for_group(group: &PromotionArtifactIdentityGroupV1) -> String {
1692    match group.identity_kind {
1693        PromotionArtifactIdentityKindV1::SealedWasm
1694        | PromotionArtifactIdentityKindV1::SealedCompressedWasm
1695        | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
1696            group.wasm_sha256.as_deref(),
1697            group.wasm_gz_sha256.as_deref(),
1698            group.candid_sha256.as_deref(),
1699            group.canonical_embedded_config_sha256.as_deref(),
1700        ),
1701        PromotionArtifactIdentityKindV1::SourceBuild => format!(
1702            "source_build:source_kind={}:locator={}:candid={}:config={}",
1703            source_kind_identity_part(single_group_source_kind(group)),
1704            optional_identity_part(single_group_source_locator(group)),
1705            optional_identity_part(group.candid_sha256.as_deref()),
1706            optional_identity_part(group.canonical_embedded_config_sha256.as_deref())
1707        ),
1708        PromotionArtifactIdentityKindV1::Deferred => format!(
1709            "deferred:source_kind={}:locator={}",
1710            source_kind_identity_part(single_group_source_kind(group)),
1711            optional_identity_part(single_group_source_locator(group))
1712        ),
1713    }
1714}
1715
1716fn source_kind_identity_part(kind: Option<RoleArtifactSourceKindV1>) -> String {
1717    kind.map_or_else(|| "not-recorded".to_string(), |kind| format!("{kind:?}"))
1718}
1719
1720fn single_group_source_kind(
1721    group: &PromotionArtifactIdentityGroupV1,
1722) -> Option<RoleArtifactSourceKindV1> {
1723    group.source_kinds.first().copied()
1724}
1725
1726fn single_group_source_locator(group: &PromotionArtifactIdentityGroupV1) -> Option<&str> {
1727    group.source_locators.first().map(String::as_str)
1728}
1729
1730fn sealed_identity_key(
1731    wasm_sha256: Option<&str>,
1732    wasm_gz_sha256: Option<&str>,
1733    candid_sha256: Option<&str>,
1734    canonical_embedded_config_sha256: Option<&str>,
1735) -> String {
1736    format!(
1737        "sealed:wasm={}:wasm_gz={}:candid={}:config={}",
1738        optional_identity_part(wasm_sha256),
1739        optional_identity_part(wasm_gz_sha256),
1740        optional_identity_part(candid_sha256),
1741        optional_identity_part(canonical_embedded_config_sha256)
1742    )
1743}
1744
1745const fn optional_identity_part(value: Option<&str>) -> &str {
1746    match value {
1747        Some(value) => value,
1748        None => "not-recorded",
1749    }
1750}
1751
1752fn validate_role_artifact_identity(
1753    role: &RolePromotionArtifactIdentityV1,
1754) -> Result<(), PromotionArtifactIdentityReportError> {
1755    ensure_identity_report_field("role", &role.role)?;
1756    ensure_identity_optional_sha256("wasm_sha256", role.wasm_sha256.as_deref())?;
1757    ensure_identity_optional_sha256("wasm_gz_sha256", role.wasm_gz_sha256.as_deref())?;
1758    ensure_identity_optional_sha256("candid_sha256", role.candid_sha256.as_deref())?;
1759    ensure_identity_optional_sha256(
1760        "canonical_embedded_config_sha256",
1761        role.canonical_embedded_config_sha256.as_deref(),
1762    )?;
1763    Ok(())
1764}
1765
1766fn validate_role_promotion_policy_decision(
1767    decision: &RolePromotionPolicyDecisionV1,
1768) -> Result<(), PromotionPolicyCheckError> {
1769    ensure_policy_field("role", &decision.role)?;
1770    if decision.allowed_promotion_levels.is_empty() {
1771        return Err(PromotionPolicyCheckError::EmptyAllowedLevels {
1772            role: decision.role.clone(),
1773        });
1774    }
1775    let mut seen = BTreeSet::new();
1776    for level in &decision.allowed_promotion_levels {
1777        if !seen.insert(*level) {
1778            return Err(PromotionPolicyCheckError::DuplicateAllowedLevel {
1779                role: decision.role.clone(),
1780                level: *level,
1781            });
1782        }
1783    }
1784    let mut seen_requirements = BTreeSet::new();
1785    for requirement in &decision.requirements {
1786        if !seen_requirements.insert(*requirement) {
1787            return Err(PromotionPolicyCheckError::DecisionMismatch {
1788                role: decision.role.clone(),
1789                field: "requirements",
1790            });
1791        }
1792    }
1793    let mut seen_claims = BTreeSet::new();
1794    for claim in &decision.claims {
1795        if !seen_claims.insert(*claim) {
1796            return Err(PromotionPolicyCheckError::DecisionMismatch {
1797                role: decision.role.clone(),
1798                field: "claims",
1799            });
1800        }
1801    }
1802    ensure_policy_decision(
1803        decision,
1804        "level_allowed",
1805        decision
1806            .allowed_promotion_levels
1807            .contains(&decision.requested_promotion_level)
1808            == decision.level_allowed,
1809    )?;
1810    ensure_policy_decision(
1811        decision,
1812        "policy_satisfied",
1813        promotion_policy_decision_satisfied(decision) == decision.policy_satisfied,
1814    )?;
1815    Ok(())
1816}
1817
1818fn promotion_policy_decision_satisfied(decision: &RolePromotionPolicyDecisionV1) -> bool {
1819    decision.level_allowed
1820        && (!contains_policy_requirement(
1821            &decision.requirements,
1822            PromotionPolicyRequirementV1::SealedBytes,
1823        ) || matches!(
1824            decision.requested_promotion_level,
1825            PromotionArtifactLevelV1::SealedWasm
1826        ))
1827        && (!contains_policy_requirement(
1828            &decision.requirements,
1829            PromotionPolicyRequirementV1::ByteIdenticalWasm,
1830        ) || contains_policy_claim(&decision.claims, PromotionPolicyClaimV1::ByteIdenticalWasm))
1831        && (!contains_policy_requirement(
1832            &decision.requirements,
1833            PromotionPolicyRequirementV1::TargetConfigDigest,
1834        ) || contains_policy_claim(
1835            &decision.claims,
1836            PromotionPolicyClaimV1::TargetConfigDigest,
1837        ))
1838}
1839
1840fn contains_policy_requirement(
1841    requirements: &[PromotionPolicyRequirementV1],
1842    needle: PromotionPolicyRequirementV1,
1843) -> bool {
1844    let mut index = 0;
1845    while index < requirements.len() {
1846        if requirements[index] as u8 == needle as u8 {
1847            return true;
1848        }
1849        index += 1;
1850    }
1851    false
1852}
1853
1854fn contains_policy_claim(
1855    claims: &[PromotionPolicyClaimV1],
1856    needle: PromotionPolicyClaimV1,
1857) -> bool {
1858    let mut index = 0;
1859    while index < claims.len() {
1860        if claims[index] as u8 == needle as u8 {
1861            return true;
1862        }
1863        index += 1;
1864    }
1865    false
1866}
1867
1868fn ensure_policy_decision(
1869    decision: &RolePromotionPolicyDecisionV1,
1870    field: &'static str,
1871    valid: bool,
1872) -> Result<(), PromotionPolicyCheckError> {
1873    if valid {
1874        Ok(())
1875    } else {
1876        Err(PromotionPolicyCheckError::DecisionMismatch {
1877            role: decision.role.clone(),
1878            field,
1879        })
1880    }
1881}
1882
1883fn validate_artifact_identity_groups(
1884    roles: &[RolePromotionArtifactIdentityV1],
1885    groups: &[PromotionArtifactIdentityGroupV1],
1886) -> Result<(), PromotionArtifactIdentityReportError> {
1887    let role_names = roles
1888        .iter()
1889        .map(|role| role.role.as_str())
1890        .collect::<BTreeSet<_>>();
1891    let mut grouped_roles = BTreeSet::new();
1892    let mut group_keys = BTreeSet::new();
1893    for group in groups {
1894        validate_artifact_identity_group(group)?;
1895        if !group_keys.insert(group.identity_key.as_str()) {
1896            return Err(
1897                PromotionArtifactIdentityReportError::DuplicateIdentityGroup {
1898                    identity_key: group.identity_key.clone(),
1899                },
1900            );
1901        }
1902        if group.roles.is_empty() {
1903            return Err(PromotionArtifactIdentityReportError::EmptyIdentityGroup {
1904                identity_key: group.identity_key.clone(),
1905            });
1906        }
1907        for role in &group.roles {
1908            if !role_names.contains(role.as_str()) {
1909                return Err(PromotionArtifactIdentityReportError::UnknownGroupedRole {
1910                    role: role.clone(),
1911                });
1912            }
1913            if !grouped_roles.insert(role.as_str()) {
1914                return Err(PromotionArtifactIdentityReportError::DuplicateGroupedRole {
1915                    role: role.clone(),
1916                });
1917            }
1918            let role_identity = roles
1919                .iter()
1920                .find(|candidate| candidate.role == *role)
1921                .expect("known role should be present");
1922            let expected = artifact_identity_key_for_role(role_identity);
1923            if expected != group.identity_key {
1924                return Err(
1925                    PromotionArtifactIdentityReportError::IdentityGroupRoleMismatch {
1926                        role: role.clone(),
1927                        expected,
1928                        found: group.identity_key.clone(),
1929                    },
1930                );
1931            }
1932        }
1933    }
1934    for role in roles {
1935        if !grouped_roles.contains(role.role.as_str()) {
1936            return Err(PromotionArtifactIdentityReportError::MissingGroupedRole {
1937                role: role.role.clone(),
1938            });
1939        }
1940    }
1941    Ok(())
1942}
1943
1944fn validate_artifact_identity_group(
1945    group: &PromotionArtifactIdentityGroupV1,
1946) -> Result<(), PromotionArtifactIdentityReportError> {
1947    ensure_identity_report_field("identity_group.identity_key", &group.identity_key)?;
1948    if group.source_kinds.is_empty() {
1949        return Err(PromotionArtifactIdentityReportError::MissingRequiredField {
1950            field: "identity_group.source_kinds",
1951        });
1952    }
1953    ensure_identity_optional_sha256("identity_group.wasm_sha256", group.wasm_sha256.as_deref())?;
1954    ensure_identity_optional_sha256(
1955        "identity_group.wasm_gz_sha256",
1956        group.wasm_gz_sha256.as_deref(),
1957    )?;
1958    ensure_identity_optional_sha256(
1959        "identity_group.candid_sha256",
1960        group.candid_sha256.as_deref(),
1961    )?;
1962    ensure_identity_optional_sha256(
1963        "identity_group.canonical_embedded_config_sha256",
1964        group.canonical_embedded_config_sha256.as_deref(),
1965    )?;
1966    let expected = artifact_identity_key_for_group(group);
1967    if expected != group.identity_key {
1968        return Err(
1969            PromotionArtifactIdentityReportError::IdentityGroupKeyMismatch {
1970                expected,
1971                found: group.identity_key.clone(),
1972            },
1973        );
1974    }
1975    Ok(())
1976}
1977
1978fn validate_role_plan_transform(
1979    role: &RolePromotionPlanTransformV1,
1980    promoted_plan: &DeploymentPlanV1,
1981) -> Result<(), PromotionPlanTransformError> {
1982    ensure_transform_field("role", &role.role)?;
1983    let Some(promoted_role) = promoted_plan
1984        .role_artifacts
1985        .iter()
1986        .find(|artifact| artifact.role == role.role)
1987    else {
1988        return Err(PromotionPlanTransformError::PromotedRoleMissing {
1989            role: role.role.clone(),
1990        });
1991    };
1992    ensure_role_matches_promoted_artifact(role, promoted_role)?;
1993    ensure_role_transform_flags_are_consistent(role)?;
1994    validate_role_materialization_link(role, promoted_role)?;
1995    Ok(())
1996}
1997
1998fn ensure_role_matches_promoted_artifact(
1999    role: &RolePromotionPlanTransformV1,
2000    promoted_role: &RoleArtifactV1,
2001) -> Result<(), PromotionPlanTransformError> {
2002    ensure_role_field_matches(
2003        role,
2004        "artifact_source_after",
2005        role.artifact_source_after == promoted_role.source,
2006    )?;
2007    ensure_role_field_matches(
2008        role,
2009        "wasm_sha256_after",
2010        role.wasm_sha256_after == promoted_role.wasm_sha256,
2011    )?;
2012    ensure_role_field_matches(
2013        role,
2014        "wasm_gz_sha256_after",
2015        role.wasm_gz_sha256_after == promoted_role.wasm_gz_sha256,
2016    )?;
2017    ensure_role_field_matches(
2018        role,
2019        "candid_sha256_after",
2020        role.candid_sha256_after == promoted_role.candid_sha256,
2021    )?;
2022    ensure_role_field_matches(
2023        role,
2024        "canonical_embedded_config_sha256_after",
2025        role.canonical_embedded_config_sha256_after
2026            == promoted_role.canonical_embedded_config_sha256,
2027    )
2028}
2029
2030fn ensure_role_transform_flags_are_consistent(
2031    role: &RolePromotionPlanTransformV1,
2032) -> Result<(), PromotionPlanTransformError> {
2033    ensure_role_field_matches(
2034        role,
2035        "artifact_identity_changed",
2036        role.artifact_identity_changed == role_summary_artifact_identity_changed(role),
2037    )?;
2038    ensure_role_field_matches(
2039        role,
2040        "embedded_config_changed",
2041        role.embedded_config_changed
2042            == (role.canonical_embedded_config_sha256_before
2043                != role.canonical_embedded_config_sha256_after),
2044    )?;
2045    if role.target_materialization_preserved {
2046        ensure_role_field_matches(
2047            role,
2048            "target_materialization_preserved",
2049            role.promotion_level == PromotionArtifactLevelV1::SourceBuild
2050                && !role.artifact_identity_changed
2051                && !role.embedded_config_changed,
2052        )?;
2053    }
2054    Ok(())
2055}
2056
2057fn validate_role_materialization_link(
2058    role: &RolePromotionPlanTransformV1,
2059    promoted_role: &RoleArtifactV1,
2060) -> Result<(), PromotionPlanTransformError> {
2061    let Some(link) = &role.source_build_materialization else {
2062        return Ok(());
2063    };
2064    ensure_role_field_matches(
2065        role,
2066        "source_build_materialization",
2067        role.promotion_level == PromotionArtifactLevelV1::SourceBuild,
2068    )?;
2069    ensure_role_field_matches(
2070        role,
2071        "source_build_materialization.role",
2072        link.role == role.role,
2073    )?;
2074    ensure_transform_field(
2075        "source_build_materialization.evidence_id",
2076        &link.evidence_id,
2077    )?;
2078    ensure_transform_field("source_build_materialization.recipe_id", &link.recipe_id)?;
2079    ensure_transform_field(
2080        "source_build_materialization.materialization_input_id",
2081        &link.materialization_input_id,
2082    )?;
2083    ensure_transform_field(
2084        "source_build_materialization.materialization_result_id",
2085        &link.materialization_result_id,
2086    )?;
2087    ensure_materialization_sha256(
2088        "source_build_materialization.materialization_input_digest",
2089        &link.materialization_input_digest,
2090    )?;
2091    ensure_materialization_sha256(
2092        "source_build_materialization.wasm_sha256",
2093        &link.wasm_sha256,
2094    )?;
2095    ensure_materialization_sha256(
2096        "source_build_materialization.wasm_gz_sha256",
2097        &link.wasm_gz_sha256,
2098    )?;
2099    ensure_materialization_sha256(
2100        "source_build_materialization.installed_module_hash",
2101        &link.installed_module_hash,
2102    )?;
2103    ensure_materialization_sha256(
2104        "source_build_materialization.candid_sha256",
2105        &link.candid_sha256,
2106    )?;
2107    ensure_role_field_matches(
2108        role,
2109        "source_build_materialization.wasm_sha256",
2110        promoted_role.wasm_sha256.as_deref() == Some(link.wasm_sha256.as_str()),
2111    )?;
2112    ensure_role_field_matches(
2113        role,
2114        "source_build_materialization.wasm_gz_sha256",
2115        promoted_role.wasm_gz_sha256.as_deref() == Some(link.wasm_gz_sha256.as_str()),
2116    )?;
2117    ensure_role_field_matches(
2118        role,
2119        "source_build_materialization.installed_module_hash",
2120        promoted_role.installed_module_hash.as_deref() == Some(link.installed_module_hash.as_str()),
2121    )?;
2122    ensure_role_field_matches(
2123        role,
2124        "source_build_materialization.candid_sha256",
2125        promoted_role.candid_sha256.as_deref() == Some(link.candid_sha256.as_str()),
2126    )
2127}
2128
2129fn role_summary_artifact_identity_changed(role: &RolePromotionPlanTransformV1) -> bool {
2130    role.artifact_source_before != role.artifact_source_after
2131        || role.wasm_sha256_before != role.wasm_sha256_after
2132        || role.wasm_gz_sha256_before != role.wasm_gz_sha256_after
2133        || role.candid_sha256_before != role.candid_sha256_after
2134}
2135
2136fn ensure_role_field_matches(
2137    role: &RolePromotionPlanTransformV1,
2138    field: &'static str,
2139    matches: bool,
2140) -> Result<(), PromotionPlanTransformError> {
2141    if matches {
2142        Ok(())
2143    } else {
2144        Err(PromotionPlanTransformError::RoleStateMismatch {
2145            role: role.role.clone(),
2146            field,
2147        })
2148    }
2149}
2150
2151fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
2152    ensure_readiness_field("role", &role.role)?;
2153    ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
2154    ensure_readiness_optional_sha256(
2155        "source_wasm_gz_sha256",
2156        role.source_wasm_gz_sha256.as_deref(),
2157    )?;
2158    ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
2159    ensure_readiness_optional_sha256(
2160        "target_wasm_gz_sha256",
2161        role.target_wasm_gz_sha256.as_deref(),
2162    )?;
2163    ensure_readiness_optional_sha256(
2164        "source_canonical_embedded_config_sha256",
2165        role.source_canonical_embedded_config_sha256.as_deref(),
2166    )?;
2167    ensure_readiness_optional_sha256(
2168        "target_canonical_embedded_config_sha256",
2169        role.target_canonical_embedded_config_sha256.as_deref(),
2170    )?;
2171    if role.restage_required != (role.target_store_has_artifact == Some(false)) {
2172        return Err(PromotionReadinessError::RestageStateMismatch {
2173            role: role.role.clone(),
2174        });
2175    }
2176    Ok(())
2177}
2178
2179fn role_promotion_readiness(
2180    input: &RolePromotionInputV1,
2181    target_artifact: &RoleArtifactV1,
2182) -> RolePromotionReadinessV1 {
2183    let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
2184    let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
2185    let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
2186    let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
2187    let byte_identical_wasm =
2188        matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
2189            || {
2190                matching_optional_digest(
2191                    source_wasm_gz_sha256.as_ref(),
2192                    target_wasm_gz_sha256.as_ref(),
2193                )
2194            },
2195        );
2196    let embedded_config_identical = matching_optional_digest(
2197        input
2198            .source
2199            .expected_canonical_embedded_config_sha256
2200            .as_ref(),
2201        target_artifact.canonical_embedded_config_sha256.as_ref(),
2202    );
2203
2204    RolePromotionReadinessV1 {
2205        role: input.role.clone(),
2206        promotion_level: input.promotion_level,
2207        source_kind: input.source.kind,
2208        source_locator: input.source.locator.clone(),
2209        source_wasm_sha256,
2210        source_wasm_gz_sha256,
2211        target_wasm_sha256,
2212        target_wasm_gz_sha256,
2213        source_canonical_embedded_config_sha256: input
2214            .source
2215            .expected_canonical_embedded_config_sha256
2216            .clone(),
2217        target_canonical_embedded_config_sha256: target_artifact
2218            .canonical_embedded_config_sha256
2219            .clone(),
2220        byte_identical_wasm,
2221        embedded_config_identical,
2222        target_store_has_artifact: input.target_store_has_artifact,
2223        restage_required: input.target_store_has_artifact == Some(false),
2224    }
2225}
2226
2227fn collect_role_findings(
2228    input: &RolePromotionInputV1,
2229    readiness: &RolePromotionReadinessV1,
2230    blockers: &mut Vec<SafetyFindingV1>,
2231    warnings: &mut Vec<SafetyFindingV1>,
2232) {
2233    if let Err(err) = validate_role_artifact_source(&input.source) {
2234        blockers.push(promotion_finding(
2235            "promotion_artifact_source_invalid",
2236            err.to_string(),
2237            SafetySeverityV1::HardFailure,
2238            &input.role,
2239        ));
2240    }
2241
2242    if input.role != input.source.role {
2243        blockers.push(promotion_finding(
2244            "promotion_source_role_mismatch",
2245            format!(
2246                "promotion input role {} does not match artifact source role {}",
2247                input.role, input.source.role
2248            ),
2249            SafetySeverityV1::HardFailure,
2250            &input.role,
2251        ));
2252    }
2253
2254    if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
2255        blockers.push(promotion_finding(
2256            "promotion_wasm_digest_mismatch",
2257            "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
2258            SafetySeverityV1::HardFailure,
2259            &input.role,
2260        ));
2261    }
2262
2263    if input.require_target_embedded_config
2264        && readiness
2265            .target_canonical_embedded_config_sha256
2266            .as_deref()
2267            .is_none_or(str::is_empty)
2268    {
2269        blockers.push(promotion_finding(
2270            "promotion_target_embedded_config_missing",
2271            "promotion requires target canonical embedded config but target plan has no digest",
2272            SafetySeverityV1::HardFailure,
2273            &input.role,
2274        ));
2275    }
2276
2277    if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
2278        && readiness.embedded_config_identical != Some(true)
2279    {
2280        blockers.push(promotion_finding(
2281            "promotion_sealed_wasm_embedded_config_mismatch",
2282            "sealed wasm promotion requires embedded config identity to be acceptable for the target",
2283            SafetySeverityV1::HardFailure,
2284            &input.role,
2285        ));
2286    }
2287
2288    if readiness.restage_required {
2289        warnings.push(promotion_finding(
2290            "promotion_target_store_restage_required",
2291            "target artifact store does not already contain the artifact; restaging is required",
2292            SafetySeverityV1::Warning,
2293            &input.role,
2294        ));
2295    }
2296}
2297
2298fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
2299    match (left.map(String::as_str), right.map(String::as_str)) {
2300        (Some(left), Some(right)) => Some(left == right),
2301        _ => None,
2302    }
2303}
2304
2305fn promotion_finding(
2306    code: impl Into<String>,
2307    message: impl Into<String>,
2308    severity: SafetySeverityV1,
2309    role: &str,
2310) -> SafetyFindingV1 {
2311    SafetyFindingV1 {
2312        code: code.into(),
2313        message: message.into(),
2314        severity,
2315        subject: Some(role.to_string()),
2316    }
2317}
2318
2319fn ensure_locator_requirement(
2320    source: &RoleArtifactSourceV1,
2321) -> Result<(), PromotionArtifactSourceError> {
2322    match source.kind {
2323        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
2324        _ => ensure_option_field("locator", source.locator.as_deref()),
2325    }
2326}
2327
2328const fn ensure_previous_receipt_requirement(
2329    source: &RoleArtifactSourceV1,
2330) -> Result<(), PromotionArtifactSourceError> {
2331    match (source.kind, source.previous_receipt_kind) {
2332        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
2333        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
2334            Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
2335        }
2336        (_, Some(_)) => {
2337            Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
2338        }
2339        (_, None) => Ok(()),
2340    }
2341}
2342
2343const fn ensure_previous_receipt_lineage_digest_requirement(
2344    source: &RoleArtifactSourceV1,
2345) -> Result<(), PromotionArtifactSourceError> {
2346    match (source.kind, source.previous_receipt_lineage_digest.as_ref()) {
2347        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
2348        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
2349            Err(PromotionArtifactSourceError::MissingPreviousReceiptLineageDigest)
2350        }
2351        (_, Some(_)) => Err(
2352            PromotionArtifactSourceError::UnexpectedPreviousReceiptLineageDigest {
2353                kind: source.kind,
2354            },
2355        ),
2356        (_, None) => Ok(()),
2357    }
2358}
2359
2360const fn ensure_digest_requirement(
2361    source: &RoleArtifactSourceV1,
2362) -> Result<(), PromotionArtifactSourceError> {
2363    let has_digest =
2364        source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
2365    match source.kind {
2366        RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
2367            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
2368        }
2369        RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
2370            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
2371        }
2372        RoleArtifactSourceKindV1::PublishedPackage
2373        | RoleArtifactSourceKindV1::PreviousReceiptArtifact
2374            if !has_digest =>
2375        {
2376            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
2377        }
2378        _ => Ok(()),
2379    }
2380}
2381
2382fn ensure_option_field(
2383    field: &'static str,
2384    value: Option<&str>,
2385) -> Result<(), PromotionArtifactSourceError> {
2386    match value {
2387        Some(value) => ensure_field(field, value),
2388        None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
2389    }
2390}
2391
2392fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
2393    if value.trim().is_empty() {
2394        return Err(PromotionArtifactSourceError::MissingRequiredField { field });
2395    }
2396    Ok(())
2397}
2398
2399fn ensure_optional_sha256(
2400    field: &'static str,
2401    value: Option<&str>,
2402) -> Result<(), PromotionArtifactSourceError> {
2403    let Some(value) = value else {
2404        return Ok(());
2405    };
2406    if is_lower_hex_sha256(value) {
2407        Ok(())
2408    } else {
2409        Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
2410    }
2411}
2412
2413fn is_lower_hex_sha256(value: &str) -> bool {
2414    value.len() == 64
2415        && value
2416            .bytes()
2417            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
2418}
2419
2420const fn ensure_readiness_status_matches_blockers(
2421    readiness: &PromotionReadinessV1,
2422) -> Result<(), PromotionReadinessError> {
2423    match (readiness.status, readiness.blockers.is_empty()) {
2424        (PromotionReadinessStatusV1::Ready, false)
2425        | (PromotionReadinessStatusV1::Blocked, true) => {
2426            Err(PromotionReadinessError::StatusBlockerMismatch {
2427                status: readiness.status,
2428                blocker_count: readiness.blockers.len(),
2429            })
2430        }
2431        _ => Ok(()),
2432    }
2433}
2434
2435fn ensure_unique_readiness_roles(
2436    roles: &[RolePromotionReadinessV1],
2437) -> Result<(), PromotionReadinessError> {
2438    let mut seen = std::collections::BTreeSet::new();
2439    for role in roles {
2440        if !seen.insert(role.role.as_str()) {
2441            return Err(PromotionReadinessError::DuplicateRole {
2442                role: role.role.clone(),
2443            });
2444        }
2445    }
2446    Ok(())
2447}
2448
2449fn ensure_unique_transform_roles(
2450    roles: &[RolePromotionPlanTransformV1],
2451) -> Result<(), PromotionPlanTransformError> {
2452    let mut seen = std::collections::BTreeSet::new();
2453    for role in roles {
2454        if !seen.insert(role.role.as_str()) {
2455            return Err(PromotionPlanTransformError::DuplicateRole {
2456                role: role.role.clone(),
2457            });
2458        }
2459    }
2460    Ok(())
2461}
2462
2463const fn ensure_policy_status_matches_blockers(
2464    check: &PromotionPolicyCheckV1,
2465) -> Result<(), PromotionPolicyCheckError> {
2466    match (check.status, check.blockers.is_empty()) {
2467        (PromotionReadinessStatusV1::Ready, false)
2468        | (PromotionReadinessStatusV1::Blocked, true) => {
2469            Err(PromotionPolicyCheckError::StatusBlockerMismatch {
2470                status: check.status,
2471                blocker_count: check.blockers.len(),
2472            })
2473        }
2474        _ => Ok(()),
2475    }
2476}
2477
2478fn ensure_unique_policy_decision_roles(
2479    roles: &[RolePromotionPolicyDecisionV1],
2480) -> Result<(), PromotionPolicyCheckError> {
2481    let mut seen = BTreeSet::new();
2482    for role in roles {
2483        if !seen.insert(role.role.as_str()) {
2484            return Err(PromotionPolicyCheckError::DuplicateRole {
2485                role: role.role.clone(),
2486            });
2487        }
2488    }
2489    Ok(())
2490}
2491
2492fn validate_policy_blockers(blockers: &[SafetyFindingV1]) -> Result<(), PromotionPolicyCheckError> {
2493    for blocker in blockers {
2494        ensure_policy_field("blocker.code", &blocker.code)?;
2495        ensure_policy_field("blocker.message", &blocker.message)?;
2496        if blocker.severity != SafetySeverityV1::HardFailure {
2497            return Err(PromotionPolicyCheckError::BlockerSeverityMismatch {
2498                severity: blocker.severity,
2499            });
2500        }
2501    }
2502    Ok(())
2503}
2504
2505const fn ensure_identity_report_status_matches_blockers(
2506    report: &PromotionArtifactIdentityReportV1,
2507) -> Result<(), PromotionArtifactIdentityReportError> {
2508    match (report.status, report.blockers.is_empty()) {
2509        (PromotionReadinessStatusV1::Ready, false)
2510        | (PromotionReadinessStatusV1::Blocked, true) => Err(
2511            PromotionArtifactIdentityReportError::StatusBlockerMismatch {
2512                status: report.status,
2513                blocker_count: report.blockers.len(),
2514            },
2515        ),
2516        _ => Ok(()),
2517    }
2518}
2519
2520fn ensure_unique_artifact_identity_roles(
2521    roles: &[RolePromotionArtifactIdentityV1],
2522) -> Result<(), PromotionArtifactIdentityReportError> {
2523    let mut seen = std::collections::BTreeSet::new();
2524    for role in roles {
2525        if !seen.insert(role.role.as_str()) {
2526            return Err(PromotionArtifactIdentityReportError::DuplicateRole {
2527                role: role.role.clone(),
2528            });
2529        }
2530    }
2531    Ok(())
2532}
2533
2534fn validate_identity_report_blockers(
2535    blockers: &[SafetyFindingV1],
2536) -> Result<(), PromotionArtifactIdentityReportError> {
2537    for blocker in blockers {
2538        ensure_identity_report_field("blocker.code", &blocker.code)?;
2539        ensure_identity_report_field("blocker.message", &blocker.message)?;
2540        if blocker.severity != SafetySeverityV1::HardFailure {
2541            return Err(
2542                PromotionArtifactIdentityReportError::BlockerSeverityMismatch {
2543                    severity: blocker.severity,
2544                },
2545            );
2546        }
2547    }
2548    Ok(())
2549}
2550
2551fn validate_readiness_findings(
2552    field: &'static str,
2553    findings: &[SafetyFindingV1],
2554    expected_severity: SafetySeverityV1,
2555) -> Result<(), PromotionReadinessError> {
2556    for finding in findings {
2557        ensure_readiness_field("finding.code", &finding.code)?;
2558        ensure_readiness_field("finding.message", &finding.message)?;
2559        if finding.severity != expected_severity {
2560            return Err(PromotionReadinessError::FindingSeverityMismatch {
2561                field,
2562                severity: finding.severity,
2563            });
2564        }
2565    }
2566    Ok(())
2567}
2568
2569fn ensure_policy_field(field: &'static str, value: &str) -> Result<(), PromotionPolicyCheckError> {
2570    if value.trim().is_empty() {
2571        return Err(PromotionPolicyCheckError::MissingRequiredField { field });
2572    }
2573    Ok(())
2574}
2575
2576fn ensure_identity_report_field(
2577    field: &'static str,
2578    value: &str,
2579) -> Result<(), PromotionArtifactIdentityReportError> {
2580    if value.trim().is_empty() {
2581        return Err(PromotionArtifactIdentityReportError::MissingRequiredField { field });
2582    }
2583    Ok(())
2584}
2585
2586fn ensure_identity_optional_sha256(
2587    field: &'static str,
2588    value: Option<&str>,
2589) -> Result<(), PromotionArtifactIdentityReportError> {
2590    let Some(value) = value else {
2591        return Ok(());
2592    };
2593    if is_lower_hex_sha256(value) {
2594        Ok(())
2595    } else {
2596        Err(PromotionArtifactIdentityReportError::InvalidSha256Digest { field })
2597    }
2598}
2599
2600fn ensure_materialization_field(
2601    field: &'static str,
2602    value: &str,
2603) -> Result<(), PromotionMaterializationIdentityError> {
2604    if value.trim().is_empty() {
2605        return Err(PromotionMaterializationIdentityError::MissingRequiredField { field });
2606    }
2607    Ok(())
2608}
2609
2610fn ensure_materialization_sha256(
2611    field: &'static str,
2612    value: &str,
2613) -> Result<(), PromotionMaterializationIdentityError> {
2614    ensure_materialization_field(field, value)?;
2615    if is_lower_hex_sha256(value) {
2616        Ok(())
2617    } else {
2618        Err(PromotionMaterializationIdentityError::InvalidSha256Digest { field })
2619    }
2620}
2621
2622const fn ensure_materialization_link(
2623    field: &'static str,
2624    valid: bool,
2625) -> Result<(), PromotionMaterializationIdentityError> {
2626    if valid {
2627        Ok(())
2628    } else {
2629        Err(PromotionMaterializationIdentityError::LinkageMismatch { field })
2630    }
2631}
2632
2633fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
2634    if value.trim().is_empty() {
2635        return Err(PromotionReadinessError::MissingRequiredField { field });
2636    }
2637    Ok(())
2638}
2639
2640fn ensure_readiness_optional_sha256(
2641    field: &'static str,
2642    value: Option<&str>,
2643) -> Result<(), PromotionReadinessError> {
2644    let Some(value) = value else {
2645        return Ok(());
2646    };
2647    if is_lower_hex_sha256(value) {
2648        Ok(())
2649    } else {
2650        Err(PromotionReadinessError::InvalidSha256Digest { field })
2651    }
2652}
2653
2654fn ensure_transform_field(
2655    field: &'static str,
2656    value: &str,
2657) -> Result<(), PromotionPlanTransformError> {
2658    if value.trim().is_empty() {
2659        return Err(PromotionPlanTransformError::MissingRequiredField { field });
2660    }
2661    Ok(())
2662}
2663
2664fn ensure_evidence_field(
2665    field: &'static str,
2666    value: &str,
2667) -> Result<(), PromotionPlanTransformEvidenceError> {
2668    if value.trim().is_empty() {
2669        return Err(PromotionPlanTransformEvidenceError::MissingRequiredField { field });
2670    }
2671    Ok(())
2672}
2673
2674fn ensure_artifact_promotion_plan_field(
2675    field: &'static str,
2676    value: &str,
2677) -> Result<(), ArtifactPromotionPlanError> {
2678    if value.trim().is_empty() {
2679        return Err(ArtifactPromotionPlanError::MissingRequiredField { field });
2680    }
2681    Ok(())
2682}
2683
2684const fn ensure_artifact_promotion_status_matches_blockers(
2685    plan: &ArtifactPromotionPlanV1,
2686) -> Result<(), ArtifactPromotionPlanError> {
2687    match (plan.status, plan.blockers.is_empty()) {
2688        (PromotionReadinessStatusV1::Ready, false)
2689        | (PromotionReadinessStatusV1::Blocked, true) => {
2690            Err(ArtifactPromotionPlanError::StatusBlockerMismatch {
2691                status: plan.status,
2692                blocker_count: plan.blockers.len(),
2693            })
2694        }
2695        _ => Ok(()),
2696    }
2697}
2698
2699fn ensure_artifact_promotion_plan_linkage(
2700    plan: &ArtifactPromotionPlanV1,
2701) -> Result<(), ArtifactPromotionPlanError> {
2702    let expected_blockers =
2703        artifact_promotion_plan_blockers(&plan.readiness, &plan.artifact_identity_report);
2704    if expected_blockers != plan.blockers {
2705        return Err(ArtifactPromotionPlanError::LinkageMismatch { field: "blockers" });
2706    }
2707    if plan.readiness.target_plan_id != plan.target_plan_id {
2708        return Err(ArtifactPromotionPlanError::LinkageMismatch {
2709            field: "readiness.target_plan_id",
2710        });
2711    }
2712    if plan.transform.target_plan_id != plan.target_plan_id {
2713        return Err(ArtifactPromotionPlanError::LinkageMismatch {
2714            field: "transform.target_plan_id",
2715        });
2716    }
2717    if plan.transform.promoted_plan_id != plan.promoted_plan_id {
2718        return Err(ArtifactPromotionPlanError::LinkageMismatch {
2719            field: "transform.promoted_plan_id",
2720        });
2721    }
2722    if plan.transform.promotion_plan_lineage_digest != plan.promotion_plan_lineage_digest {
2723        return Err(ArtifactPromotionPlanError::LinkageMismatch {
2724            field: "promotion_plan_lineage_digest",
2725        });
2726    }
2727    Ok(())
2728}
2729
2730fn ensure_target_execution_lineage_field(
2731    field: &'static str,
2732    value: &str,
2733) -> Result<(), PromotionTargetExecutionLineageError> {
2734    if value.trim().is_empty() {
2735        return Err(PromotionTargetExecutionLineageError::MissingRequiredField { field });
2736    }
2737    Ok(())
2738}
2739
2740fn ensure_target_execution_lineage_sha256(
2741    field: &'static str,
2742    value: &str,
2743) -> Result<(), PromotionTargetExecutionLineageError> {
2744    if is_lower_hex_sha256(value) {
2745        Ok(())
2746    } else {
2747        Err(PromotionTargetExecutionLineageError::InvalidSha256Digest { field })
2748    }
2749}