Skip to main content

ic_memory/ledger/
mod.rs

1pub(crate) mod claim;
2mod error;
3mod integrity;
4mod record;
5
6use crate::{
7    declaration::AllocationDeclaration,
8    physical::{CommitRecoveryError, DualCommitStore},
9    session::ValidatedAllocations,
10};
11use claim::{ClaimConflict, ClaimOutcome, validate_declaration_claim, validate_reservation_claim};
12use serde::{Deserialize, Serialize};
13
14pub use error::{
15    AllocationReservationError, AllocationRetirementError, AllocationStageError, LedgerCommitError,
16    LedgerCompatibilityError, LedgerIntegrityError,
17};
18pub use record::{
19    AllocationHistory, AllocationLedger, AllocationRecord, AllocationRetirement, AllocationState,
20    CURRENT_LEDGER_SCHEMA_VERSION, CURRENT_PHYSICAL_FORMAT_ID, GenerationRecord,
21    LedgerCompatibility, SchemaMetadataRecord,
22};
23
24///
25/// LedgerCodec
26///
27/// Integration-supplied encoding for persisted allocation ledgers.
28///
29/// Decoding returns an untrusted durable DTO. Callers should recover ledgers
30/// through [`LedgerCommitStore`], which checks physical/logical generation,
31/// compatibility, and committed ledger integrity before returning authoritative
32/// state.
33///
34
35pub trait LedgerCodec {
36    /// Encoding or decoding error type.
37    type Error;
38
39    /// Encode a logical allocation ledger into durable bytes.
40    fn encode(&self, ledger: &AllocationLedger) -> Result<Vec<u8>, Self::Error>;
41
42    /// Decode durable bytes into a logical allocation ledger.
43    fn decode(&self, bytes: &[u8]) -> Result<AllocationLedger, Self::Error>;
44}
45
46///
47/// CborLedgerCodec
48///
49/// Native CBOR ledger codec for persisted [`AllocationLedger`] payloads.
50///
51/// This is the default codec for the native IC stack:
52/// `MemoryManager` ID 0 stores an `ic-stable-structures::Cell` containing a
53/// [`crate::StableCellLedgerRecord`], whose [`LedgerCommitStore`] contains
54/// dual protected CBOR-encoded ledger generations.
55///
56
57#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
58pub struct CborLedgerCodec;
59
60impl LedgerCodec for CborLedgerCodec {
61    type Error = serde_cbor::Error;
62
63    fn encode(&self, ledger: &AllocationLedger) -> Result<Vec<u8>, Self::Error> {
64        serde_cbor::to_vec(ledger)
65    }
66
67    fn decode(&self, bytes: &[u8]) -> Result<AllocationLedger, Self::Error> {
68        serde_cbor::from_slice(bytes)
69    }
70}
71
72///
73/// LedgerCommitStore
74///
75/// Generation-scoped allocation ledger commit store.
76///
77/// This type owns the generic commit lifecycle. It deliberately does not own
78/// serialization or stable-memory IO; those remain substrate/integration
79/// responsibilities.
80///
81/// This store commits allocation ledger generations. It does not open
82/// stable-memory handles and does not allocate application slots.
83///
84/// TODO: Move commit/recovery behavior to `ledger::commit` once the staging
85/// split is also mechanical, so the commit tests can move with their fixtures.
86///
87
88#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
89pub struct LedgerCommitStore {
90    /// Protected physical commit slots.
91    physical: DualCommitStore,
92}
93
94impl LedgerCommitStore {
95    /// Borrow the protected physical commit store for diagnostics.
96    #[must_use]
97    pub const fn physical(&self) -> &DualCommitStore {
98        &self.physical
99    }
100
101    #[cfg(test)]
102    pub(crate) const fn physical_mut(&mut self) -> &mut DualCommitStore {
103        &mut self.physical
104    }
105
106    /// Recover the authoritative allocation ledger using `codec`.
107    pub fn recover<C: LedgerCodec>(
108        &self,
109        codec: &C,
110    ) -> Result<AllocationLedger, LedgerCommitError<C::Error>> {
111        self.recover_with_compatibility(codec, LedgerCompatibility::current())
112    }
113
114    /// Recover the authoritative allocation ledger using explicit compatibility rules.
115    pub fn recover_with_compatibility<C: LedgerCodec>(
116        &self,
117        codec: &C,
118        compatibility: LedgerCompatibility,
119    ) -> Result<AllocationLedger, LedgerCommitError<C::Error>> {
120        let committed = self
121            .physical
122            .authoritative()
123            .map_err(LedgerCommitError::Recovery)?;
124        let ledger = codec
125            .decode(committed.payload())
126            .map_err(LedgerCommitError::Codec)?;
127        if committed.generation() != ledger.current_generation {
128            return Err(LedgerCommitError::PhysicalLogicalGenerationMismatch {
129                physical_generation: committed.generation(),
130                logical_generation: ledger.current_generation,
131            });
132        }
133        compatibility
134            .validate(&ledger)
135            .map_err(LedgerCommitError::Compatibility)?;
136        ledger
137            .validate_committed_integrity()
138            .map_err(LedgerCommitError::Integrity)?;
139        Ok(ledger)
140    }
141
142    /// Recover the authoritative ledger, or explicitly initialize an empty store.
143    ///
144    /// Initialization is allowed only when no physical commit slot has ever
145    /// been written. Corrupt or partially written stores fail closed even when
146    /// a genesis ledger is supplied.
147    pub fn recover_or_initialize<C: LedgerCodec>(
148        &mut self,
149        codec: &C,
150        genesis: &AllocationLedger,
151    ) -> Result<AllocationLedger, LedgerCommitError<C::Error>> {
152        self.recover_or_initialize_with_compatibility(
153            codec,
154            genesis,
155            LedgerCompatibility::current(),
156        )
157    }
158
159    /// Recover the authoritative ledger, or initialize an empty store with explicit compatibility.
160    pub fn recover_or_initialize_with_compatibility<C: LedgerCodec>(
161        &mut self,
162        codec: &C,
163        genesis: &AllocationLedger,
164        compatibility: LedgerCompatibility,
165    ) -> Result<AllocationLedger, LedgerCommitError<C::Error>> {
166        match self.recover_with_compatibility(codec, compatibility) {
167            Ok(ledger) => Ok(ledger),
168            Err(LedgerCommitError::Recovery(CommitRecoveryError::NoValidGeneration))
169                if self.physical.is_uninitialized() =>
170            {
171                self.commit_with_compatibility(genesis, codec, compatibility)
172            }
173            Err(err) => Err(err),
174        }
175    }
176
177    /// Commit one logical allocation ledger generation through `codec`.
178    pub fn commit<C: LedgerCodec>(
179        &mut self,
180        ledger: &AllocationLedger,
181        codec: &C,
182    ) -> Result<AllocationLedger, LedgerCommitError<C::Error>> {
183        self.commit_with_compatibility(ledger, codec, LedgerCompatibility::current())
184    }
185
186    /// Commit one logical allocation ledger generation through explicit compatibility.
187    pub fn commit_with_compatibility<C: LedgerCodec>(
188        &mut self,
189        ledger: &AllocationLedger,
190        codec: &C,
191        compatibility: LedgerCompatibility,
192    ) -> Result<AllocationLedger, LedgerCommitError<C::Error>> {
193        compatibility
194            .validate(ledger)
195            .map_err(LedgerCommitError::Compatibility)?;
196        ledger
197            .validate_committed_integrity()
198            .map_err(LedgerCommitError::Integrity)?;
199        let payload = codec.encode(ledger).map_err(LedgerCommitError::Codec)?;
200        self.physical
201            .commit_payload_at_generation(ledger.current_generation, payload)
202            .map_err(LedgerCommitError::Recovery)?;
203        self.recover_with_compatibility(codec, compatibility)
204    }
205
206    /// Simulate a torn write of a logical ledger payload into the inactive slot.
207    #[cfg(test)]
208    pub fn write_corrupt_inactive_ledger<C: LedgerCodec>(
209        &mut self,
210        ledger: &AllocationLedger,
211        codec: &C,
212    ) -> Result<(), LedgerCommitError<C::Error>> {
213        let payload = codec.encode(ledger).map_err(LedgerCommitError::Codec)?;
214        self.physical
215            .write_corrupt_inactive_slot(ledger.current_generation, payload);
216        Ok(())
217    }
218}
219
220impl AllocationLedger {
221    // TODO: Move staging behavior to `ledger::stage` in a later mechanical split.
222    /// Return a copy of the ledger with `validated` recorded as the next generation.
223    ///
224    /// This is a pure logical update. Physical atomicity is the responsibility of
225    /// the substrate commit protocol.
226    pub fn stage_validated_generation(
227        &self,
228        validated: &ValidatedAllocations,
229        committed_at: Option<u64>,
230    ) -> Result<Self, AllocationStageError> {
231        if validated.generation() != self.current_generation {
232            return Err(AllocationStageError::StaleValidatedAllocations {
233                validated_generation: validated.generation(),
234                ledger_generation: self.current_generation,
235            });
236        }
237        let next_generation = checked_next_generation(self.current_generation)
238            .map_err(|generation| AllocationStageError::GenerationOverflow { generation })?;
239        let mut next = self.clone();
240        next.current_generation = next_generation;
241        let declaration_count = u32::try_from(validated.declarations().len()).unwrap_or(u32::MAX);
242
243        for declaration in validated.declarations() {
244            declaration.schema.validate().map_err(|error| {
245                AllocationStageError::InvalidSchemaMetadata {
246                    stable_key: declaration.stable_key.clone(),
247                    error,
248                }
249            })?;
250            record_declaration(&mut next, next_generation, declaration)?;
251        }
252
253        next.allocation_history.push_generation(GenerationRecord {
254            generation: next_generation,
255            parent_generation: Some(self.current_generation),
256            runtime_fingerprint: validated.runtime_fingerprint().map(str::to_string),
257            declaration_count,
258            committed_at,
259        });
260
261        Ok(next)
262    }
263
264    /// Return a copy of the ledger with `reservations` recorded as the next generation.
265    ///
266    /// This is a pure logical update. The caller is responsible for applying
267    /// framework policy before staging reservations.
268    pub fn stage_reservation_generation(
269        &self,
270        reservations: &[AllocationDeclaration],
271        committed_at: Option<u64>,
272    ) -> Result<Self, AllocationReservationError> {
273        let next_generation = checked_next_generation(self.current_generation)
274            .map_err(|generation| AllocationReservationError::GenerationOverflow { generation })?;
275        let mut next = self.clone();
276        next.current_generation = next_generation;
277
278        for reservation in reservations {
279            reservation.schema.validate().map_err(|error| {
280                AllocationReservationError::InvalidSchemaMetadata {
281                    stable_key: reservation.stable_key.clone(),
282                    error,
283                }
284            })?;
285            record_reservation(&mut next, next_generation, reservation)?;
286        }
287
288        next.allocation_history.push_generation(GenerationRecord {
289            generation: next_generation,
290            parent_generation: Some(self.current_generation),
291            runtime_fingerprint: None,
292            declaration_count: u32::try_from(reservations.len()).unwrap_or(u32::MAX),
293            committed_at,
294        });
295
296        Ok(next)
297    }
298
299    /// Return a copy of the ledger with one explicit retirement committed.
300    pub fn stage_retirement_generation(
301        &self,
302        retirement: &AllocationRetirement,
303        committed_at: Option<u64>,
304    ) -> Result<Self, AllocationRetirementError> {
305        let next_generation = checked_next_generation(self.current_generation)
306            .map_err(|generation| AllocationRetirementError::GenerationOverflow { generation })?;
307        let mut next = self.clone();
308        let record = next
309            .allocation_history
310            .records_mut()
311            .iter_mut()
312            .find(|record| record.stable_key == retirement.stable_key)
313            .ok_or_else(|| {
314                AllocationRetirementError::UnknownStableKey(retirement.stable_key.clone())
315            })?;
316
317        if record.slot != retirement.slot {
318            return Err(AllocationRetirementError::SlotMismatch {
319                stable_key: retirement.stable_key.clone(),
320                historical_slot: Box::new(record.slot.clone()),
321                retired_slot: Box::new(retirement.slot.clone()),
322            });
323        }
324        if record.state == AllocationState::Retired {
325            return Err(AllocationRetirementError::AlreadyRetired {
326                stable_key: retirement.stable_key.clone(),
327                slot: Box::new(record.slot.clone()),
328            });
329        }
330
331        record.state = AllocationState::Retired;
332        record.retired_generation = Some(next_generation);
333        next.current_generation = next_generation;
334        next.allocation_history.push_generation(GenerationRecord {
335            generation: next_generation,
336            parent_generation: Some(self.current_generation),
337            runtime_fingerprint: None,
338            declaration_count: 0,
339            committed_at,
340        });
341
342        Ok(next)
343    }
344}
345
346fn record_declaration(
347    ledger: &mut AllocationLedger,
348    generation: u64,
349    declaration: &AllocationDeclaration,
350) -> Result<(), AllocationStageError> {
351    match validate_declaration_claim(ledger, declaration) {
352        Ok(ClaimOutcome::Existing { record_index }) => {
353            ledger.allocation_history.records_mut()[record_index]
354                .observe_declaration(generation, declaration);
355            Ok(())
356        }
357        Ok(ClaimOutcome::New) => {
358            ledger
359                .allocation_history
360                .push_record(AllocationRecord::from_declaration(
361                    generation,
362                    declaration.clone(),
363                    AllocationState::Active,
364                ));
365            Ok(())
366        }
367        Err(conflict) => Err(map_declaration_stage_conflict(
368            ledger,
369            declaration,
370            conflict,
371        )),
372    }
373}
374
375const fn checked_next_generation(current_generation: u64) -> Result<u64, u64> {
376    match current_generation.checked_add(1) {
377        Some(next_generation) => Ok(next_generation),
378        None => Err(current_generation),
379    }
380}
381
382fn record_reservation(
383    ledger: &mut AllocationLedger,
384    generation: u64,
385    reservation: &AllocationDeclaration,
386) -> Result<(), AllocationReservationError> {
387    match validate_reservation_claim(ledger, reservation) {
388        Ok(ClaimOutcome::Existing { record_index }) => {
389            ledger.allocation_history.records_mut()[record_index]
390                .observe_reservation(generation, reservation);
391            Ok(())
392        }
393        Ok(ClaimOutcome::New) => {
394            ledger
395                .allocation_history
396                .push_record(AllocationRecord::reserved(generation, reservation.clone()));
397            Ok(())
398        }
399        Err(conflict) => Err(map_reservation_stage_conflict(
400            ledger,
401            reservation,
402            conflict,
403        )),
404    }
405}
406
407fn map_declaration_stage_conflict(
408    ledger: &AllocationLedger,
409    declaration: &AllocationDeclaration,
410    conflict: ClaimConflict,
411) -> AllocationStageError {
412    match conflict {
413        ClaimConflict::StableKeyMoved { record_index } => {
414            let record = &ledger.allocation_history.records()[record_index];
415            AllocationStageError::StableKeySlotConflict {
416                stable_key: declaration.stable_key.clone(),
417                historical_slot: Box::new(record.slot.clone()),
418                declared_slot: Box::new(declaration.slot.clone()),
419            }
420        }
421        ClaimConflict::SlotReused { record_index } => {
422            let record = &ledger.allocation_history.records()[record_index];
423            AllocationStageError::SlotStableKeyConflict {
424                slot: Box::new(declaration.slot.clone()),
425                historical_key: record.stable_key.clone(),
426                declared_key: declaration.stable_key.clone(),
427            }
428        }
429        ClaimConflict::Tombstoned { record_index } => {
430            let record = &ledger.allocation_history.records()[record_index];
431            AllocationStageError::RetiredAllocation {
432                stable_key: declaration.stable_key.clone(),
433                slot: Box::new(record.slot.clone()),
434            }
435        }
436        ClaimConflict::ActiveAllocation { .. } => {
437            unreachable!("active allocation conflicts are reservation-only")
438        }
439    }
440}
441
442fn map_reservation_stage_conflict(
443    ledger: &AllocationLedger,
444    reservation: &AllocationDeclaration,
445    conflict: ClaimConflict,
446) -> AllocationReservationError {
447    match conflict {
448        ClaimConflict::StableKeyMoved { record_index } => {
449            let record = &ledger.allocation_history.records()[record_index];
450            AllocationReservationError::StableKeySlotConflict {
451                stable_key: reservation.stable_key.clone(),
452                historical_slot: Box::new(record.slot.clone()),
453                reserved_slot: Box::new(reservation.slot.clone()),
454            }
455        }
456        ClaimConflict::SlotReused { record_index } => {
457            let record = &ledger.allocation_history.records()[record_index];
458            AllocationReservationError::SlotStableKeyConflict {
459                slot: Box::new(reservation.slot.clone()),
460                historical_key: record.stable_key.clone(),
461                reserved_key: reservation.stable_key.clone(),
462            }
463        }
464        ClaimConflict::Tombstoned { record_index } => {
465            let record = &ledger.allocation_history.records()[record_index];
466            AllocationReservationError::RetiredAllocation {
467                stable_key: reservation.stable_key.clone(),
468                slot: Box::new(record.slot.clone()),
469            }
470        }
471        ClaimConflict::ActiveAllocation { record_index } => {
472            let record = &ledger.allocation_history.records()[record_index];
473            AllocationReservationError::ActiveAllocation {
474                stable_key: reservation.stable_key.clone(),
475                slot: Box::new(record.slot.clone()),
476            }
477        }
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::{
485        declaration::{DeclarationSnapshot, DeclarationSnapshotError},
486        key::StableKey,
487        physical::CommittedGenerationBytes,
488        schema::{SchemaMetadata, SchemaMetadataError},
489        slot::AllocationSlotDescriptor,
490    };
491    use std::cell::RefCell;
492
493    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
494    struct TestCodec;
495
496    impl LedgerCodec for TestCodec {
497        type Error = &'static str;
498
499        fn encode(&self, ledger: &AllocationLedger) -> Result<Vec<u8>, Self::Error> {
500            let mut bytes = Vec::with_capacity(16);
501            bytes.extend_from_slice(&ledger.ledger_schema_version.to_le_bytes());
502            bytes.extend_from_slice(&ledger.physical_format_id.to_le_bytes());
503            bytes.extend_from_slice(&ledger.current_generation.to_le_bytes());
504            Ok(bytes)
505        }
506
507        fn decode(&self, bytes: &[u8]) -> Result<AllocationLedger, Self::Error> {
508            let bytes = <[u8; 16]>::try_from(bytes).map_err(|_| "invalid ledger")?;
509            let ledger_schema_version =
510                u32::from_le_bytes(bytes[0..4].try_into().map_err(|_| "invalid schema")?);
511            let physical_format_id =
512                u32::from_le_bytes(bytes[4..8].try_into().map_err(|_| "invalid format")?);
513            let current_generation =
514                u64::from_le_bytes(bytes[8..16].try_into().map_err(|_| "invalid generation")?);
515            let mut ledger = committed_ledger(current_generation);
516            ledger.ledger_schema_version = ledger_schema_version;
517            ledger.physical_format_id = physical_format_id;
518            Ok(ledger)
519        }
520    }
521
522    #[derive(Debug, Default)]
523    struct FullLedgerCodec {
524        ledgers: RefCell<Vec<AllocationLedger>>,
525    }
526
527    impl LedgerCodec for FullLedgerCodec {
528        type Error = &'static str;
529
530        fn encode(&self, ledger: &AllocationLedger) -> Result<Vec<u8>, Self::Error> {
531            let mut ledgers = self.ledgers.borrow_mut();
532            let index = u64::try_from(ledgers.len()).map_err(|_| "too many ledgers")?;
533            ledgers.push(ledger.clone());
534            Ok(index.to_le_bytes().to_vec())
535        }
536
537        fn decode(&self, bytes: &[u8]) -> Result<AllocationLedger, Self::Error> {
538            let bytes = <[u8; 8]>::try_from(bytes).map_err(|_| "invalid ledger index")?;
539            let index =
540                usize::try_from(u64::from_le_bytes(bytes)).map_err(|_| "invalid ledger index")?;
541            self.ledgers
542                .borrow()
543                .get(index)
544                .cloned()
545                .ok_or("unknown ledger index")
546        }
547    }
548
549    fn declaration(key: &str, id: u8, schema_version: Option<u32>) -> AllocationDeclaration {
550        AllocationDeclaration::new(
551            key,
552            AllocationSlotDescriptor::memory_manager(id).expect("usable slot"),
553            None,
554            SchemaMetadata {
555                schema_version,
556                schema_fingerprint: None,
557            },
558        )
559        .expect("declaration")
560    }
561
562    fn invalid_schema_metadata() -> SchemaMetadata {
563        SchemaMetadata {
564            schema_version: Some(0),
565            schema_fingerprint: None,
566        }
567    }
568
569    fn declaration_with_invalid_schema(key: &str, id: u8) -> AllocationDeclaration {
570        let mut declaration = declaration(key, id, Some(1));
571        declaration.schema = invalid_schema_metadata();
572        declaration
573    }
574
575    fn ledger() -> AllocationLedger {
576        AllocationLedger {
577            ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION,
578            physical_format_id: CURRENT_PHYSICAL_FORMAT_ID,
579            current_generation: 3,
580            allocation_history: AllocationHistory::default(),
581        }
582    }
583
584    fn committed_ledger(current_generation: u64) -> AllocationLedger {
585        AllocationLedger {
586            ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION,
587            physical_format_id: CURRENT_PHYSICAL_FORMAT_ID,
588            current_generation,
589            allocation_history: AllocationHistory::from_parts(
590                Vec::new(),
591                (1..=current_generation)
592                    .map(|generation| GenerationRecord {
593                        generation,
594                        parent_generation: if generation == 1 {
595                            Some(0)
596                        } else {
597                            Some(generation - 1)
598                        },
599                        runtime_fingerprint: None,
600                        declaration_count: 0,
601                        committed_at: None,
602                    })
603                    .collect(),
604            ),
605        }
606    }
607
608    fn active_record(key: &str, id: u8) -> AllocationRecord {
609        AllocationRecord::from_declaration(1, declaration(key, id, None), AllocationState::Active)
610    }
611
612    fn validated(
613        generation: u64,
614        declarations: Vec<AllocationDeclaration>,
615    ) -> crate::session::ValidatedAllocations {
616        crate::session::ValidatedAllocations::new(generation, declarations, None)
617    }
618
619    fn record<'ledger>(ledger: &'ledger AllocationLedger, key: &str) -> &'ledger AllocationRecord {
620        ledger
621            .allocation_history
622            .records()
623            .iter()
624            .find(|record| record.stable_key.as_str() == key)
625            .expect("allocation record")
626    }
627
628    #[test]
629    fn allocation_history_accessors_expose_read_only_views() {
630        let history = AllocationHistory::from_parts(
631            vec![active_record("app.users.v1", 100)],
632            vec![GenerationRecord::new(1, Some(0), None, 1, Some(42)).expect("generation record")],
633        );
634
635        assert!(!history.is_empty());
636        assert_eq!(history.records().len(), 1);
637        assert_eq!(history.generations().len(), 1);
638        assert_eq!(history.generations()[0].committed_at(), Some(42));
639    }
640
641    #[test]
642    fn record_constructors_validate_metadata() {
643        let schema_err = SchemaMetadataRecord::new(1, invalid_schema_metadata())
644            .expect_err("invalid schema must fail");
645        assert_eq!(schema_err, SchemaMetadataError::InvalidVersion);
646
647        let generation_err = GenerationRecord::new(1, Some(0), Some(String::new()), 0, None)
648            .expect_err("empty fingerprint must fail");
649        assert_eq!(
650            generation_err,
651            DeclarationSnapshotError::EmptyRuntimeFingerprint
652        );
653    }
654
655    #[test]
656    fn cbor_ledger_codec_round_trips_allocation_ledger() {
657        let ledger = committed_ledger(2);
658        let codec = CborLedgerCodec;
659
660        let encoded = codec.encode(&ledger).expect("encode ledger");
661        let decoded = codec.decode(&encoded).expect("decode ledger");
662
663        assert_eq!(decoded, ledger);
664    }
665
666    #[test]
667    fn stage_validated_generation_records_new_allocations() {
668        let declarations = vec![declaration("app.users.v1", 100, Some(1))];
669        let validated = validated(3, declarations);
670
671        let staged = ledger()
672            .stage_validated_generation(&validated, Some(42))
673            .expect("staged generation");
674
675        assert_eq!(staged.current_generation, 4);
676        assert_eq!(staged.allocation_history.records().len(), 1);
677        assert_eq!(staged.allocation_history.records()[0].first_generation, 4);
678        assert_eq!(staged.allocation_history.generations()[0].generation, 4);
679        assert_eq!(
680            staged.allocation_history.generations()[0].committed_at,
681            Some(42)
682        );
683    }
684
685    #[test]
686    fn stage_validated_generation_rejects_stale_validated_allocations() {
687        let validated = validated(2, vec![declaration("app.users.v1", 100, Some(1))]);
688
689        let err = ledger()
690            .stage_validated_generation(&validated, None)
691            .expect_err("stale validated allocations");
692
693        assert_eq!(
694            err,
695            AllocationStageError::StaleValidatedAllocations {
696                validated_generation: 2,
697                ledger_generation: 3
698            }
699        );
700    }
701
702    #[test]
703    fn stage_validated_generation_rejects_invalid_schema_metadata() {
704        let validated = crate::session::ValidatedAllocations::new(
705            3,
706            vec![declaration_with_invalid_schema("app.users.v1", 100)],
707            None,
708        );
709
710        let err = ledger()
711            .stage_validated_generation(&validated, None)
712            .expect_err("invalid schema metadata");
713
714        assert_eq!(
715            err,
716            AllocationStageError::InvalidSchemaMetadata {
717                stable_key: StableKey::parse("app.users.v1").expect("stable key"),
718                error: SchemaMetadataError::InvalidVersion,
719            }
720        );
721    }
722
723    #[test]
724    fn stage_validated_generation_rejects_generation_overflow() {
725        let ledger = AllocationLedger {
726            current_generation: u64::MAX,
727            ..ledger()
728        };
729        let validated = validated(u64::MAX, vec![declaration("app.users.v1", 100, Some(1))]);
730
731        let err = ledger
732            .stage_validated_generation(&validated, None)
733            .expect_err("overflow must fail");
734
735        assert_eq!(
736            err,
737            AllocationStageError::GenerationOverflow {
738                generation: u64::MAX
739            }
740        );
741    }
742
743    #[test]
744    fn stage_validated_generation_rejects_same_key_different_slot() {
745        let mut ledger = ledger();
746        *ledger.allocation_history.records_mut() = vec![active_record("app.users.v1", 100)];
747        let validated = validated(3, vec![declaration("app.users.v1", 101, None)]);
748
749        let err = ledger
750            .stage_validated_generation(&validated, None)
751            .expect_err("stable key cannot move slots");
752
753        assert!(matches!(
754            err,
755            AllocationStageError::StableKeySlotConflict { .. }
756        ));
757    }
758
759    #[test]
760    fn stage_validated_generation_rejects_same_slot_different_key() {
761        let mut ledger = ledger();
762        *ledger.allocation_history.records_mut() = vec![active_record("app.users.v1", 100)];
763        let validated = validated(3, vec![declaration("app.orders.v1", 100, None)]);
764
765        let err = ledger
766            .stage_validated_generation(&validated, None)
767            .expect_err("slot cannot be reused by another key");
768
769        assert!(matches!(
770            err,
771            AllocationStageError::SlotStableKeyConflict { .. }
772        ));
773    }
774
775    #[test]
776    fn stage_validated_generation_rejects_retired_redeclaration() {
777        let mut ledger = ledger();
778        let mut record = active_record("app.users.v1", 100);
779        record.state = AllocationState::Retired;
780        record.retired_generation = Some(3);
781        *ledger.allocation_history.records_mut() = vec![record];
782        let validated = validated(3, vec![declaration("app.users.v1", 100, None)]);
783
784        let err = ledger
785            .stage_validated_generation(&validated, None)
786            .expect_err("retired allocation cannot be redeclared");
787
788        assert!(matches!(
789            err,
790            AllocationStageError::RetiredAllocation { .. }
791        ));
792    }
793
794    #[test]
795    fn stage_validated_generation_preserves_omitted_records() {
796        let first = validated(
797            3,
798            vec![
799                declaration("app.users.v1", 100, Some(1)),
800                declaration("app.orders.v1", 101, Some(1)),
801            ],
802        );
803        let second = validated(4, vec![declaration("app.users.v1", 100, Some(1))]);
804
805        let staged = ledger()
806            .stage_validated_generation(&first, None)
807            .expect("first generation");
808        let staged = staged
809            .stage_validated_generation(&second, None)
810            .expect("second generation");
811
812        assert_eq!(staged.current_generation, 5);
813        assert_eq!(staged.allocation_history.records().len(), 2);
814        let omitted = staged
815            .allocation_history
816            .records()
817            .iter()
818            .find(|record| record.stable_key.as_str() == "app.orders.v1")
819            .expect("omitted record");
820        assert_eq!(omitted.state, AllocationState::Active);
821        assert_eq!(omitted.last_seen_generation, 4);
822    }
823
824    #[test]
825    fn stage_validated_generation_records_schema_metadata_history() {
826        let first = validated(3, vec![declaration("app.users.v1", 100, Some(1))]);
827        let second = validated(4, vec![declaration("app.users.v1", 100, Some(2))]);
828
829        let staged = ledger()
830            .stage_validated_generation(&first, None)
831            .expect("first generation");
832        let staged = staged
833            .stage_validated_generation(&second, None)
834            .expect("second generation");
835        let record = &staged.allocation_history.records()[0];
836
837        assert_eq!(record.schema_history.len(), 2);
838        assert_eq!(record.schema_history[0].generation, 4);
839        assert_eq!(record.schema_history[1].generation, 5);
840    }
841
842    #[test]
843    fn stage_reservation_generation_records_reserved_allocations() {
844        let reservations = vec![declaration("ic_memory.generation_log.v1", 1, None)];
845
846        let staged = ledger()
847            .stage_reservation_generation(&reservations, Some(42))
848            .expect("reserved generation");
849
850        assert_eq!(staged.current_generation, 4);
851        assert_eq!(staged.allocation_history.records().len(), 1);
852        assert_eq!(
853            staged.allocation_history.records()[0].state,
854            AllocationState::Reserved
855        );
856        assert_eq!(
857            staged.allocation_history.generations()[0].declaration_count,
858            1
859        );
860    }
861
862    #[test]
863    fn stage_reservation_generation_refreshes_existing_reserved_allocation() {
864        let first = vec![declaration("app.future_store.v1", 100, Some(1))];
865        let staged = ledger()
866            .stage_reservation_generation(&first, Some(42))
867            .expect("first reservation generation");
868
869        let second = vec![declaration("app.future_store.v1", 100, Some(2))];
870        let staged = staged
871            .stage_reservation_generation(&second, Some(43))
872            .expect("reservation refresh");
873        let record = record(&staged, "app.future_store.v1");
874
875        assert_eq!(record.state(), AllocationState::Reserved);
876        assert_eq!(record.first_generation(), 4);
877        assert_eq!(record.last_seen_generation(), 5);
878        assert_eq!(record.schema_history().len(), 2);
879        assert_eq!(record.schema_history()[1].generation(), 5);
880        assert_eq!(
881            staged.allocation_history.generations()[1].declaration_count(),
882            1
883        );
884    }
885
886    #[test]
887    fn stage_reservation_generation_rejects_generation_overflow() {
888        let ledger = AllocationLedger {
889            current_generation: u64::MAX,
890            ..ledger()
891        };
892        let reservations = vec![declaration("ic_memory.generation_log.v1", 1, None)];
893
894        let err = ledger
895            .stage_reservation_generation(&reservations, None)
896            .expect_err("overflow must fail");
897
898        assert_eq!(
899            err,
900            AllocationReservationError::GenerationOverflow {
901                generation: u64::MAX
902            }
903        );
904    }
905
906    #[test]
907    fn stage_reservation_generation_rejects_invalid_schema_metadata() {
908        let reservations = vec![declaration_with_invalid_schema(
909            "ic_memory.generation_log.v1",
910            1,
911        )];
912
913        let err = ledger()
914            .stage_reservation_generation(&reservations, None)
915            .expect_err("invalid reservation schema metadata");
916
917        assert_eq!(
918            err,
919            AllocationReservationError::InvalidSchemaMetadata {
920                stable_key: StableKey::parse("ic_memory.generation_log.v1").expect("stable key"),
921                error: SchemaMetadataError::InvalidVersion,
922            }
923        );
924    }
925
926    #[test]
927    fn stage_reservation_generation_rejects_same_key_different_slot() {
928        let mut ledger = ledger();
929        *ledger.allocation_history.records_mut() = vec![AllocationRecord::reserved(
930            3,
931            declaration("app.future_store.v1", 100, None),
932        )];
933        let reservations = vec![declaration("app.future_store.v1", 101, None)];
934
935        let err = ledger
936            .stage_reservation_generation(&reservations, None)
937            .expect_err("reservation key cannot move slots");
938
939        assert!(matches!(
940            err,
941            AllocationReservationError::StableKeySlotConflict { .. }
942        ));
943    }
944
945    #[test]
946    fn stage_reservation_generation_rejects_same_slot_different_key() {
947        let mut ledger = ledger();
948        *ledger.allocation_history.records_mut() = vec![AllocationRecord::reserved(
949            3,
950            declaration("app.future_store.v1", 100, None),
951        )];
952        let reservations = vec![declaration("app.other_future_store.v1", 100, None)];
953
954        let err = ledger
955            .stage_reservation_generation(&reservations, None)
956            .expect_err("reservation slot cannot be reused by another key");
957
958        assert!(matches!(
959            err,
960            AllocationReservationError::SlotStableKeyConflict { .. }
961        ));
962    }
963
964    #[test]
965    fn stage_reservation_generation_rejects_active_allocation() {
966        let active = validated(3, vec![declaration("app.users.v1", 100, None)]);
967        let staged = ledger()
968            .stage_validated_generation(&active, None)
969            .expect("active generation");
970        let reservations = vec![declaration("app.users.v1", 100, None)];
971
972        let err = staged
973            .stage_reservation_generation(&reservations, None)
974            .expect_err("active cannot become reserved");
975
976        assert!(matches!(
977            err,
978            AllocationReservationError::ActiveAllocation { .. }
979        ));
980    }
981
982    #[test]
983    fn stage_reservation_generation_rejects_retired_allocation() {
984        let mut ledger = ledger();
985        let mut record = active_record("app.users.v1", 100);
986        record.state = AllocationState::Retired;
987        record.retired_generation = Some(3);
988        *ledger.allocation_history.records_mut() = vec![record];
989        let reservations = vec![declaration("app.users.v1", 100, None)];
990
991        let err = ledger
992            .stage_reservation_generation(&reservations, None)
993            .expect_err("retired cannot revive");
994
995        assert!(matches!(
996            err,
997            AllocationReservationError::RetiredAllocation { .. }
998        ));
999    }
1000
1001    #[test]
1002    fn stage_validated_generation_activates_reserved_record() {
1003        let reservations = vec![declaration("app.future_store.v1", 100, Some(1))];
1004        let staged = ledger()
1005            .stage_reservation_generation(&reservations, None)
1006            .expect("reserved generation");
1007        let active = validated(4, vec![declaration("app.future_store.v1", 100, Some(2))]);
1008
1009        let staged = staged
1010            .stage_validated_generation(&active, None)
1011            .expect("active generation");
1012        let record = &staged.allocation_history.records()[0];
1013
1014        assert_eq!(record.state, AllocationState::Active);
1015        assert_eq!(record.first_generation, 4);
1016        assert_eq!(record.last_seen_generation, 5);
1017        assert_eq!(record.schema_history.len(), 2);
1018    }
1019
1020    #[test]
1021    fn stage_retirement_generation_tombstones_named_allocation() {
1022        let active = validated(3, vec![declaration("app.users.v1", 100, None)]);
1023        let staged = ledger()
1024            .stage_validated_generation(&active, None)
1025            .expect("active generation");
1026        let retirement = AllocationRetirement::new(
1027            "app.users.v1",
1028            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
1029        )
1030        .expect("retirement");
1031
1032        let staged = staged
1033            .stage_retirement_generation(&retirement, Some(42))
1034            .expect("retired generation");
1035        let record = &staged.allocation_history.records()[0];
1036
1037        assert_eq!(staged.current_generation, 5);
1038        assert_eq!(record.state, AllocationState::Retired);
1039        assert_eq!(record.retired_generation, Some(5));
1040        assert_eq!(
1041            staged.allocation_history.generations()[1].declaration_count,
1042            0
1043        );
1044    }
1045
1046    #[test]
1047    fn stage_retirement_generation_rejects_generation_overflow() {
1048        let mut ledger = ledger();
1049        ledger.current_generation = u64::MAX;
1050        *ledger.allocation_history.records_mut() = vec![active_record("app.users.v1", 100)];
1051        let retirement = AllocationRetirement::new(
1052            "app.users.v1",
1053            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
1054        )
1055        .expect("retirement");
1056
1057        let err = ledger
1058            .stage_retirement_generation(&retirement, None)
1059            .expect_err("overflow must fail");
1060
1061        assert_eq!(
1062            err,
1063            AllocationRetirementError::GenerationOverflow {
1064                generation: u64::MAX
1065            }
1066        );
1067    }
1068
1069    #[test]
1070    fn stage_retirement_generation_requires_matching_slot() {
1071        let active = validated(3, vec![declaration("app.users.v1", 100, None)]);
1072        let staged = ledger()
1073            .stage_validated_generation(&active, None)
1074            .expect("active generation");
1075        let retirement = AllocationRetirement::new(
1076            "app.users.v1",
1077            AllocationSlotDescriptor::memory_manager(101).expect("usable slot"),
1078        )
1079        .expect("retirement");
1080
1081        let err = staged
1082            .stage_retirement_generation(&retirement, None)
1083            .expect_err("slot mismatch");
1084
1085        assert!(matches!(
1086            err,
1087            AllocationRetirementError::SlotMismatch { .. }
1088        ));
1089    }
1090
1091    #[test]
1092    fn snapshot_can_feed_validated_generation() {
1093        let snapshot = DeclarationSnapshot::new(vec![declaration("app.users.v1", 100, None)])
1094            .expect("snapshot");
1095        let (declarations, runtime_fingerprint) = snapshot.into_parts();
1096        let validated =
1097            crate::session::ValidatedAllocations::new(3, declarations, runtime_fingerprint);
1098
1099        let staged = ledger()
1100            .stage_validated_generation(&validated, None)
1101            .expect("validated generation");
1102
1103        assert_eq!(staged.allocation_history.records().len(), 1);
1104    }
1105
1106    #[test]
1107    fn stage_validated_generation_records_runtime_fingerprint() {
1108        let validated = crate::session::ValidatedAllocations::new(
1109            3,
1110            vec![declaration("app.users.v1", 100, None)],
1111            Some("wasm:abc123".to_string()),
1112        );
1113
1114        let staged = ledger()
1115            .stage_validated_generation(&validated, None)
1116            .expect("validated generation");
1117
1118        assert_eq!(
1119            staged.allocation_history.generations()[0].runtime_fingerprint,
1120            Some("wasm:abc123".to_string())
1121        );
1122    }
1123
1124    #[test]
1125    fn strict_committed_integrity_accepts_full_lifecycle() {
1126        let mut ledger = committed_ledger(0);
1127        ledger
1128            .validate_committed_integrity()
1129            .expect("genesis ledger with no history");
1130
1131        ledger = ledger
1132            .stage_validated_generation(
1133                &validated(0, vec![declaration("app.users.v1", 100, Some(1))]),
1134                Some(1),
1135            )
1136            .expect("first real commit after genesis");
1137        ledger
1138            .validate_committed_integrity()
1139            .expect("first real commit");
1140
1141        ledger = ledger
1142            .stage_validated_generation(
1143                &validated(1, vec![declaration("app.users.v1", 100, Some(1))]),
1144                Some(2),
1145            )
1146            .expect("repeated active declaration");
1147        ledger
1148            .validate_committed_integrity()
1149            .expect("repeated active declaration");
1150        assert_eq!(record(&ledger, "app.users.v1").schema_history.len(), 1);
1151
1152        ledger = ledger
1153            .stage_validated_generation(
1154                &validated(2, vec![declaration("app.users.v1", 100, Some(2))]),
1155                Some(3),
1156            )
1157            .expect("schema drift");
1158        ledger
1159            .validate_committed_integrity()
1160            .expect("schema metadata drift");
1161        assert_eq!(record(&ledger, "app.users.v1").schema_history.len(), 2);
1162
1163        ledger = ledger
1164            .stage_reservation_generation(
1165                &[declaration("app.future_store.v1", 101, Some(1))],
1166                Some(4),
1167            )
1168            .expect("reservation-only generation");
1169        ledger
1170            .validate_committed_integrity()
1171            .expect("reservation-only generation");
1172        assert_eq!(
1173            record(&ledger, "app.future_store.v1").state,
1174            AllocationState::Reserved
1175        );
1176
1177        ledger = ledger
1178            .stage_validated_generation(
1179                &validated(4, vec![declaration("app.future_store.v1", 101, Some(2))]),
1180                Some(5),
1181            )
1182            .expect("reservation activation");
1183        ledger
1184            .validate_committed_integrity()
1185            .expect("reservation activation");
1186        assert_eq!(
1187            record(&ledger, "app.future_store.v1").state,
1188            AllocationState::Active
1189        );
1190
1191        let retirement = AllocationRetirement::new(
1192            "app.users.v1",
1193            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
1194        )
1195        .expect("retirement");
1196        ledger = ledger
1197            .stage_retirement_generation(&retirement, Some(6))
1198            .expect("retirement generation");
1199        ledger
1200            .validate_committed_integrity()
1201            .expect("retirement generation");
1202        assert_eq!(ledger.current_generation, 6);
1203        assert_eq!(
1204            record(&ledger, "app.users.v1").state,
1205            AllocationState::Retired
1206        );
1207        assert_eq!(
1208            record(&ledger, "app.future_store.v1").last_seen_generation,
1209            5
1210        );
1211    }
1212
1213    #[test]
1214    fn new_committed_requires_strict_generation_history() {
1215        let structurally_valid = AllocationLedger::new(
1216            CURRENT_LEDGER_SCHEMA_VERSION,
1217            CURRENT_PHYSICAL_FORMAT_ID,
1218            3,
1219            AllocationHistory::default(),
1220        )
1221        .expect("structurally valid DTO");
1222
1223        assert_eq!(structurally_valid.current_generation, 3);
1224
1225        let err = AllocationLedger::new_committed(
1226            CURRENT_LEDGER_SCHEMA_VERSION,
1227            CURRENT_PHYSICAL_FORMAT_ID,
1228            3,
1229            AllocationHistory::default(),
1230        )
1231        .expect_err("committed ledger needs generation history");
1232
1233        assert_eq!(
1234            err,
1235            LedgerIntegrityError::MissingCurrentGenerationRecord {
1236                current_generation: 3
1237            }
1238        );
1239    }
1240
1241    #[test]
1242    fn validate_integrity_rejects_duplicate_stable_keys() {
1243        let mut ledger = ledger();
1244        *ledger.allocation_history.records_mut() = vec![
1245            active_record("app.users.v1", 100),
1246            active_record("app.users.v1", 101),
1247        ];
1248
1249        let err = ledger.validate_integrity().expect_err("duplicate key");
1250
1251        assert!(matches!(
1252            err,
1253            LedgerIntegrityError::DuplicateStableKey { .. }
1254        ));
1255    }
1256
1257    #[test]
1258    fn validate_integrity_rejects_duplicate_slots() {
1259        let mut ledger = ledger();
1260        *ledger.allocation_history.records_mut() = vec![
1261            active_record("app.users.v1", 100),
1262            active_record("app.orders.v1", 100),
1263        ];
1264
1265        let err = ledger.validate_integrity().expect_err("duplicate slot");
1266
1267        assert!(matches!(err, LedgerIntegrityError::DuplicateSlot { .. }));
1268    }
1269
1270    #[test]
1271    fn validate_integrity_rejects_retired_record_without_retired_generation() {
1272        let mut ledger = ledger();
1273        let mut record = active_record("app.users.v1", 100);
1274        record.state = AllocationState::Retired;
1275        *ledger.allocation_history.records_mut() = vec![record];
1276
1277        let err = ledger
1278            .validate_integrity()
1279            .expect_err("missing retired generation");
1280
1281        assert!(matches!(
1282            err,
1283            LedgerIntegrityError::MissingRetiredGeneration { .. }
1284        ));
1285    }
1286
1287    #[test]
1288    fn validate_integrity_rejects_non_retired_record_with_retired_generation() {
1289        let mut ledger = ledger();
1290        let mut record = active_record("app.users.v1", 100);
1291        record.retired_generation = Some(2);
1292        *ledger.allocation_history.records_mut() = vec![record];
1293
1294        let err = ledger
1295            .validate_integrity()
1296            .expect_err("unexpected retired generation");
1297
1298        assert!(matches!(
1299            err,
1300            LedgerIntegrityError::UnexpectedRetiredGeneration { .. }
1301        ));
1302    }
1303
1304    #[test]
1305    fn validate_integrity_rejects_non_increasing_schema_history() {
1306        let mut ledger = ledger();
1307        let mut record = active_record("app.users.v1", 100);
1308        record.schema_history.push(SchemaMetadataRecord {
1309            generation: 1,
1310            schema: SchemaMetadata::default(),
1311        });
1312        *ledger.allocation_history.records_mut() = vec![record];
1313
1314        let err = ledger
1315            .validate_integrity()
1316            .expect_err("non-increasing schema history");
1317
1318        assert!(matches!(
1319            err,
1320            LedgerIntegrityError::NonIncreasingSchemaHistory { .. }
1321        ));
1322    }
1323
1324    #[test]
1325    fn validate_integrity_rejects_invalid_schema_metadata_history() {
1326        let mut ledger = committed_ledger(1);
1327        let mut record = active_record("app.users.v1", 100);
1328        record.schema_history[0].schema = invalid_schema_metadata();
1329        *ledger.allocation_history.records_mut() = vec![record];
1330
1331        let err = ledger
1332            .validate_committed_integrity()
1333            .expect_err("invalid committed schema metadata");
1334
1335        assert_eq!(
1336            err,
1337            LedgerIntegrityError::InvalidSchemaMetadata {
1338                stable_key: StableKey::parse("app.users.v1").expect("stable key"),
1339                generation: 1,
1340                error: SchemaMetadataError::InvalidVersion,
1341            }
1342        );
1343    }
1344
1345    #[test]
1346    fn validate_committed_integrity_requires_current_generation_record() {
1347        let err = ledger()
1348            .validate_committed_integrity()
1349            .expect_err("missing current generation");
1350
1351        assert_eq!(
1352            err,
1353            LedgerIntegrityError::MissingCurrentGenerationRecord {
1354                current_generation: 3
1355            }
1356        );
1357    }
1358
1359    #[test]
1360    fn validate_committed_integrity_rejects_generation_history_gaps() {
1361        let mut ledger = committed_ledger(3);
1362        ledger.allocation_history.generations_mut().remove(1);
1363
1364        let err = ledger
1365            .validate_committed_integrity()
1366            .expect_err("generation history gap");
1367
1368        assert!(matches!(
1369            err,
1370            LedgerIntegrityError::NonIncreasingGenerationRecords { .. }
1371        ));
1372    }
1373
1374    #[test]
1375    fn ledger_commit_store_rejects_invalid_ledger_before_write() {
1376        let mut store = LedgerCommitStore::default();
1377        let codec = TestCodec;
1378        let mut invalid = ledger();
1379        *invalid.allocation_history.records_mut() = vec![
1380            active_record("app.users.v1", 100),
1381            active_record("app.orders.v1", 100),
1382        ];
1383
1384        let err = store.commit(&invalid, &codec).expect_err("invalid ledger");
1385
1386        assert!(matches!(
1387            err,
1388            LedgerCommitError::Integrity(LedgerIntegrityError::DuplicateSlot { .. })
1389        ));
1390        assert!(store.physical().is_uninitialized());
1391    }
1392
1393    #[test]
1394    fn ledger_commit_store_recovers_latest_committed_ledger() {
1395        let mut store = LedgerCommitStore::default();
1396        let codec = TestCodec;
1397        let first = committed_ledger(1);
1398        let second = committed_ledger(2);
1399
1400        store.commit(&first, &codec).expect("first commit");
1401        store.commit(&second, &codec).expect("second commit");
1402        let recovered = store.recover(&codec).expect("recovered ledger");
1403
1404        assert_eq!(recovered.current_generation, 2);
1405    }
1406
1407    #[test]
1408    fn ledger_commit_store_recovers_compatible_genesis_and_first_real_commit() {
1409        let mut store = LedgerCommitStore::default();
1410        let codec = FullLedgerCodec::default();
1411        let genesis = committed_ledger(0);
1412
1413        let recovered = store
1414            .recover_or_initialize(&codec, &genesis)
1415            .expect("compatible genesis ledger");
1416        assert_eq!(recovered.current_generation, 0);
1417        assert!(recovered.allocation_history.generations().is_empty());
1418
1419        let first = recovered
1420            .stage_validated_generation(
1421                &validated(0, vec![declaration("app.users.v1", 100, Some(1))]),
1422                None,
1423            )
1424            .expect("first real generation");
1425        let recovered = store.commit(&first, &codec).expect("first commit");
1426
1427        assert_eq!(recovered.current_generation, 1);
1428        assert_eq!(recovered.allocation_history.generations()[0].generation, 1);
1429        assert_eq!(record(&recovered, "app.users.v1").first_generation, 1);
1430    }
1431
1432    #[test]
1433    fn ledger_commit_store_recovers_full_payload_after_corrupt_latest_slot() {
1434        let mut store = LedgerCommitStore::default();
1435        let codec = FullLedgerCodec::default();
1436        let genesis = committed_ledger(0);
1437        store.commit(&genesis, &codec).expect("genesis commit");
1438        let first = genesis
1439            .stage_validated_generation(
1440                &validated(0, vec![declaration("app.users.v1", 100, Some(1))]),
1441                None,
1442            )
1443            .expect("first generation");
1444        let first = store.commit(&first, &codec).expect("first commit");
1445        let second = first
1446            .stage_validated_generation(
1447                &validated(1, vec![declaration("app.users.v1", 100, Some(2))]),
1448                None,
1449            )
1450            .expect("second generation");
1451
1452        store
1453            .write_corrupt_inactive_ledger(&second, &codec)
1454            .expect("corrupt latest");
1455        let recovered = store.recover(&codec).expect("recover prior generation");
1456
1457        assert_eq!(recovered.current_generation, 1);
1458        assert_eq!(record(&recovered, "app.users.v1").schema_history.len(), 1);
1459    }
1460
1461    #[test]
1462    fn ledger_commit_store_recovers_identical_duplicate_slots() {
1463        let codec = FullLedgerCodec::default();
1464        let ledger = committed_ledger(0)
1465            .stage_validated_generation(
1466                &validated(0, vec![declaration("app.users.v1", 100, Some(1))]),
1467                None,
1468            )
1469            .expect("first generation");
1470        let payload = codec.encode(&ledger).expect("payload");
1471        let committed = CommittedGenerationBytes::new(ledger.current_generation, payload);
1472        let store = LedgerCommitStore {
1473            physical: DualCommitStore {
1474                slot0: Some(committed.clone()),
1475                slot1: Some(committed),
1476            },
1477        };
1478
1479        let recovered = store.recover(&codec).expect("recovered");
1480
1481        assert_eq!(recovered, ledger);
1482    }
1483
1484    #[test]
1485    fn ledger_commit_store_ignores_corrupt_inactive_ledger() {
1486        let mut store = LedgerCommitStore::default();
1487        let codec = TestCodec;
1488        let first = committed_ledger(1);
1489        let second = committed_ledger(2);
1490
1491        store.commit(&first, &codec).expect("first commit");
1492        store
1493            .write_corrupt_inactive_ledger(&second, &codec)
1494            .expect("corrupt write");
1495        let recovered = store.recover(&codec).expect("recovered ledger");
1496
1497        assert_eq!(recovered.current_generation, 1);
1498    }
1499
1500    #[test]
1501    fn ledger_commit_store_rejects_physical_logical_generation_mismatch() {
1502        let store = LedgerCommitStore {
1503            physical: DualCommitStore {
1504                slot0: Some(CommittedGenerationBytes::new(
1505                    7,
1506                    TestCodec.encode(&committed_ledger(6)).expect("payload"),
1507                )),
1508                slot1: None,
1509            },
1510        };
1511        let codec = TestCodec;
1512
1513        let err = store.recover(&codec).expect_err("mismatch");
1514
1515        assert_eq!(
1516            err,
1517            LedgerCommitError::PhysicalLogicalGenerationMismatch {
1518                physical_generation: 7,
1519                logical_generation: 6
1520            }
1521        );
1522    }
1523
1524    #[test]
1525    fn ledger_commit_store_rejects_non_next_logical_generation() {
1526        let mut store = LedgerCommitStore::default();
1527        let codec = TestCodec;
1528        store
1529            .commit(&committed_ledger(1), &codec)
1530            .expect("first commit");
1531
1532        let err = store
1533            .commit(&committed_ledger(3), &codec)
1534            .expect_err("skipped generation");
1535
1536        assert_eq!(
1537            err,
1538            LedgerCommitError::Recovery(CommitRecoveryError::UnexpectedGeneration {
1539                expected: 2,
1540                actual: 3
1541            })
1542        );
1543    }
1544
1545    #[test]
1546    fn ledger_commit_store_initializes_empty_store_explicitly() {
1547        let mut store = LedgerCommitStore::default();
1548        let codec = TestCodec;
1549        let genesis = committed_ledger(3);
1550
1551        let recovered = store
1552            .recover_or_initialize(&codec, &genesis)
1553            .expect("initialized ledger");
1554
1555        assert_eq!(recovered.current_generation, 3);
1556        assert!(!store.physical().is_uninitialized());
1557    }
1558
1559    #[test]
1560    fn ledger_commit_store_rejects_corrupt_store_even_with_genesis() {
1561        let mut store = LedgerCommitStore::default();
1562        let codec = TestCodec;
1563        store
1564            .write_corrupt_inactive_ledger(&ledger(), &codec)
1565            .expect("corrupt write");
1566
1567        let err = store
1568            .recover_or_initialize(&codec, &ledger())
1569            .expect_err("corrupt state");
1570
1571        assert!(matches!(
1572            err,
1573            LedgerCommitError::Recovery(CommitRecoveryError::NoValidGeneration)
1574        ));
1575    }
1576
1577    #[test]
1578    fn ledger_commit_store_rejects_incompatible_schema_before_write() {
1579        let mut store = LedgerCommitStore::default();
1580        let codec = TestCodec;
1581        let incompatible = AllocationLedger {
1582            ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION + 1,
1583            ..committed_ledger(0)
1584        };
1585
1586        let err = store
1587            .commit(&incompatible, &codec)
1588            .expect_err("incompatible schema");
1589
1590        assert!(matches!(
1591            err,
1592            LedgerCommitError::Compatibility(
1593                LedgerCompatibilityError::UnsupportedLedgerSchemaVersion { .. }
1594            )
1595        ));
1596        assert!(store.physical().is_uninitialized());
1597    }
1598
1599    #[test]
1600    fn ledger_commit_store_rejects_incompatible_schema_on_recovery() {
1601        let mut store = LedgerCommitStore::default();
1602        let codec = TestCodec;
1603        let incompatible = AllocationLedger {
1604            ledger_schema_version: CURRENT_LEDGER_SCHEMA_VERSION + 1,
1605            ..committed_ledger(3)
1606        };
1607        let payload = codec.encode(&incompatible).expect("payload");
1608        store
1609            .physical_mut()
1610            .commit_payload_at_generation(incompatible.current_generation, payload)
1611            .expect("physical commit");
1612
1613        let err = store.recover(&codec).expect_err("incompatible schema");
1614
1615        assert!(matches!(
1616            err,
1617            LedgerCommitError::Compatibility(
1618                LedgerCompatibilityError::UnsupportedLedgerSchemaVersion { .. }
1619            )
1620        ));
1621    }
1622
1623    #[test]
1624    fn ledger_commit_store_rejects_incompatible_physical_format() {
1625        let mut store = LedgerCommitStore::default();
1626        let codec = TestCodec;
1627        let incompatible = AllocationLedger {
1628            physical_format_id: CURRENT_PHYSICAL_FORMAT_ID + 1,
1629            ..committed_ledger(0)
1630        };
1631
1632        let err = store
1633            .recover_or_initialize(&codec, &incompatible)
1634            .expect_err("incompatible format");
1635
1636        assert!(matches!(
1637            err,
1638            LedgerCommitError::Compatibility(
1639                LedgerCompatibilityError::UnsupportedPhysicalFormat { .. }
1640            )
1641        ));
1642        assert!(store.physical().is_uninitialized());
1643    }
1644}