Skip to main content

ic_memory/
validation.rs

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