Skip to main content

canic_host/deployment_truth/
root.rs

1use super::*;
2use serde::Serialize;
3use thiserror::Error as ThisError;
4
5#[derive(Serialize)]
6struct DeploymentRootVerificationReportDigestInput<'a> {
7    report_id: &'a str,
8    requested_at: &'a str,
9    evidence_status: DeploymentRootVerificationEvidenceStatusV1,
10    state_transition: DeploymentRootVerificationStateTransitionV1,
11    deployment_name: &'a str,
12    network: &'a str,
13    expected_fleet_template: &'a str,
14    expected_root_principal: &'a str,
15    observed_deployment_name: &'a Option<String>,
16    observed_network: &'a Option<String>,
17    observed_fleet_template: &'a Option<String>,
18    observed_root_principal: &'a Option<String>,
19    observed_root_canister_id: &'a Option<String>,
20    observed_root_observation_source: &'a Option<DeploymentRootObservationSourceV1>,
21    source: DeploymentRootVerificationSourceV1,
22    source_check_id: &'a str,
23    source_check_digest: &'a str,
24    source_deployment_plan_id: &'a str,
25    source_deployment_plan_digest: &'a str,
26    source_inventory_id: &'a str,
27    source_inventory_digest: &'a str,
28    current_root_verification: DeploymentRootVerificationStateV1,
29    identity_checks: &'a [DeploymentRootVerificationCheckV1],
30    evidence_checks: &'a [DeploymentRootVerificationCheckV1],
31    blockers: &'a [SafetyFindingV1],
32    warnings: &'a [SafetyFindingV1],
33    recommended_next_actions: &'a [String],
34}
35
36#[derive(Serialize)]
37struct DeploymentRootVerificationReceiptDigestInput<'a> {
38    receipt_id: &'a str,
39    deployment_name: &'a str,
40    network: &'a str,
41    fleet_template: &'a str,
42    root_principal: &'a str,
43    previous_root_verification: DeploymentRootVerificationStateV1,
44    new_root_verification: DeploymentRootVerificationStateV1,
45    state_transition: DeploymentRootVerificationStateTransitionV1,
46    source_report_id: &'a str,
47    source_report_digest: &'a str,
48    source_report_requested_at: &'a str,
49    source_report_source: DeploymentRootVerificationSourceV1,
50    source_report_evidence_status: DeploymentRootVerificationEvidenceStatusV1,
51    source_report_current_root_verification: DeploymentRootVerificationStateV1,
52    source_report_state_transition: DeploymentRootVerificationStateTransitionV1,
53    source_root_observation_source: DeploymentRootObservationSourceV1,
54    source_observed_root_canister_id: &'a str,
55    source_check_id: &'a str,
56    source_check_digest: &'a str,
57    source_deployment_plan_id: &'a str,
58    source_deployment_plan_digest: &'a str,
59    source_inventory_id: &'a str,
60    source_inventory_digest: &'a str,
61    verified_at_unix_secs: u64,
62    local_state_path: &'a str,
63    local_state_digest_before: &'a str,
64    local_state_digest_after: &'a str,
65    warnings: &'a [SafetyFindingV1],
66}
67
68///
69/// DeploymentRootVerificationReportError
70///
71#[derive(Debug, Eq, PartialEq, ThisError)]
72pub enum DeploymentRootVerificationReportError {
73    #[error(
74        "deployment root verification report schema version {actual} does not match expected {expected}"
75    )]
76    SchemaVersionMismatch { expected: u32, actual: u32 },
77
78    #[error("deployment root verification report field `{field}` is required")]
79    MissingRequiredField { field: &'static str },
80
81    #[error("deployment root verification report field `{field}` must be lowercase SHA-256 hex")]
82    InvalidSha256Digest { field: &'static str },
83
84    #[error("deployment root verification report field `{field}` digest is stale")]
85    DigestMismatch { field: &'static str },
86
87    #[error("deployment root verification report check `{check}` is inconsistent")]
88    CheckMismatch { check: String },
89
90    #[error("deployment root verification report status is inconsistent")]
91    StatusMismatch,
92}
93
94///
95/// DeploymentRootVerificationReceiptError
96///
97#[derive(Debug, Eq, PartialEq, ThisError)]
98pub enum DeploymentRootVerificationReceiptError {
99    #[error(
100        "deployment root verification receipt schema version {actual} does not match expected {expected}"
101    )]
102    SchemaVersionMismatch { expected: u32, actual: u32 },
103
104    #[error("deployment root verification receipt field `{field}` is required")]
105    MissingRequiredField { field: &'static str },
106
107    #[error("deployment root verification receipt field `{field}` must be lowercase SHA-256 hex")]
108    InvalidSha256Digest { field: &'static str },
109
110    #[error(
111        "deployment root verification receipt field `{field}` must be a supported timestamp label"
112    )]
113    InvalidTimestampLabel { field: &'static str },
114
115    #[error("deployment root verification receipt field `{field}` digest is stale")]
116    DigestMismatch { field: &'static str },
117
118    #[error("deployment root verification receipt state transition is inconsistent")]
119    StateTransitionMismatch,
120
121    #[error("deployment root verification receipt local state digests are inconsistent")]
122    LocalStateDigestMismatch,
123
124    #[error("deployment root verification receipt source evidence is inconsistent")]
125    SourceEvidenceMismatch,
126}
127
128/// Build a passive 0.47 root-verification report from an existing
129/// deployment-truth check.
130///
131/// This report can prove evidence consistency, but it does not mutate local
132/// deployment state or record verified root state.
133#[must_use]
134pub fn deployment_root_verification_report_from_check(
135    request: DeploymentRootVerificationRequestV1,
136) -> DeploymentRootVerificationReportV1 {
137    let check = &request.deployment_check;
138    let observed_root = check.inventory.observed_root.as_ref();
139    let identity_checks = root_verification_identity_checks(&request, check, observed_root);
140    let evidence_checks = root_verification_evidence_checks(&request, check, observed_root);
141    let blockers = root_verification_blockers(&identity_checks, &evidence_checks, check);
142
143    let evidence_status = if blockers.is_empty() {
144        DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied
145    } else {
146        DeploymentRootVerificationEvidenceStatusV1::VerificationFailed
147    };
148    let state_transition =
149        root_verification_transition(evidence_status, request.current_root_verification);
150    let recommended_next_actions = root_verification_next_actions(evidence_status);
151    let mut report = DeploymentRootVerificationReportV1 {
152        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
153        report_id: request.report_id,
154        report_digest: String::new(),
155        requested_at: request.requested_at,
156        evidence_status,
157        state_transition,
158        deployment_name: request.deployment_name,
159        network: request.network,
160        expected_fleet_template: request.expected_fleet_template,
161        expected_root_principal: request.expected_root_principal,
162        observed_deployment_name: observed_root.map(|root| root.deployment_name.clone()),
163        observed_network: observed_root.map(|root| root.network.clone()),
164        observed_fleet_template: observed_root.map(|root| root.fleet_template.clone()),
165        observed_root_principal: observed_root.map(|root| root.root_principal.clone()),
166        observed_root_canister_id: observed_root.map(|root| root.observed_canister_id.clone()),
167        observed_root_observation_source: observed_root.map(|root| root.observation_source),
168        source: request.source,
169        source_check_id: check.check_id.clone(),
170        source_check_digest: stable_json_sha256_hex(check),
171        source_deployment_plan_id: check.plan.plan_id.clone(),
172        source_deployment_plan_digest: stable_json_sha256_hex(&check.plan),
173        source_inventory_id: check.inventory.inventory_id.clone(),
174        source_inventory_digest: stable_json_sha256_hex(&check.inventory),
175        current_root_verification: request.current_root_verification,
176        identity_checks,
177        evidence_checks,
178        blockers,
179        warnings: check.report.warnings.clone(),
180        recommended_next_actions,
181    };
182    report.report_digest = deployment_root_verification_report_digest(&report);
183    report
184}
185
186/// Validate archived root-verification report consistency and digest stability.
187///
188/// A valid report is still passive evidence: only a future successful
189/// receipt-backed state write can record verified root state.
190pub fn validate_deployment_root_verification_report(
191    report: &DeploymentRootVerificationReportV1,
192) -> Result<(), DeploymentRootVerificationReportError> {
193    if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
194        return Err(
195            DeploymentRootVerificationReportError::SchemaVersionMismatch {
196                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
197                actual: report.schema_version,
198            },
199        );
200    }
201    ensure_root_verification_field("report_id", report.report_id.as_str())?;
202    ensure_root_verification_sha256("report_digest", report.report_digest.as_str())?;
203    ensure_root_verification_field("requested_at", report.requested_at.as_str())?;
204    ensure_root_verification_field("deployment_name", report.deployment_name.as_str())?;
205    ensure_root_verification_field("network", report.network.as_str())?;
206    ensure_root_verification_field(
207        "expected_fleet_template",
208        report.expected_fleet_template.as_str(),
209    )?;
210    ensure_root_verification_field(
211        "expected_root_principal",
212        report.expected_root_principal.as_str(),
213    )?;
214    ensure_root_verification_field("source_check_id", report.source_check_id.as_str())?;
215    ensure_root_verification_sha256("source_check_digest", report.source_check_digest.as_str())?;
216    ensure_root_verification_field(
217        "source_deployment_plan_id",
218        report.source_deployment_plan_id.as_str(),
219    )?;
220    ensure_root_verification_sha256(
221        "source_deployment_plan_digest",
222        report.source_deployment_plan_digest.as_str(),
223    )?;
224    ensure_root_verification_field("source_inventory_id", report.source_inventory_id.as_str())?;
225    ensure_root_verification_sha256(
226        "source_inventory_digest",
227        report.source_inventory_digest.as_str(),
228    )?;
229    if report.evidence_status != report_evidence_status(report)
230        || report.state_transition != report_state_transition(report)
231    {
232        return Err(DeploymentRootVerificationReportError::StatusMismatch);
233    }
234    ensure_root_verification_report_checks_consistent(report)?;
235    if report.report_digest != deployment_root_verification_report_digest(report) {
236        return Err(DeploymentRootVerificationReportError::DigestMismatch {
237            field: "report_digest",
238        });
239    }
240    Ok(())
241}
242
243/// Calculate the stable digest for a root-verification state-transition
244/// receipt.
245#[must_use]
246pub fn deployment_root_verification_receipt_digest(
247    receipt: &DeploymentRootVerificationReceiptV1,
248) -> String {
249    stable_json_sha256_hex(&DeploymentRootVerificationReceiptDigestInput {
250        receipt_id: &receipt.receipt_id,
251        deployment_name: &receipt.deployment_name,
252        network: &receipt.network,
253        fleet_template: &receipt.fleet_template,
254        root_principal: &receipt.root_principal,
255        previous_root_verification: receipt.previous_root_verification,
256        new_root_verification: receipt.new_root_verification,
257        state_transition: receipt.state_transition,
258        source_report_id: &receipt.source_report_id,
259        source_report_digest: &receipt.source_report_digest,
260        source_report_requested_at: &receipt.source_report_requested_at,
261        source_report_source: receipt.source_report_source,
262        source_report_evidence_status: receipt.source_report_evidence_status,
263        source_report_current_root_verification: receipt.source_report_current_root_verification,
264        source_report_state_transition: receipt.source_report_state_transition,
265        source_root_observation_source: receipt.source_root_observation_source,
266        source_observed_root_canister_id: &receipt.source_observed_root_canister_id,
267        source_check_id: &receipt.source_check_id,
268        source_check_digest: &receipt.source_check_digest,
269        source_deployment_plan_id: &receipt.source_deployment_plan_id,
270        source_deployment_plan_digest: &receipt.source_deployment_plan_digest,
271        source_inventory_id: &receipt.source_inventory_id,
272        source_inventory_digest: &receipt.source_inventory_digest,
273        verified_at_unix_secs: receipt.verified_at_unix_secs,
274        local_state_path: &receipt.local_state_path,
275        local_state_digest_before: &receipt.local_state_digest_before,
276        local_state_digest_after: &receipt.local_state_digest_after,
277        warnings: &receipt.warnings,
278    })
279}
280
281/// Validate archived root-verification receipt consistency and digest
282/// stability.
283pub fn validate_deployment_root_verification_receipt(
284    receipt: &DeploymentRootVerificationReceiptV1,
285) -> Result<(), DeploymentRootVerificationReceiptError> {
286    if receipt.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
287        return Err(
288            DeploymentRootVerificationReceiptError::SchemaVersionMismatch {
289                expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
290                actual: receipt.schema_version,
291            },
292        );
293    }
294    ensure_root_verification_receipt_field("receipt_id", receipt.receipt_id.as_str())?;
295    ensure_root_verification_receipt_sha256("receipt_digest", receipt.receipt_digest.as_str())?;
296    ensure_root_verification_receipt_field("deployment_name", receipt.deployment_name.as_str())?;
297    ensure_root_verification_receipt_field("network", receipt.network.as_str())?;
298    ensure_root_verification_receipt_field("fleet_template", receipt.fleet_template.as_str())?;
299    ensure_root_verification_receipt_field("root_principal", receipt.root_principal.as_str())?;
300    ensure_root_verification_receipt_field("source_report_id", receipt.source_report_id.as_str())?;
301    ensure_root_verification_receipt_sha256(
302        "source_report_digest",
303        receipt.source_report_digest.as_str(),
304    )?;
305    ensure_root_verification_receipt_field(
306        "source_report_requested_at",
307        receipt.source_report_requested_at.as_str(),
308    )?;
309    ensure_root_verification_receipt_timestamp(
310        "source_report_requested_at",
311        receipt.source_report_requested_at.as_str(),
312    )?;
313    ensure_root_verification_receipt_field(
314        "source_observed_root_canister_id",
315        receipt.source_observed_root_canister_id.as_str(),
316    )?;
317    if receipt.source_report_evidence_status
318        != DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied
319        || receipt.source_report_source != DeploymentRootVerificationSourceV1::DeploymentTruthCheck
320        || receipt.source_report_current_root_verification != receipt.previous_root_verification
321        || receipt.source_root_observation_source
322            != DeploymentRootObservationSourceV1::IcpCanisterStatus
323        || receipt.source_observed_root_canister_id != receipt.root_principal
324        || receipt.source_report_state_transition != source_report_transition_for_receipt(receipt)
325        || !source_report_timestamp_matches_receipt(receipt)
326    {
327        return Err(DeploymentRootVerificationReceiptError::SourceEvidenceMismatch);
328    }
329    ensure_root_verification_receipt_field("source_check_id", receipt.source_check_id.as_str())?;
330    ensure_root_verification_receipt_sha256(
331        "source_check_digest",
332        receipt.source_check_digest.as_str(),
333    )?;
334    ensure_root_verification_receipt_field(
335        "source_deployment_plan_id",
336        receipt.source_deployment_plan_id.as_str(),
337    )?;
338    ensure_root_verification_receipt_sha256(
339        "source_deployment_plan_digest",
340        receipt.source_deployment_plan_digest.as_str(),
341    )?;
342    ensure_root_verification_receipt_field(
343        "source_inventory_id",
344        receipt.source_inventory_id.as_str(),
345    )?;
346    ensure_root_verification_receipt_sha256(
347        "source_inventory_digest",
348        receipt.source_inventory_digest.as_str(),
349    )?;
350    ensure_root_verification_receipt_field("local_state_path", receipt.local_state_path.as_str())?;
351    ensure_root_verification_receipt_sha256(
352        "local_state_digest_before",
353        receipt.local_state_digest_before.as_str(),
354    )?;
355    ensure_root_verification_receipt_sha256(
356        "local_state_digest_after",
357        receipt.local_state_digest_after.as_str(),
358    )?;
359
360    if receipt.new_root_verification != DeploymentRootVerificationStateV1::Verified
361        || receipt.state_transition != receipt_state_transition(receipt)
362    {
363        return Err(DeploymentRootVerificationReceiptError::StateTransitionMismatch);
364    }
365    if !receipt_local_state_digest_transition_is_valid(receipt) {
366        return Err(DeploymentRootVerificationReceiptError::LocalStateDigestMismatch);
367    }
368    if receipt.receipt_digest != deployment_root_verification_receipt_digest(receipt) {
369        return Err(DeploymentRootVerificationReceiptError::DigestMismatch {
370            field: "receipt_digest",
371        });
372    }
373    Ok(())
374}
375
376fn root_verification_identity_checks(
377    request: &DeploymentRootVerificationRequestV1,
378    check: &DeploymentCheckV1,
379    observed_root: Option<&DeploymentRootObservationV1>,
380) -> Vec<DeploymentRootVerificationCheckV1> {
381    let mut checks = Vec::new();
382    push_check(
383        &mut checks,
384        "deployment_name",
385        Some(request.deployment_name.as_str()),
386        observed_root.map(|root| root.deployment_name.as_str()),
387    );
388    push_check(
389        &mut checks,
390        "network",
391        Some(request.network.as_str()),
392        observed_root.map(|root| root.network.as_str()),
393    );
394    push_check(
395        &mut checks,
396        "fleet_template",
397        Some(request.expected_fleet_template.as_str()),
398        observed_root.map(|root| root.fleet_template.as_str()),
399    );
400    push_check(
401        &mut checks,
402        "root_principal",
403        Some(request.expected_root_principal.as_str()),
404        observed_root.map(|root| root.root_principal.as_str()),
405    );
406    push_check(
407        &mut checks,
408        "plan_deployment_name",
409        Some(request.deployment_name.as_str()),
410        Some(check.plan.deployment_identity.deployment_name.as_str()),
411    );
412    push_check(
413        &mut checks,
414        "plan_network",
415        Some(request.network.as_str()),
416        Some(check.plan.deployment_identity.network.as_str()),
417    );
418    push_check(
419        &mut checks,
420        "plan_fleet_template",
421        Some(request.expected_fleet_template.as_str()),
422        Some(check.plan.fleet_template.as_str()),
423    );
424    checks
425}
426
427fn root_verification_evidence_checks(
428    request: &DeploymentRootVerificationRequestV1,
429    check: &DeploymentCheckV1,
430    observed_root: Option<&DeploymentRootObservationV1>,
431) -> Vec<DeploymentRootVerificationCheckV1> {
432    let mut checks = Vec::new();
433    push_check(
434        &mut checks,
435        "explicit_observed_root",
436        Some("present"),
437        observed_root.map(|_| "present"),
438    );
439    push_check(
440        &mut checks,
441        "root_observation_source",
442        Some("IcpCanisterStatus"),
443        observed_root.map(root_observation_source_label),
444    );
445    push_check(
446        &mut checks,
447        "observed_root_canister_id",
448        Some(request.expected_root_principal.as_str()),
449        observed_root.map(|root| root.observed_canister_id.as_str()),
450    );
451    push_check(
452        &mut checks,
453        "source_check_id",
454        Some("present"),
455        present_value(check.check_id.as_str()),
456    );
457    push_check(
458        &mut checks,
459        "source_deployment_plan_id",
460        Some("present"),
461        present_value(check.plan.plan_id.as_str()),
462    );
463    push_check(
464        &mut checks,
465        "source_inventory_id",
466        Some("present"),
467        present_value(check.inventory.inventory_id.as_str()),
468    );
469    checks
470}
471
472fn root_verification_blockers(
473    identity_checks: &[DeploymentRootVerificationCheckV1],
474    evidence_checks: &[DeploymentRootVerificationCheckV1],
475    check: &DeploymentCheckV1,
476) -> Vec<SafetyFindingV1> {
477    let mut blockers = failed_checks("identity", identity_checks);
478    blockers.extend(failed_checks("evidence", evidence_checks));
479    blockers.extend(source_check_consistency_blockers(check));
480    blockers.extend(source_check_blockers(check));
481    blockers
482}
483
484fn push_check(
485    checks: &mut Vec<DeploymentRootVerificationCheckV1>,
486    name: impl Into<String>,
487    expected: Option<&str>,
488    observed: Option<&str>,
489) {
490    checks.push(DeploymentRootVerificationCheckV1 {
491        name: name.into(),
492        expected: expected.map(str::to_string),
493        observed: observed.map(str::to_string),
494        satisfied: expected == observed,
495    });
496}
497
498const fn present_value(value: &str) -> Option<&'static str> {
499    if value.is_empty() {
500        None
501    } else {
502        Some("present")
503    }
504}
505
506const fn root_observation_source_label(root: &DeploymentRootObservationV1) -> &str {
507    root_observation_source_label_from_source(&root.observation_source)
508}
509
510const fn root_observation_source_label_from_source(
511    source: &DeploymentRootObservationSourceV1,
512) -> &str {
513    match *source {
514        DeploymentRootObservationSourceV1::IcpCanisterStatus => "IcpCanisterStatus",
515        DeploymentRootObservationSourceV1::LocalDeploymentState => "LocalDeploymentState",
516    }
517}
518
519fn failed_checks(
520    category: &'static str,
521    checks: &[DeploymentRootVerificationCheckV1],
522) -> Vec<SafetyFindingV1> {
523    checks
524        .iter()
525        .filter(|check| !check.satisfied)
526        .map(|check| SafetyFindingV1 {
527            code: "root_verification_check_failed".to_string(),
528            message: format!("{category} check {} did not match", check.name),
529            severity: SafetySeverityV1::HardFailure,
530            subject: Some(check.name.clone()),
531        })
532        .collect()
533}
534
535fn source_check_blockers(check: &DeploymentCheckV1) -> Vec<SafetyFindingV1> {
536    let hard_failures = &check.report.hard_failures;
537    if hard_failures.is_empty() {
538        return Vec::new();
539    }
540    if hard_failures.len() == 1 && is_expected_unverified_root_finding(&hard_failures[0]) {
541        return Vec::new();
542    }
543    hard_failures.clone()
544}
545
546fn source_check_consistency_blockers(check: &DeploymentCheckV1) -> Vec<SafetyFindingV1> {
547    let mut blockers = Vec::new();
548    if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
549        blockers.push(SafetyFindingV1 {
550            code: "root_verification_source_check_schema_mismatch".to_string(),
551            message: "source deployment check schema version is unsupported".to_string(),
552            severity: SafetySeverityV1::HardFailure,
553            subject: Some(check.check_id.clone()),
554        });
555        return blockers;
556    }
557
558    let expected_diff = compare_plan_to_inventory(&check.plan, &check.inventory);
559    if check.diff != expected_diff {
560        blockers.push(SafetyFindingV1 {
561            code: "root_verification_source_check_diff_stale".to_string(),
562            message: "source deployment check diff does not match its plan and inventory"
563                .to_string(),
564            severity: SafetySeverityV1::HardFailure,
565            subject: Some(check.check_id.clone()),
566        });
567        return blockers;
568    }
569
570    let expected_report = safety_report_from_diff(
571        &check.report.report_id,
572        check.report.diff_id.clone(),
573        &check.diff,
574    );
575    if check.report != expected_report {
576        blockers.push(SafetyFindingV1 {
577            code: "root_verification_source_check_report_stale".to_string(),
578            message: "source deployment check report does not match its diff".to_string(),
579            severity: SafetySeverityV1::HardFailure,
580            subject: Some(check.check_id.clone()),
581        });
582    }
583    blockers
584}
585
586fn is_expected_unverified_root_finding(finding: &SafetyFindingV1) -> bool {
587    finding.code == "unverified_deployment_root"
588        && finding.subject.as_deref() == Some("local_state.unverified_root_canister_id")
589}
590
591const fn root_verification_transition(
592    status: DeploymentRootVerificationEvidenceStatusV1,
593    current: DeploymentRootVerificationStateV1,
594) -> DeploymentRootVerificationStateTransitionV1 {
595    match (status, current) {
596        (
597            DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied,
598            DeploymentRootVerificationStateV1::NotVerified,
599        ) => DeploymentRootVerificationStateTransitionV1::WouldPromoteNotVerifiedToVerified,
600        (
601            DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied,
602            DeploymentRootVerificationStateV1::Verified,
603        ) => DeploymentRootVerificationStateTransitionV1::NoStateChange,
604        _ => DeploymentRootVerificationStateTransitionV1::Blocked,
605    }
606}
607
608fn root_verification_next_actions(
609    status: DeploymentRootVerificationEvidenceStatusV1,
610) -> Vec<String> {
611    match status {
612        DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied => vec![
613            "run the explicit root verification command to write verified local state".to_string(),
614        ],
615        DeploymentRootVerificationEvidenceStatusV1::VerificationFailed => vec![
616            "collect a deployment-truth check with matching root evidence before verifying"
617                .to_string(),
618        ],
619        DeploymentRootVerificationEvidenceStatusV1::NotApplicable => Vec::new(),
620    }
621}
622
623fn report_evidence_status(
624    report: &DeploymentRootVerificationReportV1,
625) -> DeploymentRootVerificationEvidenceStatusV1 {
626    if report.blockers.is_empty()
627        && report.identity_checks.iter().all(|check| check.satisfied)
628        && report.evidence_checks.iter().all(|check| check.satisfied)
629    {
630        DeploymentRootVerificationEvidenceStatusV1::EvidenceSatisfied
631    } else {
632        DeploymentRootVerificationEvidenceStatusV1::VerificationFailed
633    }
634}
635
636const fn report_state_transition(
637    report: &DeploymentRootVerificationReportV1,
638) -> DeploymentRootVerificationStateTransitionV1 {
639    root_verification_transition(report.evidence_status, report.current_root_verification)
640}
641
642fn ensure_root_verification_report_checks_consistent(
643    report: &DeploymentRootVerificationReportV1,
644) -> Result<(), DeploymentRootVerificationReportError> {
645    ensure_report_check_names(
646        &report.identity_checks,
647        &[
648            "deployment_name",
649            "network",
650            "fleet_template",
651            "root_principal",
652            "plan_deployment_name",
653            "plan_network",
654            "plan_fleet_template",
655        ],
656    )?;
657    ensure_report_check_names(
658        &report.evidence_checks,
659        &[
660            "explicit_observed_root",
661            "root_observation_source",
662            "observed_root_canister_id",
663            "source_check_id",
664            "source_deployment_plan_id",
665            "source_inventory_id",
666        ],
667    )?;
668    for check in report.identity_checks.iter().chain(&report.evidence_checks) {
669        if check.satisfied != (check.expected == check.observed) {
670            return Err(DeploymentRootVerificationReportError::CheckMismatch {
671                check: check.name.clone(),
672            });
673        }
674    }
675
676    ensure_report_check_value(
677        &report.identity_checks,
678        "deployment_name",
679        Some(report.deployment_name.as_str()),
680        report.observed_deployment_name.as_deref(),
681    )?;
682    ensure_report_check_value(
683        &report.identity_checks,
684        "network",
685        Some(report.network.as_str()),
686        report.observed_network.as_deref(),
687    )?;
688    ensure_report_check_value(
689        &report.identity_checks,
690        "fleet_template",
691        Some(report.expected_fleet_template.as_str()),
692        report.observed_fleet_template.as_deref(),
693    )?;
694    ensure_report_check_value(
695        &report.identity_checks,
696        "root_principal",
697        Some(report.expected_root_principal.as_str()),
698        report.observed_root_principal.as_deref(),
699    )?;
700    let observed_root_present = report.observed_deployment_name.is_some()
701        && report.observed_network.is_some()
702        && report.observed_fleet_template.is_some()
703        && report.observed_root_principal.is_some()
704        && report.observed_root_canister_id.is_some()
705        && report.observed_root_observation_source.is_some();
706    ensure_report_check_value(
707        &report.evidence_checks,
708        "explicit_observed_root",
709        Some("present"),
710        observed_root_present.then_some("present"),
711    )?;
712    ensure_report_check_value(
713        &report.evidence_checks,
714        "root_observation_source",
715        Some("IcpCanisterStatus"),
716        report
717            .observed_root_observation_source
718            .as_ref()
719            .map(root_observation_source_label_from_source),
720    )?;
721    ensure_report_check_value(
722        &report.evidence_checks,
723        "observed_root_canister_id",
724        Some(report.expected_root_principal.as_str()),
725        report.observed_root_canister_id.as_deref(),
726    )?;
727    ensure_report_check_value(
728        &report.evidence_checks,
729        "source_check_id",
730        Some("present"),
731        present_value(report.source_check_id.as_str()),
732    )?;
733    ensure_report_check_value(
734        &report.evidence_checks,
735        "source_deployment_plan_id",
736        Some("present"),
737        present_value(report.source_deployment_plan_id.as_str()),
738    )?;
739    ensure_report_check_value(
740        &report.evidence_checks,
741        "source_inventory_id",
742        Some("present"),
743        present_value(report.source_inventory_id.as_str()),
744    )?;
745    Ok(())
746}
747
748fn ensure_report_check_names(
749    checks: &[DeploymentRootVerificationCheckV1],
750    expected: &[&'static str],
751) -> Result<(), DeploymentRootVerificationReportError> {
752    for check in checks {
753        if !expected.contains(&check.name.as_str()) {
754            return Err(DeploymentRootVerificationReportError::CheckMismatch {
755                check: check.name.clone(),
756            });
757        }
758    }
759    for expected_name in expected {
760        if checks
761            .iter()
762            .filter(|check| check.name == *expected_name)
763            .count()
764            != 1
765        {
766            return Err(DeploymentRootVerificationReportError::CheckMismatch {
767                check: (*expected_name).to_string(),
768            });
769        }
770    }
771    Ok(())
772}
773
774fn ensure_report_check_value(
775    checks: &[DeploymentRootVerificationCheckV1],
776    name: &'static str,
777    expected: Option<&str>,
778    observed: Option<&str>,
779) -> Result<(), DeploymentRootVerificationReportError> {
780    let Some(check) = checks.iter().find(|check| check.name == name) else {
781        return Err(DeploymentRootVerificationReportError::CheckMismatch {
782            check: name.to_string(),
783        });
784    };
785    if check.expected.as_deref() == expected
786        && check.observed.as_deref() == observed
787        && check.satisfied == (expected == observed)
788    {
789        Ok(())
790    } else {
791        Err(DeploymentRootVerificationReportError::CheckMismatch {
792            check: name.to_string(),
793        })
794    }
795}
796
797const fn receipt_state_transition(
798    receipt: &DeploymentRootVerificationReceiptV1,
799) -> DeploymentRootVerificationStateTransitionV1 {
800    match receipt.previous_root_verification {
801        DeploymentRootVerificationStateV1::NotVerified => {
802            DeploymentRootVerificationStateTransitionV1::PromotedNotVerifiedToVerified
803        }
804        DeploymentRootVerificationStateV1::Verified => {
805            DeploymentRootVerificationStateTransitionV1::NoStateChange
806        }
807    }
808}
809
810const fn source_report_transition_for_receipt(
811    receipt: &DeploymentRootVerificationReceiptV1,
812) -> DeploymentRootVerificationStateTransitionV1 {
813    match receipt.previous_root_verification {
814        DeploymentRootVerificationStateV1::NotVerified => {
815            DeploymentRootVerificationStateTransitionV1::WouldPromoteNotVerifiedToVerified
816        }
817        DeploymentRootVerificationStateV1::Verified => {
818            DeploymentRootVerificationStateTransitionV1::NoStateChange
819        }
820    }
821}
822
823fn receipt_local_state_digest_transition_is_valid(
824    receipt: &DeploymentRootVerificationReceiptV1,
825) -> bool {
826    match receipt.state_transition {
827        DeploymentRootVerificationStateTransitionV1::PromotedNotVerifiedToVerified => {
828            receipt.local_state_digest_before != receipt.local_state_digest_after
829        }
830        DeploymentRootVerificationStateTransitionV1::NoStateChange => {
831            receipt.local_state_digest_before == receipt.local_state_digest_after
832        }
833        DeploymentRootVerificationStateTransitionV1::NotAttempted
834        | DeploymentRootVerificationStateTransitionV1::Blocked
835        | DeploymentRootVerificationStateTransitionV1::WouldPromoteNotVerifiedToVerified => false,
836    }
837}
838
839fn deployment_root_verification_report_digest(
840    report: &DeploymentRootVerificationReportV1,
841) -> String {
842    stable_json_sha256_hex(&DeploymentRootVerificationReportDigestInput {
843        report_id: &report.report_id,
844        requested_at: &report.requested_at,
845        evidence_status: report.evidence_status,
846        state_transition: report.state_transition,
847        deployment_name: &report.deployment_name,
848        network: &report.network,
849        expected_fleet_template: &report.expected_fleet_template,
850        expected_root_principal: &report.expected_root_principal,
851        observed_deployment_name: &report.observed_deployment_name,
852        observed_network: &report.observed_network,
853        observed_fleet_template: &report.observed_fleet_template,
854        observed_root_principal: &report.observed_root_principal,
855        observed_root_canister_id: &report.observed_root_canister_id,
856        observed_root_observation_source: &report.observed_root_observation_source,
857        source: report.source,
858        source_check_id: &report.source_check_id,
859        source_check_digest: &report.source_check_digest,
860        source_deployment_plan_id: &report.source_deployment_plan_id,
861        source_deployment_plan_digest: &report.source_deployment_plan_digest,
862        source_inventory_id: &report.source_inventory_id,
863        source_inventory_digest: &report.source_inventory_digest,
864        current_root_verification: report.current_root_verification,
865        identity_checks: &report.identity_checks,
866        evidence_checks: &report.evidence_checks,
867        blockers: &report.blockers,
868        warnings: &report.warnings,
869        recommended_next_actions: &report.recommended_next_actions,
870    })
871}
872
873const fn ensure_root_verification_field(
874    field: &'static str,
875    value: &str,
876) -> Result<(), DeploymentRootVerificationReportError> {
877    if value.is_empty() {
878        Err(DeploymentRootVerificationReportError::MissingRequiredField { field })
879    } else {
880        Ok(())
881    }
882}
883
884fn ensure_root_verification_sha256(
885    field: &'static str,
886    value: &str,
887) -> Result<(), DeploymentRootVerificationReportError> {
888    if value.is_empty() {
889        return Err(DeploymentRootVerificationReportError::MissingRequiredField { field });
890    }
891    if is_lower_hex_sha256(value) {
892        Ok(())
893    } else {
894        Err(DeploymentRootVerificationReportError::InvalidSha256Digest { field })
895    }
896}
897
898const fn ensure_root_verification_receipt_field(
899    field: &'static str,
900    value: &str,
901) -> Result<(), DeploymentRootVerificationReceiptError> {
902    if value.is_empty() {
903        Err(DeploymentRootVerificationReceiptError::MissingRequiredField { field })
904    } else {
905        Ok(())
906    }
907}
908
909fn ensure_root_verification_receipt_sha256(
910    field: &'static str,
911    value: &str,
912) -> Result<(), DeploymentRootVerificationReceiptError> {
913    if value.is_empty() {
914        return Err(DeploymentRootVerificationReceiptError::MissingRequiredField { field });
915    }
916    if is_lower_hex_sha256(value) {
917        Ok(())
918    } else {
919        Err(DeploymentRootVerificationReceiptError::InvalidSha256Digest { field })
920    }
921}
922
923fn ensure_root_verification_receipt_timestamp(
924    field: &'static str,
925    value: &str,
926) -> Result<(), DeploymentRootVerificationReceiptError> {
927    if value.is_empty() {
928        return Err(DeploymentRootVerificationReceiptError::MissingRequiredField { field });
929    }
930    if is_supported_root_verification_timestamp_label(value) {
931        Ok(())
932    } else {
933        Err(DeploymentRootVerificationReceiptError::InvalidTimestampLabel { field })
934    }
935}
936
937fn is_supported_root_verification_timestamp_label(value: &str) -> bool {
938    if let Some(unix_value) = value.strip_prefix("unix:") {
939        return !unix_value.is_empty() && unix_value.bytes().all(|byte| byte.is_ascii_digit());
940    }
941    value.len() >= "1970-01-01T00:00:00Z".len() && value.contains('T') && value.ends_with('Z')
942}
943
944fn source_report_timestamp_matches_receipt(receipt: &DeploymentRootVerificationReceiptV1) -> bool {
945    let Some(unix_value) = receipt.source_report_requested_at.strip_prefix("unix:") else {
946        return true;
947    };
948    unix_value.parse::<u64>() == Ok(receipt.verified_at_unix_secs)
949}
950
951fn is_lower_hex_sha256(value: &str) -> bool {
952    value.len() == 64
953        && value
954            .bytes()
955            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
956}