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