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