Skip to main content

icydb_core/db/startup/
mod.rs

1//! Module: db::startup
2//! Responsibility: derive bounded generated-database startup readiness.
3//! Does not own: recovery execution, watchdog registration, or ordinary-operation admission.
4//! Boundary: fixed durable controls plus runtime recovery witness -> readiness or typed failure.
5
6mod driver;
7mod observe;
8pub(in crate::db) mod receipt;
9
10use candid::CandidType;
11use icydb_diagnostic_code::{Diagnostic, DiagnosticFactTag, MAX_PUBLIC_DIAGNOSTIC_FACTS};
12use serde::Deserialize;
13
14use crate::{db::StoreRegistry, error::InternalError, traits::CanisterKind};
15
16/// Current generated-database startup readiness.
17#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
18pub enum DatabaseStartupState {
19    /// Recovery controls are complete and the generated schema is reconciled.
20    Ready,
21    /// Dedicated replicated startup work remains.
22    Recovering,
23}
24
25/// One bounded outcome from the hidden replicated startup coordinator.
26#[doc(hidden)]
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum GeneratedStartupDriverStep {
29    /// Startup is already ready or has one durably observable terminal failure.
30    Terminal,
31    /// Recovery remains pending after exactly one bounded page attempt.
32    Recovering,
33    /// Recovery is complete and generated schema reconciliation must run now.
34    ApplyGeneratedSchema,
35}
36
37/// Closed owner of one terminal startup failure.
38#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
39pub enum StartupFailureKind {
40    /// Database boot, incarnation, or commit-control failure.
41    DatabaseControl,
42    /// Journal-tail or fold-continuation recovery failure.
43    JournalRecovery,
44    /// Generated-schema reconciliation failure.
45    SchemaReconciliation,
46}
47
48/// Internal bounded startup failure projected by the public facade.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct StartupFailure {
51    kind: StartupFailureKind,
52    diagnostic: Diagnostic,
53    facts: Vec<(DiagnosticFactTag, u64)>,
54}
55
56impl StartupFailure {
57    pub(in crate::db) fn from_internal(kind: StartupFailureKind, error: &InternalError) -> Self {
58        Self::new(kind, error.diagnostic(), error.diagnostic_facts())
59    }
60
61    pub(in crate::db) fn new(
62        kind: StartupFailureKind,
63        diagnostic: Diagnostic,
64        facts: Vec<(DiagnosticFactTag, u64)>,
65    ) -> Self {
66        debug_assert!(facts.len() <= MAX_PUBLIC_DIAGNOSTIC_FACTS);
67        Self {
68            kind,
69            diagnostic,
70            facts,
71        }
72    }
73
74    /// Return the subsystem that owns this terminal startup failure.
75    #[must_use]
76    pub const fn kind(&self) -> StartupFailureKind {
77        self.kind
78    }
79
80    /// Return its compact diagnostic identity.
81    #[must_use]
82    pub const fn diagnostic(&self) -> &Diagnostic {
83        &self.diagnostic
84    }
85
86    /// Borrow its bounded numeric diagnostic facts.
87    #[must_use]
88    pub const fn facts(&self) -> &[(DiagnosticFactTag, u64)] {
89        self.facts.as_slice()
90    }
91}
92
93#[cfg_attr(
94    not(test),
95    allow(
96        dead_code,
97        reason = "the classifier is consumed by the next driver slice"
98    )
99)]
100pub(in crate::db) fn classify_terminal_failure(
101    kind: StartupFailureKind,
102    error: &InternalError,
103) -> Option<StartupFailure> {
104    terminal_code_for_kind(kind, error.diagnostic().error_code())
105        .then(|| StartupFailure::from_internal(kind, error))
106}
107
108pub(in crate::db) fn classify_terminal_failure_parts(
109    kind: StartupFailureKind,
110    diagnostic: Diagnostic,
111    facts: Vec<(DiagnosticFactTag, u64)>,
112) -> Option<StartupFailure> {
113    terminal_code_for_kind(kind, diagnostic.error_code())
114        .then(|| StartupFailure::new(kind, diagnostic, facts))
115}
116
117fn terminal_code_for_kind(
118    kind: StartupFailureKind,
119    code: icydb_diagnostic_code::ErrorCode,
120) -> bool {
121    use icydb_diagnostic_code::ErrorCode;
122
123    let persisted_failure = code == ErrorCode::STORE_CORRUPTION
124        || code == ErrorCode::STORE_INVARIANT_VIOLATION
125        || code == ErrorCode::RUNTIME_CORRUPTION
126        || code == ErrorCode::RUNTIME_INCOMPATIBLE_PERSISTED_FORMAT
127        || code == ErrorCode::RUNTIME_INVARIANT_VIOLATION
128        || code == ErrorCode::RUNTIME_BOUNDARY_PERSISTED_ROW_LAYOUT_OUTSIDE_ACCEPTED_WINDOW
129        || code == ErrorCode::RUNTIME_BOUNDARY_PERSISTED_ROW_SLOT_COUNT_MISMATCH
130        || code == ErrorCode::RUNTIME_BOUNDARY_ACCEPTED_ROW_CONSTRAINT_PROGRAM_CORRUPT;
131    persisted_failure
132        || match kind {
133            StartupFailureKind::DatabaseControl => false,
134            StartupFailureKind::JournalRecovery => {
135                code == ErrorCode::RUNTIME_BOUNDARY_JOURNAL_MUTATION_REVISION_EXHAUSTED
136            }
137            StartupFailureKind::SchemaReconciliation => {
138                code == ErrorCode::SCHEMA_DDL_ADMISSION
139                    || code == ErrorCode::RUNTIME_CONFLICT
140                    || code == ErrorCode::RUNTIME_UNSUPPORTED
141                    || code == ErrorCode::RUNTIME_BOUNDARY_GENERATED_FIELD_AFTER_DDL_FIELD
142                    || code == ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION
143                    || code == ErrorCode::RUNTIME_BOUNDARY_GENERATED_CONSTRAINT_ACTIVATION_STALE
144            }
145        }
146}
147
148/// Observe one generated database without opening a request or advancing recovery.
149pub fn observe_generated_startup_state<C: CanisterKind>(
150    stores: &'static std::thread::LocalKey<StoreRegistry>,
151    submission_key: &str,
152) -> Result<DatabaseStartupState, StartupFailure> {
153    observe::observe::<C>(stores, submission_key)
154}
155
156/// Run at most one bounded recovery page without admitting ordinary work.
157#[doc(hidden)]
158pub fn drive_generated_startup_recovery_page<C: CanisterKind>(
159    session: &crate::db::DbSession<C>,
160    stores: &'static std::thread::LocalKey<StoreRegistry>,
161    submission_key: &str,
162) -> Result<GeneratedStartupDriverStep, InternalError> {
163    driver::drive_recovery_page(session, stores, submission_key)
164}
165
166/// Persist one deterministic generated-schema failure against fresh authority.
167#[doc(hidden)]
168pub fn record_generated_schema_startup_failure<C: CanisterKind>(
169    stores: &'static std::thread::LocalKey<StoreRegistry>,
170    submission_key: &str,
171    diagnostic: Diagnostic,
172    facts: Vec<(DiagnosticFactTag, u64)>,
173) -> Result<bool, InternalError> {
174    driver::record_schema_failure::<C>(stores, submission_key, diagnostic, facts)
175}
176
177/// Clear a stale startup failure after an authoritative successful handoff.
178#[doc(hidden)]
179pub fn clear_generated_startup_failure<C: CanisterKind>() -> Result<bool, InternalError> {
180    receipt::clear::<C>()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::{
187        db::{
188            DataStore, IndexStore, StoreAllocationIdentities, StoreRuntimeStorageCapabilities,
189            commit::{
190                CommitMarker, begin_commit, commit_memory_handle, configure_commit_memory_id,
191                current_commit_memory_allocation, database_incarnation_id, finish_commit,
192                mark_startup_recovery_complete_for_tests, persist_raw_commit_marker_for_tests,
193            },
194            database_format::initialize_current_database_control_for_tests,
195            schema::{
196                SchemaApplicationRecord, SchemaApplicationRecordOp, SchemaChangeOutcome,
197                SchemaChangeReceipt, SchemaStore, apply_schema_application_record_op,
198                corrupt_live_schema_checkpoint_header_for_tests, generated_schema_authority,
199                load_schema_application_record_read_only,
200            },
201            session::RequestExecutionRoot,
202        },
203        traits::Path,
204    };
205    use ic_stable_structures::Memory;
206    use icydb_diagnostic_code::{ErrorCode, ErrorOrigin as DiagnosticOrigin};
207    use icydb_schema::{SchemaProposalDigest, SchemaSubmissionKey};
208    use std::cell::RefCell;
209
210    struct FreshCanister;
211
212    impl Path for FreshCanister {
213        const PATH: &'static str = "startup_tests::FreshCanister";
214    }
215
216    impl CanisterKind for FreshCanister {
217        const COMMIT_MEMORY_ID: u8 = 232;
218        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-fresh.commit.v1";
219        const STARTUP_MEMORY_ID: u8 = 233;
220        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-fresh.control.v1";
221        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 234;
222        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.startup-fresh.integrity.v1";
223    }
224
225    thread_local! {
226        static FRESH_STORES: StoreRegistry = StoreRegistry::new();
227        static CURRENT_STORES: StoreRegistry = StoreRegistry::new();
228    }
229
230    struct CurrentCanister;
231
232    impl Path for CurrentCanister {
233        const PATH: &'static str = "startup_tests::CurrentCanister";
234    }
235
236    impl CanisterKind for CurrentCanister {
237        const COMMIT_MEMORY_ID: u8 = 228;
238        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-current.commit.v1";
239        const STARTUP_MEMORY_ID: u8 = 229;
240        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-current.control.v1";
241        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 230;
242        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
243            "icydb.test.startup-current.integrity.v1";
244    }
245
246    struct CorruptCanister;
247
248    impl Path for CorruptCanister {
249        const PATH: &'static str = "startup_tests::CorruptCanister";
250    }
251
252    impl CanisterKind for CorruptCanister {
253        const COMMIT_MEMORY_ID: u8 = 224;
254        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-corrupt.commit.v1";
255        const STARTUP_MEMORY_ID: u8 = 225;
256        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-corrupt.control.v1";
257        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 226;
258        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
259            "icydb.test.startup-corrupt.integrity.v1";
260    }
261
262    thread_local! {
263        static CORRUPT_STORES: StoreRegistry = StoreRegistry::new();
264    }
265
266    struct DriverCanister;
267
268    impl Path for DriverCanister {
269        const PATH: &'static str = "startup_tests::DriverCanister";
270    }
271
272    impl CanisterKind for DriverCanister {
273        const COMMIT_MEMORY_ID: u8 = 248;
274        const COMMIT_STABLE_KEY: &'static str = "icydb.test.startup-driver.commit.v1";
275        const STARTUP_MEMORY_ID: u8 = 249;
276        const STARTUP_STABLE_KEY: &'static str = "icydb.test.startup-driver.control.v1";
277        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 250;
278        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
279            "icydb.test.startup-driver.integrity.v1";
280    }
281
282    thread_local! {
283        static DRIVER_STORES: StoreRegistry = StoreRegistry::new();
284    }
285
286    struct HeapRecoveryFailureCanister;
287
288    impl Path for HeapRecoveryFailureCanister {
289        const PATH: &'static str = "startup_tests::HeapRecoveryFailureCanister";
290    }
291
292    impl CanisterKind for HeapRecoveryFailureCanister {
293        const COMMIT_MEMORY_ID: u8 = 251;
294        const COMMIT_STABLE_KEY: &'static str =
295            "icydb.test.startup.heap.recovery.failure.commit.v1";
296        const STARTUP_MEMORY_ID: u8 = 245;
297        const STARTUP_STABLE_KEY: &'static str =
298            "icydb.test.startup.heap.recovery.failure.control.v1";
299        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 246;
300        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
301            "icydb.test.startup.heap.recovery.failure.integrity.v1";
302    }
303
304    thread_local! {
305        static HEAP_RECOVERY_FAILURE_STORES: StoreRegistry = StoreRegistry::new();
306    }
307
308    struct HeapCheckpointFailureCanister;
309
310    impl Path for HeapCheckpointFailureCanister {
311        const PATH: &'static str = "startup_tests::HeapCheckpointFailureCanister";
312    }
313
314    impl CanisterKind for HeapCheckpointFailureCanister {
315        const COMMIT_MEMORY_ID: u8 = 254;
316        const COMMIT_STABLE_KEY: &'static str =
317            "icydb.test.startup.heap.checkpoint.failure.commit.v1";
318        const STARTUP_MEMORY_ID: u8 = 221;
319        const STARTUP_STABLE_KEY: &'static str =
320            "icydb.test.startup.heap.checkpoint.failure.control.v1";
321        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 222;
322        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
323            "icydb.test.startup.heap.checkpoint.failure.integrity.v1";
324    }
325
326    thread_local! {
327        static HEAP_CHECKPOINT_FAILURE_DATA: RefCell<DataStore> =
328            const { RefCell::new(DataStore::init_heap()) };
329        static HEAP_CHECKPOINT_FAILURE_INDEX: RefCell<IndexStore> =
330            const { RefCell::new(IndexStore::init_heap()) };
331        static HEAP_CHECKPOINT_FAILURE_SCHEMA: RefCell<SchemaStore> =
332            const { RefCell::new(SchemaStore::init_heap()) };
333        static HEAP_CHECKPOINT_FAILURE_STORES: StoreRegistry = {
334            let mut registry = StoreRegistry::new();
335            registry.register_store(
336                "startup_tests::HeapCheckpointFailureStore",
337                &HEAP_CHECKPOINT_FAILURE_DATA,
338                &HEAP_CHECKPOINT_FAILURE_INDEX,
339                &HEAP_CHECKPOINT_FAILURE_SCHEMA,
340                StoreAllocationIdentities::absent(),
341                StoreRuntimeStorageCapabilities::heap(),
342            ).expect("heap checkpoint failure store should register");
343            registry
344        };
345    }
346
347    #[test]
348    fn fresh_observation_is_recovering_and_performs_no_stable_write() {
349        assert_eq!(
350            observe_generated_startup_state::<FreshCanister>(
351                &FRESH_STORES,
352                "generated/0123456789abcdef",
353            ),
354            Ok(DatabaseStartupState::Recovering)
355        );
356        let commit = commit_memory_handle(
357            current_commit_memory_allocation().expect("commit allocation should configure"),
358        )
359        .expect("commit memory should reopen");
360        let startup =
361            receipt::startup_memory::<FreshCanister>().expect("startup memory should reopen");
362        assert_eq!(commit.size(), 0);
363        assert_eq!(startup.size(), 0);
364    }
365
366    #[test]
367    fn terminal_classification_is_typed_and_pending_or_internal_failures_remain_retryable() {
368        let corruption = InternalError::store_corruption();
369        assert!(
370            classify_terminal_failure(StartupFailureKind::JournalRecovery, &corruption).is_some()
371        );
372        let pending = InternalError::recovery_pending();
373        assert!(classify_terminal_failure(StartupFailureKind::JournalRecovery, &pending).is_none());
374        let transient = InternalError::recovery_database_format_control_unavailable();
375        assert!(
376            classify_terminal_failure(StartupFailureKind::DatabaseControl, &transient).is_none()
377        );
378    }
379
380    #[test]
381    fn malformed_fixed_boot_control_surfaces_directly_without_a_failure_receipt() {
382        configure_commit_memory_id(
383            CorruptCanister::COMMIT_MEMORY_ID,
384            CorruptCanister::COMMIT_STABLE_KEY,
385        )
386        .expect("commit allocation should configure");
387        let memory = commit_memory_handle(
388            current_commit_memory_allocation().expect("commit allocation should resolve"),
389        )
390        .expect("commit memory should open");
391        assert_eq!(memory.grow(1), 0);
392        memory.write(0, b"NOTICYDBCONTROL");
393
394        let failure = observe_generated_startup_state::<CorruptCanister>(
395            &CORRUPT_STORES,
396            "generated/0123456789abcdef",
397        )
398        .expect_err("malformed boot control must fail directly");
399        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
400        assert_eq!(
401            failure.diagnostic().class(),
402            icydb_diagnostic_code::ErrorClass::Corruption,
403        );
404        assert_eq!(
405            receipt::startup_memory::<CorruptCanister>()
406                .expect("startup memory should open")
407                .size(),
408            0,
409        );
410    }
411
412    #[test]
413    fn heap_only_malformed_marker_becomes_a_durable_database_control_failure() {
414        const SUBMISSION: &str = "generated/89abcdef01234567";
415
416        configure_commit_memory_id(
417            HeapRecoveryFailureCanister::COMMIT_MEMORY_ID,
418            HeapRecoveryFailureCanister::COMMIT_STABLE_KEY,
419        )
420        .expect("commit allocation should configure");
421        let memory = commit_memory_handle(
422            current_commit_memory_allocation().expect("commit allocation should resolve"),
423        )
424        .expect("commit memory should open");
425        initialize_current_database_control_for_tests(&memory);
426        persist_raw_commit_marker_for_tests(vec![0xff])
427            .expect("malformed marker payload should persist inside valid control authority");
428
429        assert_eq!(
430            observe_generated_startup_state::<HeapRecoveryFailureCanister>(
431                &HEAP_RECOVERY_FAILURE_STORES,
432                SUBMISSION,
433            ),
434            Ok(DatabaseStartupState::Recovering),
435            "bounded observation must not decode marker payloads",
436        );
437
438        let request_root = RequestExecutionRoot::__new_runtime_root();
439        let session = crate::db::DbSession::<HeapRecoveryFailureCanister>::new(
440            &HEAP_RECOVERY_FAILURE_STORES,
441            &request_root,
442        );
443        assert_eq!(
444            drive_generated_startup_recovery_page(
445                &session,
446                &HEAP_RECOVERY_FAILURE_STORES,
447                SUBMISSION,
448            )
449            .expect("terminal corruption should publish one durable receipt"),
450            GeneratedStartupDriverStep::Terminal,
451        );
452
453        let failure = observe_generated_startup_state::<HeapRecoveryFailureCanister>(
454            &HEAP_RECOVERY_FAILURE_STORES,
455            SUBMISSION,
456        )
457        .expect_err("the durable database-control failure should replace blind recovery retries");
458        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
459        assert_eq!(
460            failure.diagnostic().error_code(),
461            ErrorCode::RUNTIME_CORRUPTION
462        );
463        assert_eq!(
464            drive_generated_startup_recovery_page(
465                &session,
466                &HEAP_RECOVERY_FAILURE_STORES,
467                SUBMISSION,
468            )
469            .expect("exact terminal replay should not attempt recovery again"),
470            GeneratedStartupDriverStep::Terminal,
471        );
472    }
473
474    #[test]
475    fn heap_only_checkpoint_corruption_becomes_a_durable_database_control_failure() {
476        const SUBMISSION: &str = "generated/76543210fedcba98";
477
478        configure_commit_memory_id(
479            HeapCheckpointFailureCanister::COMMIT_MEMORY_ID,
480            HeapCheckpointFailureCanister::COMMIT_STABLE_KEY,
481        )
482        .expect("commit allocation should configure");
483        let memory = commit_memory_handle(
484            current_commit_memory_allocation().expect("commit allocation should resolve"),
485        )
486        .expect("commit memory should open");
487        initialize_current_database_control_for_tests(&memory);
488        corrupt_live_schema_checkpoint_header_for_tests()
489            .expect("checkpoint authority should admit focused corruption");
490
491        let request_root = RequestExecutionRoot::__new_runtime_root();
492        let session = crate::db::DbSession::<HeapCheckpointFailureCanister>::new(
493            &HEAP_CHECKPOINT_FAILURE_STORES,
494            &request_root,
495        );
496        assert_eq!(
497            drive_generated_startup_recovery_page(
498                &session,
499                &HEAP_CHECKPOINT_FAILURE_STORES,
500                SUBMISSION,
501            )
502            .expect("checkpoint corruption should publish one durable receipt"),
503            GeneratedStartupDriverStep::Terminal,
504        );
505
506        let failure = observe_generated_startup_state::<HeapCheckpointFailureCanister>(
507            &HEAP_CHECKPOINT_FAILURE_STORES,
508            SUBMISSION,
509        )
510        .expect_err("the checkpoint failure should remain visible after the timer returns");
511        assert_eq!(failure.kind(), StartupFailureKind::DatabaseControl);
512        assert_eq!(
513            failure.diagnostic().error_code(),
514            ErrorCode::RUNTIME_CORRUPTION
515        );
516    }
517
518    #[test]
519    #[expect(
520        clippy::too_many_lines,
521        reason = "one lifecycle test keeps pending, ready, marker, and receipt precedence in one scenario"
522    )]
523    fn completed_recovery_stays_recovering_until_exact_generated_schema_receipt_then_is_ready() {
524        const SUBMISSION: &str = "generated/0123456789abcdef";
525
526        configure_commit_memory_id(
527            CurrentCanister::COMMIT_MEMORY_ID,
528            CurrentCanister::COMMIT_STABLE_KEY,
529        )
530        .expect("commit allocation should configure");
531        let memory = commit_memory_handle(
532            current_commit_memory_allocation().expect("commit allocation should resolve"),
533        )
534        .expect("commit memory should open");
535        initialize_current_database_control_for_tests(&memory);
536        let incarnation = database_incarnation_id().expect("control should initialize");
537        mark_startup_recovery_complete_for_tests(&CURRENT_STORES)
538            .expect("recovery witness should publish");
539
540        assert_eq!(
541            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
542            Ok(DatabaseStartupState::Recovering),
543        );
544
545        let (database_identity, accepted_head) =
546            generated_schema_authority(&CURRENT_STORES, incarnation)
547                .expect("schema authority should resolve");
548        let submission_key =
549            SchemaSubmissionKey::try_new(SUBMISSION).expect("submission should admit");
550        let receipt = SchemaChangeReceipt::new(
551            database_identity,
552            submission_key.clone(),
553            SchemaProposalDigest::from_bytes([1; 32]),
554            accepted_head.clone(),
555            SchemaChangeOutcome::NoOp {
556                accepted_head: accepted_head.clone(),
557            },
558        )
559        .expect("terminal schema receipt should admit");
560        let record = SchemaApplicationRecord::new(receipt, Vec::new())
561            .expect("terminal schema record should admit");
562        apply_schema_application_record_op(
563            &SchemaApplicationRecordOp::insert(&record)
564                .expect("schema record operation should admit"),
565        )
566        .expect("schema record should publish");
567        let before = load_schema_application_record_read_only(database_identity, &submission_key)
568            .expect("record should load");
569
570        assert_eq!(
571            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
572            Ok(DatabaseStartupState::Ready),
573        );
574        assert_eq!(
575            load_schema_application_record_read_only(database_identity, &submission_key)
576                .expect("record should reload"),
577            before,
578            "pure readiness observation must not rewrite schema application state",
579        );
580        assert_eq!(
581            receipt::startup_memory::<CurrentCanister>()
582                .expect("startup memory should open")
583                .size(),
584            0,
585            "readiness without a failure must not allocate the receipt cell",
586        );
587
588        let marker = CommitMarker::from_parts([0x5a; 16], Vec::new())
589            .expect("empty marker should admit for control observation");
590        let interrupted = begin_commit(marker).expect("marker should persist");
591        assert_eq!(
592            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
593            Ok(DatabaseStartupState::Recovering),
594            "a marker must take precedence over a completed volatile witness",
595        );
596        finish_commit(interrupted, |_| Ok(())).expect("empty marker should clear");
597        assert_eq!(
598            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
599            Ok(DatabaseStartupState::Ready),
600        );
601
602        let accepted_head_binding = match accepted_head {
603            icydb_schema::ExpectedAcceptedHead::Empty => receipt::AcceptedHeadBinding::Empty,
604            icydb_schema::ExpectedAcceptedHead::Exact {
605                revision,
606                fingerprint,
607            } => receipt::AcceptedHeadBinding::Exact {
608                revision,
609                fingerprint: fingerprint.to_bytes(),
610            },
611        };
612        let terminal_failure = StartupFailure::new(
613            StartupFailureKind::SchemaReconciliation,
614            ErrorCode::RUNTIME_CONFLICT.diagnostic(DiagnosticOrigin::Recovery),
615            Vec::new(),
616        );
617        let memoized = receipt::StartupFailureReceipt::new(
618            terminal_failure.clone(),
619            receipt::StartupFailureBinding::SchemaReconciliation {
620                incarnation,
621                submission_key: SUBMISSION.to_string(),
622                accepted_head: accepted_head_binding,
623            },
624        )
625        .expect("memoized schema failure should admit");
626        assert!(
627            receipt::publish::<CurrentCanister>(&memoized)
628                .expect("memoized failure should publish")
629        );
630        assert_eq!(
631            observe_generated_startup_state::<CurrentCanister>(&CURRENT_STORES, SUBMISSION),
632            Err(terminal_failure),
633            "one exact matching failure receipt has priority over Ready evidence",
634        );
635        assert_eq!(
636            observe_generated_startup_state::<CurrentCanister>(
637                &CURRENT_STORES,
638                "generated/fedcba9876543210",
639            ),
640            Ok(DatabaseStartupState::Recovering),
641            "a receipt bound to another generated submission must be stale",
642        );
643        assert!(receipt::clear::<CurrentCanister>().expect("test receipt should clear"));
644    }
645
646    #[test]
647    fn driver_completes_one_recovery_page_then_memoizes_only_terminal_schema_failure() {
648        const SUBMISSION: &str = "generated/0011223344556677";
649
650        configure_commit_memory_id(
651            DriverCanister::COMMIT_MEMORY_ID,
652            DriverCanister::COMMIT_STABLE_KEY,
653        )
654        .expect("commit allocation should configure");
655        let memory = commit_memory_handle(
656            current_commit_memory_allocation().expect("commit allocation should resolve"),
657        )
658        .expect("commit memory should open");
659        initialize_current_database_control_for_tests(&memory);
660        let request_root = RequestExecutionRoot::__new_runtime_root();
661        let session = crate::db::DbSession::<DriverCanister>::new(&DRIVER_STORES, &request_root);
662
663        assert_eq!(
664            drive_generated_startup_recovery_page(&session, &DRIVER_STORES, SUBMISSION)
665                .expect("empty recovery page should complete"),
666            GeneratedStartupDriverStep::ApplyGeneratedSchema,
667        );
668        assert_eq!(
669            observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION),
670            Ok(DatabaseStartupState::Recovering),
671            "recovery completion alone must not claim generated reconciliation",
672        );
673
674        let retryable = InternalError::recovery_pending();
675        assert!(
676            !record_generated_schema_startup_failure::<DriverCanister>(
677                &DRIVER_STORES,
678                SUBMISSION,
679                retryable.diagnostic(),
680                retryable.diagnostic_facts(),
681            )
682            .expect("retryable classification should complete without publication")
683        );
684        assert_eq!(
685            receipt::startup_memory::<DriverCanister>()
686                .expect("startup memory should open")
687                .size(),
688            0,
689            "retryable failure must not allocate the receipt cell",
690        );
691
692        let terminal = InternalError::store_corruption();
693        let marker = CommitMarker::from_parts([0x7b; 16], Vec::new())
694            .expect("empty marker should admit for receipt priority");
695        let interrupted = begin_commit(marker).expect("marker should persist");
696        assert!(
697            record_generated_schema_startup_failure::<DriverCanister>(
698                &DRIVER_STORES,
699                SUBMISSION,
700                terminal.diagnostic(),
701                terminal.diagnostic_facts(),
702            )
703            .expect("terminal failure should publish")
704        );
705        let observed =
706            observe_generated_startup_state::<DriverCanister>(&DRIVER_STORES, SUBMISSION)
707                .expect_err("matching terminal receipt should surface");
708        assert_eq!(observed.kind(), StartupFailureKind::SchemaReconciliation);
709        assert_eq!(
710            observed.diagnostic().error_code(),
711            ErrorCode::STORE_CORRUPTION
712        );
713        finish_commit(interrupted, |_| Ok(())).expect("test marker should clear");
714        assert!(
715            clear_generated_startup_failure::<DriverCanister>()
716                .expect("authoritative correction should clear the receipt")
717        );
718    }
719}