Skip to main content

canic_host/deployment_truth/
receipt.rs

1use super::*;
2use thiserror::Error as ThisError;
3
4///
5/// AuthorityEvidenceError
6///
7#[derive(Debug, ThisError)]
8pub enum AuthorityEvidenceError {
9    #[error("authority evidence is missing required field: {field}")]
10    MissingRequiredField { field: &'static str },
11
12    #[error(
13        "authority evidence {component} has unsupported schema version: expected {expected}, found {found}"
14    )]
15    SchemaVersionMismatch {
16        component: &'static str,
17        expected: u32,
18        found: u32,
19    },
20
21    #[error(
22        "authority report does not match reconciliation plan: {field} differs (plan={plan_value}, report={report_value})"
23    )]
24    PlanReportMismatch {
25        field: &'static str,
26        plan_value: String,
27        report_value: String,
28    },
29
30    #[error("authority report content does not match reconciliation plan: {field} differs")]
31    PlanReportContentMismatch { field: &'static str },
32
33    #[error("authority dry-run receipt contains attempted controller actions: {count}")]
34    DryRunReceiptAttemptedActions { count: usize },
35
36    #[error("authority dry-run receipt has invalid operation status: {status:?}")]
37    DryRunReceiptStatus { status: DeploymentExecutionStatusV1 },
38
39    #[error("authority dry-run receipt has invalid command result: {result:?}")]
40    DryRunReceiptCommandResult { result: DeploymentCommandResultV1 },
41
42    #[error("authority dry-run receipt is complete but has no finished_at timestamp")]
43    DryRunReceiptMissingFinishedAt,
44
45    #[error(
46        "authority evidence generated_at does not match receipt finished_at (evidence={evidence_value}, receipt={receipt_value})"
47    )]
48    EvidenceGeneratedAtMismatch {
49        evidence_value: String,
50        receipt_value: String,
51    },
52
53    #[error(
54        "authority dry-run receipt has invalid timestamp order: {field} ({left}) is after {other_field} ({right})"
55    )]
56    DryRunReceiptTimestampOrder {
57        field: &'static str,
58        left: String,
59        other_field: &'static str,
60        right: String,
61    },
62
63    #[error(
64        "authority receipt check id does not match report check id (receipt={receipt_value}, report={report_value})"
65    )]
66    CheckIdMismatch {
67        receipt_value: String,
68        report_value: String,
69    },
70
71    #[error(
72        "authority evidence check id does not match nested {component} check id (evidence={evidence_value}, nested={nested_value})"
73    )]
74    EvidenceCheckIdMismatch {
75        component: &'static str,
76        evidence_value: String,
77        nested_value: String,
78    },
79}
80
81/// Validate that a dry-run authority evidence bundle is internally coherent.
82///
83/// This is a consistency guard for archived/read-only evidence. It does not
84/// make the evidence authoritative over live controller state.
85pub fn validate_authority_dry_run_evidence(
86    evidence: &AuthorityDryRunEvidenceV1,
87) -> Result<(), AuthorityEvidenceError> {
88    ensure_authority_evidence_schema_versions(evidence)?;
89    ensure_authority_evidence_required_fields(evidence)?;
90    ensure_authority_report_matches_plan(
91        &evidence.reconciliation_plan,
92        &evidence.authority_report,
93    )?;
94    ensure_authority_evidence_provenance(evidence)?;
95    ensure_authority_receipt_is_completed_dry_run(&evidence.authority_receipt)?;
96    ensure_evidence_generated_at_matches_finished_at(
97        &evidence.generated_at,
98        evidence.authority_receipt.finished_at.as_deref(),
99    )?;
100    ensure_authority_receipt_timestamp_order(&evidence.authority_receipt)?;
101    ensure_authority_receipt_matches_evidence(evidence)
102}
103
104/// Build a complete read-only authority dry-run evidence bundle from a
105/// deployment truth check.
106///
107/// The returned bundle is validated before it leaves this function. It remains
108/// archive/report evidence only; live controller inventory is still the
109/// authority for future reconciliation.
110pub fn authority_dry_run_evidence_from_check(
111    check: &DeploymentCheckV1,
112    evidence_id: impl Into<String>,
113    report_id: impl Into<String>,
114    receipt_id: impl Into<String>,
115    generated_at: impl Into<String>,
116) -> Result<AuthorityDryRunEvidenceV1, AuthorityEvidenceError> {
117    let generated_at = generated_at.into();
118    let reconciliation = build_authority_reconciliation_plan(check);
119    let report = authority_report_from_plan_with_check_id(
120        report_id,
121        Some(check.check_id.clone()),
122        &reconciliation,
123    );
124    let receipt = authority_dry_run_receipt_from_plan(
125        &reconciliation,
126        &report,
127        Some(check.check_id.clone()),
128        receipt_id,
129        generated_at.clone(),
130        Some(generated_at.clone()),
131    )?;
132    let evidence = AuthorityDryRunEvidenceV1 {
133        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
134        evidence_id: evidence_id.into(),
135        check_id: check.check_id.clone(),
136        generated_at,
137        reconciliation_plan: reconciliation,
138        authority_report: report,
139        authority_receipt: receipt,
140    };
141    validate_authority_dry_run_evidence(&evidence)?;
142    Ok(evidence)
143}
144
145/// Build a complete read-only authority dry-run evidence bundle using the
146/// standard local deployment-truth artifact identifiers.
147pub fn authority_dry_run_evidence_from_check_with_local_ids(
148    check: &DeploymentCheckV1,
149    generated_at: impl Into<String>,
150) -> Result<AuthorityDryRunEvidenceV1, AuthorityEvidenceError> {
151    authority_dry_run_evidence_from_check(
152        check,
153        local_authority_artifact_id(check, "authority-evidence"),
154        local_authority_artifact_id(check, "authority-report"),
155        local_authority_artifact_id(check, "authority-dry-run-receipt"),
156        generated_at,
157    )
158}
159
160/// Build a read-only authority dry-run receipt from a deployment truth check.
161///
162/// This is the receipt-only counterpart to
163/// `authority_dry_run_evidence_from_check(...)`: it preserves the same plan,
164/// report, and check provenance without constructing a full evidence bundle.
165pub fn authority_dry_run_receipt_from_check(
166    check: &DeploymentCheckV1,
167    report_id: impl Into<String>,
168    receipt_id: impl Into<String>,
169    started_at: impl Into<String>,
170    finished_at: Option<String>,
171) -> Result<AuthorityReceiptV1, AuthorityEvidenceError> {
172    let reconciliation = build_authority_reconciliation_plan(check);
173    let report = authority_report_from_plan_with_check_id(
174        report_id,
175        Some(check.check_id.clone()),
176        &reconciliation,
177    );
178    authority_dry_run_receipt_from_plan(
179        &reconciliation,
180        &report,
181        Some(check.check_id.clone()),
182        receipt_id,
183        started_at,
184        finished_at,
185    )
186}
187
188/// Build a read-only authority dry-run receipt using the standard local
189/// deployment-truth artifact identifier.
190pub fn authority_dry_run_receipt_from_check_with_local_id(
191    check: &DeploymentCheckV1,
192    generated_at: impl Into<String>,
193) -> Result<AuthorityReceiptV1, AuthorityEvidenceError> {
194    let generated_at = generated_at.into();
195    authority_dry_run_receipt_from_check(
196        check,
197        local_authority_artifact_id(check, "authority-report"),
198        local_authority_artifact_id(check, "authority-dry-run-receipt"),
199        generated_at.clone(),
200        Some(generated_at),
201    )
202}
203
204fn ensure_authority_evidence_schema_versions(
205    evidence: &AuthorityDryRunEvidenceV1,
206) -> Result<(), AuthorityEvidenceError> {
207    ensure_authority_schema_version("evidence", evidence.schema_version)?;
208    ensure_authority_schema_version("plan", evidence.reconciliation_plan.schema_version)?;
209    ensure_authority_schema_version("report", evidence.authority_report.schema_version)?;
210    ensure_authority_schema_version("receipt", evidence.authority_receipt.schema_version)
211}
212
213fn ensure_authority_evidence_required_fields(
214    evidence: &AuthorityDryRunEvidenceV1,
215) -> Result<(), AuthorityEvidenceError> {
216    ensure_required_authority_field("evidence.evidence_id", &evidence.evidence_id)?;
217    ensure_required_authority_field("evidence.check_id", &evidence.check_id)?;
218    ensure_required_authority_field("evidence.generated_at", &evidence.generated_at)?;
219    ensure_required_authority_field("plan.plan_id", &evidence.reconciliation_plan.plan_id)?;
220    ensure_required_authority_field(
221        "plan.inventory_id",
222        &evidence.reconciliation_plan.inventory_id,
223    )?;
224    ensure_required_authority_field("report.report_id", &evidence.authority_report.report_id)?;
225    ensure_required_authority_field(
226        "receipt.operation_id",
227        &evidence.authority_receipt.operation_id,
228    )?;
229    ensure_required_authority_field("receipt.started_at", &evidence.authority_receipt.started_at)?;
230    ensure_required_optional_authority_field(
231        "report.check_id",
232        evidence.authority_report.check_id.as_deref(),
233    )?;
234    ensure_required_optional_authority_field(
235        "receipt.check_id",
236        evidence.authority_receipt.check_id.as_deref(),
237    )
238}
239
240fn ensure_authority_evidence_provenance(
241    evidence: &AuthorityDryRunEvidenceV1,
242) -> Result<(), AuthorityEvidenceError> {
243    ensure_evidence_check_id_matches(
244        &evidence.check_id,
245        "report",
246        evidence.authority_report.check_id.as_deref(),
247    )?;
248    ensure_evidence_check_id_matches(
249        &evidence.check_id,
250        "receipt",
251        evidence.authority_receipt.check_id.as_deref(),
252    )?;
253    ensure_matching_authority_evidence_field(
254        "receipt.reconciliation_plan_id",
255        &evidence.reconciliation_plan.plan_id,
256        &evidence.authority_receipt.reconciliation_plan_id,
257    )?;
258    ensure_matching_authority_evidence_field(
259        "receipt.authority_report_id",
260        &evidence.authority_report.report_id,
261        &evidence.authority_receipt.authority_report_id,
262    )?;
263    ensure_matching_authority_evidence_field(
264        "receipt.inventory_id",
265        &evidence.reconciliation_plan.inventory_id,
266        &evidence.authority_receipt.inventory_id,
267    )?;
268    ensure_matching_authority_evidence_field(
269        "receipt.authority_profile_hash",
270        &optional_authority_value(evidence.reconciliation_plan.authority_profile_hash.as_ref()),
271        &optional_authority_value(evidence.authority_receipt.authority_profile_hash.as_ref()),
272    )
273}
274
275fn ensure_authority_receipt_is_completed_dry_run(
276    receipt: &AuthorityReceiptV1,
277) -> Result<(), AuthorityEvidenceError> {
278    if !receipt.attempted_actions.is_empty() {
279        return Err(AuthorityEvidenceError::DryRunReceiptAttemptedActions {
280            count: receipt.attempted_actions.len(),
281        });
282    }
283    if receipt.operation_status != DeploymentExecutionStatusV1::Complete {
284        return Err(AuthorityEvidenceError::DryRunReceiptStatus {
285            status: receipt.operation_status,
286        });
287    }
288    if receipt.command_result != DeploymentCommandResultV1::Succeeded {
289        return Err(AuthorityEvidenceError::DryRunReceiptCommandResult {
290            result: receipt.command_result.clone(),
291        });
292    }
293    Ok(())
294}
295
296fn ensure_authority_receipt_matches_evidence(
297    evidence: &AuthorityDryRunEvidenceV1,
298) -> Result<(), AuthorityEvidenceError> {
299    let expected_observations = evidence
300        .reconciliation_plan
301        .canister_actions
302        .iter()
303        .map(authority_controller_observation_from_action)
304        .collect::<Vec<_>>();
305    ensure_matching_authority_evidence_content(
306        "receipt.verified_controller_observations",
307        &expected_observations,
308        &evidence.authority_receipt.verified_controller_observations,
309    )?;
310    ensure_matching_authority_evidence_content(
311        "receipt.hard_failures",
312        &evidence.authority_report.hard_failures,
313        &evidence.authority_receipt.hard_failures,
314    )?;
315    ensure_matching_authority_evidence_content(
316        "receipt.unresolved_observation_gaps",
317        &evidence.authority_report.observation_gaps,
318        &evidence.authority_receipt.unresolved_observation_gaps,
319    )?;
320    ensure_matching_authority_evidence_content(
321        "receipt.unresolved_external_actions",
322        &evidence.authority_report.external_actions_required,
323        &evidence.authority_receipt.unresolved_external_actions,
324    )
325}
326
327const fn ensure_authority_schema_version(
328    component: &'static str,
329    found: u32,
330) -> Result<(), AuthorityEvidenceError> {
331    if found == DEPLOYMENT_TRUTH_SCHEMA_VERSION {
332        return Ok(());
333    }
334
335    Err(AuthorityEvidenceError::SchemaVersionMismatch {
336        component,
337        expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
338        found,
339    })
340}
341
342fn ensure_required_authority_field(
343    field: &'static str,
344    value: &str,
345) -> Result<(), AuthorityEvidenceError> {
346    if !value.trim().is_empty() {
347        return Ok(());
348    }
349
350    Err(AuthorityEvidenceError::MissingRequiredField { field })
351}
352
353fn ensure_required_optional_authority_field(
354    field: &'static str,
355    value: Option<&str>,
356) -> Result<(), AuthorityEvidenceError> {
357    let Some(value) = value else {
358        return Err(AuthorityEvidenceError::MissingRequiredField { field });
359    };
360    ensure_required_authority_field(field, value)
361}
362
363fn ensure_evidence_generated_at_matches_finished_at(
364    evidence_generated_at: &str,
365    receipt_finished_at: Option<&str>,
366) -> Result<(), AuthorityEvidenceError> {
367    let Some(receipt_finished_at) = receipt_finished_at else {
368        return Err(AuthorityEvidenceError::DryRunReceiptMissingFinishedAt);
369    };
370    ensure_required_authority_field("receipt.finished_at", receipt_finished_at)?;
371    if evidence_generated_at == receipt_finished_at {
372        return Ok(());
373    }
374
375    Err(AuthorityEvidenceError::EvidenceGeneratedAtMismatch {
376        evidence_value: evidence_generated_at.to_string(),
377        receipt_value: receipt_finished_at.to_string(),
378    })
379}
380
381fn ensure_authority_receipt_timestamp_order(
382    receipt: &AuthorityReceiptV1,
383) -> Result<(), AuthorityEvidenceError> {
384    let Some(finished_at) = receipt.finished_at.as_deref() else {
385        return Err(AuthorityEvidenceError::DryRunReceiptMissingFinishedAt);
386    };
387    ensure_timestamp_order(
388        "receipt.started_at",
389        &receipt.started_at,
390        "receipt.finished_at",
391        finished_at,
392    )
393}
394
395fn ensure_timestamp_order(
396    field: &'static str,
397    left: &str,
398    other_field: &'static str,
399    right: &str,
400) -> Result<(), AuthorityEvidenceError> {
401    if left <= right {
402        return Ok(());
403    }
404
405    Err(AuthorityEvidenceError::DryRunReceiptTimestampOrder {
406        field,
407        left: left.to_string(),
408        other_field,
409        right: right.to_string(),
410    })
411}
412
413/// Build an evidence-only receipt for a completed dry-run authority
414/// reconciliation.
415///
416/// The receipt records that no controller mutations were attempted. The
417/// original plan/report remain the authority for whether later apply work is
418/// safe.
419pub fn authority_dry_run_receipt_from_plan(
420    plan: &AuthorityReconciliationPlanV1,
421    report: &AuthorityReportV1,
422    check_id: Option<String>,
423    operation_id: impl Into<String>,
424    started_at: impl Into<String>,
425    finished_at: Option<String>,
426) -> Result<AuthorityReceiptV1, AuthorityEvidenceError> {
427    let operation_id = operation_id.into();
428    let started_at = started_at.into();
429    ensure_authority_receipt_source_inputs(
430        plan,
431        report,
432        &operation_id,
433        &started_at,
434        finished_at.as_deref(),
435    )?;
436    ensure_authority_report_matches_plan(plan, report)?;
437    if let (Some(receipt_check_id), Some(report_check_id)) = (&check_id, &report.check_id)
438        && receipt_check_id != report_check_id
439    {
440        return Err(AuthorityEvidenceError::CheckIdMismatch {
441            receipt_value: receipt_check_id.clone(),
442            report_value: report_check_id.clone(),
443        });
444    }
445    let receipt_check_id = check_id.or_else(|| report.check_id.clone());
446
447    Ok(AuthorityReceiptV1 {
448        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
449        operation_id,
450        check_id: receipt_check_id,
451        reconciliation_plan_id: plan.plan_id.clone(),
452        authority_report_id: report.report_id.clone(),
453        inventory_id: plan.inventory_id.clone(),
454        authority_profile_hash: plan.authority_profile_hash.clone(),
455        operation_status: DeploymentExecutionStatusV1::Complete,
456        started_at,
457        finished_at,
458        attempted_actions: Vec::new(),
459        verified_controller_observations: plan
460            .canister_actions
461            .iter()
462            .map(authority_controller_observation_from_action)
463            .collect(),
464        hard_failures: report.hard_failures.clone(),
465        unresolved_observation_gaps: report.observation_gaps.clone(),
466        unresolved_external_actions: report.external_actions_required.clone(),
467        command_result: DeploymentCommandResultV1::Succeeded,
468    })
469}
470
471fn ensure_authority_receipt_source_inputs(
472    plan: &AuthorityReconciliationPlanV1,
473    report: &AuthorityReportV1,
474    operation_id: &str,
475    started_at: &str,
476    finished_at: Option<&str>,
477) -> Result<(), AuthorityEvidenceError> {
478    ensure_authority_schema_version("plan", plan.schema_version)?;
479    ensure_authority_schema_version("report", report.schema_version)?;
480    ensure_required_authority_field("plan.plan_id", &plan.plan_id)?;
481    ensure_required_authority_field("plan.inventory_id", &plan.inventory_id)?;
482    ensure_required_authority_field("report.report_id", &report.report_id)?;
483    ensure_required_optional_authority_field("report.check_id", report.check_id.as_deref())?;
484    ensure_required_authority_field("receipt.operation_id", operation_id)?;
485    ensure_required_authority_field("receipt.started_at", started_at)?;
486    ensure_required_optional_authority_field("receipt.finished_at", finished_at)?;
487    let Some(finished_at) = finished_at else {
488        return Err(AuthorityEvidenceError::MissingRequiredField {
489            field: "receipt.finished_at",
490        });
491    };
492    ensure_timestamp_order(
493        "receipt.started_at",
494        started_at,
495        "receipt.finished_at",
496        finished_at,
497    )
498}
499
500fn ensure_authority_report_matches_plan(
501    plan: &AuthorityReconciliationPlanV1,
502    report: &AuthorityReportV1,
503) -> Result<(), AuthorityEvidenceError> {
504    ensure_matching_authority_evidence_field(
505        "reconciliation_plan_id",
506        &plan.plan_id,
507        &report.reconciliation_plan_id,
508    )?;
509    ensure_matching_authority_evidence_field(
510        "inventory_id",
511        &plan.inventory_id,
512        &report.inventory_id,
513    )?;
514    ensure_matching_authority_evidence_field(
515        "authority_profile_hash",
516        &optional_authority_value(plan.authority_profile_hash.as_ref()),
517        &optional_authority_value(report.authority_profile_hash.as_ref()),
518    )?;
519    ensure_matching_authority_evidence_content(
520        "automatic_actions",
521        &plan.automatic_actions,
522        &report.automatic_actions,
523    )?;
524    ensure_matching_authority_evidence_content(
525        "hard_failures",
526        &plan.hard_failures,
527        &report.hard_failures,
528    )?;
529    ensure_matching_authority_evidence_content(
530        "external_actions_required",
531        &plan.external_actions_required,
532        &report.external_actions_required,
533    )?;
534    ensure_authority_report_is_derived_from_plan(plan, report)
535}
536
537fn ensure_authority_report_is_derived_from_plan(
538    plan: &AuthorityReconciliationPlanV1,
539    report: &AuthorityReportV1,
540) -> Result<(), AuthorityEvidenceError> {
541    let expected_report = authority_report_from_plan_with_check_id(
542        report.report_id.clone(),
543        report.check_id.clone(),
544        plan,
545    );
546    ensure_matching_authority_evidence_content(
547        "report.status",
548        &expected_report.status,
549        &report.status,
550    )?;
551    ensure_matching_authority_evidence_content(
552        "report.summary",
553        &expected_report.summary,
554        &report.summary,
555    )?;
556    ensure_matching_authority_evidence_content(
557        "report.counts",
558        &expected_report.counts,
559        &report.counts,
560    )?;
561    ensure_matching_authority_evidence_content(
562        "report.apply_readiness",
563        &expected_report.apply_readiness,
564        &report.apply_readiness,
565    )?;
566    ensure_matching_authority_evidence_content(
567        "report.action_counts",
568        &expected_report.action_counts,
569        &report.action_counts,
570    )?;
571    ensure_matching_authority_evidence_content(
572        "report.control_class_counts",
573        &expected_report.control_class_counts,
574        &report.control_class_counts,
575    )?;
576    ensure_matching_authority_evidence_content(
577        "report.observation_gaps",
578        &expected_report.observation_gaps,
579        &report.observation_gaps,
580    )?;
581    ensure_matching_authority_evidence_content(
582        "report.next_actions",
583        &expected_report.next_actions,
584        &report.next_actions,
585    )
586}
587
588fn ensure_matching_authority_evidence_field(
589    field: &'static str,
590    plan_value: &str,
591    report_value: &str,
592) -> Result<(), AuthorityEvidenceError> {
593    if plan_value == report_value {
594        return Ok(());
595    }
596
597    Err(AuthorityEvidenceError::PlanReportMismatch {
598        field,
599        plan_value: plan_value.to_string(),
600        report_value: report_value.to_string(),
601    })
602}
603
604fn ensure_evidence_check_id_matches(
605    evidence_check_id: &str,
606    component: &'static str,
607    nested_check_id: Option<&str>,
608) -> Result<(), AuthorityEvidenceError> {
609    let Some(nested_check_id) = nested_check_id else {
610        return Ok(());
611    };
612    if evidence_check_id == nested_check_id {
613        return Ok(());
614    }
615
616    Err(AuthorityEvidenceError::EvidenceCheckIdMismatch {
617        component,
618        evidence_value: evidence_check_id.to_string(),
619        nested_value: nested_check_id.to_string(),
620    })
621}
622
623fn optional_authority_value(value: Option<&String>) -> String {
624    value.map_or_else(|| "<none>".to_string(), ToString::to_string)
625}
626
627fn ensure_matching_authority_evidence_content<T: Eq>(
628    field: &'static str,
629    plan_value: &T,
630    report_value: &T,
631) -> Result<(), AuthorityEvidenceError> {
632    if plan_value == report_value {
633        return Ok(());
634    }
635
636    Err(AuthorityEvidenceError::PlanReportContentMismatch { field })
637}
638
639/// Build a lightweight receipt for the current-install artifact materialization
640/// gate. The receipt is evidence only; live inventory/check data remains the
641/// authority for any installer decision.
642#[must_use]
643pub fn artifact_gate_phase_receipt(
644    check: &DeploymentCheckV1,
645    started_at: impl Into<String>,
646    finished_at: Option<String>,
647) -> PhaseReceiptV1 {
648    let missing = check
649        .report
650        .hard_failures
651        .iter()
652        .filter(|finding| finding.code == "artifact_missing")
653        .collect::<Vec<_>>();
654    let mut evidence = check
655        .inventory
656        .observed_artifacts
657        .iter()
658        .filter_map(|artifact| {
659            artifact
660                .file_sha256
661                .as_ref()
662                .map(|hash| format!("artifact:{}:sha256:{hash}", artifact.role))
663        })
664        .collect::<Vec<_>>();
665    evidence.extend(
666        missing
667            .iter()
668            .filter_map(|finding| finding.subject.as_ref())
669            .map(|role| format!("artifact:{role}:missing")),
670    );
671    let status = if missing.is_empty() {
672        ObservationStatusV1::Observed
673    } else {
674        ObservationStatusV1::Missing
675    };
676
677    phase_receipt(
678        "materialize_artifacts",
679        started_at,
680        finished_at,
681        "verify configured role artifacts are materialized",
682        status,
683        evidence,
684    )
685}
686
687/// Build role-scoped evidence for the current-install artifact materialization
688/// gate.
689///
690/// These records do not decide safety; they preserve the per-role facts already
691/// present in the check so later resume/reporting work can distinguish which
692/// roles were verified and which failed materialization.
693#[must_use]
694pub fn artifact_gate_role_phase_receipts(check: &DeploymentCheckV1) -> Vec<RolePhaseReceiptV1> {
695    check
696        .plan
697        .role_artifacts
698        .iter()
699        .map(|planned| {
700            let observed = check
701                .inventory
702                .observed_artifacts
703                .iter()
704                .find(|artifact| artifact.role == planned.role);
705            let failures = check
706                .report
707                .hard_failures
708                .iter()
709                .filter(|finding| finding.subject.as_deref() == Some(planned.role.as_str()))
710                .filter(|finding| finding.code.starts_with("artifact_"))
711                .collect::<Vec<_>>();
712            let error = if failures.is_empty() {
713                None
714            } else {
715                Some(
716                    failures
717                        .iter()
718                        .map(|finding| format!("{}: {}", finding.code, finding.message))
719                        .collect::<Vec<_>>()
720                        .join("; "),
721                )
722            };
723            let artifact_digest = observed
724                .and_then(|artifact| artifact.file_sha256.clone())
725                .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
726                .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
727                .or_else(|| planned.wasm_gz_sha256.clone());
728            let result = if !failures.is_empty() {
729                RolePhaseResultV1::Failed
730            } else if observed
731                .and_then(|artifact| artifact.file_sha256.as_ref())
732                .is_some()
733            {
734                RolePhaseResultV1::VerifiedAlreadyApplied
735            } else {
736                RolePhaseResultV1::NotAttempted
737            };
738
739            RolePhaseReceiptV1 {
740                role: planned.role.clone(),
741                phase: "materialize_artifacts".to_string(),
742                result,
743                previous_module_hash: None,
744                target_module_hash: planned.installed_module_hash.clone(),
745                observed_module_hash_after: None,
746                artifact_digest,
747                canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
748                error,
749            }
750        })
751        .collect()
752}
753
754/// Build one phase receipt with a verified postcondition.
755#[must_use]
756pub fn phase_receipt(
757    phase: impl Into<String>,
758    started_at: impl Into<String>,
759    finished_at: Option<String>,
760    attempted_action: impl Into<String>,
761    status: ObservationStatusV1,
762    evidence: Vec<String>,
763) -> PhaseReceiptV1 {
764    PhaseReceiptV1 {
765        phase: phase.into(),
766        started_at: started_at.into(),
767        finished_at,
768        attempted_action: attempted_action.into(),
769        verified_postcondition: VerifiedPostconditionV1 { status, evidence },
770    }
771}
772
773/// Convert typed artifact-staging receipts into compact phase evidence labels.
774///
775/// The typed receipts remain the source shape for executor work. Current
776/// install still stores phase evidence as strings, so this preserves the
777/// transport/chunk/postcondition facts without changing the persisted receipt
778/// envelope in this slice.
779#[must_use]
780pub fn staging_receipt_evidence(receipts: &[StagingReceiptV1]) -> Vec<String> {
781    let mut evidence = vec![format!("staging_receipts:{}", receipts.len())];
782
783    for receipt in receipts {
784        evidence.extend([
785            format!("staging_role:{}", receipt.role),
786            format!("staging_transport:{:?}", receipt.transport),
787            format!("staging_artifact:{}", receipt.artifact_identity),
788            format!(
789                "staging_chunks_prepared:{}",
790                receipt.prepared_chunk_hashes.len()
791            ),
792            format!("staging_chunks_published:{}", receipt.published_chunk_count),
793            format!(
794                "staging_postcondition:{:?}",
795                receipt.verified_postcondition.status
796            ),
797        ]);
798        if let Some(locator) = &receipt.wasm_store_locator {
799            evidence.push(format!("staging_wasm_store:{locator}"));
800        }
801    }
802
803    evidence
804}
805
806/// Build a deployment receipt from a validated check and phase receipts.
807#[must_use]
808pub fn deployment_receipt_from_check(
809    check: &DeploymentCheckV1,
810    operation_id: impl Into<String>,
811    started_at: impl Into<String>,
812    finished_at: Option<String>,
813    phase_receipts: Vec<PhaseReceiptV1>,
814    role_phase_receipts: Vec<RolePhaseReceiptV1>,
815    command_result: DeploymentCommandResultV1,
816) -> DeploymentReceiptV1 {
817    let operation_status = operation_status_for_command_result(&command_result);
818    deployment_receipt_from_check_with_status(
819        check,
820        operation_id,
821        operation_status,
822        started_at,
823        finished_at,
824        phase_receipts,
825        role_phase_receipts,
826        command_result,
827    )
828}
829
830/// Build a deployment receipt when the caller knows whether failure happened
831/// before or after mutation.
832#[must_use]
833#[allow(clippy::too_many_arguments)]
834pub fn deployment_receipt_from_check_with_status(
835    check: &DeploymentCheckV1,
836    operation_id: impl Into<String>,
837    operation_status: DeploymentExecutionStatusV1,
838    started_at: impl Into<String>,
839    finished_at: Option<String>,
840    phase_receipts: Vec<PhaseReceiptV1>,
841    role_phase_receipts: Vec<RolePhaseReceiptV1>,
842    command_result: DeploymentCommandResultV1,
843) -> DeploymentReceiptV1 {
844    DeploymentReceiptV1 {
845        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
846        operation_id: operation_id.into(),
847        plan_id: check.plan.plan_id.clone(),
848        execution_context: None,
849        operation_status,
850        started_at: started_at.into(),
851        finished_at,
852        operator_principal: None,
853        root_principal: check
854            .inventory
855            .observed_identity
856            .as_ref()
857            .and_then(|identity| identity.root_principal.clone())
858            .or_else(|| check.plan.deployment_identity.root_principal.clone()),
859        previous_observed_deployment_epoch: None,
860        phase_receipts,
861        role_phase_receipts,
862        final_inventory_id: Some(check.inventory.inventory_id.clone()),
863        command_result,
864    }
865}
866
867fn authority_controller_observation_from_action(
868    action: &CanisterAuthorityActionV1,
869) -> AuthorityControllerObservationV1 {
870    AuthorityControllerObservationV1 {
871        subject: authority_action_subject(action),
872        canister_id: action.canister_id.clone(),
873        role: action.role.clone(),
874        state: action.state,
875        action: action.action,
876        observed_controllers: action.observed_controllers.clone(),
877        desired_controllers: action.desired_controllers.clone(),
878        controller_delta: action.controller_delta.clone(),
879    }
880}
881
882fn authority_action_subject(action: &CanisterAuthorityActionV1) -> String {
883    action
884        .canister_id
885        .clone()
886        .or_else(|| action.role.as_ref().map(|role| format!("role:{role}")))
887        .unwrap_or_else(|| "unknown".to_string())
888}
889
890const fn operation_status_for_command_result(
891    result: &DeploymentCommandResultV1,
892) -> DeploymentExecutionStatusV1 {
893    match result {
894        DeploymentCommandResultV1::NotFinished => DeploymentExecutionStatusV1::InProgress,
895        DeploymentCommandResultV1::Succeeded => DeploymentExecutionStatusV1::Complete,
896        DeploymentCommandResultV1::Failed { .. } => {
897            DeploymentExecutionStatusV1::FailedAfterMutation
898        }
899    }
900}