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