Skip to main content

ic_memory/
diagnostics.rs

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