1use super::{
2 ArtifactSourceV1, DEPLOYMENT_TRUTH_SCHEMA_VERSION, DeploymentPlanV1, PromotionArtifactLevelV1,
3 PromotionPlanTransformEvidenceV1, PromotionPlanTransformV1, PromotionReadinessStatusV1,
4 PromotionReadinessV1, RoleArtifactSourceKindV1, RoleArtifactSourceV1, RoleArtifactV1,
5 RolePromotionInputV1, RolePromotionPlanTransformV1, RolePromotionReadinessV1, SafetyFindingV1,
6 SafetySeverityV1,
7};
8use thiserror::Error as ThisError;
9
10#[derive(Debug, ThisError)]
14pub enum PromotionArtifactSourceError {
15 #[error("promotion artifact source is missing required field: {field}")]
16 MissingRequiredField { field: &'static str },
17 #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
18 InvalidSha256Digest { field: &'static str },
19 #[error("promotion artifact source kind {kind:?} requires a digest pin")]
20 MissingDigestPin { kind: RoleArtifactSourceKindV1 },
21 #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
22 UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
23 #[error(
24 "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
25 )]
26 MissingPreviousReceiptKind,
27}
28
29#[derive(Debug, ThisError)]
33pub enum PromotionReadinessError {
34 #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
35 SchemaVersionMismatch { expected: u32, found: u32 },
36 #[error("promotion readiness is missing required field: {field}")]
37 MissingRequiredField { field: &'static str },
38 #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
39 StatusBlockerMismatch {
40 status: PromotionReadinessStatusV1,
41 blocker_count: usize,
42 },
43 #[error("promotion readiness contains duplicate role: {role}")]
44 DuplicateRole { role: String },
45 #[error("promotion readiness role {role} has inconsistent restage state")]
46 RestageStateMismatch { role: String },
47 #[error("promotion readiness finding in {field} has severity {severity:?}")]
48 FindingSeverityMismatch {
49 field: &'static str,
50 severity: SafetySeverityV1,
51 },
52 #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
53 InvalidSha256Digest { field: &'static str },
54}
55
56#[derive(Debug, ThisError)]
60pub enum PromotionPlanTransformError {
61 #[error("promotion plan transform schema mismatch: expected {expected}, found {found}")]
62 SchemaVersionMismatch { expected: u32, found: u32 },
63 #[error("promotion plan transform is missing required field: {field}")]
64 MissingRequiredField { field: &'static str },
65 #[error("promotion readiness validation failed: {0}")]
66 Readiness(#[from] PromotionReadinessError),
67 #[error("promotion readiness is blocked with {blocker_count} blocker(s)")]
68 ReadinessBlocked { blocker_count: usize },
69 #[error("promotion target plan is missing role: {role}")]
70 TargetRoleMissing { role: String },
71 #[error("promotion transform contains duplicate role: {role}")]
72 DuplicateRole { role: String },
73 #[error("promotion transform promoted plan id mismatch: expected {expected}, found {found}")]
74 PromotedPlanIdMismatch { expected: String, found: String },
75 #[error("promotion transform role {role} is missing from promoted plan")]
76 PromotedRoleMissing { role: String },
77 #[error("promotion transform role {role} has inconsistent field {field}")]
78 RoleStateMismatch { role: String, field: &'static str },
79}
80
81#[derive(Debug, ThisError)]
85pub enum PromotionPlanTransformEvidenceError {
86 #[error(
87 "promotion plan transform evidence schema mismatch: expected {expected}, found {found}"
88 )]
89 SchemaVersionMismatch { expected: u32, found: u32 },
90 #[error("promotion plan transform evidence is missing required field: {field}")]
91 MissingRequiredField { field: &'static str },
92 #[error("promotion plan transform evidence has invalid transform: {0}")]
93 Transform(#[from] PromotionPlanTransformError),
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct PromotionReadinessRequest {
101 pub readiness_id: String,
102 pub target_plan: DeploymentPlanV1,
103 pub inputs: Vec<RolePromotionInputV1>,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct PromotionPlanTransformRequest {
111 pub promoted_plan_id: String,
112 pub target_plan: DeploymentPlanV1,
113 pub inputs: Vec<RolePromotionInputV1>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct PromotionPlanTransformEvidenceRequest {
121 pub evidence_id: String,
122 pub generated_at: String,
123 pub transform: PromotionPlanTransformV1,
124}
125
126pub fn promoted_deployment_plan_from_inputs(
127 request: &PromotionPlanTransformRequest,
128) -> Result<DeploymentPlanV1, PromotionPlanTransformError> {
129 Ok(promoted_deployment_plan_transform_from_inputs(request)?.promoted_plan)
130}
131
132pub fn promoted_deployment_plan_transform_from_inputs(
133 request: &PromotionPlanTransformRequest,
134) -> Result<PromotionPlanTransformV1, PromotionPlanTransformError> {
135 ensure_transform_field("promoted_plan_id", &request.promoted_plan_id)?;
136 let readiness = promotion_readiness_from_inputs(
137 &request.promoted_plan_id,
138 &request.target_plan,
139 &request.inputs,
140 );
141 validate_promotion_readiness(&readiness)?;
142 if readiness.status == PromotionReadinessStatusV1::Blocked {
143 return Err(PromotionPlanTransformError::ReadinessBlocked {
144 blocker_count: readiness.blockers.len(),
145 });
146 }
147
148 let mut promoted_plan = request.target_plan.clone();
149 promoted_plan.plan_id.clone_from(&request.promoted_plan_id);
150 for input in &request.inputs {
151 let Some(role_artifact) = promoted_plan
152 .role_artifacts
153 .iter_mut()
154 .find(|artifact| artifact.role == input.role)
155 else {
156 return Err(PromotionPlanTransformError::TargetRoleMissing {
157 role: input.role.clone(),
158 });
159 };
160 apply_promotion_input_to_role_artifact(role_artifact, input);
161 }
162 let transform =
163 promotion_plan_transform_from_parts(&request.target_plan, promoted_plan, &request.inputs);
164 validate_promotion_plan_transform(&transform)?;
165 Ok(transform)
166}
167
168pub fn check_promotion_readiness(
169 request: &PromotionReadinessRequest,
170) -> Result<PromotionReadinessV1, PromotionReadinessError> {
171 ensure_readiness_field("readiness_id", &request.readiness_id)?;
172 let readiness = promotion_readiness_from_inputs(
173 &request.readiness_id,
174 &request.target_plan,
175 &request.inputs,
176 );
177 validate_promotion_readiness(&readiness)?;
178 Ok(readiness)
179}
180
181pub fn promotion_plan_transform_evidence(
182 request: PromotionPlanTransformEvidenceRequest,
183) -> Result<PromotionPlanTransformEvidenceV1, PromotionPlanTransformEvidenceError> {
184 ensure_evidence_field("evidence_id", &request.evidence_id)?;
185 ensure_evidence_field("generated_at", &request.generated_at)?;
186 validate_promotion_plan_transform(&request.transform)?;
187 let evidence = PromotionPlanTransformEvidenceV1 {
188 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
189 evidence_id: request.evidence_id,
190 generated_at: request.generated_at,
191 transform: request.transform,
192 };
193 validate_promotion_plan_transform_evidence(&evidence)?;
194 Ok(evidence)
195}
196
197pub fn validate_promotion_plan_transform_evidence(
198 evidence: &PromotionPlanTransformEvidenceV1,
199) -> Result<(), PromotionPlanTransformEvidenceError> {
200 if evidence.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
201 return Err(PromotionPlanTransformEvidenceError::SchemaVersionMismatch {
202 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
203 found: evidence.schema_version,
204 });
205 }
206 ensure_evidence_field("evidence_id", &evidence.evidence_id)?;
207 ensure_evidence_field("generated_at", &evidence.generated_at)?;
208 validate_promotion_plan_transform(&evidence.transform)?;
209 Ok(())
210}
211
212pub fn validate_promotion_plan_transform(
213 transform: &PromotionPlanTransformV1,
214) -> Result<(), PromotionPlanTransformError> {
215 if transform.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
216 return Err(PromotionPlanTransformError::SchemaVersionMismatch {
217 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
218 found: transform.schema_version,
219 });
220 }
221 ensure_transform_field("transform_id", &transform.transform_id)?;
222 ensure_transform_field("target_plan_id", &transform.target_plan_id)?;
223 ensure_transform_field("promoted_plan_id", &transform.promoted_plan_id)?;
224 ensure_transform_field("promoted_plan.plan_id", &transform.promoted_plan.plan_id)?;
225 if transform.promoted_plan.plan_id != transform.promoted_plan_id {
226 return Err(PromotionPlanTransformError::PromotedPlanIdMismatch {
227 expected: transform.promoted_plan_id.clone(),
228 found: transform.promoted_plan.plan_id.clone(),
229 });
230 }
231 ensure_unique_transform_roles(&transform.roles)?;
232 for role in &transform.roles {
233 validate_role_plan_transform(role, &transform.promoted_plan)?;
234 }
235 Ok(())
236}
237
238#[must_use]
239pub fn promotion_readiness_from_inputs(
240 readiness_id: impl Into<String>,
241 target_plan: &DeploymentPlanV1,
242 inputs: &[RolePromotionInputV1],
243) -> PromotionReadinessV1 {
244 let mut roles = Vec::with_capacity(inputs.len());
245 let mut blockers = Vec::new();
246 let mut warnings = Vec::new();
247
248 for input in inputs {
249 let target_artifact = target_plan
250 .role_artifacts
251 .iter()
252 .find(|artifact| artifact.role == input.role);
253 let Some(target_artifact) = target_artifact else {
254 blockers.push(promotion_finding(
255 "promotion_target_role_missing",
256 format!("target plan does not contain role {}", input.role),
257 SafetySeverityV1::HardFailure,
258 &input.role,
259 ));
260 continue;
261 };
262
263 let role_readiness = role_promotion_readiness(input, target_artifact);
264 collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
265 roles.push(role_readiness);
266 }
267
268 let status = if blockers.is_empty() {
269 PromotionReadinessStatusV1::Ready
270 } else {
271 PromotionReadinessStatusV1::Blocked
272 };
273
274 PromotionReadinessV1 {
275 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
276 readiness_id: readiness_id.into(),
277 target_plan_id: target_plan.plan_id.clone(),
278 status,
279 roles,
280 blockers,
281 warnings,
282 }
283}
284
285pub fn validate_promotion_readiness(
286 readiness: &PromotionReadinessV1,
287) -> Result<(), PromotionReadinessError> {
288 if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
289 return Err(PromotionReadinessError::SchemaVersionMismatch {
290 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
291 found: readiness.schema_version,
292 });
293 }
294 ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
295 ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
296 ensure_readiness_status_matches_blockers(readiness)?;
297 ensure_unique_readiness_roles(&readiness.roles)?;
298 for role in &readiness.roles {
299 validate_role_readiness(role)?;
300 }
301 validate_readiness_findings(
302 "blockers",
303 &readiness.blockers,
304 SafetySeverityV1::HardFailure,
305 )?;
306 validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
307 Ok(())
308}
309
310pub fn validate_role_artifact_source(
311 source: &RoleArtifactSourceV1,
312) -> Result<(), PromotionArtifactSourceError> {
313 ensure_field("role", &source.role)?;
314 ensure_locator_requirement(source)?;
315 ensure_previous_receipt_requirement(source)?;
316 ensure_digest_requirement(source)?;
317 ensure_optional_sha256(
318 "expected_wasm_sha256",
319 source.expected_wasm_sha256.as_deref(),
320 )?;
321 ensure_optional_sha256(
322 "expected_wasm_gz_sha256",
323 source.expected_wasm_gz_sha256.as_deref(),
324 )?;
325 ensure_optional_sha256(
326 "expected_candid_sha256",
327 source.expected_candid_sha256.as_deref(),
328 )?;
329 ensure_optional_sha256(
330 "expected_canonical_embedded_config_sha256",
331 source.expected_canonical_embedded_config_sha256.as_deref(),
332 )?;
333 Ok(())
334}
335
336fn apply_promotion_input_to_role_artifact(
337 role_artifact: &mut RoleArtifactV1,
338 input: &RolePromotionInputV1,
339) {
340 match input.promotion_level {
341 PromotionArtifactLevelV1::SealedWasm => {
342 role_artifact.source = artifact_source_for_promotion_source(input.source.kind);
343 apply_promotion_source_locator(role_artifact, &input.source);
344 role_artifact
345 .wasm_sha256
346 .clone_from(&input.source.expected_wasm_sha256);
347 role_artifact
348 .wasm_gz_sha256
349 .clone_from(&input.source.expected_wasm_gz_sha256);
350 role_artifact
351 .candid_sha256
352 .clone_from(&input.source.expected_candid_sha256);
353 role_artifact
354 .canonical_embedded_config_sha256
355 .clone_from(&input.source.expected_canonical_embedded_config_sha256);
356 }
357 PromotionArtifactLevelV1::SourceBuild => {}
358 }
359}
360
361const fn artifact_source_for_promotion_source(kind: RoleArtifactSourceKindV1) -> ArtifactSourceV1 {
362 match kind {
363 RoleArtifactSourceKindV1::WorkspacePackage => ArtifactSourceV1::LocalBuild,
364 RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => ArtifactSourceV1::WasmStore,
365 RoleArtifactSourceKindV1::PublishedPackage
366 | RoleArtifactSourceKindV1::LocalWasm
367 | RoleArtifactSourceKindV1::LocalWasmGz
368 | RoleArtifactSourceKindV1::PreviousReceiptArtifact => ArtifactSourceV1::External,
369 }
370}
371
372fn apply_promotion_source_locator(
373 role_artifact: &mut RoleArtifactV1,
374 source: &RoleArtifactSourceV1,
375) {
376 match source.kind {
377 RoleArtifactSourceKindV1::LocalWasm => {
378 role_artifact.wasm_path.clone_from(&source.locator);
379 }
380 RoleArtifactSourceKindV1::LocalWasmGz => {
381 role_artifact.wasm_gz_path.clone_from(&source.locator);
382 }
383 _ => {}
384 }
385}
386
387fn promotion_plan_transform_from_parts(
388 target_plan: &DeploymentPlanV1,
389 promoted_plan: DeploymentPlanV1,
390 inputs: &[RolePromotionInputV1],
391) -> PromotionPlanTransformV1 {
392 let roles = inputs
393 .iter()
394 .filter_map(|input| {
395 let before = target_plan
396 .role_artifacts
397 .iter()
398 .find(|artifact| artifact.role == input.role)?;
399 let after = promoted_plan
400 .role_artifacts
401 .iter()
402 .find(|artifact| artifact.role == input.role)?;
403 Some(role_plan_transform(input, before, after))
404 })
405 .collect();
406
407 PromotionPlanTransformV1 {
408 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
409 transform_id: format!("promotion-transform:{}", promoted_plan.plan_id),
410 target_plan_id: target_plan.plan_id.clone(),
411 promoted_plan_id: promoted_plan.plan_id.clone(),
412 promoted_plan,
413 roles,
414 }
415}
416
417fn role_plan_transform(
418 input: &RolePromotionInputV1,
419 before: &RoleArtifactV1,
420 after: &RoleArtifactV1,
421) -> RolePromotionPlanTransformV1 {
422 RolePromotionPlanTransformV1 {
423 role: input.role.clone(),
424 promotion_level: input.promotion_level,
425 source_kind: input.source.kind,
426 source_locator: input.source.locator.clone(),
427 artifact_source_before: before.source,
428 artifact_source_after: after.source,
429 wasm_sha256_before: before.wasm_sha256.clone(),
430 wasm_sha256_after: after.wasm_sha256.clone(),
431 wasm_gz_sha256_before: before.wasm_gz_sha256.clone(),
432 wasm_gz_sha256_after: after.wasm_gz_sha256.clone(),
433 candid_sha256_before: before.candid_sha256.clone(),
434 candid_sha256_after: after.candid_sha256.clone(),
435 canonical_embedded_config_sha256_before: before.canonical_embedded_config_sha256.clone(),
436 canonical_embedded_config_sha256_after: after.canonical_embedded_config_sha256.clone(),
437 artifact_identity_changed: artifact_identity_changed(before, after),
438 embedded_config_changed: before.canonical_embedded_config_sha256
439 != after.canonical_embedded_config_sha256,
440 target_materialization_preserved: input.promotion_level
441 == PromotionArtifactLevelV1::SourceBuild
442 && role_materialization_identity_matches(before, after),
443 }
444}
445
446fn artifact_identity_changed(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
447 before.source != after.source
448 || before.wasm_path != after.wasm_path
449 || before.wasm_gz_path != after.wasm_gz_path
450 || before.wasm_sha256 != after.wasm_sha256
451 || before.wasm_gz_sha256 != after.wasm_gz_sha256
452 || before.candid_path != after.candid_path
453 || before.candid_sha256 != after.candid_sha256
454}
455
456fn role_materialization_identity_matches(before: &RoleArtifactV1, after: &RoleArtifactV1) -> bool {
457 before.source == after.source
458 && before.wasm_path == after.wasm_path
459 && before.wasm_gz_path == after.wasm_gz_path
460 && before.wasm_sha256 == after.wasm_sha256
461 && before.wasm_gz_sha256 == after.wasm_gz_sha256
462 && before.candid_path == after.candid_path
463 && before.candid_sha256 == after.candid_sha256
464 && before.canonical_embedded_config_sha256 == after.canonical_embedded_config_sha256
465}
466
467fn validate_role_plan_transform(
468 role: &RolePromotionPlanTransformV1,
469 promoted_plan: &DeploymentPlanV1,
470) -> Result<(), PromotionPlanTransformError> {
471 ensure_transform_field("role", &role.role)?;
472 let Some(promoted_role) = promoted_plan
473 .role_artifacts
474 .iter()
475 .find(|artifact| artifact.role == role.role)
476 else {
477 return Err(PromotionPlanTransformError::PromotedRoleMissing {
478 role: role.role.clone(),
479 });
480 };
481 ensure_role_matches_promoted_artifact(role, promoted_role)?;
482 ensure_role_transform_flags_are_consistent(role)?;
483 Ok(())
484}
485
486fn ensure_role_matches_promoted_artifact(
487 role: &RolePromotionPlanTransformV1,
488 promoted_role: &RoleArtifactV1,
489) -> Result<(), PromotionPlanTransformError> {
490 ensure_role_field_matches(
491 role,
492 "artifact_source_after",
493 role.artifact_source_after == promoted_role.source,
494 )?;
495 ensure_role_field_matches(
496 role,
497 "wasm_sha256_after",
498 role.wasm_sha256_after == promoted_role.wasm_sha256,
499 )?;
500 ensure_role_field_matches(
501 role,
502 "wasm_gz_sha256_after",
503 role.wasm_gz_sha256_after == promoted_role.wasm_gz_sha256,
504 )?;
505 ensure_role_field_matches(
506 role,
507 "candid_sha256_after",
508 role.candid_sha256_after == promoted_role.candid_sha256,
509 )?;
510 ensure_role_field_matches(
511 role,
512 "canonical_embedded_config_sha256_after",
513 role.canonical_embedded_config_sha256_after
514 == promoted_role.canonical_embedded_config_sha256,
515 )
516}
517
518fn ensure_role_transform_flags_are_consistent(
519 role: &RolePromotionPlanTransformV1,
520) -> Result<(), PromotionPlanTransformError> {
521 ensure_role_field_matches(
522 role,
523 "artifact_identity_changed",
524 role.artifact_identity_changed == role_summary_artifact_identity_changed(role),
525 )?;
526 ensure_role_field_matches(
527 role,
528 "embedded_config_changed",
529 role.embedded_config_changed
530 == (role.canonical_embedded_config_sha256_before
531 != role.canonical_embedded_config_sha256_after),
532 )?;
533 if role.target_materialization_preserved {
534 ensure_role_field_matches(
535 role,
536 "target_materialization_preserved",
537 role.promotion_level == PromotionArtifactLevelV1::SourceBuild
538 && !role.artifact_identity_changed
539 && !role.embedded_config_changed,
540 )?;
541 }
542 Ok(())
543}
544
545fn role_summary_artifact_identity_changed(role: &RolePromotionPlanTransformV1) -> bool {
546 role.artifact_source_before != role.artifact_source_after
547 || role.wasm_sha256_before != role.wasm_sha256_after
548 || role.wasm_gz_sha256_before != role.wasm_gz_sha256_after
549 || role.candid_sha256_before != role.candid_sha256_after
550}
551
552fn ensure_role_field_matches(
553 role: &RolePromotionPlanTransformV1,
554 field: &'static str,
555 matches: bool,
556) -> Result<(), PromotionPlanTransformError> {
557 if matches {
558 Ok(())
559 } else {
560 Err(PromotionPlanTransformError::RoleStateMismatch {
561 role: role.role.clone(),
562 field,
563 })
564 }
565}
566
567fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
568 ensure_readiness_field("role", &role.role)?;
569 ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
570 ensure_readiness_optional_sha256(
571 "source_wasm_gz_sha256",
572 role.source_wasm_gz_sha256.as_deref(),
573 )?;
574 ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
575 ensure_readiness_optional_sha256(
576 "target_wasm_gz_sha256",
577 role.target_wasm_gz_sha256.as_deref(),
578 )?;
579 ensure_readiness_optional_sha256(
580 "source_canonical_embedded_config_sha256",
581 role.source_canonical_embedded_config_sha256.as_deref(),
582 )?;
583 ensure_readiness_optional_sha256(
584 "target_canonical_embedded_config_sha256",
585 role.target_canonical_embedded_config_sha256.as_deref(),
586 )?;
587 if role.restage_required != (role.target_store_has_artifact == Some(false)) {
588 return Err(PromotionReadinessError::RestageStateMismatch {
589 role: role.role.clone(),
590 });
591 }
592 Ok(())
593}
594
595fn role_promotion_readiness(
596 input: &RolePromotionInputV1,
597 target_artifact: &RoleArtifactV1,
598) -> RolePromotionReadinessV1 {
599 let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
600 let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
601 let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
602 let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
603 let byte_identical_wasm =
604 matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
605 || {
606 matching_optional_digest(
607 source_wasm_gz_sha256.as_ref(),
608 target_wasm_gz_sha256.as_ref(),
609 )
610 },
611 );
612 let embedded_config_identical = matching_optional_digest(
613 input
614 .source
615 .expected_canonical_embedded_config_sha256
616 .as_ref(),
617 target_artifact.canonical_embedded_config_sha256.as_ref(),
618 );
619
620 RolePromotionReadinessV1 {
621 role: input.role.clone(),
622 promotion_level: input.promotion_level,
623 source_kind: input.source.kind,
624 source_locator: input.source.locator.clone(),
625 source_wasm_sha256,
626 source_wasm_gz_sha256,
627 target_wasm_sha256,
628 target_wasm_gz_sha256,
629 source_canonical_embedded_config_sha256: input
630 .source
631 .expected_canonical_embedded_config_sha256
632 .clone(),
633 target_canonical_embedded_config_sha256: target_artifact
634 .canonical_embedded_config_sha256
635 .clone(),
636 byte_identical_wasm,
637 embedded_config_identical,
638 target_store_has_artifact: input.target_store_has_artifact,
639 restage_required: input.target_store_has_artifact == Some(false),
640 }
641}
642
643fn collect_role_findings(
644 input: &RolePromotionInputV1,
645 readiness: &RolePromotionReadinessV1,
646 blockers: &mut Vec<SafetyFindingV1>,
647 warnings: &mut Vec<SafetyFindingV1>,
648) {
649 if let Err(err) = validate_role_artifact_source(&input.source) {
650 blockers.push(promotion_finding(
651 "promotion_artifact_source_invalid",
652 err.to_string(),
653 SafetySeverityV1::HardFailure,
654 &input.role,
655 ));
656 }
657
658 if input.role != input.source.role {
659 blockers.push(promotion_finding(
660 "promotion_source_role_mismatch",
661 format!(
662 "promotion input role {} does not match artifact source role {}",
663 input.role, input.source.role
664 ),
665 SafetySeverityV1::HardFailure,
666 &input.role,
667 ));
668 }
669
670 if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
671 blockers.push(promotion_finding(
672 "promotion_wasm_digest_mismatch",
673 "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
674 SafetySeverityV1::HardFailure,
675 &input.role,
676 ));
677 }
678
679 if input.require_target_embedded_config
680 && readiness
681 .target_canonical_embedded_config_sha256
682 .as_deref()
683 .is_none_or(str::is_empty)
684 {
685 blockers.push(promotion_finding(
686 "promotion_target_embedded_config_missing",
687 "promotion requires target canonical embedded config but target plan has no digest",
688 SafetySeverityV1::HardFailure,
689 &input.role,
690 ));
691 }
692
693 if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
694 && readiness.embedded_config_identical != Some(true)
695 {
696 blockers.push(promotion_finding(
697 "promotion_sealed_wasm_embedded_config_mismatch",
698 "sealed wasm promotion requires embedded config identity to be acceptable for the target",
699 SafetySeverityV1::HardFailure,
700 &input.role,
701 ));
702 }
703
704 if readiness.restage_required {
705 warnings.push(promotion_finding(
706 "promotion_target_store_restage_required",
707 "target artifact store does not already contain the artifact; restaging is required",
708 SafetySeverityV1::Warning,
709 &input.role,
710 ));
711 }
712}
713
714fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
715 match (left.map(String::as_str), right.map(String::as_str)) {
716 (Some(left), Some(right)) => Some(left == right),
717 _ => None,
718 }
719}
720
721fn promotion_finding(
722 code: impl Into<String>,
723 message: impl Into<String>,
724 severity: SafetySeverityV1,
725 role: &str,
726) -> SafetyFindingV1 {
727 SafetyFindingV1 {
728 code: code.into(),
729 message: message.into(),
730 severity,
731 subject: Some(role.to_string()),
732 }
733}
734
735fn ensure_locator_requirement(
736 source: &RoleArtifactSourceV1,
737) -> Result<(), PromotionArtifactSourceError> {
738 match source.kind {
739 RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
740 _ => ensure_option_field("locator", source.locator.as_deref()),
741 }
742}
743
744const fn ensure_previous_receipt_requirement(
745 source: &RoleArtifactSourceV1,
746) -> Result<(), PromotionArtifactSourceError> {
747 match (source.kind, source.previous_receipt_kind) {
748 (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
749 (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
750 Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
751 }
752 (_, Some(_)) => {
753 Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
754 }
755 (_, None) => Ok(()),
756 }
757}
758
759const fn ensure_digest_requirement(
760 source: &RoleArtifactSourceV1,
761) -> Result<(), PromotionArtifactSourceError> {
762 let has_digest =
763 source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
764 match source.kind {
765 RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
766 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
767 }
768 RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
769 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
770 }
771 RoleArtifactSourceKindV1::PublishedPackage
772 | RoleArtifactSourceKindV1::PreviousReceiptArtifact
773 if !has_digest =>
774 {
775 Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
776 }
777 _ => Ok(()),
778 }
779}
780
781fn ensure_option_field(
782 field: &'static str,
783 value: Option<&str>,
784) -> Result<(), PromotionArtifactSourceError> {
785 match value {
786 Some(value) => ensure_field(field, value),
787 None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
788 }
789}
790
791fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
792 if value.trim().is_empty() {
793 return Err(PromotionArtifactSourceError::MissingRequiredField { field });
794 }
795 Ok(())
796}
797
798fn ensure_optional_sha256(
799 field: &'static str,
800 value: Option<&str>,
801) -> Result<(), PromotionArtifactSourceError> {
802 let Some(value) = value else {
803 return Ok(());
804 };
805 if is_lower_hex_sha256(value) {
806 Ok(())
807 } else {
808 Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
809 }
810}
811
812fn is_lower_hex_sha256(value: &str) -> bool {
813 value.len() == 64
814 && value
815 .bytes()
816 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
817}
818
819const fn ensure_readiness_status_matches_blockers(
820 readiness: &PromotionReadinessV1,
821) -> Result<(), PromotionReadinessError> {
822 match (readiness.status, readiness.blockers.is_empty()) {
823 (PromotionReadinessStatusV1::Ready, false)
824 | (PromotionReadinessStatusV1::Blocked, true) => {
825 Err(PromotionReadinessError::StatusBlockerMismatch {
826 status: readiness.status,
827 blocker_count: readiness.blockers.len(),
828 })
829 }
830 _ => Ok(()),
831 }
832}
833
834fn ensure_unique_readiness_roles(
835 roles: &[RolePromotionReadinessV1],
836) -> Result<(), PromotionReadinessError> {
837 let mut seen = std::collections::BTreeSet::new();
838 for role in roles {
839 if !seen.insert(role.role.as_str()) {
840 return Err(PromotionReadinessError::DuplicateRole {
841 role: role.role.clone(),
842 });
843 }
844 }
845 Ok(())
846}
847
848fn ensure_unique_transform_roles(
849 roles: &[RolePromotionPlanTransformV1],
850) -> Result<(), PromotionPlanTransformError> {
851 let mut seen = std::collections::BTreeSet::new();
852 for role in roles {
853 if !seen.insert(role.role.as_str()) {
854 return Err(PromotionPlanTransformError::DuplicateRole {
855 role: role.role.clone(),
856 });
857 }
858 }
859 Ok(())
860}
861
862fn validate_readiness_findings(
863 field: &'static str,
864 findings: &[SafetyFindingV1],
865 expected_severity: SafetySeverityV1,
866) -> Result<(), PromotionReadinessError> {
867 for finding in findings {
868 ensure_readiness_field("finding.code", &finding.code)?;
869 ensure_readiness_field("finding.message", &finding.message)?;
870 if finding.severity != expected_severity {
871 return Err(PromotionReadinessError::FindingSeverityMismatch {
872 field,
873 severity: finding.severity,
874 });
875 }
876 }
877 Ok(())
878}
879
880fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
881 if value.trim().is_empty() {
882 return Err(PromotionReadinessError::MissingRequiredField { field });
883 }
884 Ok(())
885}
886
887fn ensure_readiness_optional_sha256(
888 field: &'static str,
889 value: Option<&str>,
890) -> Result<(), PromotionReadinessError> {
891 let Some(value) = value else {
892 return Ok(());
893 };
894 if is_lower_hex_sha256(value) {
895 Ok(())
896 } else {
897 Err(PromotionReadinessError::InvalidSha256Digest { field })
898 }
899}
900
901fn ensure_transform_field(
902 field: &'static str,
903 value: &str,
904) -> Result<(), PromotionPlanTransformError> {
905 if value.trim().is_empty() {
906 return Err(PromotionPlanTransformError::MissingRequiredField { field });
907 }
908 Ok(())
909}
910
911fn ensure_evidence_field(
912 field: &'static str,
913 value: &str,
914) -> Result<(), PromotionPlanTransformEvidenceError> {
915 if value.trim().is_empty() {
916 return Err(PromotionPlanTransformEvidenceError::MissingRequiredField { field });
917 }
918 Ok(())
919}