Skip to main content

ic_memory/
diagnostics.rs

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