Skip to main content

canic_host/deployment_truth/
promotion.rs

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