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