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