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, ArtifactPromotionProvenanceReportV1, ArtifactSourceV1,
7    ArtifactTransportV1, BuildMaterializationEvidenceV1, BuildMaterializationInputV1,
8    BuildMaterializationResultV1, BuildRecipeIdentityV1, DEPLOYMENT_TRUTH_SCHEMA_VERSION,
9    DeploymentCheckV1, DeploymentExecutionPreflightStatusV1, DeploymentExecutionPreflightV1,
10    DeploymentPlanV1, ObservationStatusV1, PromotionArtifactIdentityGroupV1,
11    PromotionArtifactIdentityKindV1, PromotionArtifactIdentityReportV1, PromotionArtifactLevelV1,
12    PromotionMaterializationIdentityReportV1, PromotionMaterializationOutputGroupV1,
13    PromotionPlanTransformEvidenceV1, PromotionPlanTransformV1, PromotionPolicyCheckV1,
14    PromotionPolicyClaimV1, PromotionPolicyRequirementV1, PromotionReadinessStatusV1,
15    PromotionReadinessV1, PromotionTargetExecutionLineageV1, PromotionWasmStoreIdentityReportV1,
16    RoleArtifactSourceKindV1, RoleArtifactSourceV1, RoleArtifactV1,
17    RolePromotionArtifactIdentityV1, RolePromotionInputV1, RolePromotionMaterializationIdentityV1,
18    RolePromotionMaterializationLinkV1, RolePromotionPlanTransformV1,
19    RolePromotionPolicyDecisionV1, RolePromotionPolicyV1, RolePromotionProvenanceV1,
20    RolePromotionReadinessV1, RolePromotionWasmStoreIdentityV1, SafetyFindingV1, SafetySeverityV1,
21    StagingReceiptV1, stable_json_sha256_hex,
22};
23use serde::Serialize;
24use std::collections::{BTreeMap, BTreeSet};
25use thiserror::Error as ThisError;
26
27///
28/// PromotionArtifactSourceError
29///
30#[derive(Debug, ThisError)]
31pub enum PromotionArtifactSourceError {
32    #[error("promotion artifact source is missing required field: {field}")]
33    MissingRequiredField { field: &'static str },
34    #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
35    InvalidSha256Digest { field: &'static str },
36    #[error("promotion artifact source kind {kind:?} requires a digest pin")]
37    MissingDigestPin { kind: RoleArtifactSourceKindV1 },
38    #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
39    UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
40    #[error(
41        "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
42    )]
43    MissingPreviousReceiptKind,
44    #[error(
45        "promotion artifact source kind PreviousReceiptArtifact requires a source receipt lineage digest"
46    )]
47    MissingPreviousReceiptLineageDigest,
48    #[error("promotion artifact source kind {kind:?} cannot carry source receipt lineage digest")]
49    UnexpectedPreviousReceiptLineageDigest { kind: RoleArtifactSourceKindV1 },
50}
51
52///
53/// PromotionReadinessError
54///
55#[derive(Debug, ThisError)]
56pub enum PromotionReadinessError {
57    #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
58    SchemaVersionMismatch { expected: u32, found: u32 },
59    #[error("promotion readiness is missing required field: {field}")]
60    MissingRequiredField { field: &'static str },
61    #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
62    StatusBlockerMismatch {
63        status: PromotionReadinessStatusV1,
64        blocker_count: usize,
65    },
66    #[error("promotion readiness contains duplicate role: {role}")]
67    DuplicateRole { role: String },
68    #[error("promotion readiness role {role} has inconsistent restage state")]
69    RestageStateMismatch { role: String },
70    #[error("promotion readiness finding in {field} has severity {severity:?}")]
71    FindingSeverityMismatch {
72        field: &'static str,
73        severity: SafetySeverityV1,
74    },
75    #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
76    InvalidSha256Digest { field: &'static str },
77}
78
79///
80/// PromotionPlanTransformError
81///
82#[derive(Debug, ThisError)]
83pub enum PromotionPlanTransformError {
84    #[error("promotion plan transform schema mismatch: expected {expected}, found {found}")]
85    SchemaVersionMismatch { expected: u32, found: u32 },
86    #[error("promotion plan transform is missing required field: {field}")]
87    MissingRequiredField { field: &'static str },
88    #[error("promotion readiness validation failed: {0}")]
89    Readiness(#[from] PromotionReadinessError),
90    #[error("promotion readiness is blocked with {blocker_count} blocker(s)")]
91    ReadinessBlocked { blocker_count: usize },
92    #[error("promotion target plan is missing role: {role}")]
93    TargetRoleMissing { role: String },
94    #[error("promotion transform contains duplicate source/build materialization for role: {role}")]
95    DuplicateMaterializationRole { role: String },
96    #[error(
97        "promotion transform is missing source/build materialization evidence for role: {role}"
98    )]
99    MaterializationRoleMissing { role: String },
100    #[error(
101        "promotion transform contains unexpected source/build materialization for role: {role}"
102    )]
103    UnexpectedMaterializationRole { role: String },
104    #[error("promotion materialization evidence is invalid: {0}")]
105    Materialization(#[from] PromotionMaterializationIdentityError),
106    #[error("promotion transform contains duplicate role: {role}")]
107    DuplicateRole { role: String },
108    #[error("promotion transform promoted plan id mismatch: expected {expected}, found {found}")]
109    PromotedPlanIdMismatch { expected: String, found: String },
110    #[error("promotion transform role {role} is missing from promoted plan")]
111    PromotedRoleMissing { role: String },
112    #[error("promotion transform role {role} has inconsistent field {field}")]
113    RoleStateMismatch { role: String, field: &'static str },
114}
115
116///
117/// PromotionPlanTransformEvidenceError
118///
119#[derive(Debug, ThisError)]
120pub enum PromotionPlanTransformEvidenceError {
121    #[error(
122        "promotion plan transform evidence schema mismatch: expected {expected}, found {found}"
123    )]
124    SchemaVersionMismatch { expected: u32, found: u32 },
125    #[error("promotion plan transform evidence is missing required field: {field}")]
126    MissingRequiredField { field: &'static str },
127    #[error("promotion plan transform evidence has invalid transform: {0}")]
128    Transform(#[from] PromotionPlanTransformError),
129}
130
131///
132/// ArtifactPromotionPlanError
133///
134#[derive(Debug, ThisError)]
135pub enum ArtifactPromotionPlanError {
136    #[error("artifact promotion plan schema mismatch: expected {expected}, found {found}")]
137    SchemaVersionMismatch { expected: u32, found: u32 },
138    #[error("artifact promotion plan is missing required field: {field}")]
139    MissingRequiredField { field: &'static str },
140    #[error(
141        "artifact promotion plan status {status:?} does not match blocker count {blocker_count}"
142    )]
143    StatusBlockerMismatch {
144        status: PromotionReadinessStatusV1,
145        blocker_count: usize,
146    },
147    #[error("artifact promotion plan field {field} is inconsistent")]
148    LinkageMismatch { field: &'static str },
149    #[error("artifact promotion plan readiness is invalid: {0}")]
150    Readiness(#[from] PromotionReadinessError),
151    #[error("artifact promotion plan artifact identity report is invalid: {0}")]
152    ArtifactIdentityReport(#[from] PromotionArtifactIdentityReportError),
153    #[error("artifact promotion plan transform is invalid: {0}")]
154    Transform(#[from] PromotionPlanTransformError),
155    #[error("artifact promotion plan target execution lineage is invalid: {0}")]
156    TargetExecutionLineage(#[from] PromotionTargetExecutionLineageError),
157    #[error(
158        "artifact promotion plan requires target execution lineage for deployment check validation"
159    )]
160    MissingTargetExecutionLineage,
161    #[error("artifact promotion plan target deployment check is invalid: {0}")]
162    TargetCheck(#[source] DeploymentExecutionPreflightError),
163}
164
165///
166/// ArtifactPromotionProvenanceReportError
167///
168#[derive(Debug, ThisError)]
169pub enum ArtifactPromotionProvenanceReportError {
170    #[error(
171        "artifact promotion provenance report schema mismatch: expected {expected}, found {found}"
172    )]
173    SchemaVersionMismatch { expected: u32, found: u32 },
174    #[error("artifact promotion provenance report is missing required field: {field}")]
175    MissingRequiredField { field: &'static str },
176    #[error(
177        "artifact promotion provenance report status {status:?} does not match blocker count {blocker_count}"
178    )]
179    StatusBlockerMismatch {
180        status: PromotionReadinessStatusV1,
181        blocker_count: usize,
182    },
183    #[error("artifact promotion provenance report field {field} is inconsistent")]
184    LinkageMismatch { field: &'static str },
185    #[error("artifact promotion provenance report contains duplicate role: {role}")]
186    DuplicateRole { role: String },
187    #[error("artifact promotion provenance report blockers are stale")]
188    BlockerMismatch,
189    #[error("artifact promotion provenance report blocker has severity {severity:?}")]
190    BlockerSeverityMismatch { severity: SafetySeverityV1 },
191    #[error("artifact promotion provenance report has invalid artifact promotion plan: {0}")]
192    Plan(#[from] ArtifactPromotionPlanError),
193    #[error("artifact promotion provenance report has invalid wasm-store identity report: {0}")]
194    WasmStoreIdentity(#[from] PromotionWasmStoreIdentityReportError),
195    #[error(
196        "artifact promotion provenance report has invalid materialization identity report: {0}"
197    )]
198    MaterializationIdentity(#[from] PromotionMaterializationIdentityReportError),
199}
200
201///
202/// PromotionTargetExecutionLineageError
203///
204#[derive(Debug, ThisError)]
205pub enum PromotionTargetExecutionLineageError {
206    #[error(
207        "promotion target execution lineage schema mismatch: expected {expected}, found {found}"
208    )]
209    SchemaVersionMismatch { expected: u32, found: u32 },
210    #[error("promotion target execution lineage is missing required field: {field}")]
211    MissingRequiredField { field: &'static str },
212    #[error(
213        "promotion target execution lineage field {field} must be a lowercase sha256 hex digest"
214    )]
215    InvalidSha256Digest { field: &'static str },
216    #[error("promotion target execution lineage has invalid transform: {0}")]
217    Transform(#[from] PromotionPlanTransformError),
218    #[error("promotion target execution lineage has invalid execution preflight: {0}")]
219    Preflight(#[from] DeploymentExecutionPreflightError),
220    #[error("promotion target execution lineage field {field} is inconsistent")]
221    LinkageMismatch { field: &'static str },
222    #[error("promotion target execution lineage must not claim execution occurred")]
223    ExecutionAttempted,
224}
225
226///
227/// PromotionArtifactIdentityReportError
228///
229#[derive(Debug, ThisError)]
230pub enum PromotionArtifactIdentityReportError {
231    #[error(
232        "promotion artifact identity report schema mismatch: expected {expected}, found {found}"
233    )]
234    SchemaVersionMismatch { expected: u32, found: u32 },
235    #[error("promotion artifact identity report is missing required field: {field}")]
236    MissingRequiredField { field: &'static str },
237    #[error(
238        "promotion artifact identity report status {status:?} does not match blocker count {blocker_count}"
239    )]
240    StatusBlockerMismatch {
241        status: PromotionReadinessStatusV1,
242        blocker_count: usize,
243    },
244    #[error("promotion artifact identity report contains duplicate role: {role}")]
245    DuplicateRole { role: String },
246    #[error("promotion artifact identity report contains duplicate identity group: {identity_key}")]
247    DuplicateIdentityGroup { identity_key: String },
248    #[error("promotion artifact identity report identity group {identity_key} has no roles")]
249    EmptyIdentityGroup { identity_key: String },
250    #[error("promotion artifact identity report identity group contains unknown role: {role}")]
251    UnknownGroupedRole { role: String },
252    #[error("promotion artifact identity report groups role {role} more than once")]
253    DuplicateGroupedRole { role: String },
254    #[error("promotion artifact identity report does not group role: {role}")]
255    MissingGroupedRole { role: String },
256    #[error(
257        "promotion artifact identity report role {role} belongs to identity group {expected}, found {found}"
258    )]
259    IdentityGroupRoleMismatch {
260        role: String,
261        expected: String,
262        found: String,
263    },
264    #[error(
265        "promotion artifact identity report identity group key mismatch: expected {expected}, found {found}"
266    )]
267    IdentityGroupKeyMismatch { expected: String, found: String },
268    #[error(
269        "promotion artifact identity report field {field} must be a lowercase sha256 hex digest"
270    )]
271    InvalidSha256Digest { field: &'static str },
272    #[error("promotion artifact identity report blocker has severity {severity:?}")]
273    BlockerSeverityMismatch { severity: SafetySeverityV1 },
274}
275
276///
277/// PromotionWasmStoreIdentityReportError
278///
279#[derive(Debug, ThisError)]
280pub enum PromotionWasmStoreIdentityReportError {
281    #[error(
282        "promotion wasm-store identity report schema mismatch: expected {expected}, found {found}"
283    )]
284    SchemaVersionMismatch { expected: u32, found: u32 },
285    #[error("promotion wasm-store identity report is missing required field: {field}")]
286    MissingRequiredField { field: &'static str },
287    #[error(
288        "promotion wasm-store identity report status {status:?} does not match blocker count {blocker_count}"
289    )]
290    StatusBlockerMismatch {
291        status: PromotionReadinessStatusV1,
292        blocker_count: usize,
293    },
294    #[error("promotion wasm-store identity report contains duplicate role: {role}")]
295    DuplicateRole { role: String },
296    #[error(
297        "promotion wasm-store identity report staging receipt schema mismatch for role {role}: expected {expected}, found {found}"
298    )]
299    StagingReceiptSchemaVersionMismatch {
300        role: String,
301        expected: u32,
302        found: u32,
303    },
304    #[error("promotion wasm-store identity report blockers are stale")]
305    BlockerMismatch,
306    #[error("promotion wasm-store identity report blocker has severity {severity:?}")]
307    BlockerSeverityMismatch { severity: SafetySeverityV1 },
308}
309
310///
311/// PromotionMaterializationIdentityError
312///
313#[derive(Debug, ThisError)]
314pub enum PromotionMaterializationIdentityError {
315    #[error(
316        "promotion materialization identity schema mismatch: expected {expected}, found {found}"
317    )]
318    SchemaVersionMismatch { expected: u32, found: u32 },
319    #[error("promotion materialization identity is missing required field: {field}")]
320    MissingRequiredField { field: &'static str },
321    #[error(
322        "promotion materialization identity field {field} must be a lowercase sha256 hex digest"
323    )]
324    InvalidSha256Digest { field: &'static str },
325    #[error("promotion materialization identity field {field} is inconsistent")]
326    LinkageMismatch { field: &'static str },
327    #[error(
328        "promotion materialization identity digest mismatch for {field}: expected {expected}, found {found}"
329    )]
330    DigestMismatch {
331        field: &'static str,
332        expected: String,
333        found: String,
334    },
335}
336
337///
338/// PromotionMaterializationIdentityReportError
339///
340#[derive(Debug, ThisError)]
341pub enum PromotionMaterializationIdentityReportError {
342    #[error(
343        "promotion materialization identity report schema mismatch: expected {expected}, found {found}"
344    )]
345    SchemaVersionMismatch { expected: u32, found: u32 },
346    #[error("promotion materialization identity report is missing required field: {field}")]
347    MissingRequiredField { field: &'static str },
348    #[error(
349        "promotion materialization identity report status {status:?} does not match blocker count {blocker_count}"
350    )]
351    StatusBlockerMismatch {
352        status: PromotionReadinessStatusV1,
353        blocker_count: usize,
354    },
355    #[error("promotion materialization identity report contains duplicate role: {role}")]
356    DuplicateRole { role: String },
357    #[error("promotion materialization identity report contains duplicate evidence: {evidence_id}")]
358    DuplicateEvidence { evidence_id: String },
359    #[error(
360        "promotion materialization identity report contains duplicate output group: {output_identity_key}"
361    )]
362    DuplicateOutputGroup { output_identity_key: String },
363    #[error(
364        "promotion materialization identity report output group {output_identity_key} has no roles"
365    )]
366    EmptyOutputGroup { output_identity_key: String },
367    #[error("promotion materialization identity report output group contains unknown role: {role}")]
368    UnknownGroupedRole { role: String },
369    #[error("promotion materialization identity report groups role {role} more than once")]
370    DuplicateGroupedRole { role: String },
371    #[error("promotion materialization identity report does not group role: {role}")]
372    MissingGroupedRole { role: String },
373    #[error(
374        "promotion materialization identity report role {role} belongs to output group {expected}, found {found}"
375    )]
376    OutputGroupRoleMismatch {
377        role: String,
378        expected: String,
379        found: String,
380    },
381    #[error(
382        "promotion materialization identity report output group key mismatch: expected {expected}, found {found}"
383    )]
384    OutputGroupKeyMismatch { expected: String, found: String },
385    #[error("promotion materialization identity report blockers are stale")]
386    BlockerMismatch,
387    #[error("promotion materialization identity report blocker has severity {severity:?}")]
388    BlockerSeverityMismatch { severity: SafetySeverityV1 },
389    #[error("promotion materialization identity report has invalid materialization evidence: {0}")]
390    Materialization(#[from] PromotionMaterializationIdentityError),
391}
392
393///
394/// PromotionPolicyCheckError
395///
396#[derive(Debug, ThisError)]
397pub enum PromotionPolicyCheckError {
398    #[error("promotion policy check schema mismatch: expected {expected}, found {found}")]
399    SchemaVersionMismatch { expected: u32, found: u32 },
400    #[error("promotion policy check is missing required field: {field}")]
401    MissingRequiredField { field: &'static str },
402    #[error(
403        "promotion policy check status {status:?} does not match blocker count {blocker_count}"
404    )]
405    StatusBlockerMismatch {
406        status: PromotionReadinessStatusV1,
407        blocker_count: usize,
408    },
409    #[error("promotion policy check contains duplicate role: {role}")]
410    DuplicateRole { role: String },
411    #[error("promotion policy for role {role} has duplicate allowed level {level:?}")]
412    DuplicateAllowedLevel {
413        role: String,
414        level: PromotionArtifactLevelV1,
415    },
416    #[error("promotion policy for role {role} has no allowed promotion levels")]
417    EmptyAllowedLevels { role: String },
418    #[error("promotion policy decision for role {role} has inconsistent field {field}")]
419    DecisionMismatch { role: String, field: &'static str },
420    #[error("promotion policy check blocker has severity {severity:?}")]
421    BlockerSeverityMismatch { severity: SafetySeverityV1 },
422}
423
424///
425/// PromotionReadinessRequest
426///
427#[derive(Clone, Debug, Eq, PartialEq)]
428pub struct PromotionReadinessRequest {
429    pub readiness_id: String,
430    pub target_plan: DeploymentPlanV1,
431    pub inputs: Vec<RolePromotionInputV1>,
432}
433
434///
435/// PromotionReadinessWithPolicyRequest
436///
437#[derive(Clone, Debug, Eq, PartialEq)]
438pub struct PromotionReadinessWithPolicyRequest {
439    pub readiness_id: String,
440    pub target_plan: DeploymentPlanV1,
441    pub inputs: Vec<RolePromotionInputV1>,
442    pub policies: Vec<RolePromotionPolicyV1>,
443}
444
445///
446/// PromotionPlanTransformRequest
447///
448#[derive(Clone, Debug, Eq, PartialEq)]
449pub struct PromotionPlanTransformRequest {
450    pub promoted_plan_id: String,
451    pub target_plan: DeploymentPlanV1,
452    pub inputs: Vec<RolePromotionInputV1>,
453}
454
455///
456/// PromotionPlanTransformWithMaterializationRequest
457///
458#[derive(Clone, Debug, Eq, PartialEq)]
459pub struct PromotionPlanTransformWithMaterializationRequest {
460    pub promoted_plan_id: String,
461    pub target_plan: DeploymentPlanV1,
462    pub inputs: Vec<RolePromotionInputV1>,
463    pub materialization_evidence: Vec<BuildMaterializationEvidenceV1>,
464}
465
466///
467/// PromotionPlanTransformEvidenceRequest
468///
469#[derive(Clone, Debug, Eq, PartialEq)]
470pub struct PromotionPlanTransformEvidenceRequest {
471    pub evidence_id: String,
472    pub generated_at: String,
473    pub transform: PromotionPlanTransformV1,
474}
475
476///
477/// ArtifactPromotionPlanRequest
478///
479#[derive(Clone, Debug, Eq, PartialEq)]
480pub struct ArtifactPromotionPlanRequest {
481    pub plan_id: String,
482    pub generated_at: String,
483    pub readiness: PromotionReadinessV1,
484    pub artifact_identity_report: PromotionArtifactIdentityReportV1,
485    pub transform: PromotionPlanTransformV1,
486    pub target_execution_lineage: Option<PromotionTargetExecutionLineageV1>,
487}
488
489///
490/// ArtifactPromotionProvenanceReportRequest
491///
492#[derive(Clone, Debug, Eq, PartialEq)]
493pub struct ArtifactPromotionProvenanceReportRequest {
494    pub report_id: String,
495    pub artifact_promotion_plan: ArtifactPromotionPlanV1,
496    pub wasm_store_identity_report: Option<PromotionWasmStoreIdentityReportV1>,
497    pub materialization_identity_report: Option<PromotionMaterializationIdentityReportV1>,
498}
499
500///
501/// PromotionTargetExecutionLineageRequest
502///
503#[derive(Clone, Debug, Eq, PartialEq)]
504pub struct PromotionTargetExecutionLineageRequest {
505    pub lineage_id: String,
506    pub generated_at: String,
507    pub transform: PromotionPlanTransformV1,
508    pub execution_preflight: DeploymentExecutionPreflightV1,
509}
510
511///
512/// PromotionArtifactIdentityReportRequest
513///
514#[derive(Clone, Debug, Eq, PartialEq)]
515pub struct PromotionArtifactIdentityReportRequest {
516    pub report_id: String,
517    pub inputs: Vec<RolePromotionInputV1>,
518}
519
520///
521/// PromotionWasmStoreIdentityReportRequest
522///
523#[derive(Clone, Debug, Eq, PartialEq)]
524pub struct PromotionWasmStoreIdentityReportRequest {
525    pub report_id: String,
526    pub staging_receipts: Vec<StagingReceiptV1>,
527}
528
529///
530/// BuildMaterializationEvidenceRequest
531///
532#[derive(Clone, Debug, Eq, PartialEq)]
533pub struct BuildMaterializationEvidenceRequest {
534    pub evidence_id: String,
535    pub recipe: BuildRecipeIdentityV1,
536    pub materialization_input: BuildMaterializationInputV1,
537    pub materialization_result: BuildMaterializationResultV1,
538}
539
540///
541/// PromotionMaterializationIdentityReportRequest
542///
543#[derive(Clone, Debug, Eq, PartialEq)]
544pub struct PromotionMaterializationIdentityReportRequest {
545    pub report_id: String,
546    pub evidence: Vec<BuildMaterializationEvidenceV1>,
547}
548
549///
550/// PromotionPolicyCheckRequest
551///
552#[derive(Clone, Debug, Eq, PartialEq)]
553pub struct PromotionPolicyCheckRequest {
554    pub check_id: String,
555    pub inputs: Vec<RolePromotionInputV1>,
556    pub policies: Vec<RolePromotionPolicyV1>,
557}
558
559#[derive(Serialize)]
560struct PromotionPlanLineageInput<'a> {
561    target_plan_id: &'a str,
562    promoted_plan_id: &'a str,
563    promoted_plan: &'a DeploymentPlanV1,
564    roles: &'a [RolePromotionPlanTransformV1],
565}
566
567#[derive(Serialize)]
568struct PromotionTargetExecutionLineageInput<'a> {
569    promotion_plan_lineage_digest: &'a str,
570    promoted_plan_id: &'a str,
571    preflight_plan_id: &'a str,
572    preflight_safety_report_id: &'a str,
573    preflight_authority_plan_id: &'a str,
574    preflight_backend: &'a super::DeploymentExecutorBackendV1,
575    preflight_status: DeploymentExecutionPreflightStatusV1,
576    planned_phases: &'a [String],
577    required_capabilities: &'a [super::DeploymentExecutorCapabilityV1],
578    missing_capabilities: &'a [super::DeploymentExecutorCapabilityV1],
579    execution_attempted: bool,
580}
581
582pub fn promoted_deployment_plan_from_inputs(
583    request: &PromotionPlanTransformRequest,
584) -> Result<DeploymentPlanV1, PromotionPlanTransformError> {
585    Ok(promoted_deployment_plan_transform_from_inputs(request)?.promoted_plan)
586}
587
588pub fn promoted_deployment_plan_transform_from_inputs(
589    request: &PromotionPlanTransformRequest,
590) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
591    ensure_transform_field("promoted_plan_id", &request.promoted_plan_id)?;
592    let readiness = promotion_readiness_from_inputs(
593        &request.promoted_plan_id,
594        &request.target_plan,
595        &request.inputs,
596    );
597    validate_promotion_readiness(&readiness)?;
598    if readiness.status == PromotionReadinessStatusV1::Blocked {
599        return Err(PromotionPlanTransformError::ReadinessBlocked {
600            blocker_count: readiness.blockers.len(),
601        });
602    }
603
604    let mut promoted_plan = request.target_plan.clone();
605    promoted_plan.plan_id.clone_from(&request.promoted_plan_id);
606    for input in &request.inputs {
607        let Some(role_artifact) = promoted_plan
608            .role_artifacts
609            .iter_mut()
610            .find(|artifact| artifact.role == input.role)
611        else {
612            return Err(PromotionPlanTransformError::TargetRoleMissing {
613                role: input.role.clone(),
614            });
615        };
616        apply_promotion_input_to_role_artifact(role_artifact, input);
617    }
618    let transform =
619        promotion_plan_transform_from_parts(&request.target_plan, promoted_plan, &request.inputs);
620    validate_promotion_plan_transform(&transform)?;
621    Ok(transform)
622}
623
624pub fn promoted_deployment_plan_transform_from_inputs_with_materialization(
625    request: &PromotionPlanTransformWithMaterializationRequest,
626) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
627    let base_request = PromotionPlanTransformRequest {
628        promoted_plan_id: request.promoted_plan_id.clone(),
629        target_plan: request.target_plan.clone(),
630        inputs: request.inputs.clone(),
631    };
632    let mut transform = promoted_deployment_plan_transform_from_inputs(&base_request)?;
633    attach_source_build_materialization(
634        &mut transform,
635        &request.inputs,
636        &request.materialization_evidence,
637    )?;
638    refresh_promotion_plan_lineage_digest(&mut transform);
639    validate_promotion_plan_transform(&transform)?;
640    Ok(transform)
641}
642
643pub fn check_promotion_readiness(
644    request: &PromotionReadinessRequest,
645) -> Result<PromotionReadinessV1, PromotionReadinessError> {
646    ensure_readiness_field("readiness_id", &request.readiness_id)?;
647    let readiness = promotion_readiness_from_inputs(
648        &request.readiness_id,
649        &request.target_plan,
650        &request.inputs,
651    );
652    validate_promotion_readiness(&readiness)?;
653    Ok(readiness)
654}
655
656pub fn check_promotion_readiness_with_policy(
657    request: &PromotionReadinessWithPolicyRequest,
658) -> Result<PromotionReadinessV1, PromotionReadinessError> {
659    ensure_readiness_field("readiness_id", &request.readiness_id)?;
660    let readiness = promotion_readiness_from_inputs_with_policy(
661        &request.readiness_id,
662        &request.target_plan,
663        &request.inputs,
664        &request.policies,
665    );
666    validate_promotion_readiness(&readiness)?;
667    Ok(readiness)
668}
669
670pub fn check_promotion_policy(
671    request: PromotionPolicyCheckRequest,
672) -> Result<PromotionPolicyCheckV1, PromotionPolicyCheckError> {
673    ensure_policy_field("check_id", &request.check_id)?;
674    let check =
675        promotion_policy_check_from_inputs(&request.check_id, &request.inputs, &request.policies);
676    validate_promotion_policy_check(&check)?;
677    Ok(check)
678}
679
680#[must_use]
681pub fn promotion_policy_check_from_inputs(
682    check_id: impl Into<String>,
683    inputs: &[RolePromotionInputV1],
684    policies: &[RolePromotionPolicyV1],
685) -> PromotionPolicyCheckV1 {
686    let mut roles = Vec::with_capacity(inputs.len());
687    let mut blockers = Vec::new();
688    let mut seen_policy_roles = BTreeSet::new();
689    for policy in policies {
690        if !seen_policy_roles.insert(policy.role.as_str()) {
691            blockers.push(promotion_finding(
692                "promotion_policy_duplicate",
693                format!("multiple promotion policies exist for role {}", policy.role),
694                SafetySeverityV1::HardFailure,
695                &policy.role,
696            ));
697        }
698        if let Err(err) = validate_role_promotion_policy(policy) {
699            blockers.push(promotion_finding(
700                "promotion_policy_invalid",
701                err.to_string(),
702                SafetySeverityV1::HardFailure,
703                &policy.role,
704            ));
705        }
706    }
707    for input in inputs {
708        let matching_policies = policies
709            .iter()
710            .filter(|policy| policy.role == input.role)
711            .collect::<Vec<_>>();
712        match matching_policies.as_slice() {
713            [] => {
714                blockers.push(promotion_finding(
715                    "promotion_policy_missing",
716                    format!("no promotion policy exists for role {}", input.role),
717                    SafetySeverityV1::HardFailure,
718                    &input.role,
719                ));
720            }
721            [policy] => {
722                let decision = role_promotion_policy_decision(input, policy);
723                collect_policy_findings(&decision, &mut blockers);
724                roles.push(decision);
725            }
726            _ => blockers.push(promotion_finding(
727                "promotion_policy_duplicate",
728                format!("multiple promotion policies exist for role {}", input.role),
729                SafetySeverityV1::HardFailure,
730                &input.role,
731            )),
732        }
733    }
734
735    PromotionPolicyCheckV1 {
736        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
737        check_id: check_id.into(),
738        status: if blockers.is_empty() {
739            PromotionReadinessStatusV1::Ready
740        } else {
741            PromotionReadinessStatusV1::Blocked
742        },
743        roles,
744        blockers,
745    }
746}
747
748pub fn validate_promotion_policy_check(
749    check: &PromotionPolicyCheckV1,
750) -> Result<(), PromotionPolicyCheckError> {
751    if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
752        return Err(PromotionPolicyCheckError::SchemaVersionMismatch {
753            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
754            found: check.schema_version,
755        });
756    }
757    ensure_policy_field("check_id", &check.check_id)?;
758    ensure_policy_status_matches_blockers(check)?;
759    ensure_unique_policy_decision_roles(&check.roles)?;
760    for role in &check.roles {
761        validate_role_promotion_policy_decision(role)?;
762    }
763    validate_policy_blockers(&check.blockers)?;
764    Ok(())
765}
766
767pub fn promotion_artifact_identity_report_from_inputs(
768    request: PromotionArtifactIdentityReportRequest,
769) -> Result<PromotionArtifactIdentityReportV1, PromotionArtifactIdentityReportError> {
770    ensure_identity_report_field("report_id", &request.report_id)?;
771    let report = promotion_artifact_identity_report(&request.report_id, &request.inputs);
772    validate_promotion_artifact_identity_report(&report)?;
773    Ok(report)
774}
775
776pub fn promotion_wasm_store_identity_report_from_staging(
777    request: PromotionWasmStoreIdentityReportRequest,
778) -> Result<PromotionWasmStoreIdentityReportV1, PromotionWasmStoreIdentityReportError> {
779    ensure_wasm_store_identity_report_field("report_id", &request.report_id)?;
780    ensure_wasm_store_identity_staging_receipts(&request.staging_receipts)?;
781    let report =
782        promotion_wasm_store_identity_report(&request.report_id, &request.staging_receipts);
783    validate_promotion_wasm_store_identity_report(&report)?;
784    Ok(report)
785}
786
787#[must_use]
788pub fn promotion_artifact_identity_report(
789    report_id: impl Into<String>,
790    inputs: &[RolePromotionInputV1],
791) -> PromotionArtifactIdentityReportV1 {
792    let mut roles = Vec::with_capacity(inputs.len());
793    let mut blockers = Vec::new();
794    for input in inputs {
795        if let Err(err) = validate_role_artifact_source(&input.source) {
796            blockers.push(promotion_finding(
797                "promotion_artifact_source_invalid",
798                err.to_string(),
799                SafetySeverityV1::HardFailure,
800                &input.role,
801            ));
802        }
803        if input.role != input.source.role {
804            blockers.push(promotion_finding(
805                "promotion_source_role_mismatch",
806                format!(
807                    "promotion input role {} does not match artifact source role {}",
808                    input.role, input.source.role
809                ),
810                SafetySeverityV1::HardFailure,
811                &input.role,
812            ));
813        }
814        roles.push(role_promotion_artifact_identity(input));
815    }
816
817    PromotionArtifactIdentityReportV1 {
818        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
819        report_id: report_id.into(),
820        status: if blockers.is_empty() {
821            PromotionReadinessStatusV1::Ready
822        } else {
823            PromotionReadinessStatusV1::Blocked
824        },
825        identity_groups: promotion_artifact_identity_groups(&roles),
826        roles,
827        blockers,
828    }
829}
830
831#[must_use]
832pub fn promotion_wasm_store_identity_report(
833    report_id: impl Into<String>,
834    staging_receipts: &[StagingReceiptV1],
835) -> PromotionWasmStoreIdentityReportV1 {
836    let roles = staging_receipts
837        .iter()
838        .map(role_wasm_store_identity_from_staging)
839        .collect::<Vec<_>>();
840    let blockers = wasm_store_identity_blockers(&roles);
841    PromotionWasmStoreIdentityReportV1 {
842        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
843        report_id: report_id.into(),
844        status: if blockers.is_empty() {
845            PromotionReadinessStatusV1::Ready
846        } else {
847            PromotionReadinessStatusV1::Blocked
848        },
849        roles,
850        blockers,
851    }
852}
853
854pub fn validate_promotion_artifact_identity_report(
855    report: &PromotionArtifactIdentityReportV1,
856) -> Result<(), PromotionArtifactIdentityReportError> {
857    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
858        return Err(
859            PromotionArtifactIdentityReportError::SchemaVersionMismatch {
860                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
861                found: report.schema_version,
862            },
863        );
864    }
865    ensure_identity_report_field("report_id", &report.report_id)?;
866    ensure_identity_report_status_matches_blockers(report)?;
867    ensure_unique_artifact_identity_roles(&report.roles)?;
868    for role in &report.roles {
869        validate_role_artifact_identity(role)?;
870    }
871    validate_artifact_identity_groups(&report.roles, &report.identity_groups)?;
872    validate_identity_report_blockers(&report.blockers)?;
873    Ok(())
874}
875
876pub fn validate_promotion_wasm_store_identity_report(
877    report: &PromotionWasmStoreIdentityReportV1,
878) -> Result<(), PromotionWasmStoreIdentityReportError> {
879    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
880        return Err(
881            PromotionWasmStoreIdentityReportError::SchemaVersionMismatch {
882                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
883                found: report.schema_version,
884            },
885        );
886    }
887    ensure_wasm_store_identity_report_field("report_id", &report.report_id)?;
888    ensure_wasm_store_identity_status_matches_blockers(report)?;
889    ensure_unique_wasm_store_identity_roles(&report.roles)?;
890    for role in &report.roles {
891        validate_role_wasm_store_identity(role)?;
892    }
893    let expected_blockers = wasm_store_identity_blockers(&report.roles);
894    if expected_blockers != report.blockers {
895        return Err(PromotionWasmStoreIdentityReportError::BlockerMismatch);
896    }
897    validate_wasm_store_identity_blockers(&report.blockers)?;
898    Ok(())
899}
900
901pub fn promotion_plan_transform_evidence(
902    request: PromotionPlanTransformEvidenceRequest,
903) -> Result<PromotionPlanTransformEvidenceV1, PromotionPlanTransformEvidenceError> {
904    ensure_evidence_field("evidence_id", &request.evidence_id)?;
905    ensure_evidence_field("generated_at", &request.generated_at)?;
906    validate_promotion_plan_transform(&request.transform)?;
907    let evidence = PromotionPlanTransformEvidenceV1 {
908        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
909        evidence_id: request.evidence_id,
910        generated_at: request.generated_at,
911        transform: request.transform,
912    };
913    validate_promotion_plan_transform_evidence(&evidence)?;
914    Ok(evidence)
915}
916
917pub fn artifact_promotion_plan(
918    request: ArtifactPromotionPlanRequest,
919) -> Result<ArtifactPromotionPlanV1, ArtifactPromotionPlanError> {
920    ensure_artifact_promotion_plan_field("plan_id", &request.plan_id)?;
921    ensure_artifact_promotion_plan_field("generated_at", &request.generated_at)?;
922    validate_promotion_readiness(&request.readiness)?;
923    validate_promotion_artifact_identity_report(&request.artifact_identity_report)?;
924    validate_promotion_plan_transform(&request.transform)?;
925    if let Some(lineage) = &request.target_execution_lineage {
926        validate_promotion_target_execution_lineage(lineage)?;
927    }
928
929    let blockers =
930        artifact_promotion_plan_blockers(&request.readiness, &request.artifact_identity_report);
931    let status = if blockers.is_empty() {
932        PromotionReadinessStatusV1::Ready
933    } else {
934        PromotionReadinessStatusV1::Blocked
935    };
936    let plan = ArtifactPromotionPlanV1 {
937        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
938        plan_id: request.plan_id,
939        generated_at: request.generated_at,
940        status,
941        target_plan_id: request.transform.target_plan_id.clone(),
942        promoted_plan_id: request.transform.promoted_plan_id.clone(),
943        promotion_plan_lineage_digest: request.transform.promotion_plan_lineage_digest.clone(),
944        readiness: request.readiness,
945        artifact_identity_report: request.artifact_identity_report,
946        transform: request.transform,
947        target_execution_lineage: request.target_execution_lineage,
948        blockers,
949    };
950    validate_artifact_promotion_plan(&plan)?;
951    Ok(plan)
952}
953
954pub fn promotion_target_execution_lineage(
955    request: PromotionTargetExecutionLineageRequest,
956) -> Result<PromotionTargetExecutionLineageV1, PromotionTargetExecutionLineageError> {
957    ensure_target_execution_lineage_field("lineage_id", &request.lineage_id)?;
958    ensure_target_execution_lineage_field("generated_at", &request.generated_at)?;
959    validate_promotion_plan_transform(&request.transform)?;
960    validate_deployment_execution_preflight(&request.execution_preflight)?;
961
962    let target_execution_lineage_digest = promotion_target_execution_lineage_digest(
963        &request.transform,
964        &request.execution_preflight,
965        false,
966    );
967    let lineage = PromotionTargetExecutionLineageV1 {
968        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
969        lineage_id: request.lineage_id,
970        generated_at: request.generated_at,
971        target_execution_lineage_digest,
972        transform: request.transform,
973        execution_preflight: request.execution_preflight,
974        execution_attempted: false,
975    };
976    validate_promotion_target_execution_lineage(&lineage)?;
977    Ok(lineage)
978}
979
980pub fn validate_artifact_promotion_plan(
981    plan: &ArtifactPromotionPlanV1,
982) -> Result<(), ArtifactPromotionPlanError> {
983    if plan.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
984        return Err(ArtifactPromotionPlanError::SchemaVersionMismatch {
985            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
986            found: plan.schema_version,
987        });
988    }
989    ensure_artifact_promotion_plan_field("plan_id", &plan.plan_id)?;
990    ensure_artifact_promotion_plan_field("generated_at", &plan.generated_at)?;
991    ensure_artifact_promotion_plan_field("target_plan_id", &plan.target_plan_id)?;
992    ensure_artifact_promotion_plan_field("promoted_plan_id", &plan.promoted_plan_id)?;
993    ensure_artifact_promotion_plan_field(
994        "promotion_plan_lineage_digest",
995        &plan.promotion_plan_lineage_digest,
996    )?;
997    ensure_artifact_promotion_status_matches_blockers(plan)?;
998    validate_promotion_readiness(&plan.readiness)?;
999    validate_promotion_artifact_identity_report(&plan.artifact_identity_report)?;
1000    validate_promotion_plan_transform(&plan.transform)?;
1001    ensure_artifact_promotion_plan_linkage(plan)?;
1002    if let Some(lineage) = &plan.target_execution_lineage {
1003        validate_promotion_target_execution_lineage(lineage)?;
1004        if lineage.transform != plan.transform {
1005            return Err(ArtifactPromotionPlanError::LinkageMismatch {
1006                field: "target_execution_lineage.transform",
1007            });
1008        }
1009    }
1010    Ok(())
1011}
1012
1013pub fn validate_artifact_promotion_plan_for_check(
1014    plan: &ArtifactPromotionPlanV1,
1015    target_check: &DeploymentCheckV1,
1016) -> Result<(), ArtifactPromotionPlanError> {
1017    validate_artifact_promotion_plan(plan)?;
1018    if target_check.plan != plan.transform.promoted_plan {
1019        return Err(ArtifactPromotionPlanError::LinkageMismatch {
1020            field: "target_check.plan",
1021        });
1022    }
1023    let Some(lineage) = &plan.target_execution_lineage else {
1024        return Err(ArtifactPromotionPlanError::MissingTargetExecutionLineage);
1025    };
1026    validate_deployment_execution_preflight_for_check(target_check, &lineage.execution_preflight)
1027        .map_err(ArtifactPromotionPlanError::TargetCheck)?;
1028    Ok(())
1029}
1030
1031pub fn artifact_promotion_provenance_report(
1032    request: ArtifactPromotionProvenanceReportRequest,
1033) -> Result<ArtifactPromotionProvenanceReportV1, ArtifactPromotionProvenanceReportError> {
1034    ensure_provenance_report_field("report_id", &request.report_id)?;
1035    validate_artifact_promotion_plan(&request.artifact_promotion_plan)?;
1036    if let Some(report) = &request.wasm_store_identity_report {
1037        validate_promotion_wasm_store_identity_report(report)?;
1038    }
1039    if let Some(report) = &request.materialization_identity_report {
1040        validate_promotion_materialization_identity_report(report)?;
1041    }
1042    let report = build_artifact_promotion_provenance_report(request);
1043    validate_artifact_promotion_provenance_report(&report)?;
1044    Ok(report)
1045}
1046
1047pub fn validate_artifact_promotion_provenance_report(
1048    report: &ArtifactPromotionProvenanceReportV1,
1049) -> Result<(), ArtifactPromotionProvenanceReportError> {
1050    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1051        return Err(
1052            ArtifactPromotionProvenanceReportError::SchemaVersionMismatch {
1053                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1054                found: report.schema_version,
1055            },
1056        );
1057    }
1058    ensure_provenance_report_field("report_id", &report.report_id)?;
1059    ensure_provenance_report_field(
1060        "artifact_promotion_plan_id",
1061        &report.artifact_promotion_plan_id,
1062    )?;
1063    ensure_provenance_report_field("target_plan_id", &report.target_plan_id)?;
1064    ensure_provenance_report_field("promoted_plan_id", &report.promoted_plan_id)?;
1065    ensure_provenance_report_field(
1066        "promotion_plan_lineage_digest",
1067        &report.promotion_plan_lineage_digest,
1068    )?;
1069    ensure_provenance_report_field("readiness_id", &report.readiness_id)?;
1070    ensure_provenance_report_field(
1071        "artifact_identity_report_id",
1072        &report.artifact_identity_report_id,
1073    )?;
1074    ensure_provenance_report_field("transform_id", &report.transform_id)?;
1075    if let Some(lineage_id) = &report.target_execution_lineage_id {
1076        ensure_provenance_report_field("target_execution_lineage_id", lineage_id)?;
1077    }
1078    if let Some(report_id) = &report.wasm_store_identity_report_id {
1079        ensure_provenance_report_field("wasm_store_identity_report_id", report_id)?;
1080    }
1081    if let Some(report_id) = &report.materialization_identity_report_id {
1082        ensure_provenance_report_field("materialization_identity_report_id", report_id)?;
1083    }
1084    ensure_provenance_report_status_matches_blockers(report)?;
1085    ensure_unique_provenance_roles(&report.roles)?;
1086    for role in &report.roles {
1087        validate_role_promotion_provenance(role)?;
1088    }
1089    validate_provenance_report_blockers(&report.blockers)?;
1090    Ok(())
1091}
1092
1093pub fn validate_promotion_plan_transform_evidence(
1094    evidence: &PromotionPlanTransformEvidenceV1,
1095) -> Result<(), PromotionPlanTransformEvidenceError> {
1096    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1097        return Err(PromotionPlanTransformEvidenceError::SchemaVersionMismatch {
1098            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1099            found: evidence.schema_version,
1100        });
1101    }
1102    ensure_evidence_field("evidence_id", &evidence.evidence_id)?;
1103    ensure_evidence_field("generated_at", &evidence.generated_at)?;
1104    validate_promotion_plan_transform(&evidence.transform)?;
1105    Ok(())
1106}
1107
1108pub fn validate_promotion_target_execution_lineage(
1109    lineage: &PromotionTargetExecutionLineageV1,
1110) -> Result<(), PromotionTargetExecutionLineageError> {
1111    if lineage.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1112        return Err(
1113            PromotionTargetExecutionLineageError::SchemaVersionMismatch {
1114                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1115                found: lineage.schema_version,
1116            },
1117        );
1118    }
1119    ensure_target_execution_lineage_field("lineage_id", &lineage.lineage_id)?;
1120    ensure_target_execution_lineage_field("generated_at", &lineage.generated_at)?;
1121    ensure_target_execution_lineage_sha256(
1122        "target_execution_lineage_digest",
1123        &lineage.target_execution_lineage_digest,
1124    )?;
1125    validate_promotion_plan_transform(&lineage.transform)?;
1126    validate_deployment_execution_preflight(&lineage.execution_preflight)?;
1127    if lineage.execution_attempted {
1128        return Err(PromotionTargetExecutionLineageError::ExecutionAttempted);
1129    }
1130    if lineage.execution_preflight.plan_id != lineage.transform.promoted_plan_id {
1131        return Err(PromotionTargetExecutionLineageError::LinkageMismatch {
1132            field: "execution_preflight.plan_id",
1133        });
1134    }
1135    let expected = promotion_target_execution_lineage_digest(
1136        &lineage.transform,
1137        &lineage.execution_preflight,
1138        lineage.execution_attempted,
1139    );
1140    if expected != lineage.target_execution_lineage_digest {
1141        return Err(PromotionTargetExecutionLineageError::LinkageMismatch {
1142            field: "target_execution_lineage_digest",
1143        });
1144    }
1145    Ok(())
1146}
1147
1148pub fn validate_promotion_plan_transform(
1149    transform: &PromotionPlanTransformV1,
1150) -> Result<(), PromotionPlanTransformError> {
1151    if transform.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1152        return Err(PromotionPlanTransformError::SchemaVersionMismatch {
1153            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1154            found: transform.schema_version,
1155        });
1156    }
1157    ensure_transform_field("transform_id", &transform.transform_id)?;
1158    ensure_transform_field("target_plan_id", &transform.target_plan_id)?;
1159    ensure_transform_field("promoted_plan_id", &transform.promoted_plan_id)?;
1160    ensure_transform_field(
1161        "promotion_plan_lineage_digest",
1162        &transform.promotion_plan_lineage_digest,
1163    )?;
1164    ensure_transform_field("promoted_plan.plan_id", &transform.promoted_plan.plan_id)?;
1165    if transform.promoted_plan.plan_id != transform.promoted_plan_id {
1166        return Err(PromotionPlanTransformError::PromotedPlanIdMismatch {
1167            expected: transform.promoted_plan_id.clone(),
1168            found: transform.promoted_plan.plan_id.clone(),
1169        });
1170    }
1171    ensure_unique_transform_roles(&transform.roles)?;
1172    for role in &transform.roles {
1173        validate_role_plan_transform(role, &transform.promoted_plan)?;
1174    }
1175    let expected = promotion_plan_lineage_digest(
1176        &transform.target_plan_id,
1177        &transform.promoted_plan_id,
1178        &transform.promoted_plan,
1179        &transform.roles,
1180    );
1181    if expected != transform.promotion_plan_lineage_digest {
1182        return Err(PromotionPlanTransformError::RoleStateMismatch {
1183            role: "promotion_plan_lineage".to_string(),
1184            field: "promotion_plan_lineage_digest",
1185        });
1186    }
1187    Ok(())
1188}
1189
1190#[must_use]
1191pub fn promotion_readiness_from_inputs(
1192    readiness_id: impl Into<String>,
1193    target_plan: &DeploymentPlanV1,
1194    inputs: &[RolePromotionInputV1],
1195) -> PromotionReadinessV1 {
1196    let mut roles = Vec::with_capacity(inputs.len());
1197    let mut blockers = Vec::new();
1198    let mut warnings = Vec::new();
1199
1200    for input in inputs {
1201        let target_artifact = target_plan
1202            .role_artifacts
1203            .iter()
1204            .find(|artifact| artifact.role == input.role);
1205        let Some(target_artifact) = target_artifact else {
1206            blockers.push(promotion_finding(
1207                "promotion_target_role_missing",
1208                format!("target plan does not contain role {}", input.role),
1209                SafetySeverityV1::HardFailure,
1210                &input.role,
1211            ));
1212            continue;
1213        };
1214
1215        let role_readiness = role_promotion_readiness(input, target_artifact);
1216        collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
1217        roles.push(role_readiness);
1218    }
1219
1220    let status = if blockers.is_empty() {
1221        PromotionReadinessStatusV1::Ready
1222    } else {
1223        PromotionReadinessStatusV1::Blocked
1224    };
1225
1226    PromotionReadinessV1 {
1227        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1228        readiness_id: readiness_id.into(),
1229        target_plan_id: target_plan.plan_id.clone(),
1230        status,
1231        roles,
1232        blockers,
1233        warnings,
1234    }
1235}
1236
1237#[must_use]
1238pub fn promotion_readiness_from_inputs_with_policy(
1239    readiness_id: impl Into<String>,
1240    target_plan: &DeploymentPlanV1,
1241    inputs: &[RolePromotionInputV1],
1242    policies: &[RolePromotionPolicyV1],
1243) -> PromotionReadinessV1 {
1244    let readiness_id = readiness_id.into();
1245    let policy_check =
1246        promotion_policy_check_from_inputs(format!("{readiness_id}:policy"), inputs, policies);
1247    let mut readiness = promotion_readiness_from_inputs(readiness_id, target_plan, inputs);
1248    readiness.blockers.extend(policy_check.blockers);
1249    readiness.status = if readiness.blockers.is_empty() {
1250        PromotionReadinessStatusV1::Ready
1251    } else {
1252        PromotionReadinessStatusV1::Blocked
1253    };
1254    readiness
1255}
1256
1257pub fn validate_promotion_readiness(
1258    readiness: &PromotionReadinessV1,
1259) -> Result<(), PromotionReadinessError> {
1260    if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1261        return Err(PromotionReadinessError::SchemaVersionMismatch {
1262            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1263            found: readiness.schema_version,
1264        });
1265    }
1266    ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
1267    ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
1268    ensure_readiness_status_matches_blockers(readiness)?;
1269    ensure_unique_readiness_roles(&readiness.roles)?;
1270    for role in &readiness.roles {
1271        validate_role_readiness(role)?;
1272    }
1273    validate_readiness_findings(
1274        "blockers",
1275        &readiness.blockers,
1276        SafetySeverityV1::HardFailure,
1277    )?;
1278    validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
1279    Ok(())
1280}
1281
1282pub fn validate_role_artifact_source(
1283    source: &RoleArtifactSourceV1,
1284) -> Result<(), PromotionArtifactSourceError> {
1285    ensure_field("role", &source.role)?;
1286    ensure_locator_requirement(source)?;
1287    ensure_previous_receipt_requirement(source)?;
1288    ensure_digest_requirement(source)?;
1289    ensure_previous_receipt_lineage_digest_requirement(source)?;
1290    ensure_optional_sha256(
1291        "expected_wasm_sha256",
1292        source.expected_wasm_sha256.as_deref(),
1293    )?;
1294    ensure_optional_sha256(
1295        "expected_wasm_gz_sha256",
1296        source.expected_wasm_gz_sha256.as_deref(),
1297    )?;
1298    ensure_optional_sha256(
1299        "expected_candid_sha256",
1300        source.expected_candid_sha256.as_deref(),
1301    )?;
1302    ensure_optional_sha256(
1303        "expected_canonical_embedded_config_sha256",
1304        source.expected_canonical_embedded_config_sha256.as_deref(),
1305    )?;
1306    ensure_optional_sha256(
1307        "previous_receipt_lineage_digest",
1308        source.previous_receipt_lineage_digest.as_deref(),
1309    )?;
1310    Ok(())
1311}
1312
1313pub fn validate_role_promotion_policy(
1314    policy: &RolePromotionPolicyV1,
1315) -> Result<(), PromotionPolicyCheckError> {
1316    ensure_policy_field("role", &policy.role)?;
1317    if policy.allowed_promotion_levels.is_empty() {
1318        return Err(PromotionPolicyCheckError::EmptyAllowedLevels {
1319            role: policy.role.clone(),
1320        });
1321    }
1322    let mut seen = BTreeSet::new();
1323    for level in &policy.allowed_promotion_levels {
1324        if !seen.insert(*level) {
1325            return Err(PromotionPolicyCheckError::DuplicateAllowedLevel {
1326                role: policy.role.clone(),
1327                level: *level,
1328            });
1329        }
1330    }
1331    let mut seen_requirements = BTreeSet::new();
1332    for requirement in &policy.requirements {
1333        if !seen_requirements.insert(*requirement) {
1334            return Err(PromotionPolicyCheckError::DecisionMismatch {
1335                role: policy.role.clone(),
1336                field: "requirements",
1337            });
1338        }
1339    }
1340    if policy
1341        .requirements
1342        .contains(&PromotionPolicyRequirementV1::SealedBytes)
1343        && policy
1344            .allowed_promotion_levels
1345            .iter()
1346            .any(|level| *level != PromotionArtifactLevelV1::SealedWasm)
1347    {
1348        return Err(PromotionPolicyCheckError::DecisionMismatch {
1349            role: policy.role.clone(),
1350            field: "sealed_bytes",
1351        });
1352    }
1353    Ok(())
1354}
1355
1356pub fn build_materialization_evidence(
1357    request: BuildMaterializationEvidenceRequest,
1358) -> Result<BuildMaterializationEvidenceV1, PromotionMaterializationIdentityError> {
1359    ensure_materialization_field("evidence_id", &request.evidence_id)?;
1360    validate_build_recipe_identity(&request.recipe)?;
1361    validate_build_materialization_input(&request.materialization_input)?;
1362    validate_build_materialization_result(&request.materialization_result)?;
1363    let computed_materialization_input_digest =
1364        build_materialization_input_digest(&request.materialization_input);
1365    let evidence = BuildMaterializationEvidenceV1 {
1366        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1367        evidence_id: request.evidence_id,
1368        recipe_id_matches_input: request.recipe.recipe_id
1369            == request.materialization_input.build_recipe_id,
1370        recipe_id_matches_result: request.recipe.recipe_id
1371            == request.materialization_result.build_recipe_id,
1372        materialization_input_digest_matches_result: computed_materialization_input_digest
1373            == request.materialization_result.materialization_input_digest,
1374        computed_materialization_input_digest,
1375        recipe: request.recipe,
1376        materialization_input: request.materialization_input,
1377        materialization_result: request.materialization_result,
1378    };
1379    validate_build_materialization_evidence(&evidence)?;
1380    Ok(evidence)
1381}
1382
1383pub fn validate_build_materialization_evidence(
1384    evidence: &BuildMaterializationEvidenceV1,
1385) -> Result<(), PromotionMaterializationIdentityError> {
1386    if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1387        return Err(
1388            PromotionMaterializationIdentityError::SchemaVersionMismatch {
1389                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1390                found: evidence.schema_version,
1391            },
1392        );
1393    }
1394    ensure_materialization_field("evidence_id", &evidence.evidence_id)?;
1395    validate_build_recipe_identity(&evidence.recipe)?;
1396    validate_build_materialization_input(&evidence.materialization_input)?;
1397    validate_build_materialization_result(&evidence.materialization_result)?;
1398    ensure_materialization_sha256(
1399        "computed_materialization_input_digest",
1400        &evidence.computed_materialization_input_digest,
1401    )?;
1402    ensure_materialization_link(
1403        "recipe_id_matches_input",
1404        evidence.recipe_id_matches_input
1405            == (evidence.recipe.recipe_id == evidence.materialization_input.build_recipe_id),
1406    )?;
1407    ensure_materialization_link("recipe_id_matches_input", evidence.recipe_id_matches_input)?;
1408    ensure_materialization_link(
1409        "recipe_id_matches_result",
1410        evidence.recipe_id_matches_result
1411            == (evidence.recipe.recipe_id == evidence.materialization_result.build_recipe_id),
1412    )?;
1413    ensure_materialization_link(
1414        "recipe_id_matches_result",
1415        evidence.recipe_id_matches_result,
1416    )?;
1417    let computed = build_materialization_input_digest(&evidence.materialization_input);
1418    if computed != evidence.computed_materialization_input_digest {
1419        return Err(PromotionMaterializationIdentityError::DigestMismatch {
1420            field: "computed_materialization_input_digest",
1421            expected: computed,
1422            found: evidence.computed_materialization_input_digest.clone(),
1423        });
1424    }
1425    ensure_materialization_link(
1426        "materialization_input_digest_matches_result",
1427        evidence.materialization_input_digest_matches_result
1428            == (evidence.computed_materialization_input_digest
1429                == evidence.materialization_result.materialization_input_digest),
1430    )?;
1431    ensure_materialization_link(
1432        "materialization_input_digest_matches_result",
1433        evidence.materialization_input_digest_matches_result,
1434    )?;
1435    Ok(())
1436}
1437
1438pub fn promotion_materialization_identity_report_from_evidence(
1439    request: PromotionMaterializationIdentityReportRequest,
1440) -> Result<PromotionMaterializationIdentityReportV1, PromotionMaterializationIdentityReportError> {
1441    ensure_materialization_report_field("report_id", &request.report_id)?;
1442    for evidence in &request.evidence {
1443        validate_build_materialization_evidence(evidence)?;
1444    }
1445    let report = promotion_materialization_identity_report(&request.report_id, &request.evidence);
1446    validate_promotion_materialization_identity_report(&report)?;
1447    Ok(report)
1448}
1449
1450#[must_use]
1451pub fn promotion_materialization_identity_report(
1452    report_id: impl Into<String>,
1453    evidence: &[BuildMaterializationEvidenceV1],
1454) -> PromotionMaterializationIdentityReportV1 {
1455    let roles = evidence
1456        .iter()
1457        .map(role_materialization_identity_from_evidence)
1458        .collect::<Vec<_>>();
1459    let output_groups = promotion_materialization_output_groups(&roles);
1460    let blockers = Vec::new();
1461    PromotionMaterializationIdentityReportV1 {
1462        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1463        report_id: report_id.into(),
1464        status: PromotionReadinessStatusV1::Ready,
1465        roles,
1466        output_groups,
1467        blockers,
1468    }
1469}
1470
1471pub fn validate_promotion_materialization_identity_report(
1472    report: &PromotionMaterializationIdentityReportV1,
1473) -> Result<(), PromotionMaterializationIdentityReportError> {
1474    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1475        return Err(
1476            PromotionMaterializationIdentityReportError::SchemaVersionMismatch {
1477                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1478                found: report.schema_version,
1479            },
1480        );
1481    }
1482    ensure_materialization_report_field("report_id", &report.report_id)?;
1483    ensure_materialization_report_status_matches_blockers(report)?;
1484    ensure_unique_materialization_report_roles(&report.roles)?;
1485    for role in &report.roles {
1486        validate_role_materialization_identity(role)?;
1487    }
1488    validate_materialization_output_groups(&report.roles, &report.output_groups)?;
1489    let expected_blockers = Vec::<SafetyFindingV1>::new();
1490    if report.blockers != expected_blockers {
1491        return Err(PromotionMaterializationIdentityReportError::BlockerMismatch);
1492    }
1493    validate_materialization_report_blockers(&report.blockers)?;
1494    Ok(())
1495}
1496
1497#[must_use]
1498pub fn build_materialization_input_digest(input: &BuildMaterializationInputV1) -> String {
1499    stable_json_sha256_hex(input)
1500}
1501
1502pub fn validate_build_recipe_identity(
1503    recipe: &BuildRecipeIdentityV1,
1504) -> Result<(), PromotionMaterializationIdentityError> {
1505    ensure_materialization_field("recipe_id", &recipe.recipe_id)?;
1506    ensure_materialization_field("source_revision", &recipe.source_revision)?;
1507    ensure_materialization_field("package_or_role_selector", &recipe.package_or_role_selector)?;
1508    ensure_materialization_field("cargo_profile", &recipe.cargo_profile)?;
1509    ensure_materialization_sha256("cargo_features_digest", &recipe.cargo_features_digest)?;
1510    ensure_materialization_sha256("cargo_lock_digest", &recipe.cargo_lock_digest)?;
1511    ensure_materialization_field("rust_toolchain", &recipe.rust_toolchain)?;
1512    ensure_materialization_field("builder_version", &recipe.builder_version)?;
1513    ensure_materialization_field("target_triple", &recipe.target_triple)?;
1514    ensure_materialization_field("linker_identity", &recipe.linker_identity)?;
1515    ensure_materialization_field("deterministic_build_mode", &recipe.deterministic_build_mode)?;
1516    ensure_materialization_field("wasm_opt_version", &recipe.wasm_opt_version)?;
1517    ensure_materialization_field("compression_identity", &recipe.compression_identity)?;
1518    Ok(())
1519}
1520
1521pub fn validate_build_materialization_input(
1522    input: &BuildMaterializationInputV1,
1523) -> Result<(), PromotionMaterializationIdentityError> {
1524    ensure_materialization_field("materialization_input_id", &input.materialization_input_id)?;
1525    ensure_materialization_field("build_recipe_id", &input.build_recipe_id)?;
1526    ensure_materialization_sha256(
1527        "canonical_embedded_config_sha256",
1528        &input.canonical_embedded_config_sha256,
1529    )?;
1530    ensure_materialization_field("network", &input.network)?;
1531    ensure_materialization_field("root_trust_anchor", &input.root_trust_anchor)?;
1532    ensure_materialization_field("runtime_variant", &input.runtime_variant)?;
1533    Ok(())
1534}
1535
1536pub fn validate_build_materialization_result(
1537    result: &BuildMaterializationResultV1,
1538) -> Result<(), PromotionMaterializationIdentityError> {
1539    ensure_materialization_field(
1540        "materialization_result_id",
1541        &result.materialization_result_id,
1542    )?;
1543    ensure_materialization_field("build_recipe_id", &result.build_recipe_id)?;
1544    ensure_materialization_sha256(
1545        "materialization_input_digest",
1546        &result.materialization_input_digest,
1547    )?;
1548    ensure_materialization_sha256("wasm_sha256", &result.wasm_sha256)?;
1549    ensure_materialization_sha256("wasm_gz_sha256", &result.wasm_gz_sha256)?;
1550    ensure_materialization_sha256("installed_module_hash", &result.installed_module_hash)?;
1551    ensure_materialization_sha256("candid_sha256", &result.candid_sha256)?;
1552    Ok(())
1553}
1554
1555fn apply_promotion_input_to_role_artifact(
1556    role_artifact: &mut RoleArtifactV1,
1557    input: &RolePromotionInputV1,
1558) {
1559    match input.promotion_level {
1560        PromotionArtifactLevelV1::SealedWasm => {
1561            role_artifact.source = artifact_source_for_promotion_source(input.source.kind);
1562            apply_promotion_source_locator(role_artifact, &input.source);
1563            role_artifact
1564                .wasm_sha256
1565                .clone_from(&input.source.expected_wasm_sha256);
1566            role_artifact
1567                .wasm_gz_sha256
1568                .clone_from(&input.source.expected_wasm_gz_sha256);
1569            role_artifact
1570                .candid_sha256
1571                .clone_from(&input.source.expected_candid_sha256);
1572            role_artifact
1573                .canonical_embedded_config_sha256
1574                .clone_from(&input.source.expected_canonical_embedded_config_sha256);
1575        }
1576        PromotionArtifactLevelV1::SourceBuild => {}
1577    }
1578}
1579
1580const fn artifact_source_for_promotion_source(kind: RoleArtifactSourceKindV1) -> ArtifactSourceV1 {
1581    match kind {
1582        RoleArtifactSourceKindV1::WorkspacePackage => ArtifactSourceV1::LocalBuild,
1583        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => ArtifactSourceV1::WasmStore,
1584        RoleArtifactSourceKindV1::PublishedPackage
1585        | RoleArtifactSourceKindV1::LocalWasm
1586        | RoleArtifactSourceKindV1::LocalWasmGz
1587        | RoleArtifactSourceKindV1::PreviousReceiptArtifact => ArtifactSourceV1::External,
1588    }
1589}
1590
1591fn apply_promotion_source_locator(
1592    role_artifact: &mut RoleArtifactV1,
1593    source: &RoleArtifactSourceV1,
1594) {
1595    match source.kind {
1596        RoleArtifactSourceKindV1::LocalWasm => {
1597            role_artifact.wasm_path.clone_from(&source.locator);
1598        }
1599        RoleArtifactSourceKindV1::LocalWasmGz => {
1600            role_artifact.wasm_gz_path.clone_from(&source.locator);
1601        }
1602        _ => {}
1603    }
1604}
1605
1606fn promotion_plan_transform_from_parts(
1607    target_plan: &DeploymentPlanV1,
1608    promoted_plan: DeploymentPlanV1,
1609    inputs: &[RolePromotionInputV1],
1610) -> PromotionPlanTransformV1 {
1611    let roles = inputs
1612        .iter()
1613        .filter_map(|input| {
1614            let before = target_plan
1615                .role_artifacts
1616                .iter()
1617                .find(|artifact| artifact.role == input.role)?;
1618            let after = promoted_plan
1619                .role_artifacts
1620                .iter()
1621                .find(|artifact| artifact.role == input.role)?;
1622            Some(role_plan_transform(input, before, after))
1623        })
1624        .collect::<Vec<_>>();
1625    let promotion_plan_lineage_digest = promotion_plan_lineage_digest(
1626        &target_plan.plan_id,
1627        &promoted_plan.plan_id,
1628        &promoted_plan,
1629        &roles,
1630    );
1631
1632    PromotionPlanTransformV1 {
1633        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1634        transform_id: format!("promotion-transform:{}", promoted_plan.plan_id),
1635        target_plan_id: target_plan.plan_id.clone(),
1636        promoted_plan_id: promoted_plan.plan_id.clone(),
1637        promotion_plan_lineage_digest,
1638        promoted_plan,
1639        roles,
1640    }
1641}
1642
1643#[must_use]
1644pub fn promotion_plan_lineage_digest(
1645    target_plan_id: &str,
1646    promoted_plan_id: &str,
1647    promoted_plan: &DeploymentPlanV1,
1648    roles: &[RolePromotionPlanTransformV1],
1649) -> String {
1650    stable_json_sha256_hex(&PromotionPlanLineageInput {
1651        target_plan_id,
1652        promoted_plan_id,
1653        promoted_plan,
1654        roles,
1655    })
1656}
1657
1658#[must_use]
1659pub fn promotion_target_execution_lineage_digest(
1660    transform: &PromotionPlanTransformV1,
1661    preflight: &DeploymentExecutionPreflightV1,
1662    execution_attempted: bool,
1663) -> String {
1664    stable_json_sha256_hex(&PromotionTargetExecutionLineageInput {
1665        promotion_plan_lineage_digest: &transform.promotion_plan_lineage_digest,
1666        promoted_plan_id: &transform.promoted_plan_id,
1667        preflight_plan_id: &preflight.plan_id,
1668        preflight_safety_report_id: &preflight.safety_report_id,
1669        preflight_authority_plan_id: &preflight.authority_plan_id,
1670        preflight_backend: &preflight.backend,
1671        preflight_status: preflight.status,
1672        planned_phases: &preflight.planned_phases,
1673        required_capabilities: &preflight.required_capabilities,
1674        missing_capabilities: &preflight.missing_capabilities,
1675        execution_attempted,
1676    })
1677}
1678
1679fn role_plan_transform(
1680    input: &RolePromotionInputV1,
1681    before: &RoleArtifactV1,
1682    after: &RoleArtifactV1,
1683) -> RolePromotionPlanTransformV1 {
1684    RolePromotionPlanTransformV1 {
1685        role: input.role.clone(),
1686        promotion_level: input.promotion_level,
1687        source_kind: input.source.kind,
1688        source_locator: input.source.locator.clone(),
1689        artifact_source_before: before.source,
1690        artifact_source_after: after.source,
1691        wasm_sha256_before: before.wasm_sha256.clone(),
1692        wasm_sha256_after: after.wasm_sha256.clone(),
1693        wasm_gz_sha256_before: before.wasm_gz_sha256.clone(),
1694        wasm_gz_sha256_after: after.wasm_gz_sha256.clone(),
1695        candid_sha256_before: before.candid_sha256.clone(),
1696        candid_sha256_after: after.candid_sha256.clone(),
1697        canonical_embedded_config_sha256_before: before.canonical_embedded_config_sha256.clone(),
1698        canonical_embedded_config_sha256_after: after.canonical_embedded_config_sha256.clone(),
1699        artifact_identity_changed: artifact_identity_changed(before, after),
1700        embedded_config_changed: before.canonical_embedded_config_sha256
1701            != after.canonical_embedded_config_sha256,
1702        target_materialization_preserved: input.promotion_level
1703            == PromotionArtifactLevelV1::SourceBuild
1704            && role_materialization_identity_matches(before, after),
1705        source_build_materialization: None,
1706    }
1707}
1708
1709fn attach_source_build_materialization(
1710    transform: &mut PromotionPlanTransformV1,
1711    inputs: &[RolePromotionInputV1],
1712    evidence: &[BuildMaterializationEvidenceV1],
1713) -> Result<(), PromotionPlanTransformError> {
1714    let input_roles = inputs
1715        .iter()
1716        .map(|input| input.role.as_str())
1717        .collect::<BTreeSet<_>>();
1718    let mut links = BTreeMap::new();
1719    for item in evidence {
1720        validate_build_materialization_evidence(item)?;
1721        let role = item.recipe.package_or_role_selector.as_str();
1722        if !input_roles.contains(role) {
1723            return Err(PromotionPlanTransformError::UnexpectedMaterializationRole {
1724                role: role.to_string(),
1725            });
1726        }
1727        if links
1728            .insert(role.to_string(), materialization_link_from_evidence(item))
1729            .is_some()
1730        {
1731            return Err(PromotionPlanTransformError::DuplicateMaterializationRole {
1732                role: role.to_string(),
1733            });
1734        }
1735    }
1736
1737    for role in &mut transform.roles {
1738        match role.promotion_level {
1739            PromotionArtifactLevelV1::SourceBuild => {
1740                let Some(link) = links.remove(&role.role) else {
1741                    return Err(PromotionPlanTransformError::MaterializationRoleMissing {
1742                        role: role.role.clone(),
1743                    });
1744                };
1745                role.source_build_materialization = Some(link);
1746            }
1747            PromotionArtifactLevelV1::SealedWasm => {
1748                if links.remove(&role.role).is_some() {
1749                    return Err(PromotionPlanTransformError::UnexpectedMaterializationRole {
1750                        role: role.role.clone(),
1751                    });
1752                }
1753            }
1754        }
1755    }
1756
1757    if let Some(role) = links.keys().next() {
1758        return Err(PromotionPlanTransformError::UnexpectedMaterializationRole {
1759            role: role.clone(),
1760        });
1761    }
1762    Ok(())
1763}
1764
1765fn materialization_link_from_evidence(
1766    evidence: &BuildMaterializationEvidenceV1,
1767) -> RolePromotionMaterializationLinkV1 {
1768    RolePromotionMaterializationLinkV1 {
1769        role: evidence.recipe.package_or_role_selector.clone(),
1770        evidence_id: evidence.evidence_id.clone(),
1771        recipe_id: evidence.recipe.recipe_id.clone(),
1772        materialization_input_id: evidence
1773            .materialization_input
1774            .materialization_input_id
1775            .clone(),
1776        materialization_result_id: evidence
1777            .materialization_result
1778            .materialization_result_id
1779            .clone(),
1780        materialization_input_digest: evidence.computed_materialization_input_digest.clone(),
1781        wasm_sha256: evidence.materialization_result.wasm_sha256.clone(),
1782        wasm_gz_sha256: evidence.materialization_result.wasm_gz_sha256.clone(),
1783        installed_module_hash: evidence
1784            .materialization_result
1785            .installed_module_hash
1786            .clone(),
1787        candid_sha256: evidence.materialization_result.candid_sha256.clone(),
1788    }
1789}
1790
1791fn artifact_promotion_plan_blockers(
1792    readiness: &PromotionReadinessV1,
1793    artifact_identity_report: &PromotionArtifactIdentityReportV1,
1794) -> Vec<SafetyFindingV1> {
1795    let mut blockers =
1796        Vec::with_capacity(readiness.blockers.len() + artifact_identity_report.blockers.len());
1797    blockers.extend(readiness.blockers.clone());
1798    blockers.extend(artifact_identity_report.blockers.clone());
1799    blockers
1800}
1801
1802fn build_artifact_promotion_provenance_report(
1803    request: ArtifactPromotionProvenanceReportRequest,
1804) -> ArtifactPromotionProvenanceReportV1 {
1805    let plan = request.artifact_promotion_plan;
1806    let mut roles = plan
1807        .transform
1808        .roles
1809        .iter()
1810        .map(role_promotion_provenance_from_transform)
1811        .collect::<Vec<_>>();
1812    attach_wasm_store_provenance(&mut roles, request.wasm_store_identity_report.as_ref());
1813    attach_materialization_provenance(&mut roles, request.materialization_identity_report.as_ref());
1814    let blockers = artifact_promotion_provenance_blockers(
1815        &plan,
1816        request.wasm_store_identity_report.as_ref(),
1817        request.materialization_identity_report.as_ref(),
1818        &roles,
1819    );
1820    let status = if blockers.is_empty() {
1821        PromotionReadinessStatusV1::Ready
1822    } else {
1823        PromotionReadinessStatusV1::Blocked
1824    };
1825    ArtifactPromotionProvenanceReportV1 {
1826        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1827        report_id: request.report_id,
1828        status,
1829        artifact_promotion_plan_id: plan.plan_id,
1830        target_plan_id: plan.target_plan_id,
1831        promoted_plan_id: plan.promoted_plan_id,
1832        promotion_plan_lineage_digest: plan.promotion_plan_lineage_digest,
1833        readiness_id: plan.readiness.readiness_id,
1834        artifact_identity_report_id: plan.artifact_identity_report.report_id,
1835        transform_id: plan.transform.transform_id,
1836        target_execution_lineage_id: plan
1837            .target_execution_lineage
1838            .map(|lineage| lineage.lineage_id),
1839        wasm_store_identity_report_id: request
1840            .wasm_store_identity_report
1841            .map(|report| report.report_id),
1842        materialization_identity_report_id: request
1843            .materialization_identity_report
1844            .map(|report| report.report_id),
1845        execution_attempted: false,
1846        roles,
1847        blockers,
1848    }
1849}
1850
1851fn artifact_promotion_provenance_blockers(
1852    plan: &ArtifactPromotionPlanV1,
1853    wasm_store_report: Option<&PromotionWasmStoreIdentityReportV1>,
1854    materialization_report: Option<&PromotionMaterializationIdentityReportV1>,
1855    roles: &[RolePromotionProvenanceV1],
1856) -> Vec<SafetyFindingV1> {
1857    let mut blockers = plan.blockers.clone();
1858    if let Some(report) = wasm_store_report {
1859        blockers.extend(report.blockers.iter().cloned());
1860    }
1861    if let Some(report) = materialization_report {
1862        blockers.extend(report.blockers.iter().cloned());
1863    }
1864    let role_names = roles
1865        .iter()
1866        .map(|role| role.role.as_str())
1867        .collect::<BTreeSet<_>>();
1868    if let Some(report) = wasm_store_report {
1869        for role in &report.roles {
1870            if !role_names.contains(role.role.as_str()) {
1871                blockers.push(promotion_finding(
1872                    "promotion_provenance_unknown_wasm_store_role",
1873                    format!(
1874                        "wasm-store identity report contains unknown role {}",
1875                        role.role
1876                    ),
1877                    SafetySeverityV1::HardFailure,
1878                    &role.role,
1879                ));
1880            }
1881        }
1882    }
1883    if let Some(report) = materialization_report {
1884        for role in &report.roles {
1885            if !role_names.contains(role.role.as_str()) {
1886                blockers.push(promotion_finding(
1887                    "promotion_provenance_unknown_materialization_role",
1888                    format!(
1889                        "materialization identity report contains unknown role {}",
1890                        role.role
1891                    ),
1892                    SafetySeverityV1::HardFailure,
1893                    &role.role,
1894                ));
1895            }
1896        }
1897    }
1898    blockers
1899}
1900
1901fn role_promotion_provenance_from_transform(
1902    role: &RolePromotionPlanTransformV1,
1903) -> RolePromotionProvenanceV1 {
1904    RolePromotionProvenanceV1 {
1905        role: role.role.clone(),
1906        promotion_level: role.promotion_level,
1907        source_kind: role.source_kind,
1908        artifact_identity_changed: role.artifact_identity_changed,
1909        embedded_config_changed: role.embedded_config_changed,
1910        target_materialization_preserved: role.target_materialization_preserved,
1911        materialization_evidence_id: role
1912            .source_build_materialization
1913            .as_ref()
1914            .map(|materialization| materialization.evidence_id.clone()),
1915        wasm_store_locator: None,
1916    }
1917}
1918
1919fn attach_wasm_store_provenance(
1920    roles: &mut [RolePromotionProvenanceV1],
1921    report: Option<&PromotionWasmStoreIdentityReportV1>,
1922) {
1923    let Some(report) = report else {
1924        return;
1925    };
1926    for role in roles {
1927        if let Some(wasm_store_role) = report.roles.iter().find(|item| item.role == role.role) {
1928            role.wasm_store_locator = wasm_store_role.wasm_store_locator.clone();
1929        }
1930    }
1931}
1932
1933fn attach_materialization_provenance(
1934    roles: &mut [RolePromotionProvenanceV1],
1935    report: Option<&PromotionMaterializationIdentityReportV1>,
1936) {
1937    let Some(report) = report else {
1938        return;
1939    };
1940    for role in roles {
1941        if let Some(materialization_role) = report.roles.iter().find(|item| item.role == role.role)
1942        {
1943            role.materialization_evidence_id = Some(materialization_role.evidence_id.clone());
1944        }
1945    }
1946}
1947
1948fn wasm_store_identity_blockers(
1949    roles: &[RolePromotionWasmStoreIdentityV1],
1950) -> Vec<SafetyFindingV1> {
1951    let mut blockers = Vec::new();
1952    for role in roles {
1953        if role.transport != ArtifactTransportV1::WasmStore {
1954            blockers.push(promotion_finding(
1955                "promotion_wasm_store_transport_mismatch",
1956                format!("role {} was not staged through wasm_store", role.role),
1957                SafetySeverityV1::HardFailure,
1958                &role.role,
1959            ));
1960        }
1961        if role.wasm_store_locator.as_deref().is_none_or(str::is_empty) {
1962            blockers.push(promotion_finding(
1963                "promotion_wasm_store_locator_missing",
1964                format!("role {} does not record a wasm_store locator", role.role),
1965                SafetySeverityV1::HardFailure,
1966                &role.role,
1967            ));
1968        }
1969        if role.verified_postcondition.status != ObservationStatusV1::Observed {
1970            blockers.push(promotion_finding(
1971                "promotion_wasm_store_postcondition_not_observed",
1972                format!(
1973                    "role {} wasm_store postcondition is {:?}",
1974                    role.role, role.verified_postcondition.status
1975                ),
1976                SafetySeverityV1::HardFailure,
1977                &role.role,
1978            ));
1979        }
1980        if role.published_chunk_count != role.prepared_chunk_hashes.len() {
1981            blockers.push(promotion_finding(
1982                "promotion_wasm_store_chunk_count_mismatch",
1983                format!(
1984                    "role {} published {} chunk(s) for {} prepared chunk hash(es)",
1985                    role.role,
1986                    role.published_chunk_count,
1987                    role.prepared_chunk_hashes.len()
1988                ),
1989                SafetySeverityV1::HardFailure,
1990                &role.role,
1991            ));
1992        }
1993    }
1994    blockers
1995}
1996
1997fn refresh_promotion_plan_lineage_digest(transform: &mut PromotionPlanTransformV1) {
1998    transform.promotion_plan_lineage_digest = promotion_plan_lineage_digest(
1999        &transform.target_plan_id,
2000        &transform.promoted_plan_id,
2001        &transform.promoted_plan,
2002        &transform.roles,
2003    );
2004}
2005
2006fn artifact_identity_changed(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
2007    before.source != after.source
2008        || before.wasm_path != after.wasm_path
2009        || before.wasm_gz_path != after.wasm_gz_path
2010        || before.wasm_sha256 != after.wasm_sha256
2011        || before.wasm_gz_sha256 != after.wasm_gz_sha256
2012        || before.candid_path != after.candid_path
2013        || before.candid_sha256 != after.candid_sha256
2014}
2015
2016fn role_materialization_identity_matches(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
2017    before.source == after.source
2018        && before.wasm_path == after.wasm_path
2019        && before.wasm_gz_path == after.wasm_gz_path
2020        && before.wasm_sha256 == after.wasm_sha256
2021        && before.wasm_gz_sha256 == after.wasm_gz_sha256
2022        && before.candid_path == after.candid_path
2023        && before.candid_sha256 == after.candid_sha256
2024        && before.canonical_embedded_config_sha256 == after.canonical_embedded_config_sha256
2025}
2026
2027fn role_promotion_artifact_identity(
2028    input: &RolePromotionInputV1,
2029) -> RolePromotionArtifactIdentityV1 {
2030    let wasm_sha256 = input.source.expected_wasm_sha256.clone();
2031    let wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
2032    RolePromotionArtifactIdentityV1 {
2033        role: input.role.clone(),
2034        promotion_level: input.promotion_level,
2035        source_kind: input.source.kind,
2036        source_locator: input.source.locator.clone(),
2037        identity_kind: promotion_artifact_identity_kind(input.promotion_level, &input.source),
2038        digest_pinned: wasm_sha256.is_some() || wasm_gz_sha256.is_some(),
2039        wasm_sha256,
2040        wasm_gz_sha256,
2041        candid_sha256: input.source.expected_candid_sha256.clone(),
2042        canonical_embedded_config_sha256: input
2043            .source
2044            .expected_canonical_embedded_config_sha256
2045            .clone(),
2046    }
2047}
2048
2049fn role_wasm_store_identity_from_staging(
2050    receipt: &StagingReceiptV1,
2051) -> RolePromotionWasmStoreIdentityV1 {
2052    RolePromotionWasmStoreIdentityV1 {
2053        role: receipt.role.clone(),
2054        artifact_identity: receipt.artifact_identity.clone(),
2055        transport: receipt.transport,
2056        wasm_store_locator: receipt.wasm_store_locator.clone(),
2057        prepared_chunk_hashes: receipt.prepared_chunk_hashes.clone(),
2058        published_chunk_count: receipt.published_chunk_count,
2059        verified_postcondition: receipt.verified_postcondition.clone(),
2060    }
2061}
2062
2063fn role_materialization_identity_from_evidence(
2064    evidence: &BuildMaterializationEvidenceV1,
2065) -> RolePromotionMaterializationIdentityV1 {
2066    RolePromotionMaterializationIdentityV1 {
2067        role: evidence.recipe.package_or_role_selector.clone(),
2068        evidence_id: evidence.evidence_id.clone(),
2069        recipe_id: evidence.recipe.recipe_id.clone(),
2070        materialization_input_id: evidence
2071            .materialization_input
2072            .materialization_input_id
2073            .clone(),
2074        materialization_result_id: evidence
2075            .materialization_result
2076            .materialization_result_id
2077            .clone(),
2078        materialization_input_digest: evidence.computed_materialization_input_digest.clone(),
2079        canonical_embedded_config_sha256: evidence
2080            .materialization_input
2081            .canonical_embedded_config_sha256
2082            .clone(),
2083        network: evidence.materialization_input.network.clone(),
2084        root_trust_anchor: evidence.materialization_input.root_trust_anchor.clone(),
2085        runtime_variant: evidence.materialization_input.runtime_variant.clone(),
2086        wasm_sha256: evidence.materialization_result.wasm_sha256.clone(),
2087        wasm_gz_sha256: evidence.materialization_result.wasm_gz_sha256.clone(),
2088        installed_module_hash: evidence
2089            .materialization_result
2090            .installed_module_hash
2091            .clone(),
2092        candid_sha256: evidence.materialization_result.candid_sha256.clone(),
2093    }
2094}
2095
2096fn role_promotion_policy_decision(
2097    input: &RolePromotionInputV1,
2098    policy: &RolePromotionPolicyV1,
2099) -> RolePromotionPolicyDecisionV1 {
2100    let level_allowed = policy
2101        .allowed_promotion_levels
2102        .contains(&input.promotion_level);
2103    let claims = promotion_policy_claims_for_input(input);
2104    let policy_satisfied = level_allowed
2105        && (!policy
2106            .requirements
2107            .contains(&PromotionPolicyRequirementV1::SealedBytes)
2108            || input.promotion_level == PromotionArtifactLevelV1::SealedWasm)
2109        && (!policy
2110            .requirements
2111            .contains(&PromotionPolicyRequirementV1::ByteIdenticalWasm)
2112            || claims.contains(&PromotionPolicyClaimV1::ByteIdenticalWasm))
2113        && (!policy
2114            .requirements
2115            .contains(&PromotionPolicyRequirementV1::TargetConfigDigest)
2116            || claims.contains(&PromotionPolicyClaimV1::TargetConfigDigest));
2117    RolePromotionPolicyDecisionV1 {
2118        role: input.role.clone(),
2119        requested_promotion_level: input.promotion_level,
2120        allowed_promotion_levels: policy.allowed_promotion_levels.clone(),
2121        requirements: policy.requirements.clone(),
2122        claims,
2123        level_allowed,
2124        policy_satisfied,
2125    }
2126}
2127
2128fn promotion_policy_claims_for_input(input: &RolePromotionInputV1) -> Vec<PromotionPolicyClaimV1> {
2129    let mut claims = Vec::with_capacity(2);
2130    if input.require_byte_identical_wasm {
2131        claims.push(PromotionPolicyClaimV1::ByteIdenticalWasm);
2132    }
2133    if input.require_target_embedded_config {
2134        claims.push(PromotionPolicyClaimV1::TargetConfigDigest);
2135    }
2136    claims
2137}
2138
2139fn collect_policy_findings(
2140    decision: &RolePromotionPolicyDecisionV1,
2141    blockers: &mut Vec<SafetyFindingV1>,
2142) {
2143    if !decision.level_allowed {
2144        blockers.push(promotion_finding(
2145            "promotion_policy_level_not_allowed",
2146            format!(
2147                "role {} cannot use promotion level {:?}",
2148                decision.role, decision.requested_promotion_level
2149            ),
2150            SafetySeverityV1::HardFailure,
2151            &decision.role,
2152        ));
2153    }
2154    if decision
2155        .requirements
2156        .contains(&PromotionPolicyRequirementV1::SealedBytes)
2157        && decision.requested_promotion_level != PromotionArtifactLevelV1::SealedWasm
2158    {
2159        blockers.push(promotion_finding(
2160            "promotion_policy_must_use_sealed_bytes",
2161            format!("role {} must use sealed bytes", decision.role),
2162            SafetySeverityV1::HardFailure,
2163            &decision.role,
2164        ));
2165    }
2166    if decision
2167        .requirements
2168        .contains(&PromotionPolicyRequirementV1::ByteIdenticalWasm)
2169        && !decision
2170            .claims
2171            .contains(&PromotionPolicyClaimV1::ByteIdenticalWasm)
2172    {
2173        blockers.push(promotion_finding(
2174            "promotion_policy_byte_identity_required",
2175            format!("role {} requires byte-identical wasm", decision.role),
2176            SafetySeverityV1::HardFailure,
2177            &decision.role,
2178        ));
2179    }
2180    if decision
2181        .requirements
2182        .contains(&PromotionPolicyRequirementV1::TargetConfigDigest)
2183        && !decision
2184            .claims
2185            .contains(&PromotionPolicyClaimV1::TargetConfigDigest)
2186    {
2187        blockers.push(promotion_finding(
2188            "promotion_policy_target_config_digest_required",
2189            format!("role {} requires target config digest", decision.role),
2190            SafetySeverityV1::HardFailure,
2191            &decision.role,
2192        ));
2193    }
2194}
2195
2196fn promotion_artifact_identity_groups(
2197    roles: &[RolePromotionArtifactIdentityV1],
2198) -> Vec<PromotionArtifactIdentityGroupV1> {
2199    let mut groups = BTreeMap::<String, PromotionArtifactIdentityGroupV1>::new();
2200    for role in roles {
2201        let identity_key = artifact_identity_key_for_role(role);
2202        let group = groups.entry(identity_key.clone()).or_insert_with(|| {
2203            PromotionArtifactIdentityGroupV1 {
2204                identity_key,
2205                identity_kind: role.identity_kind,
2206                roles: Vec::new(),
2207                source_kinds: Vec::new(),
2208                source_locators: Vec::new(),
2209                digest_pinned: role.digest_pinned,
2210                wasm_sha256: role.wasm_sha256.clone(),
2211                wasm_gz_sha256: role.wasm_gz_sha256.clone(),
2212                candid_sha256: role.candid_sha256.clone(),
2213                canonical_embedded_config_sha256: role.canonical_embedded_config_sha256.clone(),
2214            }
2215        });
2216        if !group.source_kinds.contains(&role.source_kind) {
2217            group.source_kinds.push(role.source_kind);
2218        }
2219        if let Some(locator) = &role.source_locator
2220            && !group.source_locators.contains(locator)
2221        {
2222            group.source_locators.push(locator.clone());
2223        }
2224        group.roles.push(role.role.clone());
2225    }
2226    groups.into_values().collect()
2227}
2228
2229fn promotion_materialization_output_groups(
2230    roles: &[RolePromotionMaterializationIdentityV1],
2231) -> Vec<PromotionMaterializationOutputGroupV1> {
2232    let mut groups = BTreeMap::<String, PromotionMaterializationOutputGroupV1>::new();
2233    for role in roles {
2234        let output_identity_key = materialization_output_key_for_role(role);
2235        let group = groups
2236            .entry(output_identity_key.clone())
2237            .or_insert_with(|| PromotionMaterializationOutputGroupV1 {
2238                output_identity_key,
2239                roles: Vec::new(),
2240                wasm_sha256: role.wasm_sha256.clone(),
2241                wasm_gz_sha256: role.wasm_gz_sha256.clone(),
2242                installed_module_hash: role.installed_module_hash.clone(),
2243                candid_sha256: role.candid_sha256.clone(),
2244            });
2245        group.roles.push(role.role.clone());
2246    }
2247    groups.into_values().collect()
2248}
2249
2250const fn promotion_artifact_identity_kind(
2251    promotion_level: PromotionArtifactLevelV1,
2252    source: &RoleArtifactSourceV1,
2253) -> PromotionArtifactIdentityKindV1 {
2254    if matches!(promotion_level, PromotionArtifactLevelV1::SourceBuild) {
2255        return PromotionArtifactIdentityKindV1::SourceBuild;
2256    }
2257    match (
2258        source.expected_wasm_sha256.is_some(),
2259        source.expected_wasm_gz_sha256.is_some(),
2260    ) {
2261        (true, true) => PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm,
2262        (true, false) => PromotionArtifactIdentityKindV1::SealedWasm,
2263        (false, true) => PromotionArtifactIdentityKindV1::SealedCompressedWasm,
2264        (false, false) => PromotionArtifactIdentityKindV1::Deferred,
2265    }
2266}
2267
2268fn artifact_identity_key_for_role(role: &RolePromotionArtifactIdentityV1) -> String {
2269    match role.identity_kind {
2270        PromotionArtifactIdentityKindV1::SealedWasm
2271        | PromotionArtifactIdentityKindV1::SealedCompressedWasm
2272        | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
2273            role.wasm_sha256.as_deref(),
2274            role.wasm_gz_sha256.as_deref(),
2275            role.candid_sha256.as_deref(),
2276            role.canonical_embedded_config_sha256.as_deref(),
2277        ),
2278        PromotionArtifactIdentityKindV1::SourceBuild => format!(
2279            "source_build:source_kind={:?}:locator={}:candid={}:config={}",
2280            role.source_kind,
2281            optional_identity_part(role.source_locator.as_deref()),
2282            optional_identity_part(role.candid_sha256.as_deref()),
2283            optional_identity_part(role.canonical_embedded_config_sha256.as_deref())
2284        ),
2285        PromotionArtifactIdentityKindV1::Deferred => format!(
2286            "deferred:source_kind={:?}:locator={}",
2287            role.source_kind,
2288            optional_identity_part(role.source_locator.as_deref())
2289        ),
2290    }
2291}
2292
2293fn artifact_identity_key_for_group(group: &PromotionArtifactIdentityGroupV1) -> String {
2294    match group.identity_kind {
2295        PromotionArtifactIdentityKindV1::SealedWasm
2296        | PromotionArtifactIdentityKindV1::SealedCompressedWasm
2297        | PromotionArtifactIdentityKindV1::SealedWasmAndCompressedWasm => sealed_identity_key(
2298            group.wasm_sha256.as_deref(),
2299            group.wasm_gz_sha256.as_deref(),
2300            group.candid_sha256.as_deref(),
2301            group.canonical_embedded_config_sha256.as_deref(),
2302        ),
2303        PromotionArtifactIdentityKindV1::SourceBuild => format!(
2304            "source_build:source_kind={}:locator={}:candid={}:config={}",
2305            source_kind_identity_part(single_group_source_kind(group)),
2306            optional_identity_part(single_group_source_locator(group)),
2307            optional_identity_part(group.candid_sha256.as_deref()),
2308            optional_identity_part(group.canonical_embedded_config_sha256.as_deref())
2309        ),
2310        PromotionArtifactIdentityKindV1::Deferred => format!(
2311            "deferred:source_kind={}:locator={}",
2312            source_kind_identity_part(single_group_source_kind(group)),
2313            optional_identity_part(single_group_source_locator(group))
2314        ),
2315    }
2316}
2317
2318fn materialization_output_key_for_role(role: &RolePromotionMaterializationIdentityV1) -> String {
2319    materialization_output_key(
2320        &role.wasm_sha256,
2321        &role.wasm_gz_sha256,
2322        &role.installed_module_hash,
2323        &role.candid_sha256,
2324    )
2325}
2326
2327fn materialization_output_key_for_group(group: &PromotionMaterializationOutputGroupV1) -> String {
2328    materialization_output_key(
2329        &group.wasm_sha256,
2330        &group.wasm_gz_sha256,
2331        &group.installed_module_hash,
2332        &group.candid_sha256,
2333    )
2334}
2335
2336fn materialization_output_key(
2337    wasm_sha256: &str,
2338    wasm_gz_sha256: &str,
2339    installed_module_hash: &str,
2340    candid_sha256: &str,
2341) -> String {
2342    format!(
2343        "materialized:wasm={wasm_sha256}:wasm_gz={wasm_gz_sha256}:installed={installed_module_hash}:candid={candid_sha256}"
2344    )
2345}
2346
2347fn source_kind_identity_part(kind: Option<RoleArtifactSourceKindV1>) -> String {
2348    kind.map_or_else(|| "not-recorded".to_string(), |kind| format!("{kind:?}"))
2349}
2350
2351fn single_group_source_kind(
2352    group: &PromotionArtifactIdentityGroupV1,
2353) -> Option<RoleArtifactSourceKindV1> {
2354    group.source_kinds.first().copied()
2355}
2356
2357fn single_group_source_locator(group: &PromotionArtifactIdentityGroupV1) -> Option<&str> {
2358    group.source_locators.first().map(String::as_str)
2359}
2360
2361fn sealed_identity_key(
2362    wasm_sha256: Option<&str>,
2363    wasm_gz_sha256: Option<&str>,
2364    candid_sha256: Option<&str>,
2365    canonical_embedded_config_sha256: Option<&str>,
2366) -> String {
2367    format!(
2368        "sealed:wasm={}:wasm_gz={}:candid={}:config={}",
2369        optional_identity_part(wasm_sha256),
2370        optional_identity_part(wasm_gz_sha256),
2371        optional_identity_part(candid_sha256),
2372        optional_identity_part(canonical_embedded_config_sha256)
2373    )
2374}
2375
2376const fn optional_identity_part(value: Option<&str>) -> &str {
2377    match value {
2378        Some(value) => value,
2379        None => "not-recorded",
2380    }
2381}
2382
2383fn validate_role_artifact_identity(
2384    role: &RolePromotionArtifactIdentityV1,
2385) -> Result<(), PromotionArtifactIdentityReportError> {
2386    ensure_identity_report_field("role", &role.role)?;
2387    ensure_identity_optional_sha256("wasm_sha256", role.wasm_sha256.as_deref())?;
2388    ensure_identity_optional_sha256("wasm_gz_sha256", role.wasm_gz_sha256.as_deref())?;
2389    ensure_identity_optional_sha256("candid_sha256", role.candid_sha256.as_deref())?;
2390    ensure_identity_optional_sha256(
2391        "canonical_embedded_config_sha256",
2392        role.canonical_embedded_config_sha256.as_deref(),
2393    )?;
2394    Ok(())
2395}
2396
2397fn validate_role_promotion_policy_decision(
2398    decision: &RolePromotionPolicyDecisionV1,
2399) -> Result<(), PromotionPolicyCheckError> {
2400    ensure_policy_field("role", &decision.role)?;
2401    if decision.allowed_promotion_levels.is_empty() {
2402        return Err(PromotionPolicyCheckError::EmptyAllowedLevels {
2403            role: decision.role.clone(),
2404        });
2405    }
2406    let mut seen = BTreeSet::new();
2407    for level in &decision.allowed_promotion_levels {
2408        if !seen.insert(*level) {
2409            return Err(PromotionPolicyCheckError::DuplicateAllowedLevel {
2410                role: decision.role.clone(),
2411                level: *level,
2412            });
2413        }
2414    }
2415    let mut seen_requirements = BTreeSet::new();
2416    for requirement in &decision.requirements {
2417        if !seen_requirements.insert(*requirement) {
2418            return Err(PromotionPolicyCheckError::DecisionMismatch {
2419                role: decision.role.clone(),
2420                field: "requirements",
2421            });
2422        }
2423    }
2424    let mut seen_claims = BTreeSet::new();
2425    for claim in &decision.claims {
2426        if !seen_claims.insert(*claim) {
2427            return Err(PromotionPolicyCheckError::DecisionMismatch {
2428                role: decision.role.clone(),
2429                field: "claims",
2430            });
2431        }
2432    }
2433    ensure_policy_decision(
2434        decision,
2435        "level_allowed",
2436        decision
2437            .allowed_promotion_levels
2438            .contains(&decision.requested_promotion_level)
2439            == decision.level_allowed,
2440    )?;
2441    ensure_policy_decision(
2442        decision,
2443        "policy_satisfied",
2444        promotion_policy_decision_satisfied(decision) == decision.policy_satisfied,
2445    )?;
2446    Ok(())
2447}
2448
2449fn promotion_policy_decision_satisfied(decision: &RolePromotionPolicyDecisionV1) -> bool {
2450    decision.level_allowed
2451        && (!contains_policy_requirement(
2452            &decision.requirements,
2453            PromotionPolicyRequirementV1::SealedBytes,
2454        ) || matches!(
2455            decision.requested_promotion_level,
2456            PromotionArtifactLevelV1::SealedWasm
2457        ))
2458        && (!contains_policy_requirement(
2459            &decision.requirements,
2460            PromotionPolicyRequirementV1::ByteIdenticalWasm,
2461        ) || contains_policy_claim(&decision.claims, PromotionPolicyClaimV1::ByteIdenticalWasm))
2462        && (!contains_policy_requirement(
2463            &decision.requirements,
2464            PromotionPolicyRequirementV1::TargetConfigDigest,
2465        ) || contains_policy_claim(
2466            &decision.claims,
2467            PromotionPolicyClaimV1::TargetConfigDigest,
2468        ))
2469}
2470
2471fn contains_policy_requirement(
2472    requirements: &[PromotionPolicyRequirementV1],
2473    needle: PromotionPolicyRequirementV1,
2474) -> bool {
2475    let mut index = 0;
2476    while index < requirements.len() {
2477        if requirements[index] as u8 == needle as u8 {
2478            return true;
2479        }
2480        index += 1;
2481    }
2482    false
2483}
2484
2485fn contains_policy_claim(
2486    claims: &[PromotionPolicyClaimV1],
2487    needle: PromotionPolicyClaimV1,
2488) -> bool {
2489    let mut index = 0;
2490    while index < claims.len() {
2491        if claims[index] as u8 == needle as u8 {
2492            return true;
2493        }
2494        index += 1;
2495    }
2496    false
2497}
2498
2499fn ensure_policy_decision(
2500    decision: &RolePromotionPolicyDecisionV1,
2501    field: &'static str,
2502    valid: bool,
2503) -> Result<(), PromotionPolicyCheckError> {
2504    if valid {
2505        Ok(())
2506    } else {
2507        Err(PromotionPolicyCheckError::DecisionMismatch {
2508            role: decision.role.clone(),
2509            field,
2510        })
2511    }
2512}
2513
2514fn validate_artifact_identity_groups(
2515    roles: &[RolePromotionArtifactIdentityV1],
2516    groups: &[PromotionArtifactIdentityGroupV1],
2517) -> Result<(), PromotionArtifactIdentityReportError> {
2518    let role_names = roles
2519        .iter()
2520        .map(|role| role.role.as_str())
2521        .collect::<BTreeSet<_>>();
2522    let mut grouped_roles = BTreeSet::new();
2523    let mut group_keys = BTreeSet::new();
2524    for group in groups {
2525        validate_artifact_identity_group(group)?;
2526        if !group_keys.insert(group.identity_key.as_str()) {
2527            return Err(
2528                PromotionArtifactIdentityReportError::DuplicateIdentityGroup {
2529                    identity_key: group.identity_key.clone(),
2530                },
2531            );
2532        }
2533        if group.roles.is_empty() {
2534            return Err(PromotionArtifactIdentityReportError::EmptyIdentityGroup {
2535                identity_key: group.identity_key.clone(),
2536            });
2537        }
2538        for role in &group.roles {
2539            if !role_names.contains(role.as_str()) {
2540                return Err(PromotionArtifactIdentityReportError::UnknownGroupedRole {
2541                    role: role.clone(),
2542                });
2543            }
2544            if !grouped_roles.insert(role.as_str()) {
2545                return Err(PromotionArtifactIdentityReportError::DuplicateGroupedRole {
2546                    role: role.clone(),
2547                });
2548            }
2549            let role_identity = roles
2550                .iter()
2551                .find(|candidate| candidate.role == *role)
2552                .expect("known role should be present");
2553            let expected = artifact_identity_key_for_role(role_identity);
2554            if expected != group.identity_key {
2555                return Err(
2556                    PromotionArtifactIdentityReportError::IdentityGroupRoleMismatch {
2557                        role: role.clone(),
2558                        expected,
2559                        found: group.identity_key.clone(),
2560                    },
2561                );
2562            }
2563        }
2564    }
2565    for role in roles {
2566        if !grouped_roles.contains(role.role.as_str()) {
2567            return Err(PromotionArtifactIdentityReportError::MissingGroupedRole {
2568                role: role.role.clone(),
2569            });
2570        }
2571    }
2572    Ok(())
2573}
2574
2575fn validate_artifact_identity_group(
2576    group: &PromotionArtifactIdentityGroupV1,
2577) -> Result<(), PromotionArtifactIdentityReportError> {
2578    ensure_identity_report_field("identity_group.identity_key", &group.identity_key)?;
2579    if group.source_kinds.is_empty() {
2580        return Err(PromotionArtifactIdentityReportError::MissingRequiredField {
2581            field: "identity_group.source_kinds",
2582        });
2583    }
2584    ensure_identity_optional_sha256("identity_group.wasm_sha256", group.wasm_sha256.as_deref())?;
2585    ensure_identity_optional_sha256(
2586        "identity_group.wasm_gz_sha256",
2587        group.wasm_gz_sha256.as_deref(),
2588    )?;
2589    ensure_identity_optional_sha256(
2590        "identity_group.candid_sha256",
2591        group.candid_sha256.as_deref(),
2592    )?;
2593    ensure_identity_optional_sha256(
2594        "identity_group.canonical_embedded_config_sha256",
2595        group.canonical_embedded_config_sha256.as_deref(),
2596    )?;
2597    let expected = artifact_identity_key_for_group(group);
2598    if expected != group.identity_key {
2599        return Err(
2600            PromotionArtifactIdentityReportError::IdentityGroupKeyMismatch {
2601                expected,
2602                found: group.identity_key.clone(),
2603            },
2604        );
2605    }
2606    Ok(())
2607}
2608
2609fn validate_materialization_output_groups(
2610    roles: &[RolePromotionMaterializationIdentityV1],
2611    groups: &[PromotionMaterializationOutputGroupV1],
2612) -> Result<(), PromotionMaterializationIdentityReportError> {
2613    let role_names = roles
2614        .iter()
2615        .map(|role| role.role.as_str())
2616        .collect::<BTreeSet<_>>();
2617    let mut grouped_roles = BTreeSet::new();
2618    let mut group_keys = BTreeSet::new();
2619    for group in groups {
2620        validate_materialization_output_group(group)?;
2621        if !group_keys.insert(group.output_identity_key.as_str()) {
2622            return Err(
2623                PromotionMaterializationIdentityReportError::DuplicateOutputGroup {
2624                    output_identity_key: group.output_identity_key.clone(),
2625                },
2626            );
2627        }
2628        if group.roles.is_empty() {
2629            return Err(
2630                PromotionMaterializationIdentityReportError::EmptyOutputGroup {
2631                    output_identity_key: group.output_identity_key.clone(),
2632                },
2633            );
2634        }
2635        for role in &group.roles {
2636            if !role_names.contains(role.as_str()) {
2637                return Err(
2638                    PromotionMaterializationIdentityReportError::UnknownGroupedRole {
2639                        role: role.clone(),
2640                    },
2641                );
2642            }
2643            if !grouped_roles.insert(role.as_str()) {
2644                return Err(
2645                    PromotionMaterializationIdentityReportError::DuplicateGroupedRole {
2646                        role: role.clone(),
2647                    },
2648                );
2649            }
2650            let role_identity = roles
2651                .iter()
2652                .find(|candidate| candidate.role == *role)
2653                .expect("known role should be present");
2654            let expected = materialization_output_key_for_role(role_identity);
2655            if expected != group.output_identity_key {
2656                return Err(
2657                    PromotionMaterializationIdentityReportError::OutputGroupRoleMismatch {
2658                        role: role.clone(),
2659                        expected,
2660                        found: group.output_identity_key.clone(),
2661                    },
2662                );
2663            }
2664        }
2665    }
2666    for role in roles {
2667        if !grouped_roles.contains(role.role.as_str()) {
2668            return Err(
2669                PromotionMaterializationIdentityReportError::MissingGroupedRole {
2670                    role: role.role.clone(),
2671                },
2672            );
2673        }
2674    }
2675    Ok(())
2676}
2677
2678fn validate_materialization_output_group(
2679    group: &PromotionMaterializationOutputGroupV1,
2680) -> Result<(), PromotionMaterializationIdentityReportError> {
2681    ensure_materialization_report_field(
2682        "output_group.output_identity_key",
2683        &group.output_identity_key,
2684    )?;
2685    ensure_materialization_report_sha256("output_group.wasm_sha256", &group.wasm_sha256)?;
2686    ensure_materialization_report_sha256("output_group.wasm_gz_sha256", &group.wasm_gz_sha256)?;
2687    ensure_materialization_report_sha256(
2688        "output_group.installed_module_hash",
2689        &group.installed_module_hash,
2690    )?;
2691    ensure_materialization_report_sha256("output_group.candid_sha256", &group.candid_sha256)?;
2692    let expected = materialization_output_key_for_group(group);
2693    if expected != group.output_identity_key {
2694        return Err(
2695            PromotionMaterializationIdentityReportError::OutputGroupKeyMismatch {
2696                expected,
2697                found: group.output_identity_key.clone(),
2698            },
2699        );
2700    }
2701    Ok(())
2702}
2703
2704fn validate_role_plan_transform(
2705    role: &RolePromotionPlanTransformV1,
2706    promoted_plan: &DeploymentPlanV1,
2707) -> Result<(), PromotionPlanTransformError> {
2708    ensure_transform_field("role", &role.role)?;
2709    let Some(promoted_role) = promoted_plan
2710        .role_artifacts
2711        .iter()
2712        .find(|artifact| artifact.role == role.role)
2713    else {
2714        return Err(PromotionPlanTransformError::PromotedRoleMissing {
2715            role: role.role.clone(),
2716        });
2717    };
2718    ensure_role_matches_promoted_artifact(role, promoted_role)?;
2719    ensure_role_transform_flags_are_consistent(role)?;
2720    validate_role_materialization_link(role, promoted_role)?;
2721    Ok(())
2722}
2723
2724fn ensure_role_matches_promoted_artifact(
2725    role: &RolePromotionPlanTransformV1,
2726    promoted_role: &RoleArtifactV1,
2727) -> Result<(), PromotionPlanTransformError> {
2728    ensure_role_field_matches(
2729        role,
2730        "artifact_source_after",
2731        role.artifact_source_after == promoted_role.source,
2732    )?;
2733    ensure_role_field_matches(
2734        role,
2735        "wasm_sha256_after",
2736        role.wasm_sha256_after == promoted_role.wasm_sha256,
2737    )?;
2738    ensure_role_field_matches(
2739        role,
2740        "wasm_gz_sha256_after",
2741        role.wasm_gz_sha256_after == promoted_role.wasm_gz_sha256,
2742    )?;
2743    ensure_role_field_matches(
2744        role,
2745        "candid_sha256_after",
2746        role.candid_sha256_after == promoted_role.candid_sha256,
2747    )?;
2748    ensure_role_field_matches(
2749        role,
2750        "canonical_embedded_config_sha256_after",
2751        role.canonical_embedded_config_sha256_after
2752            == promoted_role.canonical_embedded_config_sha256,
2753    )
2754}
2755
2756fn ensure_role_transform_flags_are_consistent(
2757    role: &RolePromotionPlanTransformV1,
2758) -> Result<(), PromotionPlanTransformError> {
2759    ensure_role_field_matches(
2760        role,
2761        "artifact_identity_changed",
2762        role.artifact_identity_changed == role_summary_artifact_identity_changed(role),
2763    )?;
2764    ensure_role_field_matches(
2765        role,
2766        "embedded_config_changed",
2767        role.embedded_config_changed
2768            == (role.canonical_embedded_config_sha256_before
2769                != role.canonical_embedded_config_sha256_after),
2770    )?;
2771    if role.target_materialization_preserved {
2772        ensure_role_field_matches(
2773            role,
2774            "target_materialization_preserved",
2775            role.promotion_level == PromotionArtifactLevelV1::SourceBuild
2776                && !role.artifact_identity_changed
2777                && !role.embedded_config_changed,
2778        )?;
2779    }
2780    Ok(())
2781}
2782
2783fn validate_role_materialization_link(
2784    role: &RolePromotionPlanTransformV1,
2785    promoted_role: &RoleArtifactV1,
2786) -> Result<(), PromotionPlanTransformError> {
2787    let Some(link) = &role.source_build_materialization else {
2788        return Ok(());
2789    };
2790    ensure_role_field_matches(
2791        role,
2792        "source_build_materialization",
2793        role.promotion_level == PromotionArtifactLevelV1::SourceBuild,
2794    )?;
2795    ensure_role_field_matches(
2796        role,
2797        "source_build_materialization.role",
2798        link.role == role.role,
2799    )?;
2800    ensure_transform_field(
2801        "source_build_materialization.evidence_id",
2802        &link.evidence_id,
2803    )?;
2804    ensure_transform_field("source_build_materialization.recipe_id", &link.recipe_id)?;
2805    ensure_transform_field(
2806        "source_build_materialization.materialization_input_id",
2807        &link.materialization_input_id,
2808    )?;
2809    ensure_transform_field(
2810        "source_build_materialization.materialization_result_id",
2811        &link.materialization_result_id,
2812    )?;
2813    ensure_materialization_sha256(
2814        "source_build_materialization.materialization_input_digest",
2815        &link.materialization_input_digest,
2816    )?;
2817    ensure_materialization_sha256(
2818        "source_build_materialization.wasm_sha256",
2819        &link.wasm_sha256,
2820    )?;
2821    ensure_materialization_sha256(
2822        "source_build_materialization.wasm_gz_sha256",
2823        &link.wasm_gz_sha256,
2824    )?;
2825    ensure_materialization_sha256(
2826        "source_build_materialization.installed_module_hash",
2827        &link.installed_module_hash,
2828    )?;
2829    ensure_materialization_sha256(
2830        "source_build_materialization.candid_sha256",
2831        &link.candid_sha256,
2832    )?;
2833    ensure_role_field_matches(
2834        role,
2835        "source_build_materialization.wasm_sha256",
2836        promoted_role.wasm_sha256.as_deref() == Some(link.wasm_sha256.as_str()),
2837    )?;
2838    ensure_role_field_matches(
2839        role,
2840        "source_build_materialization.wasm_gz_sha256",
2841        promoted_role.wasm_gz_sha256.as_deref() == Some(link.wasm_gz_sha256.as_str()),
2842    )?;
2843    ensure_role_field_matches(
2844        role,
2845        "source_build_materialization.installed_module_hash",
2846        promoted_role.installed_module_hash.as_deref() == Some(link.installed_module_hash.as_str()),
2847    )?;
2848    ensure_role_field_matches(
2849        role,
2850        "source_build_materialization.candid_sha256",
2851        promoted_role.candid_sha256.as_deref() == Some(link.candid_sha256.as_str()),
2852    )
2853}
2854
2855fn role_summary_artifact_identity_changed(role: &RolePromotionPlanTransformV1) -> bool {
2856    role.artifact_source_before != role.artifact_source_after
2857        || role.wasm_sha256_before != role.wasm_sha256_after
2858        || role.wasm_gz_sha256_before != role.wasm_gz_sha256_after
2859        || role.candid_sha256_before != role.candid_sha256_after
2860}
2861
2862fn ensure_role_field_matches(
2863    role: &RolePromotionPlanTransformV1,
2864    field: &'static str,
2865    matches: bool,
2866) -> Result<(), PromotionPlanTransformError> {
2867    if matches {
2868        Ok(())
2869    } else {
2870        Err(PromotionPlanTransformError::RoleStateMismatch {
2871            role: role.role.clone(),
2872            field,
2873        })
2874    }
2875}
2876
2877fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
2878    ensure_readiness_field("role", &role.role)?;
2879    ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
2880    ensure_readiness_optional_sha256(
2881        "source_wasm_gz_sha256",
2882        role.source_wasm_gz_sha256.as_deref(),
2883    )?;
2884    ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
2885    ensure_readiness_optional_sha256(
2886        "target_wasm_gz_sha256",
2887        role.target_wasm_gz_sha256.as_deref(),
2888    )?;
2889    ensure_readiness_optional_sha256(
2890        "source_canonical_embedded_config_sha256",
2891        role.source_canonical_embedded_config_sha256.as_deref(),
2892    )?;
2893    ensure_readiness_optional_sha256(
2894        "target_canonical_embedded_config_sha256",
2895        role.target_canonical_embedded_config_sha256.as_deref(),
2896    )?;
2897    if role.restage_required != (role.target_store_has_artifact == Some(false)) {
2898        return Err(PromotionReadinessError::RestageStateMismatch {
2899            role: role.role.clone(),
2900        });
2901    }
2902    Ok(())
2903}
2904
2905fn role_promotion_readiness(
2906    input: &RolePromotionInputV1,
2907    target_artifact: &RoleArtifactV1,
2908) -> RolePromotionReadinessV1 {
2909    let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
2910    let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
2911    let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
2912    let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
2913    let byte_identical_wasm =
2914        matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
2915            || {
2916                matching_optional_digest(
2917                    source_wasm_gz_sha256.as_ref(),
2918                    target_wasm_gz_sha256.as_ref(),
2919                )
2920            },
2921        );
2922    let embedded_config_identical = matching_optional_digest(
2923        input
2924            .source
2925            .expected_canonical_embedded_config_sha256
2926            .as_ref(),
2927        target_artifact.canonical_embedded_config_sha256.as_ref(),
2928    );
2929
2930    RolePromotionReadinessV1 {
2931        role: input.role.clone(),
2932        promotion_level: input.promotion_level,
2933        source_kind: input.source.kind,
2934        source_locator: input.source.locator.clone(),
2935        source_wasm_sha256,
2936        source_wasm_gz_sha256,
2937        target_wasm_sha256,
2938        target_wasm_gz_sha256,
2939        source_canonical_embedded_config_sha256: input
2940            .source
2941            .expected_canonical_embedded_config_sha256
2942            .clone(),
2943        target_canonical_embedded_config_sha256: target_artifact
2944            .canonical_embedded_config_sha256
2945            .clone(),
2946        byte_identical_wasm,
2947        embedded_config_identical,
2948        target_store_has_artifact: input.target_store_has_artifact,
2949        restage_required: input.target_store_has_artifact == Some(false),
2950    }
2951}
2952
2953fn collect_role_findings(
2954    input: &RolePromotionInputV1,
2955    readiness: &RolePromotionReadinessV1,
2956    blockers: &mut Vec<SafetyFindingV1>,
2957    warnings: &mut Vec<SafetyFindingV1>,
2958) {
2959    if let Err(err) = validate_role_artifact_source(&input.source) {
2960        blockers.push(promotion_finding(
2961            "promotion_artifact_source_invalid",
2962            err.to_string(),
2963            SafetySeverityV1::HardFailure,
2964            &input.role,
2965        ));
2966    }
2967
2968    if input.role != input.source.role {
2969        blockers.push(promotion_finding(
2970            "promotion_source_role_mismatch",
2971            format!(
2972                "promotion input role {} does not match artifact source role {}",
2973                input.role, input.source.role
2974            ),
2975            SafetySeverityV1::HardFailure,
2976            &input.role,
2977        ));
2978    }
2979
2980    if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
2981        blockers.push(promotion_finding(
2982            "promotion_wasm_digest_mismatch",
2983            "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
2984            SafetySeverityV1::HardFailure,
2985            &input.role,
2986        ));
2987    }
2988
2989    if input.require_target_embedded_config
2990        && readiness
2991            .target_canonical_embedded_config_sha256
2992            .as_deref()
2993            .is_none_or(str::is_empty)
2994    {
2995        blockers.push(promotion_finding(
2996            "promotion_target_embedded_config_missing",
2997            "promotion requires target canonical embedded config but target plan has no digest",
2998            SafetySeverityV1::HardFailure,
2999            &input.role,
3000        ));
3001    }
3002
3003    if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
3004        && readiness.embedded_config_identical != Some(true)
3005    {
3006        blockers.push(promotion_finding(
3007            "promotion_sealed_wasm_embedded_config_mismatch",
3008            "sealed wasm promotion requires embedded config identity to be acceptable for the target",
3009            SafetySeverityV1::HardFailure,
3010            &input.role,
3011        ));
3012    }
3013
3014    if readiness.restage_required {
3015        warnings.push(promotion_finding(
3016            "promotion_target_store_restage_required",
3017            "target artifact store does not already contain the artifact; restaging is required",
3018            SafetySeverityV1::Warning,
3019            &input.role,
3020        ));
3021    }
3022}
3023
3024fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
3025    match (left.map(String::as_str), right.map(String::as_str)) {
3026        (Some(left), Some(right)) => Some(left == right),
3027        _ => None,
3028    }
3029}
3030
3031fn promotion_finding(
3032    code: impl Into<String>,
3033    message: impl Into<String>,
3034    severity: SafetySeverityV1,
3035    role: &str,
3036) -> SafetyFindingV1 {
3037    SafetyFindingV1 {
3038        code: code.into(),
3039        message: message.into(),
3040        severity,
3041        subject: Some(role.to_string()),
3042    }
3043}
3044
3045fn ensure_locator_requirement(
3046    source: &RoleArtifactSourceV1,
3047) -> Result<(), PromotionArtifactSourceError> {
3048    match source.kind {
3049        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
3050        _ => ensure_option_field("locator", source.locator.as_deref()),
3051    }
3052}
3053
3054const fn ensure_previous_receipt_requirement(
3055    source: &RoleArtifactSourceV1,
3056) -> Result<(), PromotionArtifactSourceError> {
3057    match (source.kind, source.previous_receipt_kind) {
3058        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
3059        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
3060            Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
3061        }
3062        (_, Some(_)) => {
3063            Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
3064        }
3065        (_, None) => Ok(()),
3066    }
3067}
3068
3069const fn ensure_previous_receipt_lineage_digest_requirement(
3070    source: &RoleArtifactSourceV1,
3071) -> Result<(), PromotionArtifactSourceError> {
3072    match (source.kind, source.previous_receipt_lineage_digest.as_ref()) {
3073        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
3074        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
3075            Err(PromotionArtifactSourceError::MissingPreviousReceiptLineageDigest)
3076        }
3077        (_, Some(_)) => Err(
3078            PromotionArtifactSourceError::UnexpectedPreviousReceiptLineageDigest {
3079                kind: source.kind,
3080            },
3081        ),
3082        (_, None) => Ok(()),
3083    }
3084}
3085
3086const fn ensure_digest_requirement(
3087    source: &RoleArtifactSourceV1,
3088) -> Result<(), PromotionArtifactSourceError> {
3089    let has_digest =
3090        source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
3091    match source.kind {
3092        RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
3093            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
3094        }
3095        RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
3096            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
3097        }
3098        RoleArtifactSourceKindV1::PublishedPackage
3099        | RoleArtifactSourceKindV1::PreviousReceiptArtifact
3100            if !has_digest =>
3101        {
3102            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
3103        }
3104        _ => Ok(()),
3105    }
3106}
3107
3108fn ensure_option_field(
3109    field: &'static str,
3110    value: Option<&str>,
3111) -> Result<(), PromotionArtifactSourceError> {
3112    match value {
3113        Some(value) => ensure_field(field, value),
3114        None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
3115    }
3116}
3117
3118fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
3119    if value.trim().is_empty() {
3120        return Err(PromotionArtifactSourceError::MissingRequiredField { field });
3121    }
3122    Ok(())
3123}
3124
3125fn ensure_optional_sha256(
3126    field: &'static str,
3127    value: Option<&str>,
3128) -> Result<(), PromotionArtifactSourceError> {
3129    let Some(value) = value else {
3130        return Ok(());
3131    };
3132    if is_lower_hex_sha256(value) {
3133        Ok(())
3134    } else {
3135        Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
3136    }
3137}
3138
3139fn is_lower_hex_sha256(value: &str) -> bool {
3140    value.len() == 64
3141        && value
3142            .bytes()
3143            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
3144}
3145
3146const fn ensure_readiness_status_matches_blockers(
3147    readiness: &PromotionReadinessV1,
3148) -> Result<(), PromotionReadinessError> {
3149    match (readiness.status, readiness.blockers.is_empty()) {
3150        (PromotionReadinessStatusV1::Ready, false)
3151        | (PromotionReadinessStatusV1::Blocked, true) => {
3152            Err(PromotionReadinessError::StatusBlockerMismatch {
3153                status: readiness.status,
3154                blocker_count: readiness.blockers.len(),
3155            })
3156        }
3157        _ => Ok(()),
3158    }
3159}
3160
3161fn ensure_unique_readiness_roles(
3162    roles: &[RolePromotionReadinessV1],
3163) -> Result<(), PromotionReadinessError> {
3164    let mut seen = std::collections::BTreeSet::new();
3165    for role in roles {
3166        if !seen.insert(role.role.as_str()) {
3167            return Err(PromotionReadinessError::DuplicateRole {
3168                role: role.role.clone(),
3169            });
3170        }
3171    }
3172    Ok(())
3173}
3174
3175fn ensure_unique_transform_roles(
3176    roles: &[RolePromotionPlanTransformV1],
3177) -> Result<(), PromotionPlanTransformError> {
3178    let mut seen = std::collections::BTreeSet::new();
3179    for role in roles {
3180        if !seen.insert(role.role.as_str()) {
3181            return Err(PromotionPlanTransformError::DuplicateRole {
3182                role: role.role.clone(),
3183            });
3184        }
3185    }
3186    Ok(())
3187}
3188
3189const fn ensure_policy_status_matches_blockers(
3190    check: &PromotionPolicyCheckV1,
3191) -> Result<(), PromotionPolicyCheckError> {
3192    match (check.status, check.blockers.is_empty()) {
3193        (PromotionReadinessStatusV1::Ready, false)
3194        | (PromotionReadinessStatusV1::Blocked, true) => {
3195            Err(PromotionPolicyCheckError::StatusBlockerMismatch {
3196                status: check.status,
3197                blocker_count: check.blockers.len(),
3198            })
3199        }
3200        _ => Ok(()),
3201    }
3202}
3203
3204fn ensure_unique_policy_decision_roles(
3205    roles: &[RolePromotionPolicyDecisionV1],
3206) -> Result<(), PromotionPolicyCheckError> {
3207    let mut seen = BTreeSet::new();
3208    for role in roles {
3209        if !seen.insert(role.role.as_str()) {
3210            return Err(PromotionPolicyCheckError::DuplicateRole {
3211                role: role.role.clone(),
3212            });
3213        }
3214    }
3215    Ok(())
3216}
3217
3218fn validate_policy_blockers(blockers: &[SafetyFindingV1]) -> Result<(), PromotionPolicyCheckError> {
3219    for blocker in blockers {
3220        ensure_policy_field("blocker.code", &blocker.code)?;
3221        ensure_policy_field("blocker.message", &blocker.message)?;
3222        if blocker.severity != SafetySeverityV1::HardFailure {
3223            return Err(PromotionPolicyCheckError::BlockerSeverityMismatch {
3224                severity: blocker.severity,
3225            });
3226        }
3227    }
3228    Ok(())
3229}
3230
3231const fn ensure_identity_report_status_matches_blockers(
3232    report: &PromotionArtifactIdentityReportV1,
3233) -> Result<(), PromotionArtifactIdentityReportError> {
3234    match (report.status, report.blockers.is_empty()) {
3235        (PromotionReadinessStatusV1::Ready, false)
3236        | (PromotionReadinessStatusV1::Blocked, true) => Err(
3237            PromotionArtifactIdentityReportError::StatusBlockerMismatch {
3238                status: report.status,
3239                blocker_count: report.blockers.len(),
3240            },
3241        ),
3242        _ => Ok(()),
3243    }
3244}
3245
3246fn ensure_unique_artifact_identity_roles(
3247    roles: &[RolePromotionArtifactIdentityV1],
3248) -> Result<(), PromotionArtifactIdentityReportError> {
3249    let mut seen = std::collections::BTreeSet::new();
3250    for role in roles {
3251        if !seen.insert(role.role.as_str()) {
3252            return Err(PromotionArtifactIdentityReportError::DuplicateRole {
3253                role: role.role.clone(),
3254            });
3255        }
3256    }
3257    Ok(())
3258}
3259
3260fn validate_identity_report_blockers(
3261    blockers: &[SafetyFindingV1],
3262) -> Result<(), PromotionArtifactIdentityReportError> {
3263    for blocker in blockers {
3264        ensure_identity_report_field("blocker.code", &blocker.code)?;
3265        ensure_identity_report_field("blocker.message", &blocker.message)?;
3266        if blocker.severity != SafetySeverityV1::HardFailure {
3267            return Err(
3268                PromotionArtifactIdentityReportError::BlockerSeverityMismatch {
3269                    severity: blocker.severity,
3270                },
3271            );
3272        }
3273    }
3274    Ok(())
3275}
3276
3277const fn ensure_wasm_store_identity_status_matches_blockers(
3278    report: &PromotionWasmStoreIdentityReportV1,
3279) -> Result<(), PromotionWasmStoreIdentityReportError> {
3280    match (report.status, report.blockers.is_empty()) {
3281        (PromotionReadinessStatusV1::Ready, false)
3282        | (PromotionReadinessStatusV1::Blocked, true) => Err(
3283            PromotionWasmStoreIdentityReportError::StatusBlockerMismatch {
3284                status: report.status,
3285                blocker_count: report.blockers.len(),
3286            },
3287        ),
3288        _ => Ok(()),
3289    }
3290}
3291
3292fn ensure_unique_wasm_store_identity_roles(
3293    roles: &[RolePromotionWasmStoreIdentityV1],
3294) -> Result<(), PromotionWasmStoreIdentityReportError> {
3295    let mut seen = BTreeSet::new();
3296    for role in roles {
3297        if !seen.insert(role.role.as_str()) {
3298            return Err(PromotionWasmStoreIdentityReportError::DuplicateRole {
3299                role: role.role.clone(),
3300            });
3301        }
3302    }
3303    Ok(())
3304}
3305
3306fn ensure_wasm_store_identity_staging_receipts(
3307    receipts: &[StagingReceiptV1],
3308) -> Result<(), PromotionWasmStoreIdentityReportError> {
3309    for receipt in receipts {
3310        if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
3311            return Err(
3312                PromotionWasmStoreIdentityReportError::StagingReceiptSchemaVersionMismatch {
3313                    role: receipt.role.clone(),
3314                    expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
3315                    found: receipt.schema_version,
3316                },
3317            );
3318        }
3319        ensure_wasm_store_identity_report_field("role", &receipt.role)?;
3320        ensure_wasm_store_identity_report_field("artifact_identity", &receipt.artifact_identity)?;
3321    }
3322    Ok(())
3323}
3324
3325fn validate_role_wasm_store_identity(
3326    role: &RolePromotionWasmStoreIdentityV1,
3327) -> Result<(), PromotionWasmStoreIdentityReportError> {
3328    ensure_wasm_store_identity_report_field("role", &role.role)?;
3329    ensure_wasm_store_identity_report_field("artifact_identity", &role.artifact_identity)?;
3330    if let Some(locator) = &role.wasm_store_locator {
3331        ensure_wasm_store_identity_report_field("wasm_store_locator", locator)?;
3332    }
3333    for chunk_hash in &role.prepared_chunk_hashes {
3334        ensure_wasm_store_identity_report_field("prepared_chunk_hash", chunk_hash)?;
3335    }
3336    Ok(())
3337}
3338
3339fn validate_wasm_store_identity_blockers(
3340    blockers: &[SafetyFindingV1],
3341) -> Result<(), PromotionWasmStoreIdentityReportError> {
3342    for blocker in blockers {
3343        ensure_wasm_store_identity_report_field("blocker.code", &blocker.code)?;
3344        ensure_wasm_store_identity_report_field("blocker.message", &blocker.message)?;
3345        if blocker.severity != SafetySeverityV1::HardFailure {
3346            return Err(
3347                PromotionWasmStoreIdentityReportError::BlockerSeverityMismatch {
3348                    severity: blocker.severity,
3349                },
3350            );
3351        }
3352    }
3353    Ok(())
3354}
3355
3356const fn ensure_materialization_report_status_matches_blockers(
3357    report: &PromotionMaterializationIdentityReportV1,
3358) -> Result<(), PromotionMaterializationIdentityReportError> {
3359    match (report.status, report.blockers.is_empty()) {
3360        (PromotionReadinessStatusV1::Ready, false)
3361        | (PromotionReadinessStatusV1::Blocked, true) => Err(
3362            PromotionMaterializationIdentityReportError::StatusBlockerMismatch {
3363                status: report.status,
3364                blocker_count: report.blockers.len(),
3365            },
3366        ),
3367        _ => Ok(()),
3368    }
3369}
3370
3371fn ensure_unique_materialization_report_roles(
3372    roles: &[RolePromotionMaterializationIdentityV1],
3373) -> Result<(), PromotionMaterializationIdentityReportError> {
3374    let mut seen_roles = BTreeSet::new();
3375    let mut seen_evidence = BTreeSet::new();
3376    for role in roles {
3377        if !seen_roles.insert(role.role.as_str()) {
3378            return Err(PromotionMaterializationIdentityReportError::DuplicateRole {
3379                role: role.role.clone(),
3380            });
3381        }
3382        if !seen_evidence.insert(role.evidence_id.as_str()) {
3383            return Err(
3384                PromotionMaterializationIdentityReportError::DuplicateEvidence {
3385                    evidence_id: role.evidence_id.clone(),
3386                },
3387            );
3388        }
3389    }
3390    Ok(())
3391}
3392
3393fn validate_role_materialization_identity(
3394    role: &RolePromotionMaterializationIdentityV1,
3395) -> Result<(), PromotionMaterializationIdentityReportError> {
3396    ensure_materialization_report_field("role", &role.role)?;
3397    ensure_materialization_report_field("evidence_id", &role.evidence_id)?;
3398    ensure_materialization_report_field("recipe_id", &role.recipe_id)?;
3399    ensure_materialization_report_field(
3400        "materialization_input_id",
3401        &role.materialization_input_id,
3402    )?;
3403    ensure_materialization_report_field(
3404        "materialization_result_id",
3405        &role.materialization_result_id,
3406    )?;
3407    ensure_materialization_report_sha256(
3408        "materialization_input_digest",
3409        &role.materialization_input_digest,
3410    )?;
3411    ensure_materialization_report_sha256(
3412        "canonical_embedded_config_sha256",
3413        &role.canonical_embedded_config_sha256,
3414    )?;
3415    ensure_materialization_report_field("network", &role.network)?;
3416    ensure_materialization_report_field("root_trust_anchor", &role.root_trust_anchor)?;
3417    ensure_materialization_report_field("runtime_variant", &role.runtime_variant)?;
3418    ensure_materialization_report_sha256("wasm_sha256", &role.wasm_sha256)?;
3419    ensure_materialization_report_sha256("wasm_gz_sha256", &role.wasm_gz_sha256)?;
3420    ensure_materialization_report_sha256("installed_module_hash", &role.installed_module_hash)?;
3421    ensure_materialization_report_sha256("candid_sha256", &role.candid_sha256)?;
3422    Ok(())
3423}
3424
3425fn validate_materialization_report_blockers(
3426    blockers: &[SafetyFindingV1],
3427) -> Result<(), PromotionMaterializationIdentityReportError> {
3428    for blocker in blockers {
3429        ensure_materialization_report_field("blocker.code", &blocker.code)?;
3430        ensure_materialization_report_field("blocker.message", &blocker.message)?;
3431        if blocker.severity != SafetySeverityV1::HardFailure {
3432            return Err(
3433                PromotionMaterializationIdentityReportError::BlockerSeverityMismatch {
3434                    severity: blocker.severity,
3435                },
3436            );
3437        }
3438    }
3439    Ok(())
3440}
3441
3442const fn ensure_provenance_report_status_matches_blockers(
3443    report: &ArtifactPromotionProvenanceReportV1,
3444) -> Result<(), ArtifactPromotionProvenanceReportError> {
3445    match (report.status, report.blockers.is_empty()) {
3446        (PromotionReadinessStatusV1::Ready, false)
3447        | (PromotionReadinessStatusV1::Blocked, true) => Err(
3448            ArtifactPromotionProvenanceReportError::StatusBlockerMismatch {
3449                status: report.status,
3450                blocker_count: report.blockers.len(),
3451            },
3452        ),
3453        _ => Ok(()),
3454    }
3455}
3456
3457fn ensure_unique_provenance_roles(
3458    roles: &[RolePromotionProvenanceV1],
3459) -> Result<(), ArtifactPromotionProvenanceReportError> {
3460    let mut seen = BTreeSet::new();
3461    for role in roles {
3462        if !seen.insert(role.role.as_str()) {
3463            return Err(ArtifactPromotionProvenanceReportError::DuplicateRole {
3464                role: role.role.clone(),
3465            });
3466        }
3467    }
3468    Ok(())
3469}
3470
3471fn validate_role_promotion_provenance(
3472    role: &RolePromotionProvenanceV1,
3473) -> Result<(), ArtifactPromotionProvenanceReportError> {
3474    ensure_provenance_report_field("role", &role.role)?;
3475    if let Some(evidence_id) = &role.materialization_evidence_id {
3476        ensure_provenance_report_field("materialization_evidence_id", evidence_id)?;
3477    }
3478    if let Some(locator) = &role.wasm_store_locator {
3479        ensure_provenance_report_field("wasm_store_locator", locator)?;
3480    }
3481    Ok(())
3482}
3483
3484fn validate_provenance_report_blockers(
3485    blockers: &[SafetyFindingV1],
3486) -> Result<(), ArtifactPromotionProvenanceReportError> {
3487    for blocker in blockers {
3488        ensure_provenance_report_field("blocker.code", &blocker.code)?;
3489        ensure_provenance_report_field("blocker.message", &blocker.message)?;
3490        if blocker.severity != SafetySeverityV1::HardFailure {
3491            return Err(
3492                ArtifactPromotionProvenanceReportError::BlockerSeverityMismatch {
3493                    severity: blocker.severity,
3494                },
3495            );
3496        }
3497    }
3498    Ok(())
3499}
3500
3501fn validate_readiness_findings(
3502    field: &'static str,
3503    findings: &[SafetyFindingV1],
3504    expected_severity: SafetySeverityV1,
3505) -> Result<(), PromotionReadinessError> {
3506    for finding in findings {
3507        ensure_readiness_field("finding.code", &finding.code)?;
3508        ensure_readiness_field("finding.message", &finding.message)?;
3509        if finding.severity != expected_severity {
3510            return Err(PromotionReadinessError::FindingSeverityMismatch {
3511                field,
3512                severity: finding.severity,
3513            });
3514        }
3515    }
3516    Ok(())
3517}
3518
3519fn ensure_policy_field(field: &'static str, value: &str) -> Result<(), PromotionPolicyCheckError> {
3520    if value.trim().is_empty() {
3521        return Err(PromotionPolicyCheckError::MissingRequiredField { field });
3522    }
3523    Ok(())
3524}
3525
3526fn ensure_identity_report_field(
3527    field: &'static str,
3528    value: &str,
3529) -> Result<(), PromotionArtifactIdentityReportError> {
3530    if value.trim().is_empty() {
3531        return Err(PromotionArtifactIdentityReportError::MissingRequiredField { field });
3532    }
3533    Ok(())
3534}
3535
3536fn ensure_identity_optional_sha256(
3537    field: &'static str,
3538    value: Option<&str>,
3539) -> Result<(), PromotionArtifactIdentityReportError> {
3540    let Some(value) = value else {
3541        return Ok(());
3542    };
3543    if is_lower_hex_sha256(value) {
3544        Ok(())
3545    } else {
3546        Err(PromotionArtifactIdentityReportError::InvalidSha256Digest { field })
3547    }
3548}
3549
3550fn ensure_wasm_store_identity_report_field(
3551    field: &'static str,
3552    value: &str,
3553) -> Result<(), PromotionWasmStoreIdentityReportError> {
3554    if value.trim().is_empty() {
3555        return Err(PromotionWasmStoreIdentityReportError::MissingRequiredField { field });
3556    }
3557    Ok(())
3558}
3559
3560fn ensure_materialization_report_field(
3561    field: &'static str,
3562    value: &str,
3563) -> Result<(), PromotionMaterializationIdentityReportError> {
3564    if value.trim().is_empty() {
3565        return Err(PromotionMaterializationIdentityReportError::MissingRequiredField { field });
3566    }
3567    Ok(())
3568}
3569
3570fn ensure_provenance_report_field(
3571    field: &'static str,
3572    value: &str,
3573) -> Result<(), ArtifactPromotionProvenanceReportError> {
3574    if value.trim().is_empty() {
3575        return Err(ArtifactPromotionProvenanceReportError::MissingRequiredField { field });
3576    }
3577    Ok(())
3578}
3579
3580fn ensure_materialization_report_sha256(
3581    field: &'static str,
3582    value: &str,
3583) -> Result<(), PromotionMaterializationIdentityReportError> {
3584    ensure_materialization_report_field(field, value)?;
3585    if is_lower_hex_sha256(value) {
3586        Ok(())
3587    } else {
3588        Err(
3589            PromotionMaterializationIdentityReportError::Materialization(
3590                PromotionMaterializationIdentityError::InvalidSha256Digest { field },
3591            ),
3592        )
3593    }
3594}
3595
3596fn ensure_materialization_field(
3597    field: &'static str,
3598    value: &str,
3599) -> Result<(), PromotionMaterializationIdentityError> {
3600    if value.trim().is_empty() {
3601        return Err(PromotionMaterializationIdentityError::MissingRequiredField { field });
3602    }
3603    Ok(())
3604}
3605
3606fn ensure_materialization_sha256(
3607    field: &'static str,
3608    value: &str,
3609) -> Result<(), PromotionMaterializationIdentityError> {
3610    ensure_materialization_field(field, value)?;
3611    if is_lower_hex_sha256(value) {
3612        Ok(())
3613    } else {
3614        Err(PromotionMaterializationIdentityError::InvalidSha256Digest { field })
3615    }
3616}
3617
3618const fn ensure_materialization_link(
3619    field: &'static str,
3620    valid: bool,
3621) -> Result<(), PromotionMaterializationIdentityError> {
3622    if valid {
3623        Ok(())
3624    } else {
3625        Err(PromotionMaterializationIdentityError::LinkageMismatch { field })
3626    }
3627}
3628
3629fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
3630    if value.trim().is_empty() {
3631        return Err(PromotionReadinessError::MissingRequiredField { field });
3632    }
3633    Ok(())
3634}
3635
3636fn ensure_readiness_optional_sha256(
3637    field: &'static str,
3638    value: Option<&str>,
3639) -> Result<(), PromotionReadinessError> {
3640    let Some(value) = value else {
3641        return Ok(());
3642    };
3643    if is_lower_hex_sha256(value) {
3644        Ok(())
3645    } else {
3646        Err(PromotionReadinessError::InvalidSha256Digest { field })
3647    }
3648}
3649
3650fn ensure_transform_field(
3651    field: &'static str,
3652    value: &str,
3653) -> Result<(), PromotionPlanTransformError> {
3654    if value.trim().is_empty() {
3655        return Err(PromotionPlanTransformError::MissingRequiredField { field });
3656    }
3657    Ok(())
3658}
3659
3660fn ensure_evidence_field(
3661    field: &'static str,
3662    value: &str,
3663) -> Result<(), PromotionPlanTransformEvidenceError> {
3664    if value.trim().is_empty() {
3665        return Err(PromotionPlanTransformEvidenceError::MissingRequiredField { field });
3666    }
3667    Ok(())
3668}
3669
3670fn ensure_artifact_promotion_plan_field(
3671    field: &'static str,
3672    value: &str,
3673) -> Result<(), ArtifactPromotionPlanError> {
3674    if value.trim().is_empty() {
3675        return Err(ArtifactPromotionPlanError::MissingRequiredField { field });
3676    }
3677    Ok(())
3678}
3679
3680const fn ensure_artifact_promotion_status_matches_blockers(
3681    plan: &ArtifactPromotionPlanV1,
3682) -> Result<(), ArtifactPromotionPlanError> {
3683    match (plan.status, plan.blockers.is_empty()) {
3684        (PromotionReadinessStatusV1::Ready, false)
3685        | (PromotionReadinessStatusV1::Blocked, true) => {
3686            Err(ArtifactPromotionPlanError::StatusBlockerMismatch {
3687                status: plan.status,
3688                blocker_count: plan.blockers.len(),
3689            })
3690        }
3691        _ => Ok(()),
3692    }
3693}
3694
3695fn ensure_artifact_promotion_plan_linkage(
3696    plan: &ArtifactPromotionPlanV1,
3697) -> Result<(), ArtifactPromotionPlanError> {
3698    let expected_blockers =
3699        artifact_promotion_plan_blockers(&plan.readiness, &plan.artifact_identity_report);
3700    if expected_blockers != plan.blockers {
3701        return Err(ArtifactPromotionPlanError::LinkageMismatch { field: "blockers" });
3702    }
3703    if plan.readiness.target_plan_id != plan.target_plan_id {
3704        return Err(ArtifactPromotionPlanError::LinkageMismatch {
3705            field: "readiness.target_plan_id",
3706        });
3707    }
3708    if plan.transform.target_plan_id != plan.target_plan_id {
3709        return Err(ArtifactPromotionPlanError::LinkageMismatch {
3710            field: "transform.target_plan_id",
3711        });
3712    }
3713    if plan.transform.promoted_plan_id != plan.promoted_plan_id {
3714        return Err(ArtifactPromotionPlanError::LinkageMismatch {
3715            field: "transform.promoted_plan_id",
3716        });
3717    }
3718    if plan.transform.promotion_plan_lineage_digest != plan.promotion_plan_lineage_digest {
3719        return Err(ArtifactPromotionPlanError::LinkageMismatch {
3720            field: "promotion_plan_lineage_digest",
3721        });
3722    }
3723    Ok(())
3724}
3725
3726fn ensure_target_execution_lineage_field(
3727    field: &'static str,
3728    value: &str,
3729) -> Result<(), PromotionTargetExecutionLineageError> {
3730    if value.trim().is_empty() {
3731        return Err(PromotionTargetExecutionLineageError::MissingRequiredField { field });
3732    }
3733    Ok(())
3734}
3735
3736fn ensure_target_execution_lineage_sha256(
3737    field: &'static str,
3738    value: &str,
3739) -> Result<(), PromotionTargetExecutionLineageError> {
3740    if is_lower_hex_sha256(value) {
3741        Ok(())
3742    } else {
3743        Err(PromotionTargetExecutionLineageError::InvalidSha256Digest { field })
3744    }
3745}