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