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
10mod aggregation;
11mod audit;
12mod resolution;
13
14pub use resolution::{StateManifestResolution, resolve_project_state_manifest};
15
16use canic_core::state_contract::{STATE_MANIFEST_SCHEMA_VERSION, StateManifest};
17use serde::Serialize;
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 = 2;
22
23const SCOPE_PROJECT: StateAuditScope = StateAuditScope::Project;
24const SCOPE_ROLE: StateAuditScope = StateAuditScope::Role;
25
26///
27/// StateAuditReport
28///
29/// Diagnostic report for declared state domains.
30///
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
33pub struct StateAuditReport {
34    pub schema_version: u16,
35    pub command: &'static str,
36    pub scope: StateAuditScope,
37    pub role: Option<String>,
38    pub status: StateAuditStatus,
39    pub manifest: StateManifest,
40    pub checks: Vec<StateAuditCheck>,
41    pub next_actions: Vec<String>,
42}
43
44///
45/// StateAuditCheck
46///
47/// Stable check row emitted by `canic state audit`.
48///
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
51pub struct StateAuditCheck {
52    pub category: StateAuditCategory,
53    pub code: &'static str,
54    pub status: StateAuditStatus,
55    pub severity: StateAuditSeverity,
56    pub subject: String,
57    pub detail: String,
58    pub next: Option<String>,
59    pub source: StateAuditSource,
60}
61
62///
63/// StateAuditScope
64///
65/// Stable audit scope emitted by `canic state audit`.
66///
67
68#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum StateAuditScope {
71    Project,
72    Role,
73}
74
75impl StateAuditScope {
76    #[must_use]
77    pub const fn label(self) -> &'static str {
78        match self {
79            Self::Project => "project",
80            Self::Role => "role",
81        }
82    }
83}
84
85///
86/// StateAuditCategory
87///
88/// Stable category label emitted by state-audit checks.
89///
90
91#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum StateAuditCategory {
94    Invariant,
95    Lifecycle,
96    Manifest,
97    MemoryId,
98    Migration,
99    Naming,
100    SchemaVersion,
101    Snapshot,
102    TestCoverage,
103}
104
105impl StateAuditCategory {
106    #[must_use]
107    pub const fn label(self) -> &'static str {
108        match self {
109            Self::Invariant => "invariant",
110            Self::Lifecycle => "lifecycle",
111            Self::Manifest => "manifest",
112            Self::MemoryId => "memory_id",
113            Self::Migration => "migration",
114            Self::Naming => "naming",
115            Self::SchemaVersion => "schema_version",
116            Self::Snapshot => "snapshot",
117            Self::TestCoverage => "test_coverage",
118        }
119    }
120}
121
122///
123/// StateAuditSource
124///
125/// Stable source-attribution label emitted by state-audit checks.
126///
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
129#[serde(rename_all = "snake_case")]
130pub enum StateAuditSource {
131    StateManifest,
132}
133
134impl StateAuditSource {
135    #[must_use]
136    pub const fn label(self) -> &'static str {
137        match self {
138            Self::StateManifest => "state_manifest",
139        }
140    }
141}
142
143///
144/// StateAuditStatus
145///
146/// Stable audit status for reports and checks.
147///
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum StateAuditStatus {
152    Pass,
153    Warn,
154    Fail,
155    NotEvaluated,
156}
157
158impl StateAuditStatus {
159    #[must_use]
160    pub const fn label(self) -> &'static str {
161        match self {
162            Self::Pass => "pass",
163            Self::Warn => "warn",
164            Self::Fail => "fail",
165            Self::NotEvaluated => "not_evaluated",
166        }
167    }
168}
169
170///
171/// StateAuditSeverity
172///
173/// Stable severity framing for audit checks.
174///
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
177#[serde(rename_all = "snake_case")]
178pub enum StateAuditSeverity {
179    Info,
180    Warning,
181    Blocked,
182    Unsupported,
183}
184
185#[must_use]
186pub fn build_state_audit_report(
187    resolution: &StateManifestResolution,
188    role: Option<&str>,
189) -> StateAuditReport {
190    let (manifest, contract_errors) = match resolution {
191        StateManifestResolution::Resolved { manifest, .. } => (manifest.clone(), Vec::new()),
192        StateManifestResolution::Rejected { errors } => (
193            StateManifest {
194                schema_version: STATE_MANIFEST_SCHEMA_VERSION,
195                roles: Vec::new(),
196            },
197            errors.clone(),
198        ),
199    };
200    let mut checks = audit::audit_checks(
201        &manifest,
202        if contract_errors.is_empty() {
203            role
204        } else {
205            None
206        },
207    );
208    checks.extend(contract_errors.iter().map(audit::role_contract_check));
209    aggregation::sort_checks(&mut checks);
210
211    let status = aggregation::aggregate_status(&checks);
212    let mut next_actions = aggregation::next_actions(status, role);
213    next_actions.sort();
214    next_actions.dedup();
215
216    StateAuditReport {
217        schema_version: STATE_AUDIT_SCHEMA_VERSION,
218        command: STATE_AUDIT_COMMAND,
219        scope: if role.is_some() {
220            SCOPE_ROLE
221        } else {
222            SCOPE_PROJECT
223        },
224        role: role.map(ToString::to_string),
225        status,
226        manifest,
227        checks,
228        next_actions,
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::role_contract::materialize_state_manifest;
236    use canic_core::{
237        ids::CanisterRole,
238        role_contract::{
239            AllocationOwner, BuiltInRoleKind, CanicFeatureKey, ResolvedRoleContract,
240            ResolvedStateAllocation, RoleContractFinding, SelectionProvenance, StateAllocationKey,
241            allocation::allocation_definition,
242        },
243        state_contract::{
244            MigrationPolicy, StateDomainManifest, StateMigrationManifest, StateRoleManifest,
245            StateStorage,
246        },
247    };
248    use std::{collections::BTreeSet, path::PathBuf};
249
250    use super::audit::audit_checks;
251
252    fn test_state_manifest(role: Option<&str>) -> StateManifest {
253        let contracts = match role {
254            Some("root") | None => vec![test_contract(
255                "root",
256                None,
257                &[
258                    StateAllocationKey::CoreRuntimeTopology,
259                    StateAllocationKey::CoreRootAppRegistry,
260                    StateAllocationKey::CoreRuntimeEnvironment,
261                    StateAllocationKey::CoreAuthState,
262                    StateAllocationKey::CoreReplayReceipts,
263                    StateAllocationKey::CoreRuntimeObservability,
264                    StateAllocationKey::CoreRuntimeIntent,
265                    StateAllocationKey::CanisterPool,
266                    StateAllocationKey::TemplateManifests,
267                    StateAllocationKey::TemplateChunkSets,
268                    StateAllocationKey::TemplateChunkRefs,
269                    StateAllocationKey::TemplateChunkPayloads,
270                    StateAllocationKey::ControlPlaneSubnetState,
271                ],
272            )],
273            Some("wasm_store") => vec![test_contract(
274                "wasm_store",
275                Some(BuiltInRoleKind::WasmStore),
276                &[
277                    StateAllocationKey::TemplateManifests,
278                    StateAllocationKey::TemplateChunkSets,
279                    StateAllocationKey::TemplateChunkRefs,
280                    StateAllocationKey::TemplateChunkPayloads,
281                    StateAllocationKey::WasmStoreGcState,
282                ],
283            )],
284            Some(_) => Vec::new(),
285        };
286        materialize_state_manifest(&contracts).expect("test manifest")
287    }
288
289    fn test_contract(
290        role: &str,
291        built_in: Option<BuiltInRoleKind>,
292        keys: &[StateAllocationKey],
293    ) -> ResolvedRoleContract {
294        let allocations = keys
295            .iter()
296            .map(|key| {
297                let definition = allocation_definition(*key).expect("allocation definition");
298                ResolvedStateAllocation {
299                    key: *key,
300                    owner: definition.owner,
301                    memory_ids: definition.memory_ids.to_vec(),
302                    selected_by: BTreeSet::from([if let Some(built_in) = built_in {
303                        SelectionProvenance::BuiltInRole(built_in)
304                    } else if definition.owner == AllocationOwner::CanicControlPlane {
305                        SelectionProvenance::EffectiveFeature(CanicFeatureKey::ControlPlane)
306                    } else {
307                        SelectionProvenance::Capability(
308                            canic_core::role_contract::RoleCapabilityKey::Root,
309                        )
310                    }]),
311                }
312            })
313            .collect();
314        ResolvedRoleContract {
315            role: CanisterRole::owned(role.to_string()),
316            built_in,
317            capabilities: BTreeSet::new(),
318            required_features: BTreeSet::new(),
319            effective_features: BTreeSet::new(),
320            allocations,
321        }
322    }
323
324    fn build_state_audit_report(role: Option<&str>) -> StateAuditReport {
325        let resolution = StateManifestResolution::Resolved {
326            manifest: test_state_manifest(role),
327            contracts: Vec::new(),
328        };
329        super::build_state_audit_report(&resolution, role)
330    }
331
332    #[test]
333    fn state_audit_status_owns_serialized_labels() {
334        assert_eq!(StateAuditStatus::Pass.label(), "pass");
335        assert_eq!(StateAuditStatus::Warn.label(), "warn");
336        assert_eq!(StateAuditStatus::Fail.label(), "fail");
337        assert_eq!(StateAuditStatus::NotEvaluated.label(), "not_evaluated");
338    }
339
340    #[test]
341    fn builtin_report_is_warning_only_for_reserved_memory() {
342        let report = build_state_audit_report(Some("root"));
343
344        assert_eq!(report.status, StateAuditStatus::Warn);
345        assert!(report.checks.iter().any(|check| {
346            check.code == "state_manifest_schema_version_supported"
347                && check.status == StateAuditStatus::Pass
348        }));
349        assert!(
350            report
351                .checks
352                .iter()
353                .any(|check| check.code == "reserved_memory_id_declared")
354        );
355        assert!(
356            report
357                .checks
358                .iter()
359                .all(|check| check.code != "snapshot_name_invalid")
360        );
361        assert!(
362            report
363                .checks
364                .iter()
365                .any(|check| check.code == "reserved_export_import_ok")
366        );
367        assert!(
368            report
369                .checks
370                .iter()
371                .all(|check| check.status != StateAuditStatus::Fail)
372        );
373    }
374
375    #[test]
376    fn builtin_manifest_merges_control_plane_state_by_role() {
377        let manifest = test_state_manifest(Some("root"));
378        let role = manifest.roles.first().expect("root role");
379
380        assert!(role.state.iter().any(|domain| {
381            domain.domain == "template_manifests"
382                && domain.owner == "canic-control-plane"
383                && domain.memory_id == Some(80)
384        }));
385        assert!(
386            role.state
387                .iter()
388                .any(|domain| { domain.domain == "auth_state" && domain.owner == "canic-core" })
389        );
390    }
391
392    #[test]
393    fn wasm_store_role_audits_every_owned_memory_domain_cleanly() {
394        let report = build_state_audit_report(Some("wasm_store"));
395
396        assert_eq!(report.status, StateAuditStatus::Pass);
397        let role = report
398            .manifest
399            .roles
400            .iter()
401            .find(|role| role.canister_role == "wasm_store")
402            .expect("wasm_store role");
403        let domains = role
404            .state
405            .iter()
406            .map(|domain| domain.domain.as_str())
407            .collect::<Vec<_>>();
408
409        for expected in [
410            "template_manifests",
411            "template_chunk_sets",
412            "template_chunk_refs",
413            "template_chunk_payloads",
414            "wasm_store_gc_state",
415        ] {
416            assert!(domains.contains(&expected));
417        }
418        assert_eq!(domains.len(), 5);
419    }
420
421    #[test]
422    fn complete_descriptor_registry_satisfies_state_audit_metadata_contract() {
423        let keys = canic_core::role_contract::allocation::allocation_definitions()
424            .iter()
425            .map(|definition| definition.key)
426            .collect::<Vec<_>>();
427        let manifest = materialize_state_manifest(&[test_contract("catalog", None, &keys)])
428            .expect("complete descriptor manifest");
429        let checks = audit_checks(&manifest, Some("catalog"));
430
431        assert!(
432            checks
433                .iter()
434                .all(|check| check.status != StateAuditStatus::Fail),
435            "complete descriptor registry must satisfy audit metadata: {checks:#?}"
436        );
437    }
438
439    #[test]
440    fn exact_blob_role_resolution_materializes_blob_allocations() {
441        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
442        let config = workspace.join("canisters/test/blob_storage_probe/canic.toml");
443        let resolution = resolve_project_state_manifest(&workspace, &[config], Some("test"));
444        let StateManifestResolution::Resolved {
445            manifest,
446            contracts,
447        } = resolution
448        else {
449            panic!("blob role contract should resolve")
450        };
451
452        assert_eq!(contracts.len(), 1);
453        let role = manifest.roles.first().expect("blob role manifest");
454        assert_eq!(role.canister_role, "test");
455        assert_eq!(
456            role.state
457                .iter()
458                .filter_map(|domain| domain.memory_id)
459                .filter(|memory_id| (62..=65).contains(memory_id))
460                .collect::<Vec<_>>(),
461            vec![63, 65, 64, 62]
462        );
463        assert!(role.state.iter().all(|domain| domain.owner == "canic-core"));
464    }
465
466    #[test]
467    fn placement_roles_materialize_exact_placement_state() {
468        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
469
470        for (config_path, role, expected_ids) in [
471            ("fleets/test/canic.toml", "user_hub", vec![49, 53, 54, 56]),
472            (
473                "canisters/audit/scaling_probe/canic.toml",
474                "scale_hub",
475                vec![49, 52],
476            ),
477            (
478                "canisters/test/project_hub_stub/canic.toml",
479                "project_hub",
480                vec![49, 55],
481            ),
482        ] {
483            let config = workspace.join(config_path);
484            let resolution = resolve_project_state_manifest(&workspace, &[config], Some(role));
485            let StateManifestResolution::Resolved { manifest, .. } = resolution else {
486                panic!("{role} role contract should resolve");
487            };
488            let mut actual_ids = manifest.roles[0]
489                .state
490                .iter()
491                .filter_map(|domain| domain.memory_id)
492                .filter(|memory_id| (49..=56).contains(memory_id))
493                .collect::<Vec<_>>();
494            actual_ids.sort_unstable();
495
496            assert_eq!(actual_ids, expected_ids, "unexpected state for {role}");
497        }
498    }
499
500    #[test]
501    fn exact_built_in_resolution_materializes_runtime_template_and_gc_allocations() {
502        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
503        let resolution = resolve_project_state_manifest(&workspace, &[], Some("wasm_store"));
504        let StateManifestResolution::Resolved {
505            manifest,
506            contracts,
507        } = resolution
508        else {
509            panic!("built-in wasm_store contract should resolve")
510        };
511
512        assert_eq!(contracts.len(), 1);
513        let mut ids = manifest.roles[0]
514            .state
515            .iter()
516            .filter_map(|domain| domain.memory_id)
517            .collect::<Vec<_>>();
518        ids.sort_unstable();
519        assert_eq!(
520            ids,
521            vec![
522                11, 12, 13, 15, 16, 17, 18, 20, 30, 34, 39, 40, 41, 42, 43, 80, 81, 82, 83, 85,
523            ]
524        );
525        assert_eq!(
526            manifest.roles[0]
527                .reserved_memory
528                .iter()
529                .map(|entry| entry.memory_id)
530                .collect::<Vec<_>>(),
531            vec![29, 31, 32]
532        );
533    }
534
535    #[test]
536    fn unknown_role_resolution_returns_no_manifest() {
537        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
538        let config = workspace.join("canisters/audit/root_probe/canic.toml");
539        let resolution = resolve_project_state_manifest(&workspace, &[config], Some("missing"));
540
541        assert!(matches!(
542            resolution,
543            StateManifestResolution::Rejected { errors }
544                if errors.iter().any(|finding| matches!(finding, RoleContractFinding::RoleUnknown { .. }))
545        ));
546    }
547
548    #[test]
549    fn unsupported_manifest_schema_version_fails() {
550        let mut manifest = test_state_manifest(Some("root"));
551        manifest.schema_version = STATE_MANIFEST_SCHEMA_VERSION + 1;
552
553        let checks = audit_checks(&manifest, Some("root"));
554
555        assert!(checks.iter().any(|check| {
556            check.code == "state_manifest_schema_version_unsupported"
557                && check.status == StateAuditStatus::Fail
558        }));
559    }
560
561    #[test]
562    fn duplicate_state_role_fails() {
563        let mut manifest = test_state_manifest(Some("root"));
564        let duplicate = manifest.roles[0].clone();
565        manifest.roles.push(duplicate);
566
567        let checks = audit_checks(&manifest, None);
568
569        assert!(checks.iter().any(|check| {
570            check.code == "state_role_duplicate" && check.status == StateAuditStatus::Fail
571        }));
572    }
573
574    #[test]
575    fn duplicate_memory_id_fails_within_role() {
576        let mut manifest = test_state_manifest(Some("root"));
577        let role = manifest.roles.first_mut().expect("root role");
578        role.state[1].memory_id = role.state[0].memory_id;
579
580        let checks = audit_checks(&manifest, Some("root"));
581        assert!(
582            checks
583                .iter()
584                .any(|check| check.code == "memory_id_duplicate"
585                    && check.status == StateAuditStatus::Fail)
586        );
587    }
588
589    #[test]
590    fn duplicate_state_domain_fails_within_role() {
591        let mut manifest = test_state_manifest(Some("root"));
592        let role = manifest.roles.first_mut().expect("root role");
593        let mut duplicate = role.state[0].clone();
594        duplicate.memory_id = Some(250);
595        role.state.push(duplicate);
596
597        let checks = audit_checks(&manifest, Some("root"));
598
599        assert!(
600            checks
601                .iter()
602                .any(|check| check.code == "state_domain_duplicate"
603                    && check.status == StateAuditStatus::Fail)
604        );
605    }
606
607    #[test]
608    fn reserved_memory_ids_warn_until_modeled() {
609        let report = build_state_audit_report(Some("root"));
610
611        assert!(report.checks.iter().any(|check| {
612            check.code == "reserved_memory_id_declared" && check.status == StateAuditStatus::Warn
613        }));
614    }
615
616    #[test]
617    fn active_domain_reclaiming_reserved_memory_id_fails() {
618        let mut manifest = test_state_manifest(Some("root"));
619        let role = manifest.roles.first_mut().expect("root role");
620        let reserved_id = role
621            .reserved_memory
622            .first()
623            .map(|entry| entry.memory_id)
624            .expect("reserved memory id");
625        role.state[0].memory_id = Some(reserved_id);
626
627        let checks = audit_checks(&manifest, Some("root"));
628
629        assert!(
630            checks
631                .iter()
632                .any(|check| check.code == "reserved_memory_id_collision"
633                    && check.status == StateAuditStatus::Fail)
634        );
635    }
636
637    #[test]
638    fn storage_not_applicable_is_explicit_metadata() {
639        let manifest = StateManifest {
640            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
641            roles: vec![StateRoleManifest {
642                canister_role: "root".to_string(),
643                state: vec![StateDomainManifest {
644                    domain: "external_authority".to_string(),
645                    version: 1,
646                    storage: StateStorage::NotApplicable,
647                    memory_id: None,
648                    owner: "canic-core".to_string(),
649                    record: "ExternalAuthorityRecord".to_string(),
650                    snapshot: "ExternalAuthorityData".to_string(),
651                    min_supported_version: 1,
652                    migration_policy: MigrationPolicy::NotApplicable,
653                    restore_order: Some(10),
654                    post_upgrade_invariant: Some("external_authority_invariants".to_string()),
655                    migrations: Vec::new(),
656                }],
657                reserved_memory: Vec::new(),
658            }],
659        };
660
661        let checks = audit_checks(&manifest, None);
662
663        assert!(checks.iter().any(|check| {
664            check.code == "state_domain_storage_not_applicable"
665                && check.status == StateAuditStatus::Pass
666        }));
667        assert!(
668            checks
669                .iter()
670                .all(|check| check.code != "state_domain_missing_memory_id")
671        );
672    }
673
674    #[test]
675    fn invalid_support_window_fails() {
676        let manifest = StateManifest {
677            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
678            roles: vec![StateRoleManifest {
679                canister_role: "root".to_string(),
680                state: vec![StateDomainManifest {
681                    domain: "auth_sessions".to_string(),
682                    version: 2,
683                    storage: StateStorage::StableMemory,
684                    memory_id: Some(19),
685                    owner: "canic-core".to_string(),
686                    record: "AuthSessionRecord".to_string(),
687                    snapshot: "AuthSessionsData".to_string(),
688                    min_supported_version: 3,
689                    migration_policy: MigrationPolicy::NewDomain,
690                    restore_order: Some(10),
691                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
692                    migrations: Vec::new(),
693                }],
694                reserved_memory: Vec::new(),
695            }],
696        };
697
698        let checks = audit_checks(&manifest, None);
699
700        assert!(checks.iter().any(|check| {
701            check.code == "state_domain_invalid_support_window"
702                && check.status == StateAuditStatus::Fail
703        }));
704        assert!(
705            checks
706                .iter()
707                .all(|check| check.code != "migration_available")
708        );
709    }
710
711    #[test]
712    fn duplicate_migration_declaration_fails() {
713        let manifest = StateManifest {
714            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
715            roles: vec![StateRoleManifest {
716                canister_role: "root".to_string(),
717                state: vec![StateDomainManifest {
718                    domain: "auth_sessions".to_string(),
719                    version: 3,
720                    storage: StateStorage::StableMemory,
721                    memory_id: Some(19),
722                    owner: "canic-core".to_string(),
723                    record: "AuthSessionRecord".to_string(),
724                    snapshot: "AuthSessionsData".to_string(),
725                    min_supported_version: 2,
726                    migration_policy: MigrationPolicy::Migrate,
727                    restore_order: Some(10),
728                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
729                    migrations: vec![
730                        StateMigrationManifest {
731                            from: 2,
732                            to: 3,
733                            kind: "function".to_string(),
734                            name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
735                            test: Some(
736                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
737                            ),
738                        },
739                        StateMigrationManifest {
740                            from: 2,
741                            to: 3,
742                            kind: "function".to_string(),
743                            name: Some("migrate_auth_sessions_v2_to_v3_again".to_string()),
744                            test: Some(
745                                "auth_sessions_v2_to_v3_upgrade_preserves_sessions".to_string(),
746                            ),
747                        },
748                    ],
749                }],
750                reserved_memory: Vec::new(),
751            }],
752        };
753
754        let checks = audit_checks(&manifest, None);
755
756        assert!(checks.iter().any(|check| {
757            check.code == "migration_declaration_duplicate"
758                && check.status == StateAuditStatus::Fail
759        }));
760    }
761
762    #[test]
763    fn invalid_migration_declaration_fails() {
764        let manifest = StateManifest {
765            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
766            roles: vec![StateRoleManifest {
767                canister_role: "root".to_string(),
768                state: vec![StateDomainManifest {
769                    domain: "auth_sessions".to_string(),
770                    version: 4,
771                    storage: StateStorage::StableMemory,
772                    memory_id: Some(19),
773                    owner: "canic-core".to_string(),
774                    record: "AuthSessionRecord".to_string(),
775                    snapshot: "AuthSessionsData".to_string(),
776                    min_supported_version: 2,
777                    migration_policy: MigrationPolicy::Migrate,
778                    restore_order: Some(10),
779                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
780                    migrations: vec![StateMigrationManifest {
781                        from: 2,
782                        to: 4,
783                        kind: "function".to_string(),
784                        name: Some("migrate_auth_sessions_v2_to_v4".to_string()),
785                        test: Some("auth_sessions_v2_to_v4_upgrade_preserves_sessions".to_string()),
786                    }],
787                }],
788                reserved_memory: Vec::new(),
789            }],
790        };
791
792        let checks = audit_checks(&manifest, None);
793
794        assert!(checks.iter().any(|check| {
795            check.code == "migration_declaration_invalid" && check.status == StateAuditStatus::Fail
796        }));
797    }
798
799    #[test]
800    fn missing_migration_test_warns_separately_from_missing_migration() {
801        let manifest = StateManifest {
802            schema_version: STATE_MANIFEST_SCHEMA_VERSION,
803            roles: vec![StateRoleManifest {
804                canister_role: "root".to_string(),
805                state: vec![StateDomainManifest {
806                    domain: "auth_sessions".to_string(),
807                    version: 3,
808                    storage: StateStorage::StableMemory,
809                    memory_id: Some(19),
810                    owner: "canic-core".to_string(),
811                    record: "AuthSessionRecord".to_string(),
812                    snapshot: "AuthSessionsData".to_string(),
813                    min_supported_version: 2,
814                    migration_policy: MigrationPolicy::Migrate,
815                    restore_order: Some(10),
816                    post_upgrade_invariant: Some("auth_sessions_invariants".to_string()),
817                    migrations: vec![StateMigrationManifest {
818                        from: 2,
819                        to: 3,
820                        kind: "function".to_string(),
821                        name: Some("migrate_auth_sessions_v2_to_v3".to_string()),
822                        test: None,
823                    }],
824                }],
825                reserved_memory: Vec::new(),
826            }],
827        };
828        let checks = audit_checks(&manifest, None);
829
830        assert!(
831            checks
832                .iter()
833                .any(|check| check.code == "upgrade_test_missing"
834                    && check.status == StateAuditStatus::Warn)
835        );
836        assert!(checks.iter().all(|check| check.code != "migration_missing"));
837    }
838
839    #[test]
840    fn unknown_filtered_role_fails() {
841        let report = build_state_audit_report(Some("missing"));
842
843        assert_eq!(report.status, StateAuditStatus::Fail);
844        assert_eq!(report.checks[0].code, "state_role_missing");
845    }
846}