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";
108const IDENTITY_UNOBSERVED_CODE: &str = "identity_unobserved";
109const ENVIRONMENT_MISMATCH_CODE: &str = "environment_mismatch";
110pub(in crate::deployment_truth) const FLEET_IDENTITY_MISMATCH_CODE: &str =
111    "fleet_identity_mismatch";
112const ROOT_TRUST_ANCHOR_MISMATCH_CODE: &str = "root_trust_anchor_mismatch";
113const DEPLOYMENT_MANIFEST_UNOBSERVED_CODE: &str = "deployment_manifest_unobserved";
114
115#[must_use]
116pub fn is_evidence_conflict_finding_code(code: &str) -> bool {
117    matches!(
118        code,
119        PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
120            | ARTIFACT_ROLE_CONFLICT_CODE
121            | CANISTER_ID_ROLE_CONFLICT_CODE
122            | PLANNED_CANISTER_ROLE_CONFLICT_CODE
123            | PLANNED_CANISTER_ID_CONFLICT_CODE
124            | CANISTER_POOL_ROLE_CONFLICT_CODE
125            | PLANNED_POOL_CONFLICT_CODE
126            | PLANNED_POOL_ID_CONFLICT_CODE
127            | POOL_CANISTER_ID_CONFLICT_CODE
128            | RECEIPT_PHASE_CONFLICT_CODE
129            | RECEIPT_ROLE_PHASE_CONFLICT_CODE
130            | PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE
131            | VERIFIER_ROLE_EPOCH_CONFLICT_CODE
132    )
133}
134
135///
136/// DuplicateEvidenceGroup
137///
138struct DuplicateEvidenceGroup {
139    subject: String,
140    count: usize,
141    evidence_label: String,
142    is_conflict: bool,
143}
144
145///
146/// LocalDeploymentCheckRequest
147///
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub struct LocalDeploymentCheckRequest {
150    pub fleet_name: String,
151    pub app: String,
152    pub environment: String,
153    pub artifact_environment: String,
154    pub workspace_root: std::path::PathBuf,
155    pub icp_root: std::path::PathBuf,
156    pub config_path: Option<std::path::PathBuf>,
157    pub observed_at: String,
158    pub runtime_variant: String,
159    pub build_profile: String,
160}
161
162/// Build local plan and inventory, then return the passive safety check bundle.
163pub fn check_local_deployment(
164    request: &LocalDeploymentCheckRequest,
165) -> Result<DeploymentCheckV1, DeploymentTruthError> {
166    let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
167        fleet_name: request.fleet_name.clone(),
168        app: request.app.clone(),
169        environment: request.environment.clone(),
170        artifact_environment: request.artifact_environment.clone(),
171        workspace_root: request.workspace_root.clone(),
172        icp_root: request.icp_root.clone(),
173        config_path: request.config_path.clone(),
174        runtime_variant: request.runtime_variant.clone(),
175        build_profile: request.build_profile.clone(),
176    });
177    let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
178        fleet_name: request.fleet_name.clone(),
179        environment: request.environment.clone(),
180        artifact_environment: request.artifact_environment.clone(),
181        workspace_root: request.workspace_root.clone(),
182        icp_root: request.icp_root.clone(),
183        config_path: request.config_path.clone(),
184        observed_at: request.observed_at.clone(),
185    })?;
186    let mut diff = compare_plan_to_inventory(&plan, &inventory);
187    apply_root_auth_signer_subnet_check(
188        &mut diff,
189        &inventory,
190        &request.environment,
191        &request.icp_root,
192    );
193    let report = safety_report_from_diff(
194        format!(
195            "local:{}:{}:report",
196            request.environment, request.fleet_name
197        ),
198        Some(format!(
199            "local:{}:{}:diff",
200            request.environment, request.fleet_name
201        )),
202        &diff,
203    );
204
205    Ok(DeploymentCheckV1 {
206        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
207        check_id: format!("local:{}:{}:check", request.environment, request.fleet_name),
208        plan,
209        inventory,
210        diff,
211        report,
212    })
213}
214
215fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
216    diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
217    diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
218}
219
220/// Compare intended deployment state with observed inventory into a machine diff.
221#[must_use]
222pub fn compare_plan_to_inventory(
223    plan: &DeploymentPlanV1,
224    inventory: &DeploymentInventoryV1,
225) -> DeploymentDiffV1 {
226    let mut artifact_diff = Vec::new();
227    let mut controller_diff = Vec::new();
228    let mut pool_diff = Vec::new();
229    let mut embedded_config_diff = Vec::new();
230    let mut module_hash_diff = Vec::new();
231    let mut verifier_readiness_diff = Vec::new();
232    let mut hard_failures = Vec::new();
233    let mut warnings = Vec::new();
234
235    compare_identity(plan, inventory, &mut hard_failures);
236    compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
237    compare_artifacts(
238        plan,
239        inventory,
240        &mut artifact_diff,
241        &mut hard_failures,
242        &mut warnings,
243    );
244    compare_observed_canister_id_conflicts(
245        inventory,
246        &mut controller_diff,
247        &mut hard_failures,
248        &mut warnings,
249    );
250    compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
251    compare_canisters(
252        plan,
253        inventory,
254        &mut controller_diff,
255        &mut hard_failures,
256        &mut warnings,
257    );
258    compare_pools(
259        plan,
260        inventory,
261        &mut pool_diff,
262        &mut hard_failures,
263        &mut warnings,
264    );
265    compare_module_hashes(
266        plan,
267        inventory,
268        &mut module_hash_diff,
269        &mut hard_failures,
270        &mut warnings,
271    );
272    compare_raw_config(
273        plan,
274        inventory,
275        &mut embedded_config_diff,
276        &mut hard_failures,
277    );
278    compare_embedded_config(
279        plan,
280        inventory,
281        &mut embedded_config_diff,
282        &mut hard_failures,
283        &mut warnings,
284    );
285    compare_verifier_readiness(
286        plan,
287        inventory,
288        &mut verifier_readiness_diff,
289        &mut hard_failures,
290        &mut warnings,
291    );
292    record_plan_assumptions(plan, &mut warnings);
293    for gap in &inventory.unresolved_observations {
294        warnings.push(SafetyFindingV1 {
295            code: OBSERVATION_GAP_CODE.to_string(),
296            message: gap.description.clone(),
297            severity: SafetySeverityV1::Warning,
298            subject: Some(gap.key.clone()),
299        });
300    }
301
302    let status = safety_status(&hard_failures, &warnings);
303    DeploymentDiffV1 {
304        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
305        plan_identity: plan.deployment_identity.clone(),
306        observed_identity: inventory.observed_identity.clone(),
307        artifact_diff,
308        controller_diff,
309        pool_diff,
310        embedded_config_diff,
311        module_hash_diff,
312        verifier_readiness_diff,
313        resume_safety: ResumeSafetyV1 {
314            status,
315            reasons: resume_safety_reasons(&hard_failures, &warnings),
316        },
317        hard_failures,
318        warnings,
319        resumable_phases: Vec::new(),
320    }
321}
322
323fn record_plan_assumptions(plan: &DeploymentPlanV1, warnings: &mut Vec<SafetyFindingV1>) {
324    for assumption in &plan.unresolved_assumptions {
325        warnings.push(SafetyFindingV1 {
326            code: PLAN_ASSUMPTION_CODE.to_string(),
327            message: assumption.description.clone(),
328            severity: SafetySeverityV1::Warning,
329            subject: Some(assumption.key.clone()),
330        });
331    }
332}
333
334fn compare_identity(
335    plan: &DeploymentPlanV1,
336    inventory: &DeploymentInventoryV1,
337    hard_failures: &mut Vec<SafetyFindingV1>,
338) {
339    let Some(observed) = &inventory.observed_identity else {
340        hard_failures.push(finding(
341            IDENTITY_UNOBSERVED_CODE,
342            "deployment identity was not observed",
343            SafetySeverityV1::HardFailure,
344            None,
345        ));
346        return;
347    };
348
349    if observed.environment != plan.deployment_identity.environment {
350        hard_failures.push(finding(
351            ENVIRONMENT_MISMATCH_CODE,
352            format!(
353                "plan environment {} differs from observed environment {}",
354                plan.deployment_identity.environment, observed.environment
355            ),
356            SafetySeverityV1::HardFailure,
357            Some("deployment_identity.environment".to_string()),
358        ));
359    }
360    for (field, expected, actual) in [
361        (
362            "fleet_name",
363            Some(plan.deployment_identity.fleet_name.clone()),
364            Some(observed.fleet_name.clone()),
365        ),
366        (
367            "app",
368            Some(plan.deployment_identity.app.clone()),
369            Some(observed.app.clone()),
370        ),
371        (
372            "canonical_network_id",
373            plan.deployment_identity
374                .canonical_network_id
375                .map(|value| value.to_string()),
376            observed.canonical_network_id.map(|value| value.to_string()),
377        ),
378        (
379            "fleet_id",
380            plan.deployment_identity
381                .fleet_id
382                .map(|value| value.to_string()),
383            observed.fleet_id.map(|value| value.to_string()),
384        ),
385    ] {
386        match (expected, actual) {
387            (Some(expected), Some(actual)) if expected != actual => {
388                hard_failures.push(finding(
389                    FLEET_IDENTITY_MISMATCH_CODE,
390                    format!("plan {field} {expected} differs from observed {field} {actual}"),
391                    SafetySeverityV1::HardFailure,
392                    Some(format!("deployment_identity.{field}")),
393                ));
394            }
395            (Some(expected), None) => {
396                hard_failures.push(finding(
397                    FLEET_IDENTITY_MISMATCH_CODE,
398                    format!("plan {field} {expected} was not observed"),
399                    SafetySeverityV1::HardFailure,
400                    Some(format!("deployment_identity.{field}")),
401                ));
402            }
403            _ => {}
404        }
405    }
406    if let (Some(expected), Some(actual)) = (
407        plan.deployment_identity.root_principal.as_ref(),
408        observed.root_principal.as_ref(),
409    ) && expected != actual
410    {
411        hard_failures.push(finding(
412            ROOT_TRUST_ANCHOR_MISMATCH_CODE,
413            format!("plan root {expected} differs from observed root {actual}"),
414            SafetySeverityV1::HardFailure,
415            Some("deployment_identity.root_principal".to_string()),
416        ));
417    }
418    match (
419        plan.deployment_identity.deployment_manifest_digest.as_ref(),
420        observed.deployment_manifest_digest.as_ref(),
421    ) {
422        (Some(expected), Some(actual)) if expected != actual => {
423            hard_failures.push(finding(
424                DEPLOYMENT_MANIFEST_MISMATCH_CODE,
425                "deployment manifest digest differs from the observed local config",
426                SafetySeverityV1::HardFailure,
427                Some("deployment_identity.deployment_manifest_digest".to_string()),
428            ));
429        }
430        (Some(_), None) => {
431            hard_failures.push(finding(
432                DEPLOYMENT_MANIFEST_UNOBSERVED_CODE,
433                "deployment manifest digest was not observed",
434                SafetySeverityV1::HardFailure,
435                Some("deployment_identity.deployment_manifest_digest".to_string()),
436            ));
437        }
438        _ => {}
439    }
440}
441
442fn finding(
443    code: impl Into<String>,
444    message: impl Into<String>,
445    severity: SafetySeverityV1,
446    subject: Option<String>,
447) -> SafetyFindingV1 {
448    SafetyFindingV1 {
449        code: code.into(),
450        message: message.into(),
451        severity,
452        subject,
453    }
454}
455
456fn diff_item(
457    category: impl Into<String>,
458    subject: impl Into<String>,
459    expected: Option<String>,
460    observed: Option<String>,
461    severity: SafetySeverityV1,
462) -> DiffItemV1 {
463    DiffItemV1 {
464        category: category.into(),
465        subject: subject.into(),
466        expected,
467        observed,
468        severity,
469    }
470}
471
472fn duplicate_evidence_groups<T>(
473    items: &[T],
474    subject: impl Fn(&T) -> String,
475    evidence: impl Fn(&T) -> String,
476    evidence_separator: &str,
477) -> Vec<DuplicateEvidenceGroup> {
478    let mut groups = Vec::new();
479    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
480        if entries.len() <= 1 {
481            continue;
482        }
483        let evidence_values = entries
484            .iter()
485            .map(|entry| evidence(entry))
486            .collect::<BTreeSet<_>>();
487        groups.push(DuplicateEvidenceGroup {
488            subject,
489            count: entries.len(),
490            evidence_label: evidence_values
491                .iter()
492                .cloned()
493                .collect::<Vec<_>>()
494                .join(evidence_separator),
495            is_conflict: evidence_values.len() > 1,
496        });
497    }
498    groups
499}
500
501fn duplicate_evidence_groups_by<T, K>(
502    items: &[T],
503    subject: impl Fn(&T) -> String,
504    evidence_key: impl Fn(&T) -> K,
505    evidence_label: impl Fn(&T) -> String,
506    evidence_separator: &str,
507) -> Vec<DuplicateEvidenceGroup>
508where
509    K: Ord,
510{
511    let mut groups = Vec::new();
512    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
513        if entries.len() <= 1 {
514            continue;
515        }
516        let evidence_values = entries
517            .iter()
518            .map(|entry| (evidence_key(entry), evidence_label(entry)))
519            .collect::<BTreeMap<_, _>>();
520        groups.push(DuplicateEvidenceGroup {
521            subject,
522            count: entries.len(),
523            evidence_label: evidence_values
524                .values()
525                .cloned()
526                .collect::<Vec<_>>()
527                .join(evidence_separator),
528            is_conflict: evidence_values.len() > 1,
529        });
530    }
531    groups
532}
533
534fn conflicting_assignment_groups<T>(
535    items: &[T],
536    subject: impl Fn(&T) -> Option<String>,
537    value: impl Fn(&T) -> String,
538    value_separator: &str,
539) -> Vec<DuplicateEvidenceGroup> {
540    let mut groups = Vec::new();
541    for (subject, entries) in group_by_subject(items, subject) {
542        if entries.len() <= 1 {
543            continue;
544        }
545        let values = entries
546            .iter()
547            .map(|entry| value(entry))
548            .collect::<BTreeSet<_>>();
549        if values.len() <= 1 {
550            continue;
551        }
552        groups.push(DuplicateEvidenceGroup {
553            subject,
554            count: entries.len(),
555            evidence_label: values
556                .iter()
557                .cloned()
558                .collect::<Vec<_>>()
559                .join(value_separator),
560            is_conflict: true,
561        });
562    }
563    groups
564}
565
566fn group_by_subject<T>(
567    items: &[T],
568    subject: impl Fn(&T) -> Option<String>,
569) -> BTreeMap<String, Vec<&T>> {
570    let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
571    for item in items {
572        if let Some(subject) = subject(item) {
573            by_subject.entry(subject).or_default().push(item);
574        }
575    }
576    by_subject
577}
578
579#[cfg(test)]
580mod classification_tests {
581    use super::*;
582
583    #[test]
584    fn finding_classification_uses_exact_owner_codes() {
585        assert!(is_evidence_conflict_finding_code(
586            ARTIFACT_ROLE_CONFLICT_CODE
587        ));
588        assert!(is_evidence_conflict_finding_code(
589            RECEIPT_PHASE_CONFLICT_CODE
590        ));
591        assert!(!is_evidence_conflict_finding_code("artifact_conflict"));
592        assert!(!is_evidence_conflict_finding_code("conflict"));
593
594        assert!(is_artifact_role_failure_code(ARTIFACT_MISSING_CODE));
595        assert!(!is_artifact_role_failure_code(
596            PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
597        ));
598    }
599}