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    requests::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<CommitSnapshotDiagnostic>,
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<CommitSnapshotDiagnostic>,
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, physical::CommitSnapshotDiagnostic, schema::SchemaMetadata,
360    };
361
362    #[test]
363    fn diagnostic_export_copies_ledger_records() {
364        let declaration = AllocationDeclaration::new(
365            "app.users.v1",
366            MemoryManagerSlot::new(100).expect("usable slot"),
367            SchemaMetadata::default(),
368        )
369        .expect("declaration");
370        let ledger = AllocationLedger {
371            current_generation: 3,
372            records: vec![AllocationRecord::active(&declaration)],
373        };
374
375        let export =
376            DiagnosticExport::from_ledger(&ledger, MemoryManagerSlot::new(0).expect("usable slot"));
377
378        assert_eq!(export.current_generation, 3);
379        assert_eq!(export.records.len(), 1);
380        assert_eq!(export.records[0].memory_size, None);
381
382        assert_eq!(
383            export.ledger_anchor,
384            MemoryManagerSlot::new(0).expect("usable slot")
385        );
386        assert_eq!(export.commit_recovery, None);
387
388        assert_eq!(
389            export,
390            DiagnosticExport::from_owned_ledger(ledger, export.ledger_anchor.clone())
391        );
392        let wire = serde_json::to_value(&export).expect("diagnostic JSON");
393        assert_eq!(
394            wire["records"][0]["allocation"]["schema"],
395            serde_json::json!({"schema_version": null})
396        );
397
398        let decoded: DiagnosticExport = serde_json::from_value(wire).expect("current JSON shape");
399        assert_eq!(decoded, export);
400    }
401
402    #[test]
403    fn diagnostic_export_rejects_unknown_top_level_fields() {
404        use crate::test_cbor::Value;
405
406        let export = DiagnosticExport {
407            current_generation: 0,
408            ledger_anchor: MemoryManagerSlot::new(0).expect("usable slot"),
409            records: Vec::new(),
410            commit_recovery: None,
411        };
412        let Value::Map(mut map) = crate::test_cbor::to_value(export).expect("diagnostic value")
413        else {
414            panic!("diagnostic export encodes as a map");
415        };
416        map.push((Value::Text("future_field".to_string()), Value::Bool(true)));
417        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("diagnostic bytes");
418
419        let err = crate::test_cbor::from_slice::<DiagnosticExport>(&bytes)
420            .expect_err("unknown diagnostic field must fail closed");
421
422        assert!(err.to_string().contains("future_field"));
423    }
424
425    #[test]
426    fn diagnostic_outcome_states_round_trip() {
427        let stable_cell = DiagnosticStableCell::new(
428            DiagnosticStableCellStatus::Corrupt {
429                failure: DiagnosticFailure::new(
430                    DiagnosticCode::StableCell,
431                    "bad stable-cell record",
432                ),
433            },
434            DiagnosticMemorySize::from_wasm_pages(1),
435        );
436        let allocation_pool = crate::MemoryAllocationPool::new(Vec::new(), Vec::new()).unwrap();
437        let check = DiagnosticCheck::failed(
438            DiagnosticCode::AllocationValidation,
439            "duplicate declaration",
440        );
441
442        for value in [DiagnosticCheck::passed(), check] {
443            let bytes = crate::test_cbor::to_vec(&value).expect("check bytes");
444            let decoded: DiagnosticCheck =
445                crate::test_cbor::from_slice(&bytes).expect("check round trip");
446            assert_eq!(decoded, value);
447        }
448
449        let bytes = crate::test_cbor::to_vec(&stable_cell).expect("stable-cell diagnostic bytes");
450        let decoded: DiagnosticStableCell =
451            crate::test_cbor::from_slice(&bytes).expect("stable-cell round trip");
452        assert_eq!(decoded, stable_cell);
453
454        let bytes = crate::test_cbor::to_vec(&allocation_pool).expect("pool diagnostic bytes");
455        let decoded: crate::MemoryAllocationPool =
456            crate::test_cbor::from_slice(&bytes).expect("pool round trip");
457        assert_eq!(decoded, allocation_pool);
458    }
459
460    #[test]
461    fn diagnostic_codes_have_stable_wire_names() {
462        let cases = [
463            (DiagnosticCode::StableCell, "stable_cell"),
464            (DiagnosticCode::UnsupportedFormat, "unsupported_format"),
465            (DiagnosticCode::LedgerRecovery, "ledger_recovery"),
466            (
467                DiagnosticCode::AllocationValidation,
468                "allocation_validation",
469            ),
470            (DiagnosticCode::PolicyIdentity, "policy_identity"),
471            (DiagnosticCode::RuntimeBinding, "runtime_binding"),
472        ];
473
474        for (code, expected) in cases {
475            assert_eq!(
476                crate::test_cbor::to_value(code).expect("diagnostic code value"),
477                crate::test_cbor::Value::Text(expected.to_string())
478            );
479        }
480    }
481
482    #[test]
483    fn diagnostic_export_can_include_commit_recovery_state() {
484        let ledger = AllocationLedger {
485            current_generation: 3,
486            records: Vec::new(),
487        };
488        let commit_recovery = CommitSnapshotDiagnostic::Valid { generation: 3 };
489
490        let mut export =
491            DiagnosticExport::from_ledger(&ledger, MemoryManagerSlot::new(0).expect("usable slot"));
492        export.commit_recovery = Some(commit_recovery);
493
494        assert_eq!(export.commit_recovery, Some(commit_recovery));
495    }
496
497    #[test]
498    fn diagnostic_export_can_include_memory_sizes() {
499        let declaration = AllocationDeclaration::new(
500            "app.users.v1",
501            MemoryManagerSlot::new(100).expect("usable slot"),
502            SchemaMetadata::default(),
503        )
504        .expect("declaration");
505        let ledger = AllocationLedger {
506            current_generation: 3,
507            records: vec![AllocationRecord::active(&declaration)],
508        };
509
510        let mut export =
511            DiagnosticExport::from_ledger(&ledger, MemoryManagerSlot::new(0).expect("usable slot"));
512        export.records[0].memory_size = Some(DiagnosticMemorySize::from_wasm_pages(2));
513
514        assert_eq!(
515            export.records[0].memory_size,
516            Some(DiagnosticMemorySize {
517                wasm_pages: 2,
518                bytes: 131_072,
519            })
520        );
521        let wire = serde_json::to_value(&export).expect("diagnostic JSON");
522        assert_eq!(
523            wire["records"][0]["memory_size"],
524            serde_json::json!({"wasm_pages": 2, "bytes": 131_072})
525        );
526    }
527
528    #[test]
529    fn diagnostic_export_can_report_recovery_failure() {
530        let ledger = AllocationLedger {
531            current_generation: 0,
532            records: Vec::new(),
533        };
534        let commit_recovery = CommitSnapshotDiagnostic::Empty;
535
536        let mut export =
537            DiagnosticExport::from_ledger(&ledger, MemoryManagerSlot::new(0).expect("usable slot"));
538        export.commit_recovery = Some(commit_recovery);
539
540        assert_eq!(
541            export.commit_recovery.expect("commit recovery"),
542            CommitSnapshotDiagnostic::Empty
543        );
544    }
545}