Skip to main content

ic_memory/ledger/
record.rs

1use super::{AllocationRetirementError, LedgerCompatibilityError, LedgerIntegrityError};
2use crate::{
3    declaration::{AllocationDeclaration, DeclarationSnapshotError, validate_runtime_fingerprint},
4    key::StableKey,
5    schema::{SchemaMetadata, SchemaMetadataError},
6    slot::AllocationSlotDescriptor,
7};
8use serde::{Deserialize, Serialize};
9
10/// Current allocation ledger schema version.
11pub const CURRENT_LEDGER_SCHEMA_VERSION: u32 = 1;
12
13/// Current protected physical ledger format identifier.
14pub const CURRENT_PHYSICAL_FORMAT_ID: u32 = 1;
15
16///
17/// AllocationLedger
18///
19/// Durable root of allocation history.
20///
21/// Decoded ledgers are input from persistent storage and should be treated as
22/// untrusted until compatibility and integrity validation pass. Public
23/// construction goes through [`AllocationLedger::new`], which validates
24/// structural history invariants before returning a value. Use
25/// [`AllocationLedger::new_committed`] when the value should also satisfy the
26/// strict committed-generation chain required by recovery and commit.
27///
28/// Staging APIs clone this DTO before applying a logical generation. The ledger
29/// is expected to contain allocation metadata only, bounded by the number of
30/// stable allocation identities and committed bootstrap generations, not user
31/// collection contents.
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct AllocationLedger {
35    /// Ledger schema version.
36    pub(crate) ledger_schema_version: u32,
37    /// Physical encoding format identifier.
38    pub(crate) physical_format_id: u32,
39    /// Current committed generation selected by recovery.
40    pub(crate) current_generation: u64,
41    /// Historical allocation facts.
42    pub(crate) allocation_history: AllocationHistory,
43}
44
45///
46/// AllocationHistory
47///
48/// Durable allocation records and generation history.
49///
50/// This is the durable DTO embedded in an [`AllocationLedger`]. It records
51/// allocation facts and generation diagnostics; callers should prefer ledger
52/// staging/validation methods over mutating histories directly.
53#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
54#[serde(deny_unknown_fields)]
55pub struct AllocationHistory {
56    /// Stable-key allocation records.
57    pub(crate) records: Vec<AllocationRecord>,
58    /// Committed generation records.
59    pub(crate) generations: Vec<GenerationRecord>,
60}
61
62///
63/// AllocationRecord
64///
65/// Durable ownership record for one stable key.
66///
67/// Records are historical facts, not live handles. Fields are private so stale
68/// or invalid ownership state cannot be assembled through public struct
69/// literals; use accessors for diagnostics and ledger methods for mutation.
70#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
71#[serde(deny_unknown_fields)]
72pub struct AllocationRecord {
73    /// Stable key that owns the slot.
74    pub(crate) stable_key: StableKey,
75    /// Durable allocation slot owned by the key.
76    pub(crate) slot: AllocationSlotDescriptor,
77    /// Current allocation lifecycle state.
78    pub(crate) state: AllocationState,
79    /// First committed generation that recorded this allocation.
80    pub(crate) first_generation: u64,
81    /// Latest committed generation that observed this allocation declaration.
82    pub(crate) last_seen_generation: u64,
83    /// Generation that explicitly retired this allocation.
84    pub(crate) retired_generation: Option<u64>,
85    /// Per-generation schema metadata history.
86    pub(crate) schema_history: Vec<SchemaMetadataRecord>,
87}
88
89///
90/// AllocationRetirement
91///
92/// Explicit request to tombstone one historical allocation identity.
93///
94/// Retirement prevents a stable key from being redeclared. It does not make the
95/// physical slot safe for another active stable key.
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97#[serde(deny_unknown_fields)]
98pub struct AllocationRetirement {
99    /// Stable key being retired.
100    pub stable_key: StableKey,
101    /// Allocation slot historically owned by the stable key.
102    pub slot: AllocationSlotDescriptor,
103}
104
105impl AllocationRetirement {
106    /// Build an explicit retirement request from raw parts.
107    pub fn new(
108        stable_key: impl AsRef<str>,
109        slot: AllocationSlotDescriptor,
110    ) -> Result<Self, AllocationRetirementError> {
111        Ok(Self {
112            stable_key: StableKey::parse(stable_key).map_err(AllocationRetirementError::Key)?,
113            slot,
114        })
115    }
116}
117
118///
119/// AllocationState
120///
121/// Allocation lifecycle state.
122#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub enum AllocationState {
124    /// Slot is reserved for a future allocation identity.
125    Reserved,
126    /// Slot is active and may be opened after validation.
127    Active,
128    /// Slot was explicitly retired and remains tombstoned.
129    Retired,
130}
131
132///
133/// SchemaMetadataRecord
134///
135/// Schema metadata observed in one committed generation.
136///
137/// Schema metadata is diagnostic ledger history. It is validated for bounded
138/// durable encoding, but `ic-memory` does not prove application schema
139/// compatibility or data migration correctness.
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(deny_unknown_fields)]
142pub struct SchemaMetadataRecord {
143    /// Generation that declared this schema metadata.
144    pub(crate) generation: u64,
145    /// Schema metadata declared by that generation.
146    pub(crate) schema: SchemaMetadata,
147}
148
149///
150/// GenerationRecord
151///
152/// Diagnostic metadata for one committed ledger generation.
153#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
154#[serde(deny_unknown_fields)]
155pub struct GenerationRecord {
156    /// Committed generation number.
157    pub(crate) generation: u64,
158    /// Parent generation, if recorded.
159    pub(crate) parent_generation: Option<u64>,
160    /// Optional binary/runtime fingerprint.
161    pub(crate) runtime_fingerprint: Option<String>,
162    /// Number of declarations in the generation.
163    pub(crate) declaration_count: u32,
164    /// Optional commit timestamp supplied by the integration layer.
165    pub(crate) committed_at: Option<u64>,
166}
167
168///
169/// LedgerCompatibility
170///
171/// Supported logical and physical ledger format versions.
172///
173/// Run this check on recovered ledgers before treating them as authoritative
174/// state. Integrity validation then checks allocation history invariants.
175#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
176#[serde(deny_unknown_fields)]
177pub struct LedgerCompatibility {
178    /// Minimum supported ledger schema version.
179    pub min_ledger_schema_version: u32,
180    /// Maximum supported ledger schema version.
181    pub max_ledger_schema_version: u32,
182    /// Required physical encoding format identifier.
183    pub physical_format_id: u32,
184}
185
186impl LedgerCompatibility {
187    /// Return the compatibility supported by this crate version.
188    #[must_use]
189    pub const fn current() -> Self {
190        Self {
191            min_ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION,
192            max_ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION,
193            physical_format_id: CURRENT_PHYSICAL_FORMAT_ID,
194        }
195    }
196
197    /// Validate a decoded ledger before it is used as authoritative state.
198    pub const fn validate(
199        &self,
200        ledger: &AllocationLedger,
201    ) -> Result<(), LedgerCompatibilityError> {
202        self.validate_versions(ledger.ledger_schema_version, ledger.physical_format_id)
203    }
204
205    pub(crate) const fn validate_versions(
206        &self,
207        ledger_schema_version: u32,
208        physical_format_id: u32,
209    ) -> Result<(), LedgerCompatibilityError> {
210        if ledger_schema_version < self.min_ledger_schema_version {
211            return Err(LedgerCompatibilityError::UnsupportedLedgerSchemaVersion {
212                found: ledger_schema_version,
213                min_supported: self.min_ledger_schema_version,
214                max_supported: self.max_ledger_schema_version,
215            });
216        }
217        if ledger_schema_version > self.max_ledger_schema_version {
218            return Err(LedgerCompatibilityError::UnsupportedLedgerSchemaVersion {
219                found: ledger_schema_version,
220                min_supported: self.min_ledger_schema_version,
221                max_supported: self.max_ledger_schema_version,
222            });
223        }
224        if physical_format_id != self.physical_format_id {
225            return Err(LedgerCompatibilityError::UnsupportedPhysicalFormat {
226                found: physical_format_id,
227                supported: self.physical_format_id,
228            });
229        }
230        Ok(())
231    }
232}
233
234impl Default for LedgerCompatibility {
235    fn default() -> Self {
236        Self::current()
237    }
238}
239
240///
241/// RecoveredLedger
242///
243/// Proof object for an allocation ledger that has crossed physical recovery,
244/// logical payload-envelope routing, compatibility checks, and committed
245/// integrity validation.
246///
247/// This type is not serializable and has no public constructor. It is the
248/// provenance boundary required before declarations can mint
249/// [`crate::ValidatedAllocations`].
250#[derive(Clone, Debug, Eq, PartialEq)]
251pub struct RecoveredLedger {
252    ledger: AllocationLedger,
253    physical_generation: u64,
254    ledger_schema_version: u32,
255    envelope_version: u16,
256}
257
258impl RecoveredLedger {
259    pub(crate) const fn from_trusted_parts(
260        ledger: AllocationLedger,
261        physical_generation: u64,
262        envelope_version: u16,
263    ) -> Self {
264        let ledger_schema_version = ledger.ledger_schema_version;
265        Self {
266            ledger,
267            physical_generation,
268            ledger_schema_version,
269            envelope_version,
270        }
271    }
272
273    /// Borrow the recovered canonical allocation ledger.
274    ///
275    /// The returned ledger is diagnostic/staging state. It is not itself an
276    /// authority token; callers must keep passing the `RecoveredLedger` proof
277    /// across validation boundaries.
278    #[must_use]
279    pub const fn ledger(&self) -> &AllocationLedger {
280        &self.ledger
281    }
282
283    /// Return the selected physical committed generation.
284    #[must_use]
285    pub const fn physical_generation(&self) -> u64 {
286        self.physical_generation
287    }
288
289    /// Return the recovered ledger's current logical generation.
290    #[must_use]
291    pub const fn current_generation(&self) -> u64 {
292        self.ledger.current_generation
293    }
294
295    /// Return the schema version routed by the logical payload envelope.
296    #[must_use]
297    pub const fn ledger_schema_version(&self) -> u32 {
298        self.ledger_schema_version
299    }
300
301    /// Return the payload envelope version used during recovery.
302    #[must_use]
303    pub const fn envelope_version(&self) -> u16 {
304        self.envelope_version
305    }
306
307    pub(crate) fn into_ledger(self) -> AllocationLedger {
308        self.ledger
309    }
310}
311
312impl AllocationHistory {
313    #[cfg(test)]
314    pub(crate) const fn from_parts(
315        records: Vec<AllocationRecord>,
316        generations: Vec<GenerationRecord>,
317    ) -> Self {
318        Self {
319            records,
320            generations,
321        }
322    }
323
324    /// Borrow stable-key allocation records in durable order.
325    #[must_use]
326    pub fn records(&self) -> &[AllocationRecord] {
327        &self.records
328    }
329
330    /// Borrow committed generation records in durable order.
331    #[must_use]
332    pub fn generations(&self) -> &[GenerationRecord] {
333        &self.generations
334    }
335
336    /// Return true when the history has no allocation records and no generation records.
337    #[must_use]
338    pub fn is_empty(&self) -> bool {
339        self.records.is_empty() && self.generations.is_empty()
340    }
341
342    pub(crate) const fn records_mut(&mut self) -> &mut Vec<AllocationRecord> {
343        &mut self.records
344    }
345
346    #[cfg(test)]
347    pub(crate) const fn generations_mut(&mut self) -> &mut Vec<GenerationRecord> {
348        &mut self.generations
349    }
350
351    pub(crate) fn push_record(&mut self, record: AllocationRecord) {
352        self.records.push(record);
353    }
354
355    pub(crate) fn push_generation(&mut self, generation: GenerationRecord) {
356        self.generations.push(generation);
357    }
358}
359
360impl SchemaMetadataRecord {
361    /// Build a schema metadata history record after validating the metadata.
362    pub fn new(generation: u64, schema: SchemaMetadata) -> Result<Self, SchemaMetadataError> {
363        schema.validate()?;
364        Ok(Self { generation, schema })
365    }
366
367    /// Return the generation that declared this schema metadata.
368    #[must_use]
369    pub const fn generation(&self) -> u64 {
370        self.generation
371    }
372
373    /// Return the schema metadata declared by that generation.
374    #[must_use]
375    pub const fn schema(&self) -> &SchemaMetadata {
376        &self.schema
377    }
378}
379
380impl GenerationRecord {
381    /// Build a committed generation diagnostic record after validating metadata.
382    pub fn new(
383        generation: u64,
384        parent_generation: Option<u64>,
385        runtime_fingerprint: Option<String>,
386        declaration_count: u32,
387        committed_at: Option<u64>,
388    ) -> Result<Self, DeclarationSnapshotError> {
389        validate_runtime_fingerprint(runtime_fingerprint.as_deref())?;
390        Ok(Self {
391            generation,
392            parent_generation,
393            runtime_fingerprint,
394            declaration_count,
395            committed_at,
396        })
397    }
398
399    /// Return the committed generation number.
400    #[must_use]
401    pub const fn generation(&self) -> u64 {
402        self.generation
403    }
404
405    /// Return the parent generation, if recorded.
406    #[must_use]
407    pub const fn parent_generation(&self) -> Option<u64> {
408        self.parent_generation
409    }
410
411    /// Borrow the optional binary/runtime fingerprint.
412    #[must_use]
413    pub fn runtime_fingerprint(&self) -> Option<&str> {
414        self.runtime_fingerprint.as_deref()
415    }
416
417    /// Return the number of declarations in the generation.
418    #[must_use]
419    pub const fn declaration_count(&self) -> u32 {
420        self.declaration_count
421    }
422
423    /// Return the optional commit timestamp supplied by the integration layer.
424    #[must_use]
425    pub const fn committed_at(&self) -> Option<u64> {
426        self.committed_at
427    }
428}
429
430impl AllocationRecord {
431    /// Create a new allocation record from a declaration.
432    #[must_use]
433    pub(crate) fn from_declaration(
434        generation: u64,
435        declaration: AllocationDeclaration,
436        state: AllocationState,
437    ) -> Self {
438        Self {
439            stable_key: declaration.stable_key,
440            slot: declaration.slot,
441            state,
442            first_generation: generation,
443            last_seen_generation: generation,
444            retired_generation: None,
445            schema_history: vec![
446                SchemaMetadataRecord::new(generation, declaration.schema)
447                    .expect("declarations validate schema metadata"),
448            ],
449        }
450    }
451
452    /// Create a new reserved allocation record from a declaration.
453    #[must_use]
454    pub(crate) fn reserved(generation: u64, declaration: AllocationDeclaration) -> Self {
455        Self::from_declaration(generation, declaration, AllocationState::Reserved)
456    }
457
458    /// Return the stable key that owns this allocation record.
459    #[must_use]
460    pub const fn stable_key(&self) -> &StableKey {
461        &self.stable_key
462    }
463
464    /// Return the durable allocation slot owned by this record.
465    #[must_use]
466    pub const fn slot(&self) -> &AllocationSlotDescriptor {
467        &self.slot
468    }
469
470    /// Return the current allocation lifecycle state.
471    #[must_use]
472    pub const fn state(&self) -> AllocationState {
473        self.state
474    }
475
476    /// Return the first committed generation that recorded this allocation.
477    #[must_use]
478    pub const fn first_generation(&self) -> u64 {
479        self.first_generation
480    }
481
482    /// Return the latest committed generation that observed this allocation.
483    #[must_use]
484    pub const fn last_seen_generation(&self) -> u64 {
485        self.last_seen_generation
486    }
487
488    /// Return the generation that explicitly retired this allocation, if any.
489    #[must_use]
490    pub const fn retired_generation(&self) -> Option<u64> {
491        self.retired_generation
492    }
493
494    /// Return the per-generation schema metadata history.
495    #[must_use]
496    pub fn schema_history(&self) -> &[SchemaMetadataRecord] {
497        &self.schema_history
498    }
499
500    pub(crate) fn observe_declaration(
501        &mut self,
502        generation: u64,
503        declaration: &AllocationDeclaration,
504    ) {
505        self.last_seen_generation = generation;
506        if self.state == AllocationState::Reserved {
507            self.state = AllocationState::Active;
508        }
509
510        let latest_schema = self.schema_history.last().map(|record| &record.schema);
511        if latest_schema != Some(&declaration.schema) {
512            self.schema_history.push(
513                SchemaMetadataRecord::new(generation, declaration.schema.clone())
514                    .expect("declarations validate schema metadata"),
515            );
516        }
517    }
518
519    pub(crate) fn observe_reservation(
520        &mut self,
521        generation: u64,
522        reservation: &AllocationDeclaration,
523    ) {
524        self.last_seen_generation = generation;
525
526        let latest_schema = self.schema_history.last().map(|record| &record.schema);
527        if latest_schema != Some(&reservation.schema) {
528            self.schema_history.push(
529                SchemaMetadataRecord::new(generation, reservation.schema.clone())
530                    .expect("reservations validate schema metadata"),
531            );
532        }
533    }
534}
535
536impl AllocationLedger {
537    /// Build a ledger DTO and validate structural ledger invariants.
538    ///
539    /// This constructor validates duplicate records, lifecycle state, record
540    /// generation bounds, and schema metadata records. It does not require a
541    /// complete committed-generation chain. Use
542    /// [`AllocationLedger::new_committed`] when constructing an authoritative
543    /// committed ledger DTO.
544    pub fn new(
545        ledger_schema_version: u32,
546        physical_format_id: u32,
547        current_generation: u64,
548        allocation_history: AllocationHistory,
549    ) -> Result<Self, LedgerIntegrityError> {
550        let ledger = Self {
551            ledger_schema_version,
552            physical_format_id,
553            current_generation,
554            allocation_history,
555        };
556        ledger.validate_integrity()?;
557        Ok(ledger)
558    }
559
560    /// Build a committed ledger DTO and validate strict committed-history invariants.
561    ///
562    /// This constructor runs the same committed-integrity checks used by
563    /// recovery and commit. Use it when the value should be treated as an
564    /// authoritative committed ledger, not merely as a structurally valid DTO.
565    pub fn new_committed(
566        ledger_schema_version: u32,
567        physical_format_id: u32,
568        current_generation: u64,
569        allocation_history: AllocationHistory,
570    ) -> Result<Self, LedgerIntegrityError> {
571        let ledger = Self::new(
572            ledger_schema_version,
573            physical_format_id,
574            current_generation,
575            allocation_history,
576        )?;
577        ledger.validate_committed_integrity()?;
578        Ok(ledger)
579    }
580
581    /// Return the ledger schema version.
582    #[must_use]
583    pub const fn ledger_schema_version(&self) -> u32 {
584        self.ledger_schema_version
585    }
586
587    /// Return the protected physical format identifier.
588    #[must_use]
589    pub const fn physical_format_id(&self) -> u32 {
590        self.physical_format_id
591    }
592
593    /// Return the current committed generation selected by recovery.
594    #[must_use]
595    pub const fn current_generation(&self) -> u64 {
596        self.current_generation
597    }
598
599    /// Return the historical allocation facts.
600    #[must_use]
601    pub const fn allocation_history(&self) -> &AllocationHistory {
602        &self.allocation_history
603    }
604}