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