Skip to main content

ic_memory/
validation.rs

1use crate::{
2    declaration::{DeclarationSnapshot, DeclarationSnapshotError},
3    key::StableKey,
4    ledger::{
5        AllocationLedger, LedgerCompatibilityError, LedgerIntegrityError, RecoveredLedger,
6        claim::{ClaimConflict, validate_declaration_claim},
7    },
8    policy::AllocationPolicy,
9    session::ValidatedAllocations,
10    slot::AllocationSlotDescriptor,
11};
12
13///
14/// Validate
15///
16/// Re-check constructor invariants on decoded DTOs before they become
17/// authoritative.
18pub trait Validate {
19    /// Validation error for this DTO.
20    type Error;
21
22    /// Validate this value's domain invariants.
23    fn validate(&self) -> Result<(), Self::Error>;
24}
25
26///
27/// AllocationValidationError
28///
29/// Failure to validate declarations against policy and historical ledger facts.
30#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
31pub enum AllocationValidationError<P> {
32    /// Historical ledger format is not supported by this validator.
33    #[error(transparent)]
34    Compatibility(LedgerCompatibilityError),
35    /// Historical ledger was decoded or assembled with invalid committed state.
36    #[error(transparent)]
37    LedgerIntegrity(LedgerIntegrityError),
38    /// Declaration snapshot was decoded or assembled with invalid DTOs.
39    #[error(transparent)]
40    Snapshot(DeclarationSnapshotError),
41    /// Policy adapter rejected the declaration.
42    #[error("allocation policy rejected a declaration")]
43    Policy(P),
44    /// Stable key was historically bound to a different slot.
45    #[error("stable key '{stable_key}' was historically bound to a different allocation slot")]
46    StableKeySlotConflict {
47        /// Stable key that was redeclared.
48        stable_key: StableKey,
49        /// Historical slot for the stable key.
50        historical_slot: Box<AllocationSlotDescriptor>,
51        /// Slot claimed by the current declaration.
52        declared_slot: Box<AllocationSlotDescriptor>,
53    },
54    /// Slot was historically bound to a different stable key.
55    #[error("allocation slot '{slot:?}' was historically bound to stable key '{historical_key}'")]
56    SlotStableKeyConflict {
57        /// Slot claimed by the current declaration.
58        slot: Box<AllocationSlotDescriptor>,
59        /// Historical stable key for the slot.
60        historical_key: StableKey,
61        /// Stable key claimed by the current declaration.
62        declared_key: StableKey,
63    },
64    /// Current declaration attempted to revive a retired allocation.
65    #[error("stable key '{stable_key}' was explicitly retired and cannot be redeclared")]
66    RetiredAllocation {
67        /// Retired stable key.
68        stable_key: StableKey,
69        /// Retired allocation slot.
70        slot: Box<AllocationSlotDescriptor>,
71    },
72}
73
74/// Validate a committed ledger and current declarations before opening.
75///
76/// This is the authority boundary for [`ValidatedAllocations`]: the historical
77/// ledger must pass compatibility and committed-integrity checks before current
78/// declarations are checked against framework policy and ledger history. This
79/// proves allocation ABI safety only. It does not prove store-level schema
80/// compatibility.
81pub fn validate_allocations<P: AllocationPolicy>(
82    recovered: &RecoveredLedger,
83    snapshot: DeclarationSnapshot,
84    policy: &P,
85) -> Result<ValidatedAllocations, AllocationValidationError<P::Error>> {
86    let ledger = recovered.ledger();
87
88    snapshot
89        .validate()
90        .map_err(AllocationValidationError::Snapshot)?;
91
92    for declaration in snapshot.declarations() {
93        policy
94            .validate_key(&declaration.stable_key)
95            .map_err(AllocationValidationError::Policy)?;
96        policy
97            .validate_slot(&declaration.stable_key, &declaration.slot)
98            .map_err(AllocationValidationError::Policy)?;
99
100        validate_declaration_history(ledger, declaration)?;
101    }
102
103    let (declarations, runtime_fingerprint) = snapshot.into_parts();
104
105    Ok(ValidatedAllocations::new(
106        ledger.current_generation,
107        declarations,
108        runtime_fingerprint,
109    ))
110}
111
112fn validate_declaration_history<P>(
113    ledger: &AllocationLedger,
114    declaration: &crate::declaration::AllocationDeclaration,
115) -> Result<(), AllocationValidationError<P>> {
116    validate_declaration_claim(ledger, declaration)
117        .map(|_| ())
118        .map_err(|conflict| map_validation_claim_conflict(ledger, declaration, conflict))
119}
120
121fn map_validation_claim_conflict<P>(
122    ledger: &AllocationLedger,
123    declaration: &crate::declaration::AllocationDeclaration,
124    conflict: ClaimConflict,
125) -> AllocationValidationError<P> {
126    match conflict {
127        ClaimConflict::StableKeyMoved { record_index } => {
128            let record = &ledger.allocation_history().records()[record_index];
129            AllocationValidationError::StableKeySlotConflict {
130                stable_key: declaration.stable_key.clone(),
131                historical_slot: Box::new(record.slot.clone()),
132                declared_slot: Box::new(declaration.slot.clone()),
133            }
134        }
135        ClaimConflict::SlotReused { record_index } => {
136            let record = &ledger.allocation_history().records()[record_index];
137            AllocationValidationError::SlotStableKeyConflict {
138                slot: Box::new(declaration.slot.clone()),
139                historical_key: record.stable_key.clone(),
140                declared_key: declaration.stable_key.clone(),
141            }
142        }
143        ClaimConflict::Tombstoned { record_index } => {
144            let record = &ledger.allocation_history().records()[record_index];
145            AllocationValidationError::RetiredAllocation {
146                stable_key: declaration.stable_key.clone(),
147                slot: Box::new(record.slot.clone()),
148            }
149        }
150        ClaimConflict::ActiveAllocation { .. } => {
151            unreachable!("active allocation conflicts are reservation-only")
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::{
160        declaration::AllocationDeclaration,
161        ledger::{
162            AllocationHistory, AllocationRecord, AllocationState,
163            CURRENT_LEDGER_PAYLOAD_ENVELOPE_VERSION, GenerationRecord,
164        },
165        schema::SchemaMetadata,
166        slot::AllocationSlotDescriptor,
167    };
168
169    #[derive(Debug, Eq, PartialEq)]
170    struct TestPolicy;
171
172    impl AllocationPolicy for TestPolicy {
173        type Error = &'static str;
174
175        fn validate_key(&self, key: &StableKey) -> Result<(), Self::Error> {
176            if key.as_str().starts_with("bad.") {
177                return Err("bad key");
178            }
179            Ok(())
180        }
181
182        fn validate_slot(
183            &self,
184            _key: &StableKey,
185            slot: &AllocationSlotDescriptor,
186        ) -> Result<(), Self::Error> {
187            if slot
188                == &AllocationSlotDescriptor::memory_manager_unchecked(
189                    crate::MEMORY_MANAGER_INVALID_ID,
190                )
191            {
192                return Err("bad slot");
193            }
194            Ok(())
195        }
196
197        fn validate_reserved_slot(
198            &self,
199            _key: &StableKey,
200            _slot: &AllocationSlotDescriptor,
201        ) -> Result<(), Self::Error> {
202            Ok(())
203        }
204    }
205
206    fn ledger(records: Vec<AllocationRecord>) -> AllocationLedger {
207        let generations = (1..=7)
208            .map(|generation| {
209                GenerationRecord::new(
210                    generation,
211                    if generation == 1 {
212                        Some(0)
213                    } else {
214                        Some(generation - 1)
215                    },
216                    None,
217                    0,
218                    None,
219                )
220                .expect("generation record")
221            })
222            .collect();
223
224        AllocationLedger {
225            ledger_schema_version: 1,
226            physical_format_id: 1,
227            current_generation: 7,
228            allocation_history: AllocationHistory::from_parts(records, generations),
229        }
230    }
231
232    fn declaration(key: &str, id: u8) -> AllocationDeclaration {
233        AllocationDeclaration::new(
234            key,
235            AllocationSlotDescriptor::memory_manager(id).expect("usable slot"),
236            None,
237            SchemaMetadata::default(),
238        )
239        .expect("declaration")
240    }
241
242    fn active_record(key: &str, id: u8) -> AllocationRecord {
243        AllocationRecord::from_declaration(1, declaration(key, id), AllocationState::Active)
244    }
245
246    fn recovered(records: Vec<AllocationRecord>) -> RecoveredLedger {
247        RecoveredLedger::from_trusted_parts(
248            ledger(records),
249            7,
250            CURRENT_LEDGER_PAYLOAD_ENVELOPE_VERSION,
251        )
252    }
253
254    #[test]
255    fn accepts_matching_historical_owner() {
256        let snapshot =
257            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
258
259        let validated = validate_allocations(
260            &recovered(vec![active_record("app.users.v1", 100)]),
261            snapshot,
262            &TestPolicy,
263        )
264        .expect("validated");
265
266        assert_eq!(validated.generation(), 7);
267    }
268
269    #[test]
270    fn omitted_historical_records_do_not_fail_validation() {
271        let snapshot =
272            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
273
274        validate_allocations(
275            &recovered(vec![
276                active_record("app.users.v1", 100),
277                active_record("app.orders.v1", 101),
278            ]),
279            snapshot,
280            &TestPolicy,
281        )
282        .expect("omitted records are preserved, not retired");
283    }
284
285    #[test]
286    fn rejects_same_key_different_slot() {
287        let snapshot =
288            DeclarationSnapshot::new(vec![declaration("app.users.v1", 101)]).expect("snapshot");
289
290        let err = validate_allocations(
291            &recovered(vec![active_record("app.users.v1", 100)]),
292            snapshot,
293            &TestPolicy,
294        )
295        .expect_err("conflict");
296
297        assert!(matches!(
298            err,
299            AllocationValidationError::StableKeySlotConflict { .. }
300        ));
301    }
302
303    #[test]
304    fn rejects_same_slot_different_key() {
305        let snapshot =
306            DeclarationSnapshot::new(vec![declaration("app.orders.v1", 100)]).expect("snapshot");
307
308        let err = validate_allocations(
309            &recovered(vec![active_record("app.users.v1", 100)]),
310            snapshot,
311            &TestPolicy,
312        )
313        .expect_err("conflict");
314
315        assert!(matches!(
316            err,
317            AllocationValidationError::SlotStableKeyConflict { .. }
318        ));
319    }
320
321    #[test]
322    fn rejects_retired_redeclaration() {
323        let mut record = active_record("app.users.v1", 100);
324        record.state = AllocationState::Retired;
325        record.retired_generation = Some(3);
326        let snapshot =
327            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
328
329        let err = validate_allocations(&recovered(vec![record]), snapshot, &TestPolicy)
330            .expect_err("retired");
331
332        assert!(matches!(
333            err,
334            AllocationValidationError::RetiredAllocation { .. }
335        ));
336    }
337
338    #[test]
339    fn policy_rejections_fail_before_validation_succeeds() {
340        let snapshot =
341            DeclarationSnapshot::new(vec![declaration("bad.users.v1", 100)]).expect("snapshot");
342
343        let err = validate_allocations(&recovered(Vec::new()), snapshot, &TestPolicy)
344            .expect_err("policy failure");
345
346        assert_eq!(err, AllocationValidationError::Policy("bad key"));
347    }
348}