Skip to main content

canic_host/state_manifest/
mod.rs

1//! Module: state_manifest
2//!
3//! Responsibility: build host-side state manifest and audit reports from
4//! Rust-authored Canic state declarations.
5//! Does not own: stable-memory inspection, migration execution, CLI parsing, or
6//! runtime introspection.
7//! Boundary: consumes passive declaration metadata from `canic-core` and emits
8//! diagnostic-only reports.
9
10use canic_control_plane::state_contract::canic_control_plane_state_manifest;
11use canic_core::state_contract::{
12    MigrationPolicy, RemovedStateManifest, ReservedMemoryManifest, STATE_MANIFEST_SCHEMA_VERSION,
13    StateDomainManifest, StateManifest, StateMigrationManifest, StateRoleManifest, StateStorage,
14    canic_state_manifest,
15};
16use serde::Serialize;
17use std::collections::BTreeMap;
18
19pub const STATE_AUDIT_COMMAND: &str = "canic state audit";
20pub const STATE_MANIFEST_COMMAND: &str = "canic state manifest";
21pub const STATE_AUDIT_SCHEMA_VERSION: u16 = 1;
22
23const SCOPE_PROJECT: StateAuditScope = StateAuditScope::Project;
24const SCOPE_ROLE: StateAuditScope = StateAuditScope::Role;
25const SOURCE_STATE_MANIFEST: StateAuditSource = StateAuditSource::StateManifest;
26const CATEGORY_MANIFEST: StateAuditCategory = StateAuditCategory::Manifest;
27const CATEGORY_SCHEMA_VERSION: StateAuditCategory = StateAuditCategory::SchemaVersion;
28const CATEGORY_MEMORY_ID: StateAuditCategory = StateAuditCategory::MemoryId;
29const CATEGORY_MIGRATION: StateAuditCategory = StateAuditCategory::Migration;
30const CATEGORY_REMOVED_STATE: StateAuditCategory = StateAuditCategory::RemovedState;
31const CATEGORY_SNAPSHOT: StateAuditCategory = StateAuditCategory::Snapshot;
32const CATEGORY_NAMING: StateAuditCategory = StateAuditCategory::Naming;
33const CATEGORY_LIFECYCLE: StateAuditCategory = StateAuditCategory::Lifecycle;
34const CATEGORY_INVARIANT: StateAuditCategory = StateAuditCategory::Invariant;
35const CATEGORY_TEST_COVERAGE: StateAuditCategory = StateAuditCategory::TestCoverage;
36
37///
38/// StateAuditReport
39///
40/// Diagnostic report for declared state domains.
41///
42
43#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
44pub struct StateAuditReport {
45    pub schema_version: u16,
46    pub command: &'static str,
47    pub scope: StateAuditScope,
48    pub role: Option<String>,
49    pub status: StateAuditStatus,
50    pub manifest: StateManifest,
51    pub checks: Vec<StateAuditCheck>,
52    pub next_actions: Vec<String>,
53}
54
55///
56/// StateAuditCheck
57///
58/// Stable check row emitted by `canic state audit`.
59///
60
61#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62pub struct StateAuditCheck {
63    pub category: StateAuditCategory,
64    pub code: &'static str,
65    pub status: StateAuditStatus,
66    pub severity: StateAuditSeverity,
67    pub subject: String,
68    pub detail: String,
69    pub next: Option<String>,
70    pub source: StateAuditSource,
71}
72
73///
74/// StateAuditScope
75///
76/// Stable audit scope emitted by `canic state audit`.
77///
78
79#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum StateAuditScope {
82    Project,
83    Role,
84}
85
86impl StateAuditScope {
87    #[must_use]
88    pub const fn label(self) -> &'static str {
89        match self {
90            Self::Project => "project",
91            Self::Role => "role",
92        }
93    }
94}
95
96///
97/// StateAuditCategory
98///
99/// Stable category label emitted by state-audit checks.
100///
101
102#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum StateAuditCategory {
105    Invariant,
106    Lifecycle,
107    Manifest,
108    MemoryId,
109    Migration,
110    Naming,
111    RemovedState,
112    SchemaVersion,
113    Snapshot,
114    TestCoverage,
115}
116
117impl StateAuditCategory {
118    #[must_use]
119    pub const fn label(self) -> &'static str {
120        match self {
121            Self::Invariant => "invariant",
122            Self::Lifecycle => "lifecycle",
123            Self::Manifest => "manifest",
124            Self::MemoryId => "memory_id",
125            Self::Migration => "migration",
126            Self::Naming => "naming",
127            Self::RemovedState => "removed_state",
128            Self::SchemaVersion => "schema_version",
129            Self::Snapshot => "snapshot",
130            Self::TestCoverage => "test_coverage",
131        }
132    }
133}
134
135///
136/// StateAuditSource
137///
138/// Stable source-attribution label emitted by state-audit checks.
139///
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
142#[serde(rename_all = "snake_case")]
143pub enum StateAuditSource {
144    StateManifest,
145}
146
147impl StateAuditSource {
148    #[must_use]
149    pub const fn label(self) -> &'static str {
150        match self {
151            Self::StateManifest => "state_manifest",
152        }
153    }
154}
155
156///
157/// StateAuditStatus
158///
159/// Stable audit status for reports and checks.
160///
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
163#[serde(rename_all = "snake_case")]
164pub enum StateAuditStatus {
165    Pass,
166    Warn,
167    Fail,
168    NotEvaluated,
169}
170
171///
172/// StateAuditSeverity
173///
174/// Stable severity framing for audit checks.
175///
176
177#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
178#[serde(rename_all = "snake_case")]
179pub enum StateAuditSeverity {
180    Info,
181    Warning,
182    Blocked,
183    Unsupported,
184}
185
186#[must_use]
187pub fn declared_state_manifest(role: Option<&str>) -> StateManifest {
188    let mut manifest = merge_manifests(vec![
189        canic_state_manifest(),
190        canic_control_plane_state_manifest(),
191    ]);
192    if let Some(role) = role {
193        manifest
194            .roles
195            .retain(|entry| entry.canister_role.as_str() == role);
196    }
197    manifest
198}
199
200fn merge_manifests(manifests: Vec<StateManifest>) -> StateManifest {
201    let mut by_role = BTreeMap::<String, StateRoleManifest>::new();
202
203    for manifest in manifests {
204        for role in manifest.roles {
205            let entry = by_role
206                .entry(role.canister_role.clone())
207                .or_insert_with(|| StateRoleManifest {
208                    canister_role: role.canister_role.clone(),
209                    state: Vec::new(),
210                    removed_state: Vec::new(),
211                    reserved_memory: Vec::new(),
212                });
213            entry.state.extend(role.state);
214            entry.removed_state.extend(role.removed_state);
215            entry.reserved_memory.extend(role.reserved_memory);
216        }
217    }
218
219    let mut roles = by_role.into_values().collect::<Vec<_>>();
220    sort_roles(&mut roles);
221    StateManifest {
222        schema_version: STATE_MANIFEST_SCHEMA_VERSION,
223        roles,
224    }
225}
226
227fn sort_roles(roles: &mut [StateRoleManifest]) {
228    roles.sort_by(|left, right| left.canister_role.cmp(&right.canister_role));
229    for role in roles {
230        role.state
231            .sort_by(|left, right| left.domain.cmp(&right.domain));
232        role.removed_state
233            .sort_by(|left, right| left.domain.cmp(&right.domain));
234        role.reserved_memory
235            .sort_by_key(|reservation| reservation.memory_id);
236        for domain in &mut role.state {
237            domain
238                .migrations
239                .sort_by_key(|migration| (migration.from, migration.to));
240        }
241    }
242}
243
244#[must_use]
245pub fn build_state_audit_report(role: Option<&str>) -> StateAuditReport {
246    let manifest = declared_state_manifest(role);
247    let mut checks = audit_checks(&manifest, role);
248    sort_checks(&mut checks);
249
250    let status = aggregate_status(&checks);
251    let mut next_actions = next_actions(status, role);
252    next_actions.sort();
253    next_actions.dedup();
254
255    StateAuditReport {
256        schema_version: STATE_AUDIT_SCHEMA_VERSION,
257        command: STATE_AUDIT_COMMAND,
258        scope: if role.is_some() {
259            SCOPE_ROLE
260        } else {
261            SCOPE_PROJECT
262        },
263        role: role.map(ToString::to_string),
264        status,
265        manifest,
266        checks,
267        next_actions,
268    }
269}
270
271fn audit_checks(manifest: &StateManifest, role_filter: Option<&str>) -> Vec<StateAuditCheck> {
272    let mut checks = manifest_schema_checks(manifest);
273
274    if manifest.roles.is_empty() {
275        if let Some(check) = role_filter.map(role_not_found_check) {
276            checks.push(check);
277        }
278        return checks;
279    }
280
281    checks.extend(role_identity_checks(&manifest.roles));
282
283    for role in &manifest.roles {
284        checks.push(pass(
285            CATEGORY_MANIFEST,
286            "state_domain_registered",
287            &role.canister_role,
288            format!(
289                "{} state domain(s) registered for {}",
290                role.state.len(),
291                role.canister_role
292            ),
293        ));
294        checks.extend(domain_identity_checks(&role.canister_role, &role.state));
295        checks.extend(memory_id_checks(
296            &role.canister_role,
297            &role.state,
298            &role.removed_state,
299        ));
300        checks.extend(role_state_checks(&role.canister_role, &role.state));
301        checks.extend(removed_state_checks(
302            &role.canister_role,
303            &role.removed_state,
304        ));
305        checks.extend(reserved_memory_checks(
306            &role.canister_role,
307            &role.state,
308            &role.removed_state,
309            &role.reserved_memory,
310        ));
311    }
312    checks
313}
314
315fn role_identity_checks(roles: &[StateRoleManifest]) -> Vec<StateAuditCheck> {
316    let mut by_role = BTreeMap::<&str, usize>::new();
317    for role in roles {
318        *by_role.entry(&role.canister_role).or_default() += 1;
319    }
320
321    by_role
322        .into_iter()
323        .filter(|&(_, count)| count > 1)
324        .map(|(role, count)| {
325            fail(
326                CATEGORY_MANIFEST,
327                "state_role_duplicate",
328                role,
329                format!("state role {role} is declared {count} times"),
330                "declare each canister role once in the state manifest",
331            )
332        })
333        .collect()
334}
335
336fn role_not_found_check(role: &str) -> StateAuditCheck {
337    fail(
338        CATEGORY_MANIFEST,
339        "state_role_missing",
340        role,
341        format!("no state manifest role named {role} is declared"),
342        "choose a declared role or omit --role to audit all declared roles",
343    )
344}
345
346fn manifest_schema_checks(manifest: &StateManifest) -> Vec<StateAuditCheck> {
347    if manifest.schema_version == STATE_MANIFEST_SCHEMA_VERSION {
348        vec![pass(
349            CATEGORY_SCHEMA_VERSION,
350            "state_manifest_schema_version_supported",
351            "state_manifest",
352            format!(
353                "manifest schema version {} is supported",
354                manifest.schema_version
355            ),
356        )]
357    } else {
358        vec![fail(
359            CATEGORY_SCHEMA_VERSION,
360            "state_manifest_schema_version_unsupported",
361            "state_manifest",
362            format!(
363                "manifest schema version {} is not supported by schema version {}",
364                manifest.schema_version, STATE_MANIFEST_SCHEMA_VERSION
365            ),
366            "regenerate the manifest from current Rust state declarations",
367        )]
368    }
369}
370
371fn domain_identity_checks(role: &str, domains: &[StateDomainManifest]) -> Vec<StateAuditCheck> {
372    let mut by_domain = BTreeMap::<&str, usize>::new();
373    for domain in domains {
374        *by_domain.entry(&domain.domain).or_default() += 1;
375    }
376
377    by_domain
378        .into_iter()
379        .filter(|&(_, count)| count > 1)
380        .map(|(domain, count)| {
381            fail(
382                CATEGORY_MANIFEST,
383                "state_domain_duplicate",
384                &format!("{role}/{domain}"),
385                format!("state domain {domain} is declared {count} times for role {role}"),
386                "declare each state domain once per canister role",
387            )
388        })
389        .collect()
390}
391
392fn memory_id_checks(
393    role: &str,
394    domains: &[StateDomainManifest],
395    removed: &[RemovedStateManifest],
396) -> Vec<StateAuditCheck> {
397    let mut by_id = BTreeMap::<u8, Vec<&str>>::new();
398    for domain in domains
399        .iter()
400        .filter(|domain| domain.storage == StateStorage::StableMemory)
401    {
402        if let Some(memory_id) = domain.memory_id {
403            by_id.entry(memory_id).or_default().push(&domain.domain);
404        }
405    }
406
407    let mut checks = Vec::new();
408    let duplicates = by_id
409        .iter()
410        .filter(|(_, domains)| domains.len() > 1)
411        .collect::<Vec<_>>();
412    if duplicates.is_empty() {
413        checks.push(pass(
414            CATEGORY_MEMORY_ID,
415            "memory_id_unique",
416            role,
417            format!("all stable-memory domains for {role} use unique memory IDs"),
418        ));
419    } else {
420        checks.extend(duplicates.into_iter().map(|(memory_id, domains)| {
421            fail(
422                CATEGORY_MEMORY_ID,
423                "memory_id_duplicate",
424                &format!("{role}/memory_id/{memory_id}"),
425                format!("memory id {memory_id} is used by {}", domains.join(", ")),
426                "assign a unique memory id or add an explicit migration design",
427            )
428        }));
429    }
430
431    checks.extend(removed_memory_id_checks(role, &by_id, removed));
432    checks
433}
434
435fn removed_memory_id_checks(
436    role: &str,
437    active_by_id: &BTreeMap<u8, Vec<&str>>,
438    removed: &[RemovedStateManifest],
439) -> Vec<StateAuditCheck> {
440    removed
441        .iter()
442        .filter_map(|entry| {
443            let memory_id = entry.memory_id?;
444            let subject = format!("{role}/memory_id/{memory_id}");
445            if let Some(active_domains) = active_by_id.get(&memory_id) {
446                Some(fail(
447                    CATEGORY_REMOVED_STATE,
448                    "removed_state_memory_id_reclaimed",
449                    &subject,
450                    format!(
451                        "removed state {} reserved memory id {memory_id}, but active domain(s) {} use it",
452                        entry.domain,
453                        active_domains.join(", ")
454                    ),
455                    "keep retired memory ids reserved or add an explicit migration design",
456                ))
457            } else {
458                Some(pass(
459                    CATEGORY_REMOVED_STATE,
460                    "removed_state_memory_id_reserved",
461                    &subject,
462                    format!(
463                        "removed state {} keeps retired memory id {memory_id} reserved",
464                        entry.domain
465                    ),
466                ))
467            }
468        })
469        .collect()
470}
471
472fn role_state_checks(role: &str, domains: &[StateDomainManifest]) -> Vec<StateAuditCheck> {
473    let mut checks = Vec::new();
474    for domain in domains {
475        checks.extend(schema_checks(role, domain));
476        checks.extend(storage_checks(role, domain));
477        checks.extend(naming_checks(role, domain));
478        checks.extend(export_import_contract_checks(role, domain));
479        checks.extend(migration_checks(role, domain));
480        checks.extend(lifecycle_checks(role, domain));
481    }
482    checks
483}
484
485fn schema_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
486    let subject = domain_subject(role, domain);
487    if domain.version == 0 {
488        vec![fail(
489            CATEGORY_SCHEMA_VERSION,
490            "state_domain_missing_version",
491            &subject,
492            "state domain does not declare a positive schema version".to_string(),
493            "declare the current schema version for this state domain",
494        )]
495    } else {
496        vec![pass(
497            CATEGORY_SCHEMA_VERSION,
498            "state_domain_registered",
499            &subject,
500            format!("declares schema version {}", domain.version),
501        )]
502    }
503}
504
505fn storage_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
506    let subject = domain_subject(role, domain);
507    match (domain.storage, domain.memory_id) {
508        (StateStorage::StableMemory, Some(memory_id)) => vec![pass(
509            CATEGORY_MEMORY_ID,
510            "memory_id_unique",
511            &subject,
512            format!("stable-memory domain declares memory id {memory_id}"),
513        )],
514        (StateStorage::StableMemory, None) => vec![fail(
515            CATEGORY_MEMORY_ID,
516            "state_domain_missing_memory_id",
517            &subject,
518            "stable-memory domain does not declare a memory id".to_string(),
519            "declare the stable-memory id owned by this domain",
520        )],
521        (StateStorage::HeapOnly, None) => vec![pass(
522            CATEGORY_MEMORY_ID,
523            "state_domain_declares_no_stable_memory",
524            &subject,
525            "heap-only domain explicitly declares no stable memory".to_string(),
526        )],
527        (StateStorage::HeapOnly, Some(memory_id)) => vec![warn(
528            CATEGORY_MEMORY_ID,
529            "state_domain_declares_no_stable_memory",
530            &subject,
531            format!("heap-only domain also declares memory id {memory_id}"),
532            "remove the memory id or change storage to stable_memory",
533        )],
534        (StateStorage::NotApplicable, None) => vec![pass(
535            CATEGORY_MEMORY_ID,
536            "state_domain_storage_not_applicable",
537            &subject,
538            "domain explicitly declares storage is not applicable".to_string(),
539        )],
540        (StateStorage::NotApplicable, Some(memory_id)) => vec![warn(
541            CATEGORY_MEMORY_ID,
542            "state_domain_storage_not_applicable",
543            &subject,
544            format!("storage-not-applicable domain also declares memory id {memory_id}"),
545            "remove the memory id or choose a concrete storage substrate",
546        )],
547    }
548}
549
550fn naming_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
551    let subject = domain_subject(role, domain);
552    let record_check = if domain.record.ends_with("Record") {
553        pass(
554            CATEGORY_NAMING,
555            "record_name_valid",
556            &subject,
557            format!(
558                "record type {} follows the Record suffix convention",
559                domain.record
560            ),
561        )
562    } else {
563        warn(
564            CATEGORY_NAMING,
565            "record_name_invalid",
566            &subject,
567            format!("record type {} does not end with Record", domain.record),
568            "rename persisted records to use the Record suffix when safe",
569        )
570    };
571    let snapshot_check = if domain.snapshot.ends_with("Data") {
572        pass(
573            CATEGORY_SNAPSHOT,
574            "snapshot_name_valid",
575            &subject,
576            format!(
577                "snapshot type {} follows the Data suffix convention",
578                domain.snapshot
579            ),
580        )
581    } else {
582        warn(
583            CATEGORY_SNAPSHOT,
584            "snapshot_name_invalid",
585            &subject,
586            format!("snapshot type {} does not end with Data", domain.snapshot),
587            "introduce canonical *Data snapshot types before relying on this domain for migration audits",
588        )
589    };
590
591    vec![record_check, snapshot_check]
592}
593
594fn export_import_contract_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
595    let subject = domain_subject(role, domain);
596    if domain.snapshot.ends_with("Data") {
597        vec![pass(
598            CATEGORY_SNAPSHOT,
599            "reserved_export_import_ok",
600            &subject,
601            format!(
602                "snapshot type {} is a canonical Data shape for export/import boundaries",
603                domain.snapshot
604            ),
605        )]
606    } else {
607        vec![warn(
608            CATEGORY_SNAPSHOT,
609            "reserved_export_import_violation",
610            &subject,
611            format!(
612                "snapshot type {} is not a canonical Data shape for export/import boundaries",
613                domain.snapshot
614            ),
615            "reserve export/import for canonical *Data snapshot shapes",
616        )]
617    }
618}
619
620fn migration_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
621    let subject = domain_subject(role, domain);
622    if domain.min_supported_version == 0 || domain.min_supported_version > domain.version {
623        return vec![fail(
624            CATEGORY_MIGRATION,
625            "state_domain_invalid_support_window",
626            &subject,
627            format!(
628                "min_supported_version {} is not valid for current version {}",
629                domain.min_supported_version, domain.version
630            ),
631            "set min_supported_version to a positive version less than or equal to the current version",
632        )];
633    }
634
635    if domain.min_supported_version == domain.version {
636        return vec![pass(
637            CATEGORY_MIGRATION,
638            "migration_available",
639            &subject,
640            "no older supported schema version requires migration".to_string(),
641        )];
642    }
643
644    match domain.migration_policy {
645        MigrationPolicy::Migrate => {
646            let mut checks = migration_declaration_checks(role, domain);
647            checks.extend(migration_path_checks(role, domain));
648            checks
649        }
650        MigrationPolicy::ManualMigrationRequired => vec![warn(
651            CATEGORY_MIGRATION,
652            "manual_migration_required_declared",
653            &subject,
654            format!(
655                "manual migration is declared for supported versions {} through {}",
656                domain.min_supported_version,
657                domain.version.saturating_sub(1)
658            ),
659            "treat manual migration as a release gate when the old version is in production support",
660        )],
661        MigrationPolicy::DiscardDeclared => vec![pass(
662            CATEGORY_MIGRATION,
663            "migration_unsupported_declared",
664            &subject,
665            "old supported state is declared as discarded by policy".to_string(),
666        )],
667        MigrationPolicy::NotApplicable | MigrationPolicy::NewDomain => vec![fail(
668            CATEGORY_MIGRATION,
669            "migration_missing",
670            &subject,
671            "supported old versions exist but no migration policy can handle them".to_string(),
672            "declare migration, manual migration, discard, or hard-cut min_supported_version",
673        )],
674    }
675}
676
677fn migration_declaration_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
678    let mut checks = Vec::new();
679    let mut by_edge = BTreeMap::<(u32, u32), usize>::new();
680
681    for migration in &domain.migrations {
682        *by_edge.entry((migration.from, migration.to)).or_default() += 1;
683        let expected_next = migration.from.checked_add(1);
684        if migration.from == 0
685            || migration.to == 0
686            || migration.from >= migration.to
687            || expected_next != Some(migration.to)
688            || migration.from < domain.min_supported_version
689            || migration.to > domain.version
690        {
691            let subject = format!(
692                "{}/{domain} v{} -> v{}",
693                role,
694                migration.from,
695                migration.to,
696                domain = domain.domain
697            );
698            checks.push(fail(
699                CATEGORY_MIGRATION,
700                "migration_declaration_invalid",
701                &subject,
702                format!(
703                    "declared migration edge v{} -> v{} is outside the supported window {}..={}",
704                    migration.from, migration.to, domain.min_supported_version, domain.version
705                ),
706                "declare only one-step migrations inside the supported version window",
707            ));
708        }
709    }
710
711    checks.extend(by_edge.into_iter().filter(|&(_, count)| count > 1).map(
712        |((from, to), count)| {
713            let subject = format!("{}/{domain} v{from} -> v{to}", role, domain = domain.domain);
714            fail(
715                CATEGORY_MIGRATION,
716                "migration_declaration_duplicate",
717                &subject,
718                format!("migration edge v{from} -> v{to} is declared {count} times"),
719                "declare each migration edge once",
720            )
721        },
722    ));
723    checks
724}
725
726fn migration_path_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
727    let mut checks = Vec::new();
728    for from in domain.min_supported_version..domain.version {
729        let to = from + 1;
730        let subject = format!("{}/{domain} v{from} -> v{to}", role, domain = domain.domain);
731        match migration_for(domain, from, to) {
732            Some(migration) => checks.push(migration_available_check(&subject, migration)),
733            None => checks.push(fail(
734                CATEGORY_MIGRATION,
735                "migration_missing",
736                &subject,
737                format!("no declared migration covers v{from} -> v{to}"),
738                "declare migration coverage or hard-cut min_supported_version",
739            )),
740        }
741    }
742    checks
743}
744
745fn migration_available_check(subject: &str, migration: &StateMigrationManifest) -> StateAuditCheck {
746    if migration.test.is_some() {
747        pass(
748            CATEGORY_TEST_COVERAGE,
749            "upgrade_test_declared",
750            subject,
751            format!(
752                "migration {} declares upgrade test coverage",
753                migration_label(migration)
754            ),
755        )
756    } else {
757        warn(
758            CATEGORY_TEST_COVERAGE,
759            "upgrade_test_missing",
760            subject,
761            format!(
762                "migration {} has no declared upgrade test",
763                migration_label(migration)
764            ),
765            "declare upgrade test coverage or hard-cut min_supported_version",
766        )
767    }
768}
769
770fn migration_for(
771    domain: &StateDomainManifest,
772    from: u32,
773    to: u32,
774) -> Option<&StateMigrationManifest> {
775    domain
776        .migrations
777        .iter()
778        .find(|migration| migration.from == from && migration.to == to)
779}
780
781fn lifecycle_checks(role: &str, domain: &StateDomainManifest) -> Vec<StateAuditCheck> {
782    let subject = domain_subject(role, domain);
783    let restore_check = if domain.restore_order.is_some() {
784        pass(
785            CATEGORY_LIFECYCLE,
786            "restore_order_declared",
787            &subject,
788            "restore order is declared".to_string(),
789        )
790    } else {
791        warn(
792            CATEGORY_LIFECYCLE,
793            "restore_order_missing",
794            &subject,
795            "restore order is not declared".to_string(),
796            "declare lifecycle restore order before broad upgrade gating",
797        )
798    };
799    let invariant_check = if domain.post_upgrade_invariant.is_some() {
800        pass(
801            CATEGORY_INVARIANT,
802            "post_upgrade_invariant_declared",
803            &subject,
804            "post-upgrade invariant declaration is present".to_string(),
805        )
806    } else {
807        warn(
808            CATEGORY_INVARIANT,
809            "post_upgrade_invariant_missing",
810            &subject,
811            "post-upgrade invariant declaration is missing".to_string(),
812            "declare invariant checks or document why this domain is not applicable",
813        )
814    };
815
816    vec![restore_check, invariant_check]
817}
818
819fn removed_state_checks(role: &str, removed: &[RemovedStateManifest]) -> Vec<StateAuditCheck> {
820    removed
821        .iter()
822        .flat_map(|entry| {
823            let subject = format!("{role}/{}", entry.domain);
824            let mut checks = Vec::new();
825            checks.push(if entry.disposition.trim().is_empty() {
826                fail(
827                    CATEGORY_REMOVED_STATE,
828                    "removed_state_disposition_missing",
829                    &subject,
830                    "removed state does not declare a disposition".to_string(),
831                    "declare whether the state is migrated, discarded, or manually handled",
832                )
833            } else {
834                pass(
835                    CATEGORY_REMOVED_STATE,
836                    "removed_state_disposition_declared",
837                    &subject,
838                    format!("removed state disposition declared: {}", entry.disposition),
839                )
840            });
841            checks.push(if entry.reason.trim().is_empty() {
842                warn(
843                    CATEGORY_REMOVED_STATE,
844                    "removed_state_reason_missing",
845                    &subject,
846                    "removed state disposition does not declare a reason".to_string(),
847                    "document why the removed state can be migrated, discarded, or manually handled",
848                )
849            } else {
850                pass(
851                    CATEGORY_REMOVED_STATE,
852                    "removed_state_reason_declared",
853                    &subject,
854                    format!("removed state reason declared: {}", entry.reason),
855                )
856            });
857            checks.push(
858                if entry.test.as_ref().is_some_and(|test| !test.trim().is_empty()) {
859                    pass(
860                        CATEGORY_TEST_COVERAGE,
861                        "removed_state_test_declared",
862                        &subject,
863                        "removed state declares upgrade test coverage".to_string(),
864                    )
865                } else {
866                    warn(
867                        CATEGORY_TEST_COVERAGE,
868                        "removed_state_test_missing",
869                        &subject,
870                        "removed state has no declared upgrade test coverage".to_string(),
871                        "declare upgrade test coverage for the removed-state disposition",
872                    )
873                },
874            );
875            checks
876        })
877        .collect()
878}
879
880fn reserved_memory_checks(
881    role: &str,
882    domains: &[StateDomainManifest],
883    removed: &[RemovedStateManifest],
884    reserved: &[ReservedMemoryManifest],
885) -> Vec<StateAuditCheck> {
886    let active_by_id = active_memory_ids(domains);
887    let removed_by_id = removed_memory_ids(removed);
888    let mut reserved_by_id = BTreeMap::<u8, Vec<&str>>::new();
889    for entry in reserved {
890        reserved_by_id
891            .entry(entry.memory_id)
892            .or_default()
893            .push(&entry.label);
894    }
895
896    let mut checks = Vec::new();
897    for entry in reserved {
898        let subject = format!("{role}/memory_id/{}", entry.memory_id);
899        if let Some(active_domains) = active_by_id.get(&entry.memory_id) {
900            checks.push(fail(
901                CATEGORY_MEMORY_ID,
902                "reserved_memory_id_collision",
903                &subject,
904                format!(
905                    "reserved memory id {} for {} is used by active domain(s) {}",
906                    entry.memory_id,
907                    entry.label,
908                    active_domains.join(", ")
909                ),
910                "declare one owner for the memory id or add an explicit migration design",
911            ));
912        } else if let Some(removed_domains) = removed_by_id.get(&entry.memory_id) {
913            checks.push(fail(
914                CATEGORY_MEMORY_ID,
915                "reserved_memory_id_collision",
916                &subject,
917                format!(
918                    "reserved memory id {} for {} is already declared by removed state {}",
919                    entry.memory_id,
920                    entry.label,
921                    removed_domains.join(", ")
922                ),
923                "keep the memory id in exactly one manifest section",
924            ));
925        } else if reserved_by_id
926            .get(&entry.memory_id)
927            .is_some_and(|labels| labels.len() > 1)
928        {
929            checks.push(fail(
930                CATEGORY_MEMORY_ID,
931                "reserved_memory_id_duplicate",
932                &subject,
933                format!(
934                    "reserved memory id {} is listed for {}",
935                    entry.memory_id,
936                    reserved_by_id
937                        .get(&entry.memory_id)
938                        .map(|labels| labels.join(", "))
939                        .unwrap_or_default()
940                ),
941                "reserve each memory id once",
942            ));
943        } else {
944            checks.push(warn(
945                CATEGORY_MEMORY_ID,
946                "reserved_memory_id_declared",
947                &subject,
948                format!(
949                    "memory id {} is reserved for {} but is not yet modeled as an active or removed state domain",
950                    entry.memory_id, entry.label
951                ),
952                "model this reservation as a precise state domain or removed-state disposition when the state shape is known",
953            ));
954        }
955    }
956    checks
957}
958
959fn active_memory_ids(domains: &[StateDomainManifest]) -> BTreeMap<u8, Vec<&str>> {
960    let mut by_id = BTreeMap::<u8, Vec<&str>>::new();
961    for domain in domains
962        .iter()
963        .filter(|domain| domain.storage == StateStorage::StableMemory)
964    {
965        if let Some(memory_id) = domain.memory_id {
966            by_id.entry(memory_id).or_default().push(&domain.domain);
967        }
968    }
969    by_id
970}
971
972fn removed_memory_ids(removed: &[RemovedStateManifest]) -> BTreeMap<u8, Vec<&str>> {
973    let mut by_id = BTreeMap::<u8, Vec<&str>>::new();
974    for entry in removed {
975        if let Some(memory_id) = entry.memory_id {
976            by_id.entry(memory_id).or_default().push(&entry.domain);
977        }
978    }
979    by_id
980}
981
982fn domain_subject(role: &str, domain: &StateDomainManifest) -> String {
983    format!("{role}/{}", domain.domain)
984}
985
986fn migration_label(migration: &StateMigrationManifest) -> String {
987    migration
988        .name
989        .clone()
990        .unwrap_or_else(|| format!("{}->{}", migration.from, migration.to))
991}
992
993fn pass(
994    category: StateAuditCategory,
995    code: &'static str,
996    subject: &str,
997    detail: String,
998) -> StateAuditCheck {
999    StateAuditCheck {
1000        category,
1001        code,
1002        status: StateAuditStatus::Pass,
1003        severity: StateAuditSeverity::Info,
1004        subject: subject.to_string(),
1005        detail,
1006        next: None,
1007        source: SOURCE_STATE_MANIFEST,
1008    }
1009}
1010
1011fn warn(
1012    category: StateAuditCategory,
1013    code: &'static str,
1014    subject: &str,
1015    detail: String,
1016    next: &str,
1017) -> StateAuditCheck {
1018    StateAuditCheck {
1019        category,
1020        code,
1021        status: StateAuditStatus::Warn,
1022        severity: StateAuditSeverity::Warning,
1023        subject: subject.to_string(),
1024        detail,
1025        next: Some(next.to_string()),
1026        source: SOURCE_STATE_MANIFEST,
1027    }
1028}
1029
1030fn fail(
1031    category: StateAuditCategory,
1032    code: &'static str,
1033    subject: &str,
1034    detail: String,
1035    next: &str,
1036) -> StateAuditCheck {
1037    StateAuditCheck {
1038        category,
1039        code,
1040        status: StateAuditStatus::Fail,
1041        severity: StateAuditSeverity::Blocked,
1042        subject: subject.to_string(),
1043        detail,
1044        next: Some(next.to_string()),
1045        source: SOURCE_STATE_MANIFEST,
1046    }
1047}
1048
1049fn aggregate_status(checks: &[StateAuditCheck]) -> StateAuditStatus {
1050    if checks.is_empty() {
1051        return StateAuditStatus::NotEvaluated;
1052    }
1053    if checks
1054        .iter()
1055        .any(|check| check.status == StateAuditStatus::Fail)
1056    {
1057        return StateAuditStatus::Fail;
1058    }
1059    if checks
1060        .iter()
1061        .any(|check| check.status == StateAuditStatus::Warn)
1062    {
1063        return StateAuditStatus::Warn;
1064    }
1065    StateAuditStatus::Pass
1066}
1067
1068fn next_actions(status: StateAuditStatus, role: Option<&str>) -> Vec<String> {
1069    let scope = role.unwrap_or("project");
1070    match status {
1071        StateAuditStatus::Pass => vec![format!(
1072            "state metadata declarations for {scope} have no blocking findings"
1073        )],
1074        StateAuditStatus::Warn => vec![format!(
1075            "review warning checks before using {scope} state metadata as an upgrade gate"
1076        )],
1077        StateAuditStatus::Fail => vec![format!(
1078            "fix failing state metadata checks before upgrade or release gating for {scope}"
1079        )],
1080        StateAuditStatus::NotEvaluated => vec![format!(
1081            "declare state metadata before auditing {scope} upgrade safety"
1082        )],
1083    }
1084}
1085
1086fn sort_checks(checks: &mut [StateAuditCheck]) {
1087    checks.sort_by(|left, right| {
1088        (
1089            status_rank(left.status),
1090            left.category,
1091            left.code,
1092            left.subject.as_str(),
1093        )
1094            .cmp(&(
1095                status_rank(right.status),
1096                right.category,
1097                right.code,
1098                right.subject.as_str(),
1099            ))
1100    });
1101}
1102
1103const fn status_rank(status: StateAuditStatus) -> u8 {
1104    match status {
1105        StateAuditStatus::Fail => 0,
1106        StateAuditStatus::Warn => 1,
1107        StateAuditStatus::NotEvaluated => 2,
1108        StateAuditStatus::Pass => 3,
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use canic_core::state_contract::{MigrationPolicy, StateRoleManifest};
1116
1117    #[test]
1118    fn builtin_report_is_warning_only_for_reserved_memory() {
1119        let report = build_state_audit_report(Some("root"));
1120
1121        assert_eq!(report.status, StateAuditStatus::Warn);
1122        assert!(report.checks.iter().any(|check| {
1123            check.code == "state_manifest_schema_version_supported"
1124                && check.status == StateAuditStatus::Pass
1125        }));
1126        assert!(
1127            report
1128                .checks
1129                .iter()
1130                .any(|check| check.code == "reserved_memory_id_declared")
1131        );
1132        assert!(
1133            report
1134                .checks
1135                .iter()
1136                .all(|check| check.code != "snapshot_name_invalid")
1137        );
1138        assert!(
1139            report
1140                .checks
1141                .iter()
1142                .any(|check| check.code == "reserved_export_import_ok")
1143        );
1144        assert!(
1145            report
1146                .checks
1147                .iter()
1148                .all(|check| check.status != StateAuditStatus::Fail)
1149        );
1150    }
1151
1152    #[test]
1153    fn builtin_manifest_merges_control_plane_state_by_role() {
1154        let manifest = declared_state_manifest(Some("root"));
1155        let role = manifest.roles.first().expect("root role");
1156
1157        assert!(role.state.iter().any(|domain| {
1158            domain.domain == "template_manifests"
1159                && domain.owner == "canic-control-plane"
1160                && domain.memory_id == Some(80)
1161        }));
1162        assert!(
1163            role.state
1164                .iter()
1165                .any(|domain| { domain.domain == "auth_state" && domain.owner == "canic-core" })
1166        );
1167    }
1168
1169    #[test]
1170    fn wasm_store_role_audits_cleanly() {
1171        let report = build_state_audit_report(Some("wasm_store"));
1172
1173        assert_eq!(report.status, StateAuditStatus::Pass);
1174        assert!(report.manifest.roles.iter().any(|role| {
1175            role.canister_role == "wasm_store"
1176                && role
1177                    .state
1178                    .iter()
1179                    .any(|domain| domain.domain == "wasm_store_gc_state")
1180        }));
1181    }
1182
1183    #[test]
1184    fn unsupported_manifest_schema_version_fails() {
1185        let mut manifest = declared_state_manifest(Some("root"));
1186        manifest.schema_version = STATE_MANIFEST_SCHEMA_VERSION + 1;
1187
1188        let checks = audit_checks(&manifest, Some("root"));
1189
1190        assert!(checks.iter().any(|check| {
1191            check.code == "state_manifest_schema_version_unsupported"
1192                && check.status == StateAuditStatus::Fail
1193        }));
1194    }
1195
1196    #[test]
1197    fn duplicate_state_role_fails() {
1198        let mut manifest = declared_state_manifest(Some("root"));
1199        let duplicate = manifest.roles[0].clone();
1200        manifest.roles.push(duplicate);
1201
1202        let checks = audit_checks(&manifest, None);
1203
1204        assert!(checks.iter().any(|check| {
1205            check.code == "state_role_duplicate" && check.status == StateAuditStatus::Fail
1206        }));
1207    }
1208
1209    #[test]
1210    fn duplicate_memory_id_fails_within_role() {
1211        let mut manifest = declared_state_manifest(Some("root"));
1212        let role = manifest.roles.first_mut().expect("root role");
1213        role.state[1].memory_id = role.state[0].memory_id;
1214
1215        let checks = audit_checks(&manifest, Some("root"));
1216        assert!(
1217            checks
1218                .iter()
1219                .any(|check| check.code == "memory_id_duplicate"
1220                    && check.status == StateAuditStatus::Fail)
1221        );
1222    }
1223
1224    #[test]
1225    fn duplicate_state_domain_fails_within_role() {
1226        let mut manifest = declared_state_manifest(Some("root"));
1227        let role = manifest.roles.first_mut().expect("root role");
1228        let mut duplicate = role.state[0].clone();
1229        duplicate.memory_id = Some(250);
1230        role.state.push(duplicate);
1231
1232        let checks = audit_checks(&manifest, Some("root"));
1233
1234        assert!(
1235            checks
1236                .iter()
1237                .any(|check| check.code == "state_domain_duplicate"
1238                    && check.status == StateAuditStatus::Fail)
1239        );
1240    }
1241
1242    #[test]
1243    fn active_domain_reclaiming_removed_memory_id_fails() {
1244        let mut manifest = declared_state_manifest(Some("root"));
1245        let role = manifest.roles.first_mut().expect("root role");
1246        let retired_id = role
1247            .removed_state
1248            .first()
1249            .and_then(|entry| entry.memory_id)
1250            .expect("retired memory id");
1251        role.state[0].memory_id = Some(retired_id);
1252
1253        let checks = audit_checks(&manifest, Some("root"));
1254
1255        assert!(
1256            checks
1257                .iter()
1258                .any(|check| check.code == "removed_state_memory_id_reclaimed"
1259                    && check.status == StateAuditStatus::Fail)
1260        );
1261    }
1262
1263    #[test]
1264    fn reserved_memory_ids_warn_until_modeled() {
1265        let report = build_state_audit_report(Some("root"));
1266
1267        assert!(report.checks.iter().any(|check| {
1268            check.code == "reserved_memory_id_declared" && check.status == StateAuditStatus::Warn
1269        }));
1270    }
1271
1272    #[test]
1273    fn active_domain_reclaiming_reserved_memory_id_fails() {
1274        let mut manifest = declared_state_manifest(Some("root"));
1275        let role = manifest.roles.first_mut().expect("root role");
1276        let reserved_id = role
1277            .reserved_memory
1278            .first()
1279            .map(|entry| entry.memory_id)
1280            .expect("reserved memory id");
1281        role.state[0].memory_id = Some(reserved_id);
1282
1283        let checks = audit_checks(&manifest, Some("root"));
1284
1285        assert!(
1286            checks
1287                .iter()
1288                .any(|check| check.code == "reserved_memory_id_collision"
1289                    && check.status == StateAuditStatus::Fail)
1290        );
1291    }
1292
1293    #[test]
1294    fn storage_not_applicable_is_explicit_metadata() {
1295        let manifest = StateManifest {
1296            schema_version: 1,
1297            roles: vec![StateRoleManifest {
1298                canister_role: "root".to_string(),
1299                state: vec![StateDomainManifest {
1300                    domain: "external_authority".to_string(),
1301                    version: 1,
1302                    storage: StateStorage::NotApplicable,
1303                    memory_id: None,
1304                    owner: "canic-core".to_string(),
1305                    record: "ExternalAuthorityRecord".to_string(),
1306                    snapshot: "ExternalAuthorityData".to_string(),
1307                    min_supported_version: 1,
1308                    migration_policy: MigrationPolicy::NotApplicable,
1309                    restore_order: Some(10),
1310                    post_upgrade_invariant: Some("external_authority_invariants".to_string()),
1311                    migrations: Vec::new(),
1312                }],
1313                removed_state: Vec::new(),
1314                reserved_memory: Vec::new(),
1315            }],
1316        };
1317
1318        let checks = audit_checks(&manifest, None);
1319
1320        assert!(checks.iter().any(|check| {
1321            check.code == "state_domain_storage_not_applicable"
1322                && check.status == StateAuditStatus::Pass
1323        }));
1324        assert!(
1325            checks
1326                .iter()
1327                .all(|check| check.code != "state_domain_missing_memory_id")
1328        );
1329    }
1330
1331    #[test]
1332    fn invalid_support_window_fails() {
1333        let manifest = StateManifest {
1334            schema_version: 1,
1335            roles: vec![StateRoleManifest {
1336                canister_role: "root".to_string(),
1337                state: vec![StateDomainManifest {
1338                    domain: "auth_sessions".to_string(),
1339                    version: 2,
1340                    storage: StateStorage::StableMemory,
1341                    memory_id: Some(19),
1342                    owner: "canic-core".to_string(),
1343                    record: "AuthSessionRecord".to_string(),
1344                    snapshot: "AuthSessionsData".to_string(),
1345                    min_supported_version: 3,
1346                    migration_policy: MigrationPolicy::NewDomain,
1347                    restore_order: Some(10),
1348                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1349                    migrations: Vec::new(),
1350                }],
1351                removed_state: Vec::new(),
1352                reserved_memory: Vec::new(),
1353            }],
1354        };
1355
1356        let checks = audit_checks(&manifest, None);
1357
1358        assert!(checks.iter().any(|check| {
1359            check.code == "state_domain_invalid_support_window"
1360                && check.status == StateAuditStatus::Fail
1361        }));
1362        assert!(
1363            checks
1364                .iter()
1365                .all(|check| check.code != "migration_available")
1366        );
1367    }
1368
1369    #[test]
1370    fn duplicate_migration_declaration_fails() {
1371        let manifest = StateManifest {
1372            schema_version: 1,
1373            roles: vec![StateRoleManifest {
1374                canister_role: "root".to_string(),
1375                state: vec![StateDomainManifest {
1376                    domain: "auth_sessions".to_string(),
1377                    version: 3,
1378                    storage: StateStorage::StableMemory,
1379                    memory_id: Some(19),
1380                    owner: "canic-core".to_string(),
1381                    record: "AuthSessionRecord".to_string(),
1382                    snapshot: "AuthSessionsData".to_string(),
1383                    min_supported_version: 2,
1384                    migration_policy: MigrationPolicy::Migrate,
1385                    restore_order: Some(10),
1386                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1387                    migrations: vec![
1388                        StateMigrationManifest {
1389                            from: 2,
1390                            to: 3,
1391                            kind: "function".to_string(),
1392                            name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
1393                            test: Some(
1394                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
1395                            ),
1396                        },
1397                        StateMigrationManifest {
1398                            from: 2,
1399                            to: 3,
1400                            kind: "function".to_string(),
1401                            name: Some("migrate_auth_sessions_v2_to_v3_again".to_string()),
1402                            test: Some(
1403                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
1404                            ),
1405                        },
1406                    ],
1407                }],
1408                removed_state: Vec::new(),
1409                reserved_memory: Vec::new(),
1410            }],
1411        };
1412
1413        let checks = audit_checks(&manifest, None);
1414
1415        assert!(checks.iter().any(|check| {
1416            check.code == "migration_declaration_duplicate"
1417                && check.status == StateAuditStatus::Fail
1418        }));
1419    }
1420
1421    #[test]
1422    fn invalid_migration_declaration_fails() {
1423        let manifest = StateManifest {
1424            schema_version: 1,
1425            roles: vec![StateRoleManifest {
1426                canister_role: "root".to_string(),
1427                state: vec![StateDomainManifest {
1428                    domain: "auth_sessions".to_string(),
1429                    version: 4,
1430                    storage: StateStorage::StableMemory,
1431                    memory_id: Some(19),
1432                    owner: "canic-core".to_string(),
1433                    record: "AuthSessionRecord".to_string(),
1434                    snapshot: "AuthSessionsData".to_string(),
1435                    min_supported_version: 2,
1436                    migration_policy: MigrationPolicy::Migrate,
1437                    restore_order: Some(10),
1438                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1439                    migrations: vec![StateMigrationManifest {
1440                        from: 2,
1441                        to: 4,
1442                        kind: "function".to_string(),
1443                        name: Some("migrate_auth_sessions_v2_to_v4".to_string()),
1444                        test: Some("auth_sessions_v2_to_v4_upgrade_preserves_sessions".to_string()),
1445                    }],
1446                }],
1447                removed_state: Vec::new(),
1448                reserved_memory: Vec::new(),
1449            }],
1450        };
1451
1452        let checks = audit_checks(&manifest, None);
1453
1454        assert!(checks.iter().any(|check| {
1455            check.code == "migration_declaration_invalid" && check.status == StateAuditStatus::Fail
1456        }));
1457    }
1458
1459    #[test]
1460    fn missing_migration_test_warns_separately_from_missing_migration() {
1461        let manifest = StateManifest {
1462            schema_version: 1,
1463            roles: vec![StateRoleManifest {
1464                canister_role: "root".to_string(),
1465                state: vec![StateDomainManifest {
1466                    domain: "auth_sessions".to_string(),
1467                    version: 3,
1468                    storage: StateStorage::StableMemory,
1469                    memory_id: Some(19),
1470                    owner: "canic-core".to_string(),
1471                    record: "AuthSessionRecord".to_string(),
1472                    snapshot: "AuthSessionsData".to_string(),
1473                    min_supported_version: 2,
1474                    migration_policy: MigrationPolicy::Migrate,
1475                    restore_order: Some(10),
1476                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
1477                    migrations: vec![StateMigrationManifest {
1478                        from: 2,
1479                        to: 3,
1480                        kind: "function".to_string(),
1481                        name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
1482                        test: None,
1483                    }],
1484                }],
1485                removed_state: Vec::new(),
1486                reserved_memory: Vec::new(),
1487            }],
1488        };
1489        let checks = audit_checks(&manifest, None);
1490
1491        assert!(
1492            checks
1493                .iter()
1494                .any(|check| check.code == "upgrade_test_missing"
1495                    && check.status == StateAuditStatus::Warn)
1496        );
1497        assert!(checks.iter().all(|check| check.code != "migration_missing"));
1498    }
1499
1500    #[test]
1501    fn removed_state_missing_reason_and_test_warn() {
1502        let manifest = StateManifest {
1503            schema_version: 1,
1504            roles: vec![StateRoleManifest {
1505                canister_role: "root".to_string(),
1506                state: Vec::new(),
1507                removed_state: vec![RemovedStateManifest {
1508                    domain: "legacy_cache".to_string(),
1509                    last_version: 1,
1510                    removed_in_version: 2,
1511                    memory_id: Some(99),
1512                    disposition: "discarded".to_string(),
1513                    reason: String::new(),
1514                    test: None,
1515                }],
1516                reserved_memory: Vec::new(),
1517            }],
1518        };
1519
1520        let checks = audit_checks(&manifest, None);
1521
1522        assert!(checks.iter().any(|check| {
1523            check.code == "removed_state_reason_missing" && check.status == StateAuditStatus::Warn
1524        }));
1525        assert!(checks.iter().any(|check| {
1526            check.code == "removed_state_test_missing" && check.status == StateAuditStatus::Warn
1527        }));
1528    }
1529
1530    #[test]
1531    fn unknown_filtered_role_fails() {
1532        let report = build_state_audit_report(Some("missing"));
1533
1534        assert_eq!(report.status, StateAuditStatus::Fail);
1535        assert_eq!(report.checks[0].code, "state_role_missing");
1536    }
1537}