Skip to main content

ic_memory/
diagnostics.rs

1use crate::{
2    constants::WASM_PAGE_SIZE_BYTES,
3    declaration::AllocationDeclaration,
4    ledger::{AllocationLedger, AllocationRecord, GenerationRecord},
5    physical::CommitStoreDiagnostic,
6    policy::PolicyIdentity,
7    registry::SealedDeclarationFingerprint,
8    slot::{AllocationSlotDescriptor, MemoryManagerAuthorityRecord, MemoryManagerRangeAuthority},
9};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13///
14/// DiagnosticExport
15///
16/// Read-only machine-readable allocation ledger export.
17#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct DiagnosticExport {
20    /// Current committed generation.
21    pub current_generation: u64,
22    /// Ledger anchor descriptor.
23    pub ledger_anchor: AllocationSlotDescriptor,
24    /// Allocation records.
25    pub records: Vec<DiagnosticRecord>,
26    /// Generation records.
27    pub generations: Vec<DiagnosticGeneration>,
28    /// Optional protected commit recovery diagnostic.
29    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
30    pub commit_recovery: Option<CommitStoreDiagnostic>,
31}
32
33///
34/// DiagnosticRuntimeBinding
35///
36/// Operator-facing view of the policy identity and declaration snapshot bound
37/// to one successful memory-runtime bootstrap.
38///
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(deny_unknown_fields)]
42pub struct DiagnosticRuntimeBinding {
43    /// Bounded semantic identity of the bootstrap policy.
44    pub policy_identity: PolicyIdentity,
45    /// Deterministic fingerprint of the sealed declaration snapshot.
46    pub declaration_fingerprint: SealedDeclarationFingerprint,
47}
48
49impl DiagnosticRuntimeBinding {
50    /// Build one runtime binding diagnostic.
51    #[must_use]
52    pub const fn new(
53        policy_identity: PolicyIdentity,
54        declaration_fingerprint: SealedDeclarationFingerprint,
55    ) -> Self {
56        Self {
57            policy_identity,
58            declaration_fingerprint,
59        }
60    }
61}
62
63///
64/// MemoryRuntimeDoctorReport
65///
66/// Preflight and runtime diagnostic report for one concrete
67/// [`crate::MemoryRuntime`].
68///
69/// This report is intended for operator-facing diagnostics. Recoverable
70/// runtime problems, such as corrupt stable-cell bytes or commit recovery
71/// failure, are represented as fields instead of aborting report construction.
72///
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct MemoryRuntimeDoctorReport {
77    /// Whether this runtime has completed bootstrap validation.
78    pub bootstrapped: bool,
79    /// Policy identity supplied for this diagnostic evaluation.
80    pub tested_policy_identity: Result<PolicyIdentity, DiagnosticFailure>,
81    /// Sealed declaration fingerprint supplied for this diagnostic evaluation.
82    pub tested_declaration_fingerprint: SealedDeclarationFingerprint,
83    /// Binding published by this runtime's successful bootstrap, when present.
84    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
85    pub established_bootstrap_binding: Option<DiagnosticRuntimeBinding>,
86    /// Whether the tested identity and declarations match the established
87    /// bootstrap binding.
88    pub bootstrap_binding: DiagnosticCheck,
89    /// Ledger anchor descriptor used by this runtime.
90    pub ledger_anchor: AllocationSlotDescriptor,
91    /// Stable-cell ledger storage status.
92    pub stable_cell: DiagnosticStableCell,
93    /// Protected commit recovery status when a ledger record was readable.
94    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
95    pub commit_recovery: Option<CommitStoreDiagnostic>,
96    /// Recovered allocation ledger export when protected recovery succeeded.
97    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
98    pub ledger: Option<DiagnosticExport>,
99    /// Static declarations registered by linked crates.
100    pub registered_declarations: Vec<DiagnosticDeclaration>,
101    /// Static range authority registered by linked crates and the effective
102    /// authority table supplied to this runtime.
103    pub range_authority: DiagnosticRangeAuthority,
104    /// Declaration validation result under the tested caller-supplied policy.
105    pub validation: DiagnosticCheck,
106}
107
108///
109/// DiagnosticDeclaration
110///
111/// Read-only diagnostic view of one static allocation declaration.
112///
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(deny_unknown_fields)]
116pub struct DiagnosticDeclaration {
117    /// Crate or integration authority that registered the declaration.
118    pub authority: String,
119    /// Allocation declaration registered by that authority.
120    pub declaration: AllocationDeclaration,
121}
122
123impl DiagnosticDeclaration {
124    /// Build a diagnostic declaration record.
125    #[must_use]
126    pub fn new(authority: impl Into<String>, declaration: AllocationDeclaration) -> Self {
127        Self {
128            authority: authority.into(),
129            declaration,
130        }
131    }
132}
133
134///
135/// DiagnosticCode
136///
137/// Stable machine-readable category for an operator diagnostic failure.
138///
139
140#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub enum DiagnosticCode {
142    /// Stable-cell storage could not be decoded.
143    #[serde(rename = "stable_cell")]
144    StableCell,
145    /// Persisted bytes use a recognized but unsupported durable format.
146    #[serde(rename = "unsupported_format")]
147    UnsupportedFormat,
148    /// Protected ledger recovery failed.
149    #[serde(rename = "ledger_recovery")]
150    LedgerRecovery,
151    /// Current declarations failed allocation validation.
152    #[serde(rename = "allocation_validation")]
153    AllocationValidation,
154    /// Runtime bootstrap policy identity was invalid.
155    #[serde(rename = "policy_identity")]
156    PolicyIdentity,
157    /// Tested bootstrap identity or declarations differ from runtime state.
158    #[serde(rename = "runtime_binding")]
159    RuntimeBinding,
160}
161
162///
163/// DiagnosticFailure
164///
165/// Machine-readable diagnostic code paired with an operator-facing message.
166///
167
168#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
169#[serde(deny_unknown_fields)]
170pub struct DiagnosticFailure {
171    /// Stable diagnostic category.
172    pub code: DiagnosticCode,
173    /// Human-readable failure detail.
174    pub message: String,
175}
176
177impl DiagnosticFailure {
178    /// Build a coded diagnostic failure.
179    #[must_use]
180    pub fn new(code: DiagnosticCode, message: impl Into<String>) -> Self {
181        Self {
182            code,
183            message: message.into(),
184        }
185    }
186}
187
188///
189/// DiagnosticRangeAuthority
190///
191/// Read-only diagnostic view of registered and effective range authority.
192///
193
194#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
195#[serde(deny_unknown_fields)]
196pub struct DiagnosticRangeAuthority {
197    /// Range records registered directly by linked crates.
198    pub registered_records: Vec<MemoryManagerAuthorityRecord>,
199    /// Validated effective range authority from the sealed declarations.
200    pub effective_authority: MemoryManagerRangeAuthority,
201}
202
203impl DiagnosticRangeAuthority {
204    /// Build a range-authority diagnostic.
205    #[must_use]
206    pub const fn new(
207        registered_records: Vec<MemoryManagerAuthorityRecord>,
208        effective_authority: MemoryManagerRangeAuthority,
209    ) -> Self {
210        Self {
211            registered_records,
212            effective_authority,
213        }
214    }
215}
216
217///
218/// DiagnosticStableCell
219///
220/// Read-only diagnostic view of the stable-cell ledger storage envelope.
221///
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224#[serde(deny_unknown_fields)]
225pub struct DiagnosticStableCell {
226    /// Stable-cell status.
227    pub status: DiagnosticStableCellStatus,
228    /// Backing memory size for the ledger cell.
229    pub memory_size: DiagnosticMemorySize,
230}
231
232impl DiagnosticStableCell {
233    /// Build a stable-cell diagnostic.
234    #[must_use]
235    pub const fn new(
236        status: DiagnosticStableCellStatus,
237        memory_size: DiagnosticMemorySize,
238    ) -> Self {
239        Self {
240            status,
241            memory_size,
242        }
243    }
244}
245
246///
247/// DiagnosticStableCellStatus
248///
249/// Stable-cell ledger storage status.
250///
251
252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
253#[serde(deny_unknown_fields)]
254pub enum DiagnosticStableCellStatus {
255    /// The ledger memory is empty and can be initialized.
256    Empty,
257    /// The stable-cell envelope and ledger record decoded successfully.
258    Readable,
259    /// The ledger memory is present but could not be decoded as the expected
260    /// stable-cell ledger record.
261    Corrupt {
262        /// Stable-cell envelope or ledger-record decode failure.
263        failure: DiagnosticFailure,
264    },
265}
266
267///
268/// DiagnosticCheck
269///
270/// Read-only diagnostic status for a preflight check.
271///
272
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(deny_unknown_fields)]
275pub enum DiagnosticCheck {
276    /// The check could not run because prerequisite state was unavailable.
277    NotRun {
278        /// Stable diagnostic category.
279        code: DiagnosticCode,
280        /// Reason the check could not run.
281        message: String,
282    },
283    /// The check completed successfully.
284    Passed,
285    /// The check ran and found a problem.
286    Failed {
287        /// Stable diagnostic category.
288        code: DiagnosticCode,
289        /// Validation failure.
290        message: String,
291    },
292}
293
294impl DiagnosticCheck {
295    /// Build a passed diagnostic check.
296    #[must_use]
297    pub const fn passed() -> Self {
298        Self::Passed
299    }
300
301    /// Build a failed diagnostic check.
302    #[must_use]
303    pub fn failed(code: DiagnosticCode, message: impl Into<String>) -> Self {
304        Self::Failed {
305            code,
306            message: message.into(),
307        }
308    }
309
310    /// Build a skipped diagnostic check.
311    #[must_use]
312    pub fn not_run(code: DiagnosticCode, message: impl Into<String>) -> Self {
313        Self::NotRun {
314            code,
315            message: message.into(),
316        }
317    }
318}
319
320impl DiagnosticExport {
321    /// Build a read-only diagnostic export from an allocation ledger.
322    #[must_use]
323    pub fn from_ledger(ledger: &AllocationLedger, ledger_anchor: AllocationSlotDescriptor) -> Self {
324        Self::from_ledger_with_commit_recovery(ledger, ledger_anchor, None)
325    }
326
327    /// Build a read-only diagnostic export with protected commit recovery state.
328    #[must_use]
329    pub fn from_ledger_with_commit_recovery(
330        ledger: &AllocationLedger,
331        ledger_anchor: AllocationSlotDescriptor,
332        commit_recovery: Option<CommitStoreDiagnostic>,
333    ) -> Self {
334        Self::from_ledger_with_commit_recovery_and_memory_sizes(
335            ledger,
336            ledger_anchor,
337            commit_recovery,
338            std::iter::empty(),
339        )
340    }
341
342    /// Build a read-only diagnostic export with live memory sizes.
343    #[must_use]
344    pub fn from_ledger_with_memory_sizes(
345        ledger: &AllocationLedger,
346        ledger_anchor: AllocationSlotDescriptor,
347        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
348    ) -> Self {
349        Self::from_ledger_with_commit_recovery_and_memory_sizes(
350            ledger,
351            ledger_anchor,
352            None,
353            memory_sizes,
354        )
355    }
356
357    /// Build a read-only diagnostic export with protected recovery state and live memory sizes.
358    #[must_use]
359    pub fn from_ledger_with_commit_recovery_and_memory_sizes(
360        ledger: &AllocationLedger,
361        ledger_anchor: AllocationSlotDescriptor,
362        commit_recovery: Option<CommitStoreDiagnostic>,
363        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
364    ) -> Self {
365        let memory_sizes: BTreeMap<_, _> = memory_sizes.into_iter().collect();
366        Self {
367            current_generation: ledger.current_generation,
368            ledger_anchor,
369            records: ledger
370                .allocation_history()
371                .records()
372                .iter()
373                .cloned()
374                .map(|allocation| {
375                    let memory_size = memory_sizes.get(allocation.slot()).copied();
376                    DiagnosticRecord {
377                        allocation,
378                        memory_size,
379                    }
380                })
381                .collect(),
382            generations: ledger
383                .allocation_history()
384                .generations()
385                .iter()
386                .cloned()
387                .map(|generation| DiagnosticGeneration { generation })
388                .collect(),
389            commit_recovery,
390        }
391    }
392}
393
394///
395/// DiagnosticRecord
396///
397/// Read-only diagnostic allocation record.
398#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
399#[serde(deny_unknown_fields)]
400pub struct DiagnosticRecord {
401    /// Allocation record.
402    pub allocation: AllocationRecord,
403    /// Live backing memory size, when the exporter measured one.
404    ///
405    /// This is allocation size reported by the backing memory, not logical user
406    /// payload size inside the stable structure.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub memory_size: Option<DiagnosticMemorySize>,
409}
410
411///
412/// DiagnosticMemorySize
413///
414/// Live size reported by a backing stable memory.
415///
416
417#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
418#[serde(deny_unknown_fields)]
419pub struct DiagnosticMemorySize {
420    /// WebAssembly pages reported by the memory.
421    pub wasm_pages: u64,
422    /// Bytes represented by the page count.
423    pub bytes: u64,
424}
425
426impl DiagnosticMemorySize {
427    /// Build a size from a WebAssembly page count.
428    #[must_use]
429    pub const fn from_wasm_pages(wasm_pages: u64) -> Self {
430        Self {
431            wasm_pages,
432            bytes: wasm_pages.saturating_mul(WASM_PAGE_SIZE_BYTES),
433        }
434    }
435}
436
437///
438/// DiagnosticGeneration
439///
440/// Read-only diagnostic generation record.
441#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
442#[serde(deny_unknown_fields)]
443pub struct DiagnosticGeneration {
444    /// Generation record.
445    pub generation: GenerationRecord,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::{
452        declaration::AllocationDeclaration,
453        ledger::{AllocationHistory, AllocationRecord},
454        physical::{CommitRecoveryError, CommitSlotDiagnostic, CommitStoreDiagnostic},
455        schema::SchemaMetadata,
456    };
457
458    #[test]
459    fn diagnostic_export_copies_ledger_records() {
460        let declaration = AllocationDeclaration::new(
461            "app.users.v1",
462            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
463            None,
464            SchemaMetadata::default(),
465        )
466        .expect("declaration");
467        let ledger = AllocationLedger {
468            current_generation: 3,
469            allocation_history: AllocationHistory::from_parts(
470                vec![AllocationRecord::active(3, declaration)],
471                vec![GenerationRecord {
472                    generation: 3,
473                    parent_generation: 2,
474                    runtime_fingerprint: Some("wasm:abc123".to_string()),
475                    declaration_count: 1,
476                    committed_at: None,
477                }],
478            ),
479        };
480
481        let export = DiagnosticExport::from_ledger(
482            &ledger,
483            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
484        );
485
486        assert_eq!(export.current_generation, 3);
487        assert_eq!(export.records.len(), 1);
488        assert_eq!(export.records[0].memory_size, None);
489        assert_eq!(export.generations.len(), 1);
490        assert_eq!(
491            export.ledger_anchor,
492            AllocationSlotDescriptor::memory_manager(0).expect("usable slot")
493        );
494        assert_eq!(export.commit_recovery, None);
495    }
496
497    #[test]
498    fn diagnostic_export_rejects_unknown_top_level_fields() {
499        use crate::test_cbor::Value;
500
501        let export = DiagnosticExport {
502            current_generation: 0,
503            ledger_anchor: AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
504            records: Vec::new(),
505            generations: Vec::new(),
506            commit_recovery: None,
507        };
508        let Value::Map(mut map) = crate::test_cbor::to_value(export).expect("diagnostic value")
509        else {
510            panic!("diagnostic export encodes as a map");
511        };
512        crate::test_cbor::map_insert(
513            &mut map,
514            Value::Text("future_field".to_string()),
515            Value::Bool(true),
516        );
517        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("diagnostic bytes");
518
519        let err = crate::test_cbor::from_slice::<DiagnosticExport>(&bytes)
520            .expect_err("unknown diagnostic field must fail closed");
521
522        assert!(err.to_string().contains("future_field"));
523    }
524
525    #[test]
526    fn diagnostic_outcome_states_round_trip() {
527        let stable_cell = DiagnosticStableCell::new(
528            DiagnosticStableCellStatus::Corrupt {
529                failure: DiagnosticFailure::new(
530                    DiagnosticCode::StableCell,
531                    "bad stable-cell record",
532                ),
533            },
534            DiagnosticMemorySize::from_wasm_pages(1),
535        );
536        let range_authority =
537            DiagnosticRangeAuthority::new(Vec::new(), MemoryManagerRangeAuthority::default());
538        let check = DiagnosticCheck::failed(
539            DiagnosticCode::AllocationValidation,
540            "duplicate declaration",
541        );
542
543        for value in [DiagnosticCheck::passed(), check] {
544            let bytes = crate::test_cbor::to_vec(&value).expect("check bytes");
545            let decoded: DiagnosticCheck =
546                crate::test_cbor::from_slice(&bytes).expect("check round trip");
547            assert_eq!(decoded, value);
548        }
549
550        let bytes = crate::test_cbor::to_vec(&stable_cell).expect("stable-cell diagnostic bytes");
551        let decoded: DiagnosticStableCell =
552            crate::test_cbor::from_slice(&bytes).expect("stable-cell round trip");
553        assert_eq!(decoded, stable_cell);
554
555        let bytes = crate::test_cbor::to_vec(&range_authority).expect("range diagnostic bytes");
556        let decoded: DiagnosticRangeAuthority =
557            crate::test_cbor::from_slice(&bytes).expect("range round trip");
558        assert_eq!(decoded, range_authority);
559    }
560
561    #[test]
562    fn diagnostic_codes_have_stable_wire_names() {
563        let cases = [
564            (DiagnosticCode::StableCell, "stable_cell"),
565            (DiagnosticCode::UnsupportedFormat, "unsupported_format"),
566            (DiagnosticCode::LedgerRecovery, "ledger_recovery"),
567            (
568                DiagnosticCode::AllocationValidation,
569                "allocation_validation",
570            ),
571            (DiagnosticCode::PolicyIdentity, "policy_identity"),
572            (DiagnosticCode::RuntimeBinding, "runtime_binding"),
573        ];
574
575        for (code, expected) in cases {
576            assert_eq!(
577                crate::test_cbor::to_value(code).expect("diagnostic code value"),
578                crate::test_cbor::Value::Text(expected.to_string())
579            );
580        }
581    }
582
583    #[test]
584    fn diagnostic_export_can_include_commit_recovery_state() {
585        let ledger = AllocationLedger {
586            current_generation: 3,
587            allocation_history: AllocationHistory::default(),
588        };
589        let commit_recovery = CommitStoreDiagnostic {
590            slot0: CommitSlotDiagnostic::Valid { generation: 3 },
591            slot1: CommitSlotDiagnostic::Empty,
592            recovery: Ok(3),
593        };
594
595        let export = DiagnosticExport::from_ledger_with_commit_recovery(
596            &ledger,
597            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
598            Some(commit_recovery),
599        );
600
601        assert_eq!(export.commit_recovery, Some(commit_recovery));
602    }
603
604    #[test]
605    fn diagnostic_export_can_include_memory_sizes() {
606        let declaration = AllocationDeclaration::new(
607            "app.users.v1",
608            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
609            None,
610            SchemaMetadata::default(),
611        )
612        .expect("declaration");
613        let ledger = AllocationLedger {
614            current_generation: 3,
615            allocation_history: AllocationHistory::from_parts(
616                vec![AllocationRecord::active(3, declaration)],
617                Vec::new(),
618            ),
619        };
620
621        let export = DiagnosticExport::from_ledger_with_memory_sizes(
622            &ledger,
623            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
624            [(
625                AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
626                DiagnosticMemorySize::from_wasm_pages(2),
627            )],
628        );
629
630        assert_eq!(
631            export.records[0].memory_size,
632            Some(DiagnosticMemorySize {
633                wasm_pages: 2,
634                bytes: 131_072,
635            })
636        );
637        let wire = serde_json::to_value(&export).expect("diagnostic JSON");
638        assert_eq!(
639            wire["records"][0]["memory_size"],
640            serde_json::json!({"wasm_pages": 2, "bytes": 131_072})
641        );
642    }
643
644    #[test]
645    fn diagnostic_export_can_report_recovery_failure() {
646        let ledger = AllocationLedger {
647            current_generation: 0,
648            allocation_history: AllocationHistory::default(),
649        };
650        let commit_recovery = CommitStoreDiagnostic {
651            slot0: CommitSlotDiagnostic::Empty,
652            slot1: CommitSlotDiagnostic::Empty,
653            recovery: Err(CommitRecoveryError::NoValidGeneration),
654        };
655
656        let export = DiagnosticExport::from_ledger_with_commit_recovery(
657            &ledger,
658            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
659            Some(commit_recovery),
660        );
661
662        assert_eq!(
663            export.commit_recovery.expect("commit recovery").recovery,
664            Err(CommitRecoveryError::NoValidGeneration)
665        );
666    }
667}