Skip to main content

ic_memory/
diagnostics.rs

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