Skip to main content

ic_memory/
validation.rs

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