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