1use 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#[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#[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#[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#[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#[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#[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#[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}