Skip to main content

canic_host/deployment_truth/report/
mod.rs

1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4mod artifacts;
5mod canisters;
6mod config_digests;
7mod controllers;
8mod module_hashes;
9mod pools;
10mod receipt_resume;
11mod root_subnet;
12mod safety;
13mod verifier_readiness;
14
15use artifacts::compare_artifacts;
16#[cfg(test)]
17pub(super) use artifacts::{
18    ARTIFACT_DUPLICATE_DIFF_CATEGORY, ARTIFACT_FILE_DIGEST_MISMATCH_CODE,
19    ARTIFACT_FILE_SHA256_DIFF_CATEGORY, ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
20    DUPLICATE_ARTIFACT_OBSERVED_CODE, DUPLICATE_PLANNED_ARTIFACT_ROLE_CODE,
21    PLANNED_ARTIFACT_DUPLICATE_DIFF_CATEGORY, PLANNED_ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
22};
23pub(in crate::deployment_truth) use artifacts::{
24    ARTIFACT_MISSING_CODE, is_artifact_role_failure_code,
25};
26pub(super) use artifacts::{ARTIFACT_ROLE_CONFLICT_CODE, PLANNED_ARTIFACT_ROLE_CONFLICT_CODE};
27#[cfg(test)]
28pub(super) use canisters::{
29    CANISTER_DUPLICATE_DIFF_CATEGORY, CANISTER_EXTRA_DIFF_CATEGORY,
30    CANISTER_ID_ROLE_CONFLICT_DIFF_CATEGORY, CANISTER_ROLE_AMBIGUOUS_CODE,
31    CANISTER_ROLE_AMBIGUOUS_DIFF_CATEGORY, CANISTER_ROLE_MISMATCH_CODE, CANISTER_UNOBSERVED_CODE,
32    DUPLICATE_CANISTER_OBSERVED_CODE, DUPLICATE_PLANNED_CANISTER_ROLE_CODE,
33    EXTRA_CANISTER_OBSERVED_CODE, PLANNED_CANISTER_DUPLICATE_DIFF_CATEGORY,
34    PLANNED_CANISTER_ID_CONFLICT_DIFF_CATEGORY, PLANNED_CANISTER_ROLE_CONFLICT_DIFF_CATEGORY,
35    ROLE_MISMATCH_DIFF_CATEGORY, SUBNET_REGISTRY_ROLE_MISSING_CODE, UNSAFE_CONTROL_CLASS_CODE,
36};
37pub(super) use canisters::{
38    CANISTER_ID_ROLE_CONFLICT_CODE, PLANNED_CANISTER_ID_CONFLICT_CODE,
39    PLANNED_CANISTER_ROLE_CONFLICT_CODE,
40};
41use canisters::{compare_canisters, compare_observed_canister_id_conflicts};
42#[cfg(test)]
43pub(super) use config_digests::{RAW_CONFIG_DIGEST_MISMATCH_CODE, RAW_CONFIG_SHA256_DIFF_CATEGORY};
44use config_digests::{compare_embedded_config, compare_raw_config};
45use controllers::compare_authority_profile;
46#[cfg(test)]
47pub(super) use controllers::{
48    CONTROLLER_AUTHORITY_OVERLAP_CODE, CONTROLLER_EXTRA_DIFF_CATEGORY,
49    CONTROLLER_MISSING_DIFF_CATEGORY, CONTROLLERS_UNOBSERVED_CODE,
50    EXPECTED_CONTROLLER_MISSING_CODE, EXTRA_CONTROLLER_OBSERVED_CODE,
51};
52use module_hashes::compare_module_hashes;
53#[cfg(test)]
54pub(super) use module_hashes::{
55    INSTALLED_MODULE_HASH_AMBIGUOUS_CODE, INSTALLED_MODULE_HASH_AMBIGUOUS_DIFF_CATEGORY,
56    INSTALLED_MODULE_HASH_DIFF_CATEGORY, INSTALLED_MODULE_HASH_MISMATCH_CODE,
57};
58pub(super) use pools::{
59    CANISTER_POOL_ROLE_CONFLICT_CODE, PLANNED_POOL_CONFLICT_CODE, PLANNED_POOL_ID_CONFLICT_CODE,
60    POOL_CANISTER_ID_CONFLICT_CODE,
61};
62#[cfg(test)]
63pub(super) use pools::{
64    CANISTER_POOL_ROLE_CONFLICT_DIFF_CATEGORY, DUPLICATE_PLANNED_POOL_CODE,
65    DUPLICATE_POOL_CANISTER_OBSERVED_CODE, EXTRA_POOL_CANISTER_OBSERVED_CODE,
66    PLANNED_POOL_CONFLICT_DIFF_CATEGORY, PLANNED_POOL_DUPLICATE_DIFF_CATEGORY,
67    PLANNED_POOL_ID_CONFLICT_DIFF_CATEGORY, POOL_CANISTER_DIFF_CATEGORY,
68    POOL_CANISTER_DUPLICATE_DIFF_CATEGORY, POOL_CANISTER_ID_CONFLICT_DIFF_CATEGORY,
69    POOL_CANISTER_ID_DIFF_CATEGORY, POOL_CANISTER_ID_MISMATCH_CODE, POOL_CANISTER_MISSING_CODE,
70    POOL_CONTROL_CLASS_DIFF_CATEGORY, POOL_EXTRA_DIFF_CATEGORY, UNSAFE_POOL_CONTROL_CLASS_CODE,
71};
72use pools::{compare_observed_canister_pool_role_conflicts, compare_pools};
73pub use receipt_resume::compare_plan_inventory_and_receipt;
74#[cfg(test)]
75pub(super) use receipt_resume::{
76    DUPLICATE_RECEIPT_PHASE_CODE, DUPLICATE_RECEIPT_ROLE_PHASE_CODE,
77    RECEIPT_EXECUTION_STATUS_MISMATCH_CODE, RECEIPT_PLAN_MISMATCH_CODE,
78    RECEIPT_POSTCONDITION_UNVERIFIED_CODE,
79};
80pub(super) use receipt_resume::{RECEIPT_PHASE_CONFLICT_CODE, RECEIPT_ROLE_PHASE_CONFLICT_CODE};
81#[cfg(test)]
82pub(super) use root_subnet::ROOT_AUTH_CLOUD_ENGINE_SUBNET_CODE;
83pub(super) use root_subnet::apply_root_auth_signer_subnet_check;
84#[cfg(test)]
85pub(super) use root_subnet::{
86    RootSubnetEvidence, RootSubnetEvidenceSource, apply_root_auth_signer_subnet_check_with_source,
87};
88pub use safety::safety_report_from_diff;
89pub(in crate::deployment_truth::report) use safety::{resume_safety_reasons, safety_status};
90use verifier_readiness::compare_verifier_readiness;
91#[cfg(test)]
92pub(super) use verifier_readiness::{
93    DUPLICATE_PLANNED_VERIFIER_ROLE_EPOCH_CODE, DUPLICATE_VERIFIER_ROLE_EPOCH_OBSERVED_CODE,
94    PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY,
95    PLANNED_VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_NOT_OBSERVED_LABEL,
96    VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_DIFF_CATEGORY,
97    VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_STALE_CODE,
98    VERIFIER_ROLE_EPOCH_UNOBSERVED_CODE,
99};
100pub(super) use verifier_readiness::{
101    PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE, VERIFIER_ROLE_EPOCH_CONFLICT_CODE,
102};
103
104pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_MISMATCH_CODE: &str =
105    "deployment_manifest_mismatch";
106pub(in crate::deployment_truth) const OBSERVATION_GAP_CODE: &str = "observation_gap";
107pub(in crate::deployment_truth) const PLAN_ASSUMPTION_CODE: &str = "plan_assumption";
108pub(in crate::deployment_truth) const IDENTITY_UNOBSERVED_CODE: &str = "identity_unobserved";
109pub(in crate::deployment_truth) const ENVIRONMENT_MISMATCH_CODE: &str = "environment_mismatch";
110pub(in crate::deployment_truth) const FLEET_IDENTITY_MISMATCH_CODE: &str =
111    "fleet_identity_mismatch";
112pub(in crate::deployment_truth) const ROOT_TRUST_ANCHOR_MISMATCH_CODE: &str =
113    "root_trust_anchor_mismatch";
114pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_UNOBSERVED_CODE: &str =
115    "deployment_manifest_unobserved";
116
117#[must_use]
118pub fn is_evidence_conflict_finding_code(code: &str) -> bool {
119    matches!(
120        code,
121        PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
122            | ARTIFACT_ROLE_CONFLICT_CODE
123            | CANISTER_ID_ROLE_CONFLICT_CODE
124            | PLANNED_CANISTER_ROLE_CONFLICT_CODE
125            | PLANNED_CANISTER_ID_CONFLICT_CODE
126            | CANISTER_POOL_ROLE_CONFLICT_CODE
127            | PLANNED_POOL_CONFLICT_CODE
128            | PLANNED_POOL_ID_CONFLICT_CODE
129            | POOL_CANISTER_ID_CONFLICT_CODE
130            | RECEIPT_PHASE_CONFLICT_CODE
131            | RECEIPT_ROLE_PHASE_CONFLICT_CODE
132            | PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE
133            | VERIFIER_ROLE_EPOCH_CONFLICT_CODE
134    )
135}
136
137///
138/// DuplicateEvidenceGroup
139///
140struct DuplicateEvidenceGroup {
141    subject: String,
142    count: usize,
143    evidence_label: String,
144    is_conflict: bool,
145}
146
147///
148/// LocalDeploymentCheckRequest
149///
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct LocalDeploymentCheckRequest {
152    pub fleet_name: String,
153    pub app: String,
154    pub environment: String,
155    pub artifact_environment: String,
156    pub workspace_root: std::path::PathBuf,
157    pub icp_root: std::path::PathBuf,
158    pub config_path: Option<std::path::PathBuf>,
159    pub observed_at: String,
160    pub runtime_variant: String,
161    pub build_profile: String,
162}
163
164/// Build local plan and inventory, then return the passive safety check bundle.
165pub fn check_local_deployment(
166    request: &LocalDeploymentCheckRequest,
167) -> Result<DeploymentCheckV1, DeploymentTruthError> {
168    let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
169        fleet_name: request.fleet_name.clone(),
170        app: request.app.clone(),
171        environment: request.environment.clone(),
172        artifact_environment: request.artifact_environment.clone(),
173        workspace_root: request.workspace_root.clone(),
174        icp_root: request.icp_root.clone(),
175        config_path: request.config_path.clone(),
176        runtime_variant: request.runtime_variant.clone(),
177        build_profile: request.build_profile.clone(),
178    });
179    let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
180        fleet_name: request.fleet_name.clone(),
181        environment: request.environment.clone(),
182        artifact_environment: request.artifact_environment.clone(),
183        workspace_root: request.workspace_root.clone(),
184        icp_root: request.icp_root.clone(),
185        config_path: request.config_path.clone(),
186        observed_at: request.observed_at.clone(),
187    })?;
188    let mut diff = compare_plan_to_inventory(&plan, &inventory);
189    apply_root_auth_signer_subnet_check(
190        &mut diff,
191        &inventory,
192        &request.environment,
193        &request.icp_root,
194    );
195    let report = safety_report_from_diff(
196        format!(
197            "local:{}:{}:report",
198            request.environment, request.fleet_name
199        ),
200        Some(format!(
201            "local:{}:{}:diff",
202            request.environment, request.fleet_name
203        )),
204        &diff,
205    );
206
207    Ok(DeploymentCheckV1 {
208        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
209        check_id: format!("local:{}:{}:check", request.environment, request.fleet_name),
210        plan,
211        inventory,
212        diff,
213        report,
214    })
215}
216
217fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
218    diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
219    diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
220}
221
222/// Compare intended deployment state with observed inventory into a machine diff.
223#[must_use]
224pub fn compare_plan_to_inventory(
225    plan: &DeploymentPlanV1,
226    inventory: &DeploymentInventoryV1,
227) -> DeploymentDiffV1 {
228    let mut artifact_diff = Vec::new();
229    let mut controller_diff = Vec::new();
230    let mut pool_diff = Vec::new();
231    let mut embedded_config_diff = Vec::new();
232    let mut module_hash_diff = Vec::new();
233    let mut verifier_readiness_diff = Vec::new();
234    let mut hard_failures = Vec::new();
235    let mut warnings = Vec::new();
236
237    compare_identity(plan, inventory, &mut hard_failures);
238    compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
239    compare_artifacts(
240        plan,
241        inventory,
242        &mut artifact_diff,
243        &mut hard_failures,
244        &mut warnings,
245    );
246    compare_observed_canister_id_conflicts(
247        inventory,
248        &mut controller_diff,
249        &mut hard_failures,
250        &mut warnings,
251    );
252    compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
253    compare_canisters(
254        plan,
255        inventory,
256        &mut controller_diff,
257        &mut hard_failures,
258        &mut warnings,
259    );
260    compare_pools(
261        plan,
262        inventory,
263        &mut pool_diff,
264        &mut hard_failures,
265        &mut warnings,
266    );
267    compare_module_hashes(
268        plan,
269        inventory,
270        &mut module_hash_diff,
271        &mut hard_failures,
272        &mut warnings,
273    );
274    compare_raw_config(
275        plan,
276        inventory,
277        &mut embedded_config_diff,
278        &mut hard_failures,
279    );
280    compare_embedded_config(
281        plan,
282        inventory,
283        &mut embedded_config_diff,
284        &mut hard_failures,
285        &mut warnings,
286    );
287    compare_verifier_readiness(
288        plan,
289        inventory,
290        &mut verifier_readiness_diff,
291        &mut hard_failures,
292        &mut warnings,
293    );
294    record_plan_assumptions(plan, &mut warnings);
295    for gap in &inventory.unresolved_observations {
296        warnings.push(SafetyFindingV1 {
297            code: OBSERVATION_GAP_CODE.to_string(),
298            message: gap.description.clone(),
299            severity: SafetySeverityV1::Warning,
300            subject: Some(gap.key.clone()),
301        });
302    }
303
304    let status = safety_status(&hard_failures, &warnings);
305    DeploymentDiffV1 {
306        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
307        plan_identity: plan.deployment_identity.clone(),
308        observed_identity: inventory.observed_identity.clone(),
309        artifact_diff,
310        controller_diff,
311        pool_diff,
312        embedded_config_diff,
313        module_hash_diff,
314        verifier_readiness_diff,
315        resume_safety: ResumeSafetyV1 {
316            status,
317            reasons: resume_safety_reasons(&hard_failures, &warnings),
318        },
319        hard_failures,
320        warnings,
321        resumable_phases: Vec::new(),
322    }
323}
324
325fn record_plan_assumptions(plan: &DeploymentPlanV1, warnings: &mut Vec<SafetyFindingV1>) {
326    for assumption in &plan.unresolved_assumptions {
327        warnings.push(SafetyFindingV1 {
328            code: PLAN_ASSUMPTION_CODE.to_string(),
329            message: assumption.description.clone(),
330            severity: SafetySeverityV1::Warning,
331            subject: Some(assumption.key.clone()),
332        });
333    }
334}
335
336fn compare_identity(
337    plan: &DeploymentPlanV1,
338    inventory: &DeploymentInventoryV1,
339    hard_failures: &mut Vec<SafetyFindingV1>,
340) {
341    let Some(observed) = &inventory.observed_identity else {
342        hard_failures.push(finding(
343            IDENTITY_UNOBSERVED_CODE,
344            "deployment identity was not observed",
345            SafetySeverityV1::HardFailure,
346            None,
347        ));
348        return;
349    };
350
351    if observed.environment != plan.deployment_identity.environment {
352        hard_failures.push(finding(
353            ENVIRONMENT_MISMATCH_CODE,
354            format!(
355                "plan environment {} differs from observed environment {}",
356                plan.deployment_identity.environment, observed.environment
357            ),
358            SafetySeverityV1::HardFailure,
359            Some("deployment_identity.environment".to_string()),
360        ));
361    }
362    for (field, expected, actual) in [
363        (
364            "fleet_name",
365            Some(plan.deployment_identity.fleet_name.clone()),
366            Some(observed.fleet_name.clone()),
367        ),
368        (
369            "app",
370            Some(plan.deployment_identity.app.clone()),
371            Some(observed.app.clone()),
372        ),
373        (
374            "canonical_network_id",
375            plan.deployment_identity
376                .canonical_network_id
377                .map(|value| value.to_string()),
378            observed.canonical_network_id.map(|value| value.to_string()),
379        ),
380        (
381            "fleet_id",
382            plan.deployment_identity
383                .fleet_id
384                .map(|value| value.to_string()),
385            observed.fleet_id.map(|value| value.to_string()),
386        ),
387    ] {
388        match (expected, actual) {
389            (Some(expected), Some(actual)) if expected != actual => {
390                hard_failures.push(finding(
391                    FLEET_IDENTITY_MISMATCH_CODE,
392                    format!("plan {field} {expected} differs from observed {field} {actual}"),
393                    SafetySeverityV1::HardFailure,
394                    Some(format!("deployment_identity.{field}")),
395                ));
396            }
397            (Some(expected), None) => {
398                hard_failures.push(finding(
399                    FLEET_IDENTITY_MISMATCH_CODE,
400                    format!("plan {field} {expected} was not observed"),
401                    SafetySeverityV1::HardFailure,
402                    Some(format!("deployment_identity.{field}")),
403                ));
404            }
405            _ => {}
406        }
407    }
408    if let (Some(expected), Some(actual)) = (
409        plan.deployment_identity.root_principal.as_ref(),
410        observed.root_principal.as_ref(),
411    ) && expected != actual
412    {
413        hard_failures.push(finding(
414            ROOT_TRUST_ANCHOR_MISMATCH_CODE,
415            format!("plan root {expected} differs from observed root {actual}"),
416            SafetySeverityV1::HardFailure,
417            Some("deployment_identity.root_principal".to_string()),
418        ));
419    }
420    match (
421        plan.deployment_identity.deployment_manifest_digest.as_ref(),
422        observed.deployment_manifest_digest.as_ref(),
423    ) {
424        (Some(expected), Some(actual)) if expected != actual => {
425            hard_failures.push(finding(
426                DEPLOYMENT_MANIFEST_MISMATCH_CODE,
427                "deployment manifest digest differs from the observed local config",
428                SafetySeverityV1::HardFailure,
429                Some("deployment_identity.deployment_manifest_digest".to_string()),
430            ));
431        }
432        (Some(_), None) => {
433            hard_failures.push(finding(
434                DEPLOYMENT_MANIFEST_UNOBSERVED_CODE,
435                "deployment manifest digest was not observed",
436                SafetySeverityV1::HardFailure,
437                Some("deployment_identity.deployment_manifest_digest".to_string()),
438            ));
439        }
440        _ => {}
441    }
442}
443
444fn finding(
445    code: impl Into<String>,
446    message: impl Into<String>,
447    severity: SafetySeverityV1,
448    subject: Option<String>,
449) -> SafetyFindingV1 {
450    SafetyFindingV1 {
451        code: code.into(),
452        message: message.into(),
453        severity,
454        subject,
455    }
456}
457
458fn diff_item(
459    category: impl Into<String>,
460    subject: impl Into<String>,
461    expected: Option<String>,
462    observed: Option<String>,
463    severity: SafetySeverityV1,
464) -> DiffItemV1 {
465    DiffItemV1 {
466        category: category.into(),
467        subject: subject.into(),
468        expected,
469        observed,
470        severity,
471    }
472}
473
474fn duplicate_evidence_groups<T>(
475    items: &[T],
476    subject: impl Fn(&T) -> String,
477    evidence: impl Fn(&T) -> String,
478    evidence_separator: &str,
479) -> Vec<DuplicateEvidenceGroup> {
480    let mut groups = Vec::new();
481    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
482        if entries.len() <= 1 {
483            continue;
484        }
485        let evidence_values = entries
486            .iter()
487            .map(|entry| evidence(entry))
488            .collect::<BTreeSet<_>>();
489        groups.push(DuplicateEvidenceGroup {
490            subject,
491            count: entries.len(),
492            evidence_label: evidence_values
493                .iter()
494                .cloned()
495                .collect::<Vec<_>>()
496                .join(evidence_separator),
497            is_conflict: evidence_values.len() > 1,
498        });
499    }
500    groups
501}
502
503fn duplicate_evidence_groups_by<T, K>(
504    items: &[T],
505    subject: impl Fn(&T) -> String,
506    evidence_key: impl Fn(&T) -> K,
507    evidence_label: impl Fn(&T) -> String,
508    evidence_separator: &str,
509) -> Vec<DuplicateEvidenceGroup>
510where
511    K: Ord,
512{
513    let mut groups = Vec::new();
514    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
515        if entries.len() <= 1 {
516            continue;
517        }
518        let evidence_values = entries
519            .iter()
520            .map(|entry| (evidence_key(entry), evidence_label(entry)))
521            .collect::<BTreeMap<_, _>>();
522        groups.push(DuplicateEvidenceGroup {
523            subject,
524            count: entries.len(),
525            evidence_label: evidence_values
526                .values()
527                .cloned()
528                .collect::<Vec<_>>()
529                .join(evidence_separator),
530            is_conflict: evidence_values.len() > 1,
531        });
532    }
533    groups
534}
535
536fn conflicting_assignment_groups<T>(
537    items: &[T],
538    subject: impl Fn(&T) -> Option<String>,
539    value: impl Fn(&T) -> String,
540    value_separator: &str,
541) -> Vec<DuplicateEvidenceGroup> {
542    let mut groups = Vec::new();
543    for (subject, entries) in group_by_subject(items, subject) {
544        if entries.len() <= 1 {
545            continue;
546        }
547        let values = entries
548            .iter()
549            .map(|entry| value(entry))
550            .collect::<BTreeSet<_>>();
551        if values.len() <= 1 {
552            continue;
553        }
554        groups.push(DuplicateEvidenceGroup {
555            subject,
556            count: entries.len(),
557            evidence_label: values
558                .iter()
559                .cloned()
560                .collect::<Vec<_>>()
561                .join(value_separator),
562            is_conflict: true,
563        });
564    }
565    groups
566}
567
568fn group_by_subject<T>(
569    items: &[T],
570    subject: impl Fn(&T) -> Option<String>,
571) -> BTreeMap<String, Vec<&T>> {
572    let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
573    for item in items {
574        if let Some(subject) = subject(item) {
575            by_subject.entry(subject).or_default().push(item);
576        }
577    }
578    by_subject
579}
580
581#[cfg(test)]
582mod classification_tests {
583    use super::*;
584
585    #[test]
586    fn finding_classification_uses_exact_owner_codes() {
587        assert!(is_evidence_conflict_finding_code(
588            ARTIFACT_ROLE_CONFLICT_CODE
589        ));
590        assert!(is_evidence_conflict_finding_code(
591            RECEIPT_PHASE_CONFLICT_CODE
592        ));
593        assert!(!is_evidence_conflict_finding_code("artifact_conflict"));
594        assert!(!is_evidence_conflict_finding_code("conflict"));
595
596        assert!(is_artifact_role_failure_code(ARTIFACT_MISSING_CODE));
597        assert!(!is_artifact_role_failure_code(
598            PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
599        ));
600    }
601}