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