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