Skip to main content

icydb_core/db/schema/
application.rs

1//! Module: db::schema::application
2//! Responsibility: issue proposal targets and admit exact schema-application requests.
3//! Does not own: proposal lowering, accepted candidate construction, or activation progress.
4//! Boundary: recovered runtime/catalog authority plus one proposal -> atomic publication and receipt.
5
6use crate::{
7    db::{
8        Db,
9        codec::{
10            finalize_hash_sha256, new_hash_sha256_prefixed, write_hash_len_u32, write_hash_str_u32,
11            write_hash_tag_u8, write_hash_u64,
12        },
13        commit::{
14            AcceptedSchemaPublication, DatabaseControlOp, database_incarnation_id,
15            ensure_recovery_admitted, publish_accepted_schema_candidates_with_application_record,
16            publish_accepted_schema_candidates_with_database_control,
17            publish_generated_row_local_abort_with_application_record,
18        },
19        data::DataStore,
20        index::{IndexState, IndexStore},
21        integrity::DatabaseIncarnationId,
22        registry::{
23            StoreAllocationIdentity, StoreAllocationIdentityCapability, StoreCommitParticipation,
24            StoreDurability, StoreHandle, StoreRecoveryCapability, StoreRelationSourceCapability,
25            StoreRelationTargetCapability, StoreRuntimeStorageMode, StoreSchemaMetadataCapability,
26        },
27        relation::prove_empty_reverse_relation_domain,
28        schema::ensure_schema_migration_ready_for_ordinary_operations,
29        schema::{
30            AcceptedSchemaRevision, AcceptedSchemaRevisionBundle, CandidateSchemaRevision,
31            ConstraintActivationKind, ConstraintActivationState, ConstraintId, ConstraintOrigin,
32            ConstraintValidationPhase, ConstraintValidationProgress, ExistingProposalStore,
33            MAX_IDENTITY_STATE_RECORDS_PER_DATABASE, ProposalStoreTarget, SchemaApplicationRecord,
34            SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob, SchemaChangeJobId,
35            SchemaChangeOutcome, SchemaChangeProgress, SchemaChangeProgressStatus,
36            SchemaChangeReceipt, SchemaChangeValidationPhase, StagedUserIndexDomainError,
37            UnpublishedRowLocalValidation, advance_accepted_row_local_constraint_activation,
38            constraint_validation_finding_output, derive_schema_change_job_id,
39            load_schema_application_record_read_only, lower_existing_schema_proposal,
40            lower_initial_schema_proposal, prove_empty_user_index_domain,
41            validate_unpublished_row_local_candidate_bounded, with_schema_application_store,
42        },
43    },
44    error::InternalError,
45    traits::CanisterKind,
46    types::EntityTag,
47};
48use candid::CandidType;
49use icydb_schema::{
50    ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposal, SchemaProposalDigest,
51    SchemaSubmissionKey, TargetDatabaseIdentity, TargetStoreIdentity,
52};
53use serde::Deserialize;
54use sha2::Digest;
55use std::cell::Cell;
56#[cfg(feature = "migration")]
57use std::collections::BTreeMap;
58
59#[cfg(feature = "migration")]
60use crate::db::schema::{
61    PersistedSchemaMigrationEntity, PersistedSchemaMigrationFindingKind,
62    PersistedSchemaMigrationIndex, PersistedSchemaMigrationPhase,
63    PersistedSchemaMigrationTransition, SchemaMigrationCommand, SchemaMigrationEntityTransition,
64    SchemaMigrationFinding, SchemaMigrationFindingKind, SchemaMigrationPhase,
65    SchemaMigrationReceipt, SchemaMigrationRecord, SchemaMigrationRecordOp,
66    SchemaMigrationStatusPage, SchemaMigrationStatusRequest,
67    live_schema_checkpoint::{load_entity_source_lineage_catalog, load_schema_migration_record},
68    migration_execution::{
69        cleanup_migration_staging_page, final_validate_migration_page,
70        migration_derived_domain_count, publish_migration_rewrite_page, rewrite_migration_page,
71    },
72    migration_lineage::{
73        AcceptedEntitySourceLineage, AcceptedEntitySourceLineageCatalog,
74        AcceptedEntitySourceLineageState, EntitySourceLineageCatalogOp,
75    },
76    migration_planner::{
77        PlannedEntitySourceLineage, SchemaMigrationPlanningError, plan_entity_source_adoption,
78        plan_initial_entity_source_lineage, plan_schema_migration,
79    },
80    migration_validation::{stage_migration_index_entries, validate_migration_page},
81};
82
83#[cfg(feature = "migration")]
84use icydb_diagnostic_code::SchemaMigrationCode;
85#[cfg(feature = "migration")]
86use icydb_schema::{EntitySourceKey, SchemaMigrationPlanDigest};
87
88const DATABASE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.database.v1";
89const STORE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.store.v1";
90const ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE: &[u8] = b"icydb.accepted-schema.database-head.v1";
91#[cfg(feature = "migration")]
92const SCHEMA_MIGRATION_SUBMISSION_PROFILE: &[u8] = b"icydb.schema-migration.submission.v1";
93
94#[derive(Clone, Copy)]
95struct GeneratedDatabaseIdentityCacheEntry {
96    registry: usize,
97    incarnation: DatabaseIncarnationId,
98    identity: TargetDatabaseIdentity,
99}
100
101thread_local! {
102    // Generated store registration is immutable after wiring. This heap-only
103    // derivation cache is replaced when the database incarnation changes; it
104    // never caches readiness, receipts, or mutable accepted-schema authority.
105    static GENERATED_DATABASE_IDENTITY: Cell<Option<GeneratedDatabaseIdentityCacheEntry>> =
106        const { Cell::new(None) };
107}
108
109///
110/// SchemaApplicationStore
111///
112/// One registered store path paired with the opaque routing token accepted by
113/// the current database incarnation.
114///
115
116#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
117pub struct SchemaApplicationStore {
118    path: String,
119    identity: TargetStoreIdentity,
120}
121
122impl SchemaApplicationStore {
123    /// Borrow the registered store path.
124    #[must_use]
125    pub const fn path(&self) -> &str {
126        self.path.as_str()
127    }
128
129    /// Return the opaque routing identity for this store.
130    #[must_use]
131    pub const fn identity(&self) -> TargetStoreIdentity {
132        self.identity
133    }
134}
135
136///
137/// SchemaApplicationTarget
138///
139/// Point-in-time optimistic application context issued from recovered runtime
140/// authority. Callers compose proposals against these opaque identities and
141/// this exact database-wide accepted head.
142///
143
144#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
145pub struct SchemaApplicationTarget {
146    database_identity: TargetDatabaseIdentity,
147    accepted_head: ExpectedAcceptedHead,
148    stores: Vec<SchemaApplicationStore>,
149}
150
151impl SchemaApplicationTarget {
152    /// Return the opaque current database identity.
153    #[must_use]
154    pub const fn database_identity(&self) -> TargetDatabaseIdentity {
155        self.database_identity
156    }
157
158    /// Borrow the exact optimistic accepted head.
159    #[must_use]
160    pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
161        &self.accepted_head
162    }
163
164    /// Borrow registered stores in canonical path order.
165    #[must_use]
166    pub const fn stores(&self) -> &[SchemaApplicationStore] {
167        self.stores.as_slice()
168    }
169}
170
171///
172/// StoreApplicationAuthority
173///
174/// Canonically ordered registry facts used to derive opaque proposal routing
175/// identities without exposing physical allocation details.
176///
177
178#[derive(Clone, Copy)]
179struct StoreApplicationAuthority {
180    path: &'static str,
181    handle: StoreHandle,
182}
183
184/// Catalog authority resolved for one pending generated row-local abort.
185struct PendingApplicationAbort {
186    authority: StoreApplicationAuthority,
187    current: AcceptedSchemaRevisionBundle,
188    entity_tag: EntityTag,
189    constraint_id: ConstraintId,
190    remove_validation_job: bool,
191}
192
193///
194/// AcceptedStoreHead
195///
196/// Exact store-local root facts contributing to the database-wide optimistic
197/// accepted head. Absence is represented explicitly by the enclosing option.
198///
199
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201struct AcceptedStoreHead {
202    revision: u64,
203    fingerprint: [u8; 32],
204}
205
206/// One new generated row-local activation awaiting direct bounded proof.
207#[derive(Clone)]
208struct DirectGeneratedRowLocalProof {
209    candidate_index: usize,
210    store: StoreHandle,
211    store_path: &'static str,
212    entity_tag: crate::types::EntityTag,
213    entity_path: String,
214    constraint_id: ConstraintId,
215    historical_rows: u64,
216}
217
218/// One generated row-local constraint whose proof requires durable continuation.
219#[derive(Clone)]
220struct PendingGeneratedRowLocalConstraint {
221    proof: DirectGeneratedRowLocalProof,
222}
223
224/// Catalog-native application staging retained until marker publication.
225struct LoweredApplication {
226    current_bundles: Vec<Option<AcceptedSchemaRevisionBundle>>,
227    candidates: Vec<CandidateSchemaRevision>,
228    pending: Option<PendingGeneratedRowLocalConstraint>,
229}
230
231/// Issue the current proposal-application target from recovered authority.
232pub(in crate::db) fn schema_application_target<C: CanisterKind>(
233    db: &Db<C>,
234) -> Result<SchemaApplicationTarget, InternalError> {
235    ensure_recovery_admitted(db)?;
236    let incarnation = database_incarnation_id()?;
237    let mut stores = db.with_store_registry(|registry| {
238        registry
239            .iter()
240            .map(|(path, handle)| StoreApplicationAuthority { path, handle })
241            .collect::<Vec<_>>()
242    });
243    stores.sort_unstable_by(|left, right| left.path.cmp(right.path));
244
245    let database_identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
246    let mut accepted_heads = Vec::with_capacity(stores.len());
247    let mut application_stores = Vec::with_capacity(stores.len());
248    for store in &stores {
249        let root = store
250            .handle
251            .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
252            .map(|selection| AcceptedStoreHead {
253                revision: selection.root().revision().get(),
254                fingerprint: selection.root().fingerprint().as_bytes(),
255            });
256        accepted_heads.push((store.path, root));
257        application_stores.push(SchemaApplicationStore {
258            path: store.path.to_string(),
259            identity: derive_store_identity(database_identity, store),
260        });
261    }
262
263    Ok(SchemaApplicationTarget {
264        database_identity,
265        accepted_head: derive_accepted_head(accepted_heads.as_slice()),
266        stores: application_stores,
267    })
268}
269
270/// Load one durable schema-application receipt by its exact idempotency
271/// identity.
272pub(in crate::db) fn schema_application_receipt<C: CanisterKind>(
273    db: &Db<C>,
274    database_identity: TargetDatabaseIdentity,
275    submission_key: &SchemaSubmissionKey,
276) -> Result<Option<SchemaChangeReceipt>, InternalError> {
277    ensure_recovery_admitted(db)?;
278    with_schema_application_store(|store| {
279        store
280            .load(database_identity, submission_key)
281            .map(|record| record.map(|record| record.receipt().clone()))
282    })
283}
284
285fn exact_schema_application_receipt(
286    proposal: &SchemaProposal,
287    proposal_digest: SchemaProposalDigest,
288) -> Result<Option<SchemaChangeReceipt>, InternalError> {
289    let Some(record) = with_schema_application_store(|store| {
290        store.load(proposal.target_database(), proposal.submission_key())
291    })?
292    else {
293        return Ok(None);
294    };
295    let receipt = record.receipt();
296    if !receipt.is_exact_submission(
297        proposal.target_database(),
298        proposal.submission_key(),
299        proposal_digest,
300        proposal.expected_head(),
301    ) {
302        return Err(InternalError::schema_application_conflict());
303    }
304    Ok(Some(receipt.clone()))
305}
306
307/// Advance one durable pending schema application by at most one canonical
308/// validation step.
309pub(in crate::db) fn continue_schema_application<C: CanisterKind>(
310    db: &Db<C>,
311    job_id: SchemaChangeJobId,
312    acknowledged_receipt: Option<u64>,
313) -> Result<SchemaChangeProgress, InternalError> {
314    ensure_recovery_admitted(db)?;
315    ensure_schema_migration_ready_for_ordinary_operations()?;
316    let record = with_schema_application_store(|store| store.load_job(job_id))?
317        .ok_or_else(InternalError::schema_application_conflict)?;
318    let target = schema_application_target(db)?;
319    if target.database_identity() != record.receipt().database_identity() {
320        return Err(InternalError::schema_application_conflict());
321    }
322    let candidate_head = match record.receipt().outcome() {
323        SchemaChangeOutcome::Pending {
324            job,
325            candidate_head,
326        } if job.id() == job_id => candidate_head,
327        SchemaChangeOutcome::Applied { .. } => {
328            return Ok(SchemaChangeProgress::new(
329                record.receipt().clone(),
330                SchemaChangeProgressStatus::Applied,
331            ));
332        }
333        SchemaChangeOutcome::Aborted { .. } => {
334            return Ok(SchemaChangeProgress::new(
335                record.receipt().clone(),
336                SchemaChangeProgressStatus::Aborted,
337            ));
338        }
339        _ => return Err(InternalError::store_corruption()),
340    };
341    let [activation] = record.activations() else {
342        return Err(InternalError::store_corruption());
343    };
344    let authorities = application_authorities(db);
345    let authority = authorities
346        .iter()
347        .find(|authority| {
348            derive_store_identity(target.database_identity(), authority) == activation.store()
349        })
350        .ok_or_else(InternalError::store_corruption)?;
351    let entity_tag = EntityTag::new(activation.entity_tag());
352    let constraint_id = ConstraintId::new(activation.constraint_id())
353        .ok_or_else(InternalError::store_corruption)?;
354    let bundle = authority
355        .handle
356        .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
357        .ok_or_else(InternalError::store_corruption)?;
358    if bundle.store_path() != authority.path {
359        return Err(InternalError::store_corruption());
360    }
361    let snapshot = bundle
362        .entity_snapshots()
363        .get(&entity_tag)
364        .ok_or_else(InternalError::store_corruption)?;
365
366    let accepted = snapshot
367        .constraint_catalog()
368        .constraints()
369        .iter()
370        .any(|constraint| {
371            constraint.id() == constraint_id
372                && constraint.origin() == ConstraintOrigin::Generated
373                && matches!(
374                    constraint.kind(),
375                    crate::db::schema::AcceptedConstraintKind::Check { .. }
376                        | crate::db::schema::AcceptedConstraintKind::TargetedRule { .. }
377                )
378        });
379    let pending = snapshot.constraint_catalog().activation(constraint_id);
380    if accepted && pending.is_none() {
381        return finalize_schema_application(
382            db,
383            &record,
384            candidate_head,
385            SchemaChangeProgressStatus::Applied,
386        );
387    }
388    let pending = pending.ok_or_else(InternalError::store_corruption)?;
389    if pending.origin() != ConstraintOrigin::Generated
390        || !matches!(
391            pending.kind(),
392            ConstraintActivationKind::Check { .. } | ConstraintActivationKind::TargetedRule { .. }
393        )
394    {
395        return Err(InternalError::store_corruption());
396    }
397    let entity_path = snapshot.entity_path().to_string();
398    let progress = advance_accepted_row_local_constraint_activation(
399        authority.handle,
400        authority.path,
401        entity_tag,
402        entity_path.as_str(),
403        constraint_id,
404        acknowledged_receipt,
405    )?;
406    let status = schema_change_progress_status(snapshot, entity_tag, constraint_id, progress)?;
407    if status == SchemaChangeProgressStatus::Applied {
408        finalize_schema_application(db, &record, candidate_head, status)
409    } else {
410        Ok(SchemaChangeProgress::new(record.receipt().clone(), status))
411    }
412}
413
414/// Abort one pending generated row-local application.
415///
416/// A retained finding page must be acknowledged by exact sequence before the
417/// activation and its validation job can be retired. Terminal outcomes replay
418/// without mutating accepted authority.
419pub(in crate::db) fn abort_schema_application<C: CanisterKind>(
420    db: &Db<C>,
421    job_id: SchemaChangeJobId,
422    acknowledged_receipt: Option<u64>,
423) -> Result<SchemaChangeProgress, InternalError> {
424    ensure_recovery_admitted(db)?;
425    ensure_schema_migration_ready_for_ordinary_operations()?;
426    let record = with_schema_application_store(|store| store.load_job(job_id))?
427        .ok_or_else(InternalError::schema_application_conflict)?;
428    let target = schema_application_target(db)?;
429    if target.database_identity() != record.receipt().database_identity() {
430        return Err(InternalError::schema_application_conflict());
431    }
432    match record.receipt().outcome() {
433        SchemaChangeOutcome::Applied { .. } => {
434            return Ok(SchemaChangeProgress::new(
435                record.receipt().clone(),
436                SchemaChangeProgressStatus::Applied,
437            ));
438        }
439        SchemaChangeOutcome::Aborted { .. } => {
440            return Ok(SchemaChangeProgress::new(
441                record.receipt().clone(),
442                SchemaChangeProgressStatus::Aborted,
443            ));
444        }
445        SchemaChangeOutcome::Pending { job, .. } if job.id() == job_id => {}
446        SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Pending { .. } => {
447            return Err(InternalError::store_corruption());
448        }
449    }
450
451    let authorities = application_authorities(db);
452    let abort = prepare_pending_application_abort(
453        target.database_identity(),
454        &record,
455        authorities.as_slice(),
456        acknowledged_receipt,
457    )?;
458    let candidate = aborted_generated_row_local_candidate(
459        &abort.current,
460        abort.entity_tag,
461        abort.constraint_id,
462    )?;
463    let accepted_head =
464        accepted_head_after_candidates(authorities.as_slice(), std::slice::from_ref(&candidate))?;
465    let receipt = SchemaChangeReceipt::new(
466        record.receipt().database_identity(),
467        record.receipt().submission_key().clone(),
468        record.receipt().proposal_digest(),
469        record.receipt().prior_head().clone(),
470        SchemaChangeOutcome::Aborted { accepted_head },
471    )?;
472    let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
473    let operation = SchemaApplicationRecordOp::replace(&record, &terminal)?;
474    if abort.remove_validation_job {
475        publish_generated_row_local_abort_with_application_record(
476            abort.authority.path,
477            abort.authority.handle,
478            abort.current.revision(),
479            &candidate,
480            abort.entity_tag,
481            abort.constraint_id,
482            operation,
483        )?;
484    } else {
485        publish_accepted_schema_candidates_with_application_record(
486            vec![AcceptedSchemaPublication::new(
487                abort.authority.path,
488                abort.authority.handle,
489                abort.current.revision(),
490                &candidate,
491            )],
492            operation,
493        )?;
494    }
495    Ok(SchemaChangeProgress::new(
496        receipt,
497        SchemaChangeProgressStatus::Aborted,
498    ))
499}
500
501fn prepare_pending_application_abort(
502    database_identity: TargetDatabaseIdentity,
503    record: &SchemaApplicationRecord,
504    authorities: &[StoreApplicationAuthority],
505    acknowledged_receipt: Option<u64>,
506) -> Result<PendingApplicationAbort, InternalError> {
507    let [activation] = record.activations() else {
508        return Err(InternalError::store_corruption());
509    };
510    let authority = authorities
511        .iter()
512        .copied()
513        .find(|authority| derive_store_identity(database_identity, authority) == activation.store())
514        .ok_or_else(InternalError::store_corruption)?;
515    let entity_tag = EntityTag::new(activation.entity_tag());
516    let constraint_id = ConstraintId::new(activation.constraint_id())
517        .ok_or_else(InternalError::store_corruption)?;
518    let current = authority
519        .handle
520        .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
521        .ok_or_else(InternalError::store_corruption)?;
522    if current.store_path() != authority.path {
523        return Err(InternalError::store_corruption());
524    }
525    let pending = current
526        .entity_snapshots()
527        .get(&entity_tag)
528        .and_then(|snapshot| snapshot.constraint_catalog().activation(constraint_id))
529        .filter(|pending| {
530            pending.origin() == ConstraintOrigin::Generated
531                && matches!(
532                    pending.kind(),
533                    ConstraintActivationKind::Check { .. }
534                        | ConstraintActivationKind::TargetedRule { .. }
535                )
536        })
537        .ok_or_else(InternalError::store_corruption)?;
538    let remove_validation_job = pending_generated_row_local_job_retirement(
539        authority,
540        entity_tag,
541        constraint_id,
542        pending.state(),
543        acknowledged_receipt,
544    )?;
545    Ok(PendingApplicationAbort {
546        authority,
547        current,
548        entity_tag,
549        constraint_id,
550        remove_validation_job,
551    })
552}
553
554fn pending_generated_row_local_job_retirement(
555    authority: StoreApplicationAuthority,
556    entity_tag: EntityTag,
557    constraint_id: ConstraintId,
558    state: ConstraintActivationState,
559    acknowledged_receipt: Option<u64>,
560) -> Result<bool, InternalError> {
561    let job = authority
562        .handle
563        .with_schema(|store| store.constraint_validation_job(entity_tag, constraint_id))?;
564    match state {
565        ConstraintActivationState::EnforcingNewWrites => {
566            if acknowledged_receipt.is_some() || job.is_some() {
567                return Err(InternalError::schema_application_conflict());
568            }
569            Ok(false)
570        }
571        ConstraintActivationState::Validating => {
572            let mut job = job.ok_or_else(InternalError::store_corruption)?;
573            if !job.acknowledge_receipt(acknowledged_receipt) {
574                return Err(InternalError::schema_application_conflict());
575            }
576            Ok(true)
577        }
578    }
579}
580
581fn aborted_generated_row_local_candidate(
582    current: &AcceptedSchemaRevisionBundle,
583    entity_tag: EntityTag,
584    constraint_id: ConstraintId,
585) -> Result<CandidateSchemaRevision, InternalError> {
586    let snapshot = current
587        .entity_snapshots()
588        .get(&entity_tag)
589        .cloned()
590        .ok_or_else(InternalError::store_corruption)?;
591    let _activation = snapshot
592        .constraint_catalog()
593        .activation(constraint_id)
594        .filter(|activation| {
595            activation.origin() == ConstraintOrigin::Generated
596                && matches!(
597                    activation.kind(),
598                    ConstraintActivationKind::Check { .. }
599                        | ConstraintActivationKind::TargetedRule { .. }
600                )
601        })
602        .ok_or_else(InternalError::store_corruption)?;
603    let catalog = snapshot
604        .constraint_catalog()
605        .clone()
606        .with_aborted_activation(constraint_id)
607        .map_err(|_| InternalError::store_invariant())?;
608    let accepted_identity_remains = catalog
609        .constraints()
610        .iter()
611        .any(|constraint| constraint.id() == constraint_id);
612    let mut snapshots = current.entity_snapshots().clone();
613    snapshots.insert(entity_tag, snapshot.with_constraint_catalog(catalog));
614    let mut source_bindings = current.source_bindings().clone();
615    if !accepted_identity_remains {
616        source_bindings.remove_constraint_identity(entity_tag, constraint_id)?;
617    }
618    let revision = current
619        .revision()
620        .checked_next()
621        .ok_or_else(InternalError::store_unsupported)?;
622    let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
623        revision,
624        current.store_path(),
625        current.enum_catalog().clone(),
626        current.composite_catalog().clone(),
627        source_bindings,
628        snapshots,
629    )?;
630    CandidateSchemaRevision::new(bundle)
631}
632
633/// Apply one exact source-keyed schema proposal through catalog-native
634/// accepted candidates and the durable application-receipt boundary.
635pub(in crate::db) fn apply_schema<C: CanisterKind>(
636    db: &Db<C>,
637    proposal: &SchemaProposal,
638) -> Result<SchemaChangeReceipt, InternalError> {
639    ensure_recovery_admitted(db)?;
640    ensure_schema_migration_ready_for_ordinary_operations()?;
641    let proposal_digest = proposal
642        .digest()
643        .map_err(|_| InternalError::store_unsupported())?;
644    if let Some(receipt) = exact_schema_application_receipt(proposal, proposal_digest)? {
645        return Ok(receipt);
646    }
647
648    let target = schema_application_target(db)?;
649    if target.database_identity() != proposal.target_database()
650        || target.accepted_head() != proposal.expected_head()
651    {
652        return Err(InternalError::schema_application_conflict());
653    }
654
655    preflight_ordinary_source_application(db, proposal, &target)?;
656
657    let authorities = application_authorities(db);
658    let LoweredApplication {
659        current_bundles,
660        candidates,
661        pending,
662    } = lower_application_candidates(&target, proposal, authorities.as_slice())?;
663    validate_database_identity_state_capacity(
664        authorities.as_slice(),
665        candidates.as_slice(),
666        database_incarnation_id()?,
667    )?;
668    let accepted_head = if let Some(pending) = pending.as_ref() {
669        let final_candidates =
670            final_candidates_for_pending_row_local_constraint(&candidates, pending)?;
671        accepted_head_after_candidates(authorities.as_slice(), final_candidates.as_slice())?
672    } else if candidates.is_empty() {
673        target.accepted_head().clone()
674    } else {
675        accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?
676    };
677    #[cfg(feature = "migration")]
678    let outcome_head = accepted_head.clone();
679    #[cfg(not(feature = "migration"))]
680    let outcome_head = accepted_head;
681    let outcome = if pending.is_some() {
682        let job_id = derive_schema_change_job_id(
683            target.database_identity(),
684            proposal.submission_key(),
685            proposal_digest,
686            target.accepted_head(),
687        )?;
688        SchemaChangeOutcome::Pending {
689            job: SchemaChangeJob::new(job_id),
690            candidate_head: outcome_head,
691        }
692    } else if candidates.is_empty() {
693        SchemaChangeOutcome::NoOp {
694            accepted_head: outcome_head,
695        }
696    } else {
697        SchemaChangeOutcome::Applied {
698            accepted_head: outcome_head,
699        }
700    };
701    let receipt = SchemaChangeReceipt::new(
702        target.database_identity(),
703        proposal.submission_key().clone(),
704        proposal_digest,
705        target.accepted_head().clone(),
706        outcome,
707    )?;
708    let activations = match pending {
709        Some(pending) => {
710            let authority = authorities
711                .iter()
712                .find(|authority| authority.path == pending.proof.store_path)
713                .ok_or_else(InternalError::store_invariant)?;
714            vec![SchemaChangeActivation::new(
715                derive_store_identity(target.database_identity(), authority),
716                pending.proof.entity_tag.value(),
717                pending.proof.constraint_id.get(),
718            )?]
719        }
720        None => Vec::new(),
721    };
722    let record = SchemaApplicationRecord::new(receipt.clone(), activations)?;
723    let operation = SchemaApplicationRecordOp::insert(&record)?;
724    #[cfg(feature = "migration")]
725    let database_control = attach_ordinary_lineage_publication(
726        proposal,
727        target.accepted_head(),
728        &accepted_head,
729        candidates.as_slice(),
730        operation,
731    )?;
732    #[cfg(not(feature = "migration"))]
733    let database_control = vec![DatabaseControlOp::SchemaApplication(operation)];
734    let publications =
735        application_publications(authorities.as_slice(), &current_bundles, &candidates)?;
736    publish_accepted_schema_candidates_with_database_control(publications, database_control)?;
737    Ok(receipt)
738}
739
740fn preflight_ordinary_source_application<C: CanisterKind>(
741    db: &Db<C>,
742    proposal: &SchemaProposal,
743    target: &SchemaApplicationTarget,
744) -> Result<(), InternalError> {
745    if proposal.migration().is_none()
746        || matches!(target.accepted_head(), ExpectedAcceptedHead::Empty)
747    {
748        return Ok(());
749    }
750    #[cfg(feature = "migration")]
751    if current_proposal_lineage_is_applied(db, proposal, target.accepted_head())? {
752        return Ok(());
753    }
754    #[cfg(feature = "migration")]
755    preflight_unpublished_schema_migration(target, proposal, db)?;
756    #[cfg(not(feature = "migration"))]
757    let _ = db;
758    Err(InternalError::store_unsupported())
759}
760
761/// Execute one explicit source-migration operation against the exact deployed
762/// generated proposal. Metadata-only adoption and advance complete in one
763/// marker; physical work remains rejected until the durable runner exists.
764#[cfg(feature = "migration")]
765pub(in crate::db) fn migrate_schema<C: CanisterKind>(
766    db: &Db<C>,
767    proposal: &SchemaProposal,
768    command: SchemaMigrationCommand,
769) -> Result<SchemaMigrationStatusPage, InternalError> {
770    ensure_recovery_admitted(db)?;
771    match command {
772        SchemaMigrationCommand::Adopt {
773            expected_database,
774            expected_head,
775        } => adopt_entity_source_lineage(db, proposal, expected_database, &expected_head),
776        SchemaMigrationCommand::Advance {
777            expected_database,
778            expected_head,
779            expected_plan,
780            acknowledged_finding_page,
781        } => advance_metadata_schema_migration(
782            db,
783            proposal,
784            expected_database,
785            &expected_head,
786            expected_plan,
787            acknowledged_finding_page,
788        ),
789        SchemaMigrationCommand::Abort {
790            expected_database,
791            expected_head,
792            expected_plan,
793        } => {
794            let plan = proposal.migration().ok_or_else(|| {
795                InternalError::schema_migration(SchemaMigrationCode::MissingMigration)
796            })?;
797            if proposal.target_database() != expected_database || plan.digest() != expected_plan {
798                return Err(InternalError::schema_migration(
799                    SchemaMigrationCode::PlanChanged,
800                ));
801            }
802            if let Some(record) = exact_active_migration_record(
803                proposal,
804                expected_database,
805                &expected_head,
806                expected_plan,
807            )? {
808                let target = schema_application_target(db)?;
809                validate_active_migration_target(&record, &target)?;
810                if record.phase() == PersistedSchemaMigrationPhase::Applied
811                    || record.phase() == PersistedSchemaMigrationPhase::Aborted
812                {
813                    return active_migration_status(proposal, &target, &record);
814                }
815                if !record.phase().abortable() {
816                    return Err(InternalError::schema_migration(
817                        SchemaMigrationCode::AbortTooLate,
818                    ));
819                }
820                let planned = recompile_active_physical_migration(db, proposal, &record)?;
821                let authorities = application_authorities(db);
822                let store_identities = authorities
823                    .iter()
824                    .map(|authority| {
825                        (
826                            authority.path,
827                            derive_store_identity(record.database_identity(), authority),
828                        )
829                    })
830                    .collect::<BTreeMap<_, _>>();
831                let (progress, exhausted) = cleanup_migration_staging_page(
832                    db,
833                    &planned,
834                    record.progress(),
835                    &store_identities,
836                )?;
837                let phase = if exhausted {
838                    PersistedSchemaMigrationPhase::Aborted
839                } else {
840                    record.phase()
841                };
842                let advanced = record.transition(phase, progress)?;
843                let operation = SchemaMigrationRecordOp::replace(&record, &advanced)?;
844                publish_accepted_schema_candidates_with_database_control(
845                    Vec::new(),
846                    vec![DatabaseControlOp::SchemaMigration(operation)],
847                )?;
848                return active_migration_status(proposal, &target, &advanced);
849            }
850            let target = exact_migration_target(db, expected_database, &expected_head)?;
851            let status = schema_migration_status_for_target(db, proposal, &target)?;
852            if status.phase() == SchemaMigrationPhase::Applied {
853                Ok(status)
854            } else {
855                // No active nonterminal migration exists at this boundary, so
856                // the request fails closed.
857                Err(InternalError::schema_migration(
858                    SchemaMigrationCode::MissingMigration,
859                ))
860            }
861        }
862    }
863}
864
865/// Return one bounded deployed-source migration status page.
866#[cfg(feature = "migration")]
867pub(in crate::db) fn schema_migration_status<C: CanisterKind>(
868    db: &Db<C>,
869    proposal: &SchemaProposal,
870    request: &SchemaMigrationStatusRequest,
871) -> Result<SchemaMigrationStatusPage, InternalError> {
872    ensure_recovery_admitted(db)?;
873    if !request.validate() || request.cursor().is_some() {
874        return Err(InternalError::cursor_invalid_continuation());
875    }
876    let target = schema_application_target(db)?;
877    schema_migration_status_for_target(db, proposal, &target)
878}
879
880/// Admit generated ordinary endpoint startup while an exact prepared
881/// migration deliberately leaves predecessor authority live. Every later
882/// phase remains owned by the database-wide gate.
883#[cfg(feature = "migration")]
884pub(in crate::db) fn defer_generated_schema_application_for_prepared_migration<C: CanisterKind>(
885    db: &Db<C>,
886    proposal: &SchemaProposal,
887) -> Result<bool, InternalError> {
888    ensure_recovery_admitted(db)?;
889    let Some(record) = load_schema_migration_record()? else {
890        return Ok(false);
891    };
892    if matches!(
893        record.phase(),
894        PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted
895    ) {
896        return Ok(false);
897    }
898    validate_active_migration_deployment(proposal, &record)?;
899    let target = schema_application_target(db)?;
900    validate_active_migration_target(&record, &target)?;
901    match record.phase() {
902        PersistedSchemaMigrationPhase::Prepared => Ok(true),
903        PersistedSchemaMigrationPhase::Validating
904        | PersistedSchemaMigrationPhase::ReadyToRewrite
905        | PersistedSchemaMigrationPhase::RewritingRows
906        | PersistedSchemaMigrationPhase::RebuildingIndexes
907        | PersistedSchemaMigrationPhase::FinalValidation
908        | PersistedSchemaMigrationPhase::Publishing
909        | PersistedSchemaMigrationPhase::Rejected => Err(InternalError::schema_migration(
910            SchemaMigrationCode::MigrationInProgress,
911        )),
912        PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted => {
913            Ok(false)
914        }
915    }
916}
917
918#[cfg(feature = "migration")]
919fn adopt_entity_source_lineage<C: CanisterKind>(
920    db: &Db<C>,
921    proposal: &SchemaProposal,
922    expected_database: TargetDatabaseIdentity,
923    expected_head: &ExpectedAcceptedHead,
924) -> Result<SchemaMigrationStatusPage, InternalError> {
925    if proposal.migration().is_some() || proposal.target_database() != expected_database {
926        return Err(InternalError::schema_migration(
927            SchemaMigrationCode::PlanChanged,
928        ));
929    }
930    let proposal_digest = proposal
931        .digest()
932        .map_err(|_| InternalError::store_unsupported())?;
933    let submission_key = migration_submission_key(None)?;
934    if let Some(record) = load_exact_migration_record(
935        expected_database,
936        &submission_key,
937        proposal_digest,
938        expected_head,
939    )? {
940        let replay_target = exact_migration_replay_target(db, expected_database, &record)?;
941        return schema_migration_status_for_target(db, proposal, &replay_target);
942    }
943    let target = exact_migration_target(db, expected_database, expected_head)?;
944
945    let authorities = application_authorities(db);
946    let current_bundles = load_current_application_bundles(authorities.as_slice())?;
947    let stores = existing_proposal_stores(
948        target.database_identity(),
949        authorities.as_slice(),
950        current_bundles.as_slice(),
951    );
952    let stored_before = load_entity_source_lineage_catalog()?;
953    let before = stored_before.clone().unwrap_or_default();
954    let planned = plan_entity_source_adoption(proposal, stores.as_slice(), &before)
955        .map_err(schema_migration_planning_error)?;
956    let after = lineage_after_planned(&before, planned.as_slice(), expected_head)?;
957    let receipt = SchemaChangeReceipt::new(
958        expected_database,
959        submission_key,
960        proposal_digest,
961        expected_head.clone(),
962        SchemaChangeOutcome::NoOp {
963            accepted_head: expected_head.clone(),
964        },
965    )?;
966    let record = SchemaApplicationRecord::new(receipt, Vec::new())?;
967    let operation = SchemaApplicationRecordOp::insert(&record)?;
968    let lineage = EntitySourceLineageCatalogOp::replace(stored_before.as_ref(), &after)?;
969    publish_accepted_schema_candidates_with_database_control(
970        Vec::new(),
971        vec![
972            DatabaseControlOp::SchemaApplication(operation),
973            DatabaseControlOp::EntitySourceLineage(lineage),
974        ],
975    )?;
976    schema_migration_status_for_target(db, proposal, &target)
977}
978
979#[cfg(feature = "migration")]
980#[expect(
981    clippy::too_many_lines,
982    reason = "one migration entry point keeps preparation and exact replay ordering visible"
983)]
984fn advance_metadata_schema_migration<C: CanisterKind>(
985    db: &Db<C>,
986    proposal: &SchemaProposal,
987    expected_database: TargetDatabaseIdentity,
988    expected_head: &ExpectedAcceptedHead,
989    expected_plan: SchemaMigrationPlanDigest,
990    acknowledged_finding_page: Option<u64>,
991) -> Result<SchemaMigrationStatusPage, InternalError> {
992    let plan = proposal
993        .migration()
994        .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::MissingMigration))?;
995    if proposal.target_database() != expected_database || plan.digest() != expected_plan {
996        return Err(InternalError::schema_migration(
997            SchemaMigrationCode::PlanChanged,
998        ));
999    }
1000    let proposal_digest = proposal
1001        .digest()
1002        .map_err(|_| InternalError::store_unsupported())?;
1003    if let Some(record) =
1004        exact_active_migration_record(proposal, expected_database, expected_head, expected_plan)?
1005    {
1006        let target = schema_application_target(db)?;
1007        validate_active_migration_target(&record, &target)?;
1008        return advance_active_schema_migration(
1009            db,
1010            proposal,
1011            &target,
1012            &record,
1013            acknowledged_finding_page,
1014        );
1015    }
1016    if acknowledged_finding_page.is_some() {
1017        return Err(InternalError::schema_migration(
1018            SchemaMigrationCode::CandidateMismatch,
1019        ));
1020    }
1021    let submission_key = migration_submission_key(Some(expected_plan))?;
1022    if let Some(record) = load_exact_migration_record(
1023        expected_database,
1024        &submission_key,
1025        proposal_digest,
1026        expected_head,
1027    )? {
1028        let replay_target = exact_migration_replay_target(db, expected_database, &record)?;
1029        return schema_migration_status_for_target(db, proposal, &replay_target);
1030    }
1031    exact_migration_target(db, expected_database, expected_head)?;
1032
1033    let authorities = application_authorities(db);
1034    let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1035    let stores = existing_proposal_stores(
1036        expected_database,
1037        authorities.as_slice(),
1038        current_bundles.as_slice(),
1039    );
1040    let before = load_entity_source_lineage_catalog()?
1041        .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1042    let planned = plan_schema_migration(proposal, stores.as_slice(), &before)
1043        .map_err(schema_migration_planning_error)?;
1044    let mut candidates = planned.candidates().to_vec();
1045    let pending = if planned.requires_physical_validation() {
1046        // The ordinary online preflight intentionally rejects non-empty field
1047        // removal and direct index-generation replacement. The offline
1048        // migration validator owns those same historical proofs against its
1049        // unpublished candidate instead.
1050        None
1051    } else {
1052        preflight_existing_application(
1053            authorities.as_slice(),
1054            current_bundles.as_slice(),
1055            &mut candidates,
1056        )?
1057    };
1058    if pending.is_some() {
1059        return Err(InternalError::schema_migration(
1060            SchemaMigrationCode::MigrationInProgress,
1061        ));
1062    }
1063    if candidates.is_empty() || planned.lineage().is_empty() {
1064        return Err(InternalError::schema_migration(
1065            SchemaMigrationCode::EmptyEntityVersionBump,
1066        ));
1067    }
1068    validate_database_identity_state_capacity(
1069        authorities.as_slice(),
1070        candidates.as_slice(),
1071        database_incarnation_id()?,
1072    )?;
1073    let accepted_head =
1074        accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?;
1075    if planned.requires_physical_validation() {
1076        ensure_physical_migration_stores_are_journaled(db, &planned)?;
1077        let record = prepared_physical_schema_migration(
1078            proposal,
1079            &planned,
1080            candidates.as_slice(),
1081            expected_database,
1082            expected_head,
1083            &accepted_head,
1084            proposal_digest,
1085            expected_plan,
1086            stores.as_slice(),
1087        )?;
1088        let operation = SchemaMigrationRecordOp::insert(&record)?;
1089        publish_accepted_schema_candidates_with_database_control(
1090            Vec::new(),
1091            vec![DatabaseControlOp::SchemaMigration(operation)],
1092        )?;
1093        let target = schema_application_target(db)?;
1094        validate_active_migration_target(&record, &target)?;
1095        return active_migration_status(proposal, &target, &record);
1096    }
1097    let after = lineage_after_planned(&before, planned.lineage(), &accepted_head)?;
1098    let receipt = SchemaChangeReceipt::new(
1099        expected_database,
1100        submission_key,
1101        proposal_digest,
1102        expected_head.clone(),
1103        SchemaChangeOutcome::Applied { accepted_head },
1104    )?;
1105    let record = SchemaApplicationRecord::new(receipt, Vec::new())?;
1106    let operation = SchemaApplicationRecordOp::insert(&record)?;
1107    let lineage = EntitySourceLineageCatalogOp::replace(Some(&before), &after)?;
1108    let publications = application_publications(
1109        authorities.as_slice(),
1110        current_bundles.as_slice(),
1111        candidates.as_slice(),
1112    )?;
1113    publish_accepted_schema_candidates_with_database_control(
1114        publications,
1115        vec![
1116            DatabaseControlOp::SchemaApplication(operation),
1117            DatabaseControlOp::EntitySourceLineage(lineage),
1118        ],
1119    )?;
1120    let applied_target = schema_application_target(db)?;
1121    schema_migration_status_for_target(db, proposal, &applied_target)
1122}
1123
1124#[cfg(feature = "migration")]
1125fn ensure_physical_migration_stores_are_journaled<C: CanisterKind>(
1126    db: &Db<C>,
1127    planned: &crate::db::schema::migration_planner::PlannedSchemaMigration,
1128) -> Result<(), InternalError> {
1129    for program in planned.programs() {
1130        let store = db.store_handle(program.store_path())?;
1131        if store.storage_capabilities().recovery()
1132            != StoreRecoveryCapability::StableBasePlusJournalReplay
1133        {
1134            return Err(InternalError::schema_migration(
1135                SchemaMigrationCode::PhysicalRunnerMissing,
1136            ));
1137        }
1138    }
1139    Ok(())
1140}
1141
1142#[cfg(feature = "migration")]
1143#[expect(
1144    clippy::too_many_arguments,
1145    reason = "migration preparation binds every immutable deployment and candidate identity"
1146)]
1147fn prepared_physical_schema_migration(
1148    proposal: &SchemaProposal,
1149    planned: &crate::db::schema::migration_planner::PlannedSchemaMigration,
1150    candidates: &[CandidateSchemaRevision],
1151    database_identity: TargetDatabaseIdentity,
1152    accepted_before: &ExpectedAcceptedHead,
1153    candidate_head: &ExpectedAcceptedHead,
1154    submission_digest: SchemaProposalDigest,
1155    plan_digest: SchemaMigrationPlanDigest,
1156    stores: &[ExistingProposalStore<'_>],
1157) -> Result<SchemaMigrationRecord, InternalError> {
1158    let plan = proposal
1159        .migration()
1160        .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::MissingMigration))?;
1161    let transitions = plan
1162        .transitions()
1163        .iter()
1164        .map(|transition| {
1165            PersistedSchemaMigrationTransition::try_new(
1166                transition.entity().clone(),
1167                transition.from().get(),
1168                transition
1169                    .from()
1170                    .get()
1171                    .checked_add(1)
1172                    .ok_or_else(InternalError::store_invariant)?,
1173            )
1174        })
1175        .collect::<Result<Vec<_>, InternalError>>()?;
1176    let entities = planned
1177        .lineage()
1178        .iter()
1179        .map(|entity| {
1180            PersistedSchemaMigrationEntity::try_new(
1181                entity.store(),
1182                entity.entity(),
1183                entity.digest(),
1184            )
1185        })
1186        .collect::<Result<Vec<_>, InternalError>>()?;
1187    let mut staged_indexes = Vec::new();
1188    for candidate in candidates {
1189        let store = stores
1190            .iter()
1191            .find(|store| store.path == candidate.store_path())
1192            .ok_or_else(InternalError::store_invariant)?;
1193        for (entity, snapshot) in candidate.bundle().entity_snapshots() {
1194            let before = store.bundle.entity_snapshots().get(entity);
1195            for index in snapshot
1196                .indexes()
1197                .iter()
1198                .filter(|index| {
1199                    before
1200                        .and_then(|before| {
1201                            before
1202                                .indexes()
1203                                .iter()
1204                                .find(|old| old.schema_id() == index.schema_id())
1205                        })
1206                        .is_none_or(|old| old.physical_generation() != index.physical_generation())
1207                })
1208                .chain(snapshot.candidate_indexes())
1209            {
1210                staged_indexes.push(PersistedSchemaMigrationIndex::try_new(
1211                    store.identity,
1212                    *entity,
1213                    u64::from(index.schema_id().get()),
1214                    index.physical_generation(),
1215                )?);
1216            }
1217        }
1218    }
1219    staged_indexes.sort_unstable();
1220    staged_indexes.dedup();
1221    SchemaMigrationRecord::prepared(
1222        database_identity,
1223        accepted_before.clone(),
1224        candidate_head.clone(),
1225        submission_digest,
1226        plan_digest,
1227        transitions,
1228        entities,
1229        staged_indexes,
1230    )
1231}
1232
1233#[cfg(feature = "migration")]
1234#[expect(
1235    clippy::too_many_lines,
1236    reason = "the closed phase match keeps every durable migration transition and publication boundary exhaustive"
1237)]
1238fn advance_active_schema_migration<C: CanisterKind>(
1239    db: &Db<C>,
1240    proposal: &SchemaProposal,
1241    target: &SchemaApplicationTarget,
1242    record: &SchemaMigrationRecord,
1243    acknowledged_finding_page: Option<u64>,
1244) -> Result<SchemaMigrationStatusPage, InternalError> {
1245    match record.phase() {
1246        PersistedSchemaMigrationPhase::Prepared => {
1247            if acknowledged_finding_page.is_some() {
1248                return Err(InternalError::schema_migration(
1249                    SchemaMigrationCode::CandidateMismatch,
1250                ));
1251            }
1252            let validating = record.transition(
1253                PersistedSchemaMigrationPhase::Validating,
1254                record.progress().clone(),
1255            )?;
1256            publish_migration_record_replacement(record, &validating)?;
1257            active_migration_status(proposal, target, &validating)
1258        }
1259        PersistedSchemaMigrationPhase::Validating => {
1260            if acknowledged_finding_page.is_some() {
1261                return Err(InternalError::schema_migration(
1262                    SchemaMigrationCode::CandidateMismatch,
1263                ));
1264            }
1265            let planned = recompile_active_physical_migration(db, proposal, record)?;
1266            let page = validate_migration_page(db, &planned, record.progress())?;
1267            let (progress, staged_entries, exhausted) = page.into_parts();
1268            let phase = if progress.findings().is_empty() {
1269                if exhausted {
1270                    PersistedSchemaMigrationPhase::ReadyToRewrite
1271                } else {
1272                    PersistedSchemaMigrationPhase::Validating
1273                }
1274            } else {
1275                PersistedSchemaMigrationPhase::Rejected
1276            };
1277            if progress.findings().is_empty() {
1278                // Candidate generations are planner-invisible. Staging them
1279                // before the cursor marker makes retry idempotent and ensures
1280                // durable progress never names absent physical proof.
1281                stage_migration_index_entries(staged_entries)?;
1282            }
1283            let advanced = record.transition(phase, progress)?;
1284            publish_migration_record_replacement(record, &advanced)?;
1285            active_migration_status(proposal, target, &advanced)
1286        }
1287        PersistedSchemaMigrationPhase::Rejected => {
1288            if acknowledged_finding_page.is_some()
1289                && acknowledged_finding_page != record.progress().finding_page()
1290            {
1291                return Err(InternalError::schema_migration(
1292                    SchemaMigrationCode::CandidateMismatch,
1293                ));
1294            }
1295            active_migration_status(proposal, target, record)
1296        }
1297        PersistedSchemaMigrationPhase::ReadyToRewrite => {
1298            if acknowledged_finding_page.is_some() {
1299                return Err(InternalError::schema_migration(
1300                    SchemaMigrationCode::CandidateMismatch,
1301                ));
1302            }
1303            let progress = record.progress().begin_row_phase()?;
1304            let rewriting =
1305                record.transition(PersistedSchemaMigrationPhase::RewritingRows, progress)?;
1306            publish_migration_record_replacement(record, &rewriting)?;
1307            active_migration_status(proposal, target, &rewriting)
1308        }
1309        PersistedSchemaMigrationPhase::RewritingRows => {
1310            if acknowledged_finding_page.is_some() {
1311                return Err(InternalError::schema_migration(
1312                    SchemaMigrationCode::CandidateMismatch,
1313                ));
1314            }
1315            let planned = recompile_active_physical_migration(db, proposal, record)?;
1316            let page =
1317                rewrite_migration_page(db, &planned, record.progress(), record.plan_digest())?;
1318            let (progress, effects, exhausted) = page.into_parts();
1319            if progress.rows_rewritten() > progress.rows_validated()
1320                || (exhausted && progress.rows_rewritten() != progress.rows_validated())
1321            {
1322                return Err(InternalError::schema_migration(
1323                    SchemaMigrationCode::ProgressCorrupt,
1324                ));
1325            }
1326            let phase = if exhausted {
1327                PersistedSchemaMigrationPhase::RebuildingIndexes
1328            } else {
1329                PersistedSchemaMigrationPhase::RewritingRows
1330            };
1331            let advanced = record.transition(phase, progress)?;
1332            let operation = SchemaMigrationRecordOp::replace(record, &advanced)?;
1333            publish_migration_rewrite_page(effects, operation)?;
1334            active_migration_status(proposal, target, &advanced)
1335        }
1336        PersistedSchemaMigrationPhase::RebuildingIndexes => {
1337            if acknowledged_finding_page.is_some() {
1338                return Err(InternalError::schema_migration(
1339                    SchemaMigrationCode::CandidateMismatch,
1340                ));
1341            }
1342            let planned = recompile_active_physical_migration(db, proposal, record)?;
1343            let rebuilt = migration_derived_domain_count(db, &planned)?;
1344            let progress = record
1345                .progress()
1346                .begin_row_phase()?
1347                .with_index_progress(None, rebuilt)?;
1348            let validating =
1349                record.transition(PersistedSchemaMigrationPhase::FinalValidation, progress)?;
1350            publish_migration_record_replacement(record, &validating)?;
1351            active_migration_status(proposal, target, &validating)
1352        }
1353        PersistedSchemaMigrationPhase::FinalValidation => {
1354            if acknowledged_finding_page.is_some() {
1355                return Err(InternalError::schema_migration(
1356                    SchemaMigrationCode::CandidateMismatch,
1357                ));
1358            }
1359            let planned = recompile_active_physical_migration(db, proposal, record)?;
1360            let page = final_validate_migration_page(db, &planned, record.progress())?;
1361            let (progress, exhausted) = page.into_parts();
1362            let phase = if exhausted {
1363                PersistedSchemaMigrationPhase::Publishing
1364            } else {
1365                PersistedSchemaMigrationPhase::FinalValidation
1366            };
1367            let advanced = record.transition(phase, progress)?;
1368            publish_migration_record_replacement(record, &advanced)?;
1369            active_migration_status(proposal, target, &advanced)
1370        }
1371        PersistedSchemaMigrationPhase::Publishing => {
1372            if acknowledged_finding_page.is_some() {
1373                return Err(InternalError::schema_migration(
1374                    SchemaMigrationCode::CandidateMismatch,
1375                ));
1376            }
1377            publish_completed_physical_migration(db, proposal, record)
1378        }
1379        PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted => {
1380            if acknowledged_finding_page.is_some() {
1381                return Err(InternalError::schema_migration(
1382                    SchemaMigrationCode::CandidateMismatch,
1383                ));
1384            }
1385            active_migration_status(proposal, target, record)
1386        }
1387    }
1388}
1389
1390#[cfg(feature = "migration")]
1391fn recompile_active_physical_migration<C: CanisterKind>(
1392    db: &Db<C>,
1393    proposal: &SchemaProposal,
1394    record: &SchemaMigrationRecord,
1395) -> Result<crate::db::schema::migration_planner::PlannedSchemaMigration, InternalError> {
1396    let authorities = application_authorities(db);
1397    let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1398    let stores = existing_proposal_stores(
1399        record.database_identity(),
1400        authorities.as_slice(),
1401        current_bundles.as_slice(),
1402    );
1403    let lineage = load_entity_source_lineage_catalog()?
1404        .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1405    let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
1406        .map_err(schema_migration_planning_error)?;
1407    if !planned.requires_physical_validation() {
1408        return Err(InternalError::schema_migration(
1409            SchemaMigrationCode::PlanChanged,
1410        ));
1411    }
1412    let candidate_head =
1413        accepted_head_after_candidates(authorities.as_slice(), planned.candidates())?;
1414    if &candidate_head != record.candidate_head() {
1415        return Err(InternalError::schema_migration(
1416            SchemaMigrationCode::CandidateMismatch,
1417        ));
1418    }
1419    Ok(planned)
1420}
1421
1422#[cfg(feature = "migration")]
1423fn publish_completed_physical_migration<C: CanisterKind>(
1424    db: &Db<C>,
1425    proposal: &SchemaProposal,
1426    record: &SchemaMigrationRecord,
1427) -> Result<SchemaMigrationStatusPage, InternalError> {
1428    let target = schema_application_target(db)?;
1429    validate_active_migration_target(record, &target)?;
1430    let planned = recompile_active_physical_migration(db, proposal, record)?;
1431    let authorities = application_authorities(db);
1432    let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1433    let candidate_head =
1434        accepted_head_after_candidates(authorities.as_slice(), planned.candidates())?;
1435    if &candidate_head != record.candidate_head() {
1436        return Err(InternalError::schema_migration(
1437            SchemaMigrationCode::PublicationRaceLost,
1438        ));
1439    }
1440    let lineage_before = load_entity_source_lineage_catalog()?
1441        .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1442    let lineage_after =
1443        lineage_after_planned(&lineage_before, planned.lineage(), record.candidate_head())?;
1444    let receipt = SchemaChangeReceipt::new(
1445        record.database_identity(),
1446        migration_submission_key(Some(record.plan_digest()))?,
1447        record.submission_digest(),
1448        record.accepted_before().clone(),
1449        SchemaChangeOutcome::Applied {
1450            accepted_head: record.candidate_head().clone(),
1451        },
1452    )?;
1453    let application = SchemaApplicationRecord::new(receipt, Vec::new())?;
1454    let application = SchemaApplicationRecordOp::insert(&application)?;
1455    let lineage = EntitySourceLineageCatalogOp::replace(Some(&lineage_before), &lineage_after)?;
1456    let applied = record.transition(
1457        PersistedSchemaMigrationPhase::Applied,
1458        record.progress().clone(),
1459    )?;
1460    let migration = SchemaMigrationRecordOp::replace(record, &applied)?;
1461    let publications = application_publications(
1462        authorities.as_slice(),
1463        current_bundles.as_slice(),
1464        planned.candidates(),
1465    )?;
1466    publish_accepted_schema_candidates_with_database_control(
1467        publications,
1468        vec![
1469            DatabaseControlOp::SchemaApplication(application),
1470            DatabaseControlOp::EntitySourceLineage(lineage),
1471            DatabaseControlOp::SchemaMigration(migration),
1472        ],
1473    )?;
1474    db.mark_all_registered_index_stores_ready()?;
1475    let applied_target = schema_application_target(db)?;
1476    active_migration_status(proposal, &applied_target, &applied)
1477}
1478
1479#[cfg(feature = "migration")]
1480fn publish_migration_record_replacement(
1481    before: &SchemaMigrationRecord,
1482    after: &SchemaMigrationRecord,
1483) -> Result<(), InternalError> {
1484    let operation = SchemaMigrationRecordOp::replace(before, after)?;
1485    publish_accepted_schema_candidates_with_database_control(
1486        Vec::new(),
1487        vec![DatabaseControlOp::SchemaMigration(operation)],
1488    )
1489}
1490
1491#[cfg(feature = "migration")]
1492fn attach_ordinary_lineage_publication(
1493    proposal: &SchemaProposal,
1494    prior_head: &ExpectedAcceptedHead,
1495    accepted_head: &ExpectedAcceptedHead,
1496    candidates: &[CandidateSchemaRevision],
1497    operation: SchemaApplicationRecordOp,
1498) -> Result<Vec<DatabaseControlOp>, InternalError> {
1499    let mut operations = vec![DatabaseControlOp::SchemaApplication(operation)];
1500    let stored_before = load_entity_source_lineage_catalog()?;
1501    let planned = if matches!(prior_head, ExpectedAcceptedHead::Empty) {
1502        plan_initial_entity_source_lineage(proposal, candidates)
1503            .map_err(schema_migration_planning_error)?
1504    } else {
1505        Vec::new()
1506    };
1507    if planned.is_empty() && (stored_before.is_none() || prior_head == accepted_head) {
1508        return Ok(operations);
1509    }
1510    let before = stored_before.clone().unwrap_or_default();
1511    let after = lineage_after_planned(&before, planned.as_slice(), accepted_head)?;
1512    if before == after {
1513        return Ok(operations);
1514    }
1515    operations.push(DatabaseControlOp::EntitySourceLineage(
1516        EntitySourceLineageCatalogOp::replace(stored_before.as_ref(), &after)?,
1517    ));
1518    Ok(operations)
1519}
1520
1521#[cfg(feature = "migration")]
1522fn exact_migration_target<C: CanisterKind>(
1523    db: &Db<C>,
1524    expected_database: TargetDatabaseIdentity,
1525    expected_head: &ExpectedAcceptedHead,
1526) -> Result<SchemaApplicationTarget, InternalError> {
1527    let target = schema_application_target(db)?;
1528    if target.database_identity() != expected_database || target.accepted_head() != expected_head {
1529        return Err(InternalError::schema_migration(
1530            SchemaMigrationCode::StaleAcceptedHead,
1531        ));
1532    }
1533    Ok(target)
1534}
1535
1536#[cfg(feature = "migration")]
1537fn exact_migration_replay_target<C: CanisterKind>(
1538    db: &Db<C>,
1539    expected_database: TargetDatabaseIdentity,
1540    record: &SchemaApplicationRecord,
1541) -> Result<SchemaApplicationTarget, InternalError> {
1542    let target = schema_application_target(db)?;
1543    if target.database_identity() != expected_database
1544        || target.accepted_head() != migration_record_accepted_head(record)?
1545    {
1546        return Err(InternalError::schema_migration(
1547            SchemaMigrationCode::PlanChanged,
1548        ));
1549    }
1550    Ok(target)
1551}
1552
1553#[cfg(feature = "migration")]
1554fn schema_migration_status_for_target<C: CanisterKind>(
1555    db: &Db<C>,
1556    proposal: &SchemaProposal,
1557    target: &SchemaApplicationTarget,
1558) -> Result<SchemaMigrationStatusPage, InternalError> {
1559    if let Some(record) = load_schema_migration_record()? {
1560        validate_active_migration_deployment(proposal, &record)?;
1561        validate_active_migration_target(&record, target)?;
1562        return active_migration_status(proposal, target, &record);
1563    }
1564    let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
1565    let plan_digest = proposal
1566        .migration()
1567        .map(icydb_schema::SchemaMigrationPlan::digest);
1568    let transitions = migration_transitions(proposal)?;
1569    let submission_key = migration_submission_key(plan_digest)?;
1570    let terminal = load_migration_record_for_status(target.database_identity(), &submission_key)?
1571        .map(|record| public_migration_receipt(&record, plan_digest))
1572        .transpose()?;
1573    let unadopted = lineage.entries().is_empty()
1574        || lineage
1575            .entries()
1576            .values()
1577            .any(|entry| matches!(entry.state(), AcceptedEntitySourceLineageState::Unadopted));
1578    let applied = current_proposal_lineage_is_applied(db, proposal, target.accepted_head())?;
1579    let phase = if unadopted {
1580        SchemaMigrationPhase::Unadopted
1581    } else if proposal.migration().is_none() {
1582        SchemaMigrationPhase::Adopted
1583    } else if applied {
1584        SchemaMigrationPhase::Applied
1585    } else {
1586        SchemaMigrationPhase::Idle
1587    };
1588    let terminal = terminal.filter(|receipt| {
1589        receipt.accepted_head() == target.accepted_head()
1590            && receipt.plan_digest() == plan_digest
1591            && matches!(
1592                phase,
1593                SchemaMigrationPhase::Adopted | SchemaMigrationPhase::Applied
1594            )
1595    });
1596    Ok(SchemaMigrationStatusPage::new(
1597        target.database_identity(),
1598        target.accepted_head().clone(),
1599        plan_digest,
1600        phase,
1601        transitions,
1602        0,
1603        0,
1604        0,
1605        Vec::new(),
1606        None,
1607        terminal,
1608    ))
1609}
1610
1611#[cfg(feature = "migration")]
1612fn exact_active_migration_record(
1613    proposal: &SchemaProposal,
1614    expected_database: TargetDatabaseIdentity,
1615    expected_head: &ExpectedAcceptedHead,
1616    expected_plan: SchemaMigrationPlanDigest,
1617) -> Result<Option<SchemaMigrationRecord>, InternalError> {
1618    let Some(record) = load_schema_migration_record()? else {
1619        return Ok(None);
1620    };
1621    if record.database_identity() != expected_database
1622        || record.accepted_before() != expected_head
1623        || record.plan_digest() != expected_plan
1624    {
1625        return Err(InternalError::schema_migration(
1626            SchemaMigrationCode::PlanChanged,
1627        ));
1628    }
1629    validate_active_migration_deployment(proposal, &record)?;
1630    Ok(Some(record))
1631}
1632
1633#[cfg(feature = "migration")]
1634fn validate_active_migration_deployment(
1635    proposal: &SchemaProposal,
1636    record: &SchemaMigrationRecord,
1637) -> Result<(), InternalError> {
1638    let plan = proposal
1639        .migration()
1640        .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::PlanChanged))?;
1641    let proposal_digest = proposal
1642        .digest()
1643        .map_err(|_| InternalError::store_unsupported())?;
1644    if proposal.target_database() != record.database_identity()
1645        || plan.digest() != record.plan_digest()
1646        || proposal_digest != record.submission_digest()
1647    {
1648        return Err(InternalError::schema_migration(
1649            SchemaMigrationCode::PlanChanged,
1650        ));
1651    }
1652    Ok(())
1653}
1654
1655#[cfg(feature = "migration")]
1656fn validate_active_migration_target(
1657    record: &SchemaMigrationRecord,
1658    target: &SchemaApplicationTarget,
1659) -> Result<(), InternalError> {
1660    let expected_head = if record.phase() == PersistedSchemaMigrationPhase::Applied {
1661        record.candidate_head()
1662    } else {
1663        record.accepted_before()
1664    };
1665    if target.database_identity() != record.database_identity()
1666        || target.accepted_head() != expected_head
1667    {
1668        return Err(InternalError::schema_migration(
1669            SchemaMigrationCode::PlanChanged,
1670        ));
1671    }
1672    Ok(())
1673}
1674
1675#[cfg(feature = "migration")]
1676fn active_migration_status(
1677    proposal: &SchemaProposal,
1678    target: &SchemaApplicationTarget,
1679    record: &SchemaMigrationRecord,
1680) -> Result<SchemaMigrationStatusPage, InternalError> {
1681    validate_active_migration_deployment(proposal, record)?;
1682    validate_active_migration_target(record, target)?;
1683    let transitions = record
1684        .transitions()
1685        .iter()
1686        .map(|transition| {
1687            SchemaMigrationEntityTransition::new(
1688                transition.entity().clone(),
1689                Some(transition.predecessor_version()),
1690                transition.target_version(),
1691            )
1692        })
1693        .collect();
1694    let findings = record
1695        .progress()
1696        .findings()
1697        .iter()
1698        .map(|finding| {
1699            let kind = match finding.kind() {
1700                PersistedSchemaMigrationFindingKind::Transform => {
1701                    SchemaMigrationFindingKind::Transform
1702                }
1703                PersistedSchemaMigrationFindingKind::UniqueIndex => {
1704                    SchemaMigrationFindingKind::UniqueIndex
1705                }
1706                PersistedSchemaMigrationFindingKind::Relation => {
1707                    SchemaMigrationFindingKind::Relation
1708                }
1709                PersistedSchemaMigrationFindingKind::Constraint => {
1710                    SchemaMigrationFindingKind::Constraint
1711                }
1712            };
1713            SchemaMigrationFinding::new(
1714                kind,
1715                finding.entity().value(),
1716                finding.primary_key().to_vec(),
1717            )
1718        })
1719        .collect();
1720    let phase = match record.phase() {
1721        PersistedSchemaMigrationPhase::Prepared => SchemaMigrationPhase::Prepared,
1722        PersistedSchemaMigrationPhase::Validating => SchemaMigrationPhase::Validating,
1723        PersistedSchemaMigrationPhase::ReadyToRewrite => SchemaMigrationPhase::ReadyToRewrite,
1724        PersistedSchemaMigrationPhase::RewritingRows => SchemaMigrationPhase::RewritingRows,
1725        PersistedSchemaMigrationPhase::RebuildingIndexes => SchemaMigrationPhase::RebuildingIndexes,
1726        PersistedSchemaMigrationPhase::FinalValidation => SchemaMigrationPhase::FinalValidation,
1727        PersistedSchemaMigrationPhase::Publishing => SchemaMigrationPhase::Publishing,
1728        PersistedSchemaMigrationPhase::Applied => SchemaMigrationPhase::Applied,
1729        PersistedSchemaMigrationPhase::Rejected => SchemaMigrationPhase::Rejected,
1730        PersistedSchemaMigrationPhase::Aborted => SchemaMigrationPhase::Aborted,
1731    };
1732    let terminal_receipt = (record.phase() == PersistedSchemaMigrationPhase::Applied).then(|| {
1733        SchemaMigrationReceipt::new(
1734            record.database_identity(),
1735            Some(record.plan_digest()),
1736            record.accepted_before().clone(),
1737            record.candidate_head().clone(),
1738        )
1739    });
1740    Ok(SchemaMigrationStatusPage::new(
1741        record.database_identity(),
1742        target.accepted_head().clone(),
1743        Some(record.plan_digest()),
1744        phase,
1745        transitions,
1746        record.progress().rows_validated(),
1747        record.progress().rows_rewritten(),
1748        record.progress().indexes_rebuilt(),
1749        findings,
1750        None,
1751        terminal_receipt,
1752    ))
1753}
1754
1755#[cfg(feature = "migration")]
1756fn load_current_application_bundles(
1757    authorities: &[StoreApplicationAuthority],
1758) -> Result<Vec<Option<AcceptedSchemaRevisionBundle>>, InternalError> {
1759    authorities
1760        .iter()
1761        .map(|authority| {
1762            authority
1763                .handle
1764                .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
1765        })
1766        .collect()
1767}
1768
1769#[cfg(feature = "migration")]
1770fn existing_proposal_stores<'a>(
1771    database_identity: TargetDatabaseIdentity,
1772    authorities: &[StoreApplicationAuthority],
1773    bundles: &'a [Option<AcceptedSchemaRevisionBundle>],
1774) -> Vec<ExistingProposalStore<'a>> {
1775    authorities
1776        .iter()
1777        .zip(bundles)
1778        .filter_map(|(authority, bundle)| {
1779            bundle.as_ref().map(|bundle| ExistingProposalStore {
1780                path: authority.path,
1781                identity: derive_store_identity(database_identity, authority),
1782                bundle,
1783            })
1784        })
1785        .collect()
1786}
1787
1788#[cfg(feature = "migration")]
1789fn lineage_after_planned(
1790    before: &AcceptedEntitySourceLineageCatalog,
1791    planned: &[PlannedEntitySourceLineage],
1792    accepted_head: &ExpectedAcceptedHead,
1793) -> Result<AcceptedEntitySourceLineageCatalog, InternalError> {
1794    let mut entries = BTreeMap::new();
1795    for (key, entry) in before.entries() {
1796        let next = match entry.state() {
1797            AcceptedEntitySourceLineageState::Unadopted => {
1798                AcceptedEntitySourceLineage::unadopted(accepted_head.clone())?
1799            }
1800            AcceptedEntitySourceLineageState::Adopted {
1801                version,
1802                source_digest,
1803            } => AcceptedEntitySourceLineage::adopted(
1804                accepted_head.clone(),
1805                *version,
1806                *source_digest,
1807            )?,
1808        };
1809        entries.insert(*key, next);
1810    }
1811    for next in planned {
1812        entries.insert(
1813            (next.store(), next.entity()),
1814            AcceptedEntitySourceLineage::adopted(
1815                accepted_head.clone(),
1816                next.version(),
1817                next.digest(),
1818            )?,
1819        );
1820    }
1821    AcceptedEntitySourceLineageCatalog::try_new(entries)
1822}
1823
1824#[cfg(feature = "migration")]
1825fn schema_migration_planning_error(error: SchemaMigrationPlanningError) -> InternalError {
1826    let reason = match error {
1827        SchemaMigrationPlanningError::Unadopted => SchemaMigrationCode::Unadopted,
1828        SchemaMigrationPlanningError::MissingMigration => SchemaMigrationCode::MissingMigration,
1829        SchemaMigrationPlanningError::VersionGap => SchemaMigrationCode::VersionGap,
1830        SchemaMigrationPlanningError::Downgrade => SchemaMigrationCode::Downgrade,
1831        SchemaMigrationPlanningError::EmptyEntityVersionBump => {
1832            SchemaMigrationCode::EmptyEntityVersionBump
1833        }
1834        SchemaMigrationPlanningError::StaleAcceptedHead => SchemaMigrationCode::StaleAcceptedHead,
1835        SchemaMigrationPlanningError::UnknownFromObject => SchemaMigrationCode::UnknownFromObject,
1836        SchemaMigrationPlanningError::UnknownToObject => SchemaMigrationCode::UnknownToObject,
1837        SchemaMigrationPlanningError::KindMismatch => SchemaMigrationCode::KindMismatch,
1838        SchemaMigrationPlanningError::IdentityConflict => SchemaMigrationCode::IdentityConflict,
1839        SchemaMigrationPlanningError::UnexplainedSchemaDifference => {
1840            SchemaMigrationCode::UnexplainedSchemaDifference
1841        }
1842        SchemaMigrationPlanningError::UnsupportedTransform => {
1843            SchemaMigrationCode::UnsupportedTransform
1844        }
1845        SchemaMigrationPlanningError::RekeyedCatalogInvalid
1846        | SchemaMigrationPlanningError::CandidateMismatch => SchemaMigrationCode::CandidateMismatch,
1847        SchemaMigrationPlanningError::CorruptLineage => SchemaMigrationCode::ProgressCorrupt,
1848    };
1849    InternalError::schema_migration(reason)
1850}
1851
1852#[cfg(feature = "migration")]
1853fn migration_submission_key(
1854    plan_digest: Option<SchemaMigrationPlanDigest>,
1855) -> Result<SchemaSubmissionKey, InternalError> {
1856    let mut hasher = new_hash_sha256_prefixed(SCHEMA_MIGRATION_SUBMISSION_PROFILE);
1857    match plan_digest {
1858        None => write_hash_tag_u8(&mut hasher, 0),
1859        Some(digest) => {
1860            write_hash_tag_u8(&mut hasher, 1);
1861            hasher.update(digest.to_bytes());
1862        }
1863    }
1864    let digest = finalize_hash_sha256(hasher);
1865    let mut encoded = String::with_capacity(80);
1866    encoded.push_str("migration/");
1867    for byte in digest {
1868        use std::fmt::Write as _;
1869        write!(&mut encoded, "{byte:02x}").map_err(|_| InternalError::store_invariant())?;
1870    }
1871    SchemaSubmissionKey::try_new(encoded).map_err(|_| InternalError::store_invariant())
1872}
1873
1874#[cfg(feature = "migration")]
1875fn load_migration_record_for_status(
1876    database_identity: TargetDatabaseIdentity,
1877    submission_key: &SchemaSubmissionKey,
1878) -> Result<Option<SchemaApplicationRecord>, InternalError> {
1879    let record =
1880        with_schema_application_store(|store| store.load(database_identity, submission_key))?;
1881    if record
1882        .as_ref()
1883        .is_some_and(|record| record.receipt().database_identity() != database_identity)
1884    {
1885        return Err(InternalError::schema_migration(
1886            SchemaMigrationCode::ProgressCorrupt,
1887        ));
1888    }
1889    Ok(record)
1890}
1891
1892#[cfg(feature = "migration")]
1893fn load_exact_migration_record(
1894    database_identity: TargetDatabaseIdentity,
1895    submission_key: &SchemaSubmissionKey,
1896    proposal_digest: SchemaProposalDigest,
1897    prior_head: &ExpectedAcceptedHead,
1898) -> Result<Option<SchemaApplicationRecord>, InternalError> {
1899    let Some(record) =
1900        with_schema_application_store(|store| store.load(database_identity, submission_key))?
1901    else {
1902        return Ok(None);
1903    };
1904    if !record.receipt().is_exact_submission(
1905        database_identity,
1906        submission_key,
1907        proposal_digest,
1908        prior_head,
1909    ) {
1910        return Err(InternalError::schema_migration(
1911            SchemaMigrationCode::PlanChanged,
1912        ));
1913    }
1914    Ok(Some(record))
1915}
1916
1917#[cfg(feature = "migration")]
1918fn public_migration_receipt(
1919    record: &SchemaApplicationRecord,
1920    plan_digest: Option<SchemaMigrationPlanDigest>,
1921) -> Result<SchemaMigrationReceipt, InternalError> {
1922    let accepted_head = migration_record_accepted_head(record)?.clone();
1923    Ok(SchemaMigrationReceipt::new(
1924        record.receipt().database_identity(),
1925        plan_digest,
1926        record.receipt().prior_head().clone(),
1927        accepted_head,
1928    ))
1929}
1930
1931#[cfg(feature = "migration")]
1932fn migration_record_accepted_head(
1933    record: &SchemaApplicationRecord,
1934) -> Result<&ExpectedAcceptedHead, InternalError> {
1935    match record.receipt().outcome() {
1936        SchemaChangeOutcome::NoOp { accepted_head }
1937        | SchemaChangeOutcome::Applied { accepted_head } => Ok(accepted_head),
1938        SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => Err(
1939            InternalError::schema_migration(SchemaMigrationCode::ProgressCorrupt),
1940        ),
1941    }
1942}
1943
1944#[cfg(feature = "migration")]
1945fn migration_transitions(
1946    proposal: &SchemaProposal,
1947) -> Result<Vec<SchemaMigrationEntityTransition>, InternalError> {
1948    if let Some(plan) = proposal.migration() {
1949        return plan
1950            .transitions()
1951            .iter()
1952            .map(|transition| {
1953                let target = proposal_entity(proposal, transition.entity())?;
1954                Ok(SchemaMigrationEntityTransition::new(
1955                    transition.entity().clone(),
1956                    Some(transition.from().get()),
1957                    target.version().get(),
1958                ))
1959            })
1960            .collect();
1961    }
1962    proposal
1963        .fragments()
1964        .iter()
1965        .flat_map(icydb_schema::SchemaFragment::entities)
1966        .map(|entity| {
1967            Ok(SchemaMigrationEntityTransition::new(
1968                entity.source_key().clone(),
1969                None,
1970                entity.version().get(),
1971            ))
1972        })
1973        .collect()
1974}
1975
1976#[cfg(feature = "migration")]
1977fn proposal_entity<'a>(
1978    proposal: &'a SchemaProposal,
1979    source: &EntitySourceKey,
1980) -> Result<&'a icydb_schema::EntityFragment, InternalError> {
1981    proposal
1982        .fragments()
1983        .iter()
1984        .flat_map(icydb_schema::SchemaFragment::entities)
1985        .find(|entity| entity.source_key() == source)
1986        .ok_or_else(InternalError::store_invariant)
1987}
1988
1989#[cfg(feature = "migration")]
1990fn current_proposal_lineage_is_applied<C: CanisterKind>(
1991    db: &Db<C>,
1992    proposal: &SchemaProposal,
1993    accepted_head: &ExpectedAcceptedHead,
1994) -> Result<bool, InternalError> {
1995    let Some(lineage) = load_entity_source_lineage_catalog()? else {
1996        return Ok(false);
1997    };
1998    let entities = proposal
1999        .fragments()
2000        .iter()
2001        .flat_map(icydb_schema::SchemaFragment::entities)
2002        .collect::<Vec<_>>();
2003    if entities.len() != lineage.entries().len() {
2004        return Ok(false);
2005    }
2006    let target = proposal.target_database();
2007    let authorities = application_authorities(db);
2008    for entity in entities {
2009        let source = entity.source_key();
2010        let digest = proposal
2011            .entity_source_digest(source)
2012            .map_err(|_| InternalError::store_invariant())?;
2013        let mut matched = false;
2014        for authority in &authorities {
2015            let store_identity = derive_store_identity(target, authority);
2016            let entity_tag = authority
2017                .handle
2018                .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
2019                .and_then(|bundle| bundle.source_bindings().entity(source));
2020            let Some(entity_tag) = entity_tag else {
2021                continue;
2022            };
2023            let Some(entry) = lineage.get(store_identity, entity_tag) else {
2024                return Ok(false);
2025            };
2026            matched = entry.accepted_head() == accepted_head
2027                && matches!(
2028                    entry.state(),
2029                    AcceptedEntitySourceLineageState::Adopted { version, source_digest }
2030                        if version.get() == entity.version().get() && *source_digest == digest
2031                );
2032            break;
2033        }
2034        if !matched {
2035            return Ok(false);
2036        }
2037    }
2038    Ok(true)
2039}
2040
2041#[cfg(feature = "migration")]
2042fn preflight_unpublished_schema_migration<C: CanisterKind>(
2043    target: &SchemaApplicationTarget,
2044    proposal: &SchemaProposal,
2045    db: &Db<C>,
2046) -> Result<(), InternalError> {
2047    let authorities = application_authorities(db);
2048    let current_bundles = authorities
2049        .iter()
2050        .map(|authority| {
2051            authority
2052                .handle
2053                .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2054        })
2055        .collect::<Result<Vec<_>, InternalError>>()?;
2056    let stores = authorities
2057        .iter()
2058        .zip(&current_bundles)
2059        .filter_map(|(authority, bundle)| {
2060            bundle.as_ref().map(|bundle| ExistingProposalStore {
2061                path: authority.path,
2062                identity: derive_store_identity(target.database_identity(), authority),
2063                bundle,
2064            })
2065        })
2066        .collect::<Vec<_>>();
2067    let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
2068    let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
2069        .map_err(schema_migration_planning_error)?;
2070    if planned.candidates().is_empty() || planned.lineage().is_empty() {
2071        return Err(InternalError::store_invariant());
2072    }
2073    for next in planned.lineage() {
2074        let current = lineage
2075            .get(next.store(), next.entity())
2076            .ok_or_else(InternalError::store_invariant)?;
2077        let AcceptedEntitySourceLineageState::Adopted {
2078            version,
2079            source_digest,
2080        } = current.state()
2081        else {
2082            return Err(InternalError::store_invariant());
2083        };
2084        let expected_version = version
2085            .get()
2086            .checked_add(1)
2087            .ok_or_else(InternalError::store_invariant)?;
2088        if next.version().get() != expected_version || next.digest() == *source_digest {
2089            return Err(InternalError::store_invariant());
2090        }
2091    }
2092    Ok(())
2093}
2094
2095fn lower_application_candidates(
2096    target: &SchemaApplicationTarget,
2097    proposal: &SchemaProposal,
2098    authorities: &[StoreApplicationAuthority],
2099) -> Result<LoweredApplication, InternalError> {
2100    let current_bundles = authorities
2101        .iter()
2102        .map(|authority| {
2103            authority
2104                .handle
2105                .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2106        })
2107        .collect::<Result<Vec<_>, InternalError>>()?;
2108    let initial_application = matches!(target.accepted_head(), ExpectedAcceptedHead::Empty);
2109    let mut candidates = match target.accepted_head() {
2110        ExpectedAcceptedHead::Empty => {
2111            let stores = authorities
2112                .iter()
2113                .map(|authority| ProposalStoreTarget {
2114                    path: authority.path,
2115                    identity: derive_store_identity(target.database_identity(), authority),
2116                })
2117                .collect::<Vec<_>>();
2118            let candidates = lower_initial_schema_proposal(proposal, stores.as_slice())?;
2119            #[cfg(feature = "migration")]
2120            {
2121                let planned = plan_initial_entity_source_lineage(proposal, &candidates)
2122                    .map_err(schema_migration_planning_error)?;
2123                if planned.len()
2124                    != proposal
2125                        .fragments()
2126                        .iter()
2127                        .map(|fragment| fragment.entities().len())
2128                        .sum::<usize>()
2129                {
2130                    return Err(InternalError::store_invariant());
2131                }
2132            }
2133            candidates
2134        }
2135        ExpectedAcceptedHead::Exact { .. }
2136            if proposal.fragments().is_empty() && proposal.removals().is_empty() =>
2137        {
2138            Vec::new()
2139        }
2140        ExpectedAcceptedHead::Exact { .. } => {
2141            let stores = authorities
2142                .iter()
2143                .zip(&current_bundles)
2144                .filter_map(|(authority, bundle)| {
2145                    bundle.as_ref().map(|bundle| ExistingProposalStore {
2146                        path: authority.path,
2147                        identity: derive_store_identity(target.database_identity(), authority),
2148                        bundle,
2149                    })
2150                })
2151                .collect::<Vec<_>>();
2152            lower_existing_schema_proposal(proposal, stores.as_slice())?
2153        }
2154    };
2155    let pending = if initial_application {
2156        preflight_initial_application(authorities, &candidates)?;
2157        None
2158    } else {
2159        preflight_existing_application(authorities, &current_bundles, &mut candidates)?
2160    };
2161    Ok(LoweredApplication {
2162        current_bundles,
2163        candidates,
2164        pending,
2165    })
2166}
2167
2168fn validate_database_identity_state_capacity(
2169    authorities: &[StoreApplicationAuthority],
2170    candidates: &[CandidateSchemaRevision],
2171    incarnation: crate::db::integrity::DatabaseIncarnationId,
2172) -> Result<(), InternalError> {
2173    let mut total = 0usize;
2174    for authority in authorities {
2175        let count = match candidates
2176            .iter()
2177            .find(|candidate| candidate.store_path() == authority.path)
2178        {
2179            Some(candidate) => authority.handle.with_schema(|store| {
2180                store.projected_identity_state_count(incarnation, candidate)
2181            })?,
2182            None => authority
2183                .handle
2184                .with_schema(|store| store.identity_state_inventory_for_integrity(incarnation))?
2185                .len(),
2186        };
2187        total = include_identity_state_count(total, count)?;
2188    }
2189    Ok(())
2190}
2191
2192fn include_identity_state_count(total: usize, count: usize) -> Result<usize, InternalError> {
2193    let total = total
2194        .checked_add(count)
2195        .ok_or_else(InternalError::identity_state_capacity_exhausted)?;
2196    if total > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
2197        return Err(InternalError::identity_state_capacity_exhausted());
2198    }
2199    Ok(total)
2200}
2201
2202fn preflight_initial_application(
2203    authorities: &[StoreApplicationAuthority],
2204    candidates: &[crate::db::schema::CandidateSchemaRevision],
2205) -> Result<(), InternalError> {
2206    for candidate in candidates {
2207        let authority = authorities
2208            .iter()
2209            .find(|authority| authority.path == candidate.store_path())
2210            .ok_or_else(InternalError::store_invariant)?;
2211        if authority.handle.with_data(DataStore::len) != 0
2212            || authority.handle.index_state() != IndexState::Ready
2213            || !authority.handle.with_index(IndexStore::is_empty)
2214        {
2215            return Err(InternalError::store_unsupported());
2216        }
2217    }
2218    Ok(())
2219}
2220
2221/// Complete generated row-local additions only after a bounded exact proof.
2222///
2223/// Empty domains use maintained exact cardinality. At most one non-empty
2224/// activation may consume the canonical exact scan budget. A journaled
2225/// proof that exceeds that page becomes one durable pending application;
2226/// volatile or additional non-empty proofs reject before publication.
2227fn preflight_existing_application(
2228    authorities: &[StoreApplicationAuthority],
2229    current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2230    candidates: &mut [CandidateSchemaRevision],
2231) -> Result<Option<PendingGeneratedRowLocalConstraint>, InternalError> {
2232    require_empty_physical_entity_removal(authorities, current_bundles, candidates)?;
2233    require_empty_physical_field_removals(authorities, current_bundles, candidates)?;
2234    require_empty_physical_index_removals(authorities, current_bundles, candidates)?;
2235    require_empty_physical_relation_removals(authorities, current_bundles, candidates)?;
2236    let proofs = generated_row_local_constraint_proofs(authorities, current_bundles, candidates)?;
2237    if proofs
2238        .iter()
2239        .filter(|proof| proof.historical_rows != 0)
2240        .count()
2241        > 1
2242    {
2243        return Err(InternalError::store_unsupported());
2244    }
2245
2246    let mut pending = None;
2247    for candidate_index in 0..candidates.len() {
2248        let candidate = candidates
2249            .get(candidate_index)
2250            .cloned()
2251            .ok_or_else(InternalError::store_invariant)?;
2252        let candidate_proofs = proofs
2253            .iter()
2254            .filter(|proof| proof.candidate_index == candidate_index)
2255            .collect::<Vec<_>>();
2256        if candidate_proofs.is_empty() {
2257            continue;
2258        }
2259
2260        let mut snapshots = candidate.bundle().entity_snapshots().clone();
2261        for proof in candidate_proofs {
2262            let mut promote = true;
2263            if proof.historical_rows != 0 {
2264                match validate_unpublished_row_local_candidate_bounded(
2265                    proof.store,
2266                    proof.store_path,
2267                    proof.entity_tag,
2268                    proof.entity_path.as_str(),
2269                    &candidate,
2270                    proof.constraint_id,
2271                )? {
2272                    UnpublishedRowLocalValidation::Complete { .. } => {}
2273                    UnpublishedRowLocalValidation::Incomplete => {
2274                        if proof.store.storage_capabilities().recovery()
2275                            != StoreRecoveryCapability::StableBasePlusJournalReplay
2276                            || pending.is_some()
2277                        {
2278                            return Err(InternalError::store_unsupported());
2279                        }
2280                        pending = Some(PendingGeneratedRowLocalConstraint {
2281                            proof: (*proof).clone(),
2282                        });
2283                        promote = false;
2284                    }
2285                }
2286            }
2287            if !promote {
2288                continue;
2289            }
2290            let snapshot = snapshots
2291                .get(&proof.entity_tag)
2292                .cloned()
2293                .ok_or_else(InternalError::store_invariant)?;
2294            let catalog = snapshot
2295                .constraint_catalog()
2296                .clone()
2297                .with_directly_validated_activation(proof.constraint_id)
2298                .map_err(|_| InternalError::store_invariant())?;
2299            snapshots.insert(proof.entity_tag, snapshot.with_constraint_catalog(catalog));
2300        }
2301        let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2302            candidate.revision(),
2303            candidate.bundle().store_path(),
2304            candidate.bundle().enum_catalog().clone(),
2305            candidate.bundle().composite_catalog().clone(),
2306            candidate.bundle().source_bindings().clone(),
2307            snapshots,
2308        )?;
2309        candidates[candidate_index] = CandidateSchemaRevision::new(bundle)?;
2310    }
2311    Ok(pending)
2312}
2313
2314/// Prove one exact generated entity removal has no retained logical or
2315/// physical authority.
2316///
2317/// The source row domain, every user-index generation, and every outgoing
2318/// reverse-relation generation must be empty. The accepted-after topology must
2319/// also contain no retained relation targeting the removed entity.
2320fn require_empty_physical_entity_removal(
2321    authorities: &[StoreApplicationAuthority],
2322    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2323    candidates: &[CandidateSchemaRevision],
2324) -> Result<(), InternalError> {
2325    let mut removed_entity = None;
2326    for candidate in candidates {
2327        let (position, source_authority) = authorities
2328            .iter()
2329            .enumerate()
2330            .find(|(_, authority)| authority.path == candidate.store_path())
2331            .ok_or_else(InternalError::store_invariant)?;
2332        let current = current_bundles
2333            .get(position)
2334            .and_then(Option::as_ref)
2335            .ok_or_else(InternalError::store_invariant)?;
2336        let removed = current
2337            .entity_snapshots()
2338            .iter()
2339            .filter(|(entity_tag, _)| {
2340                !candidate
2341                    .bundle()
2342                    .entity_snapshots()
2343                    .contains_key(entity_tag)
2344            })
2345            .collect::<Vec<_>>();
2346        if removed.is_empty() {
2347            continue;
2348        }
2349        let [(entity_tag, snapshot)] = removed.as_slice() else {
2350            return Err(InternalError::store_unsupported());
2351        };
2352        let entity_tag = **entity_tag;
2353        let snapshot = *snapshot;
2354        if removed_entity.is_some()
2355            || current.entity_snapshots().len()
2356                != candidate
2357                    .bundle()
2358                    .entity_snapshots()
2359                    .len()
2360                    .saturating_add(1)
2361        {
2362            return Err(InternalError::store_unsupported());
2363        }
2364        require_exact_empty_entity(source_authority.handle, entity_tag)?;
2365        source_authority
2366            .handle
2367            .with_index(|store| prove_empty_user_index_domain(store, entity_tag))
2368            .map_err(StagedUserIndexDomainError::into_internal_error)?;
2369        for relation in snapshot.relations() {
2370            let target_store = accepted_entity_store_for_path(
2371                authorities,
2372                current_bundles,
2373                relation.target_path(),
2374            )?;
2375            target_store.with_index(|store| {
2376                prove_empty_reverse_relation_domain(store, entity_tag, snapshot, relation)
2377            })?;
2378        }
2379        removed_entity = Some(snapshot.entity_path());
2380    }
2381
2382    let Some(removed_path) = removed_entity else {
2383        return Ok(());
2384    };
2385    for (position, authority) in authorities.iter().enumerate() {
2386        let after = candidates
2387            .iter()
2388            .find(|candidate| candidate.store_path() == authority.path)
2389            .map(CandidateSchemaRevision::bundle)
2390            .or_else(|| current_bundles.get(position).and_then(Option::as_ref));
2391        let Some(after) = after else {
2392            continue;
2393        };
2394        if after
2395            .entity_snapshots()
2396            .values()
2397            .flat_map(crate::db::schema::PersistedSchemaSnapshot::relations)
2398            .any(|relation| relation.target_path() == removed_path)
2399        {
2400            return Err(InternalError::store_unsupported());
2401        }
2402    }
2403    Ok(())
2404}
2405
2406/// Prove that every removed relation has neither source rows nor surviving
2407/// entries in its exact target-owned reverse physical generation.
2408fn require_empty_physical_relation_removals(
2409    authorities: &[StoreApplicationAuthority],
2410    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2411    candidates: &[CandidateSchemaRevision],
2412) -> Result<(), InternalError> {
2413    for candidate in candidates {
2414        let (position, source_authority) = authorities
2415            .iter()
2416            .enumerate()
2417            .find(|(_, authority)| authority.path == candidate.store_path())
2418            .ok_or_else(InternalError::store_invariant)?;
2419        let current = current_bundles
2420            .get(position)
2421            .and_then(Option::as_ref)
2422            .ok_or_else(InternalError::store_invariant)?;
2423        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2424            let before = current
2425                .entity_snapshots()
2426                .get(entity_tag)
2427                .ok_or_else(InternalError::store_invariant)?;
2428            let removed = before
2429                .relations()
2430                .iter()
2431                .filter(|relation| {
2432                    !after
2433                        .relations()
2434                        .iter()
2435                        .any(|candidate| candidate.id() == relation.id())
2436                })
2437                .collect::<Vec<_>>();
2438            if removed.is_empty() {
2439                continue;
2440            }
2441            let added = after.relations().iter().any(|relation| {
2442                !before
2443                    .relations()
2444                    .iter()
2445                    .any(|accepted| accepted.id() == relation.id())
2446            });
2447            let [removed] = removed.as_slice() else {
2448                return Err(InternalError::store_unsupported());
2449            };
2450            if added || before.relations().len() != after.relations().len().saturating_add(1) {
2451                return Err(InternalError::store_unsupported());
2452            }
2453            require_exact_empty_entity(source_authority.handle, *entity_tag)?;
2454            let target_store = accepted_entity_store_for_path(
2455                authorities,
2456                current_bundles,
2457                removed.target_path(),
2458            )?;
2459            target_store.with_index(|store| {
2460                prove_empty_reverse_relation_domain(store, *entity_tag, before, removed)
2461            })?;
2462        }
2463    }
2464    Ok(())
2465}
2466
2467fn accepted_entity_store_for_path(
2468    authorities: &[StoreApplicationAuthority],
2469    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2470    entity_path: &str,
2471) -> Result<StoreHandle, InternalError> {
2472    let mut resolved = None;
2473    for (position, bundle) in current_bundles.iter().enumerate() {
2474        let Some(bundle) = bundle else {
2475            continue;
2476        };
2477        if !bundle
2478            .entity_snapshots()
2479            .values()
2480            .any(|snapshot| snapshot.entity_path() == entity_path)
2481        {
2482            continue;
2483        }
2484        if resolved.is_some() {
2485            return Err(InternalError::store_invariant());
2486        }
2487        resolved = authorities.get(position).map(|authority| authority.handle);
2488    }
2489    resolved.ok_or_else(InternalError::store_unsupported)
2490}
2491
2492/// Prove that every dense index-removal candidate has neither authoritative
2493/// rows nor stale physical user-index state. The staged replacement is empty
2494/// by construction and is discarded before schema-only publication.
2495fn require_empty_physical_index_removals(
2496    authorities: &[StoreApplicationAuthority],
2497    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2498    candidates: &[CandidateSchemaRevision],
2499) -> Result<(), InternalError> {
2500    for candidate in candidates {
2501        let (position, authority) = authorities
2502            .iter()
2503            .enumerate()
2504            .find(|(_, authority)| authority.path == candidate.store_path())
2505            .ok_or_else(InternalError::store_invariant)?;
2506        let current = current_bundles
2507            .get(position)
2508            .and_then(Option::as_ref)
2509            .ok_or_else(InternalError::store_invariant)?;
2510        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2511            let before = current
2512                .entity_snapshots()
2513                .get(entity_tag)
2514                .ok_or_else(InternalError::store_invariant)?;
2515            if before.indexes().len() == after.indexes().len() {
2516                continue;
2517            }
2518            if before.indexes().len() != after.indexes().len().saturating_add(1) {
2519                return Err(InternalError::store_unsupported());
2520            }
2521            require_exact_empty_entity(authority.handle, *entity_tag)?;
2522            authority
2523                .handle
2524                .with_index(|store| prove_empty_user_index_domain(store, *entity_tag))
2525                .map_err(StagedUserIndexDomainError::into_internal_error)?;
2526        }
2527    }
2528    Ok(())
2529}
2530
2531/// Prove that every dense field-removal candidate has no historical row to
2532/// rewrite. Missing or corrupt maintained cardinality fails closed.
2533fn require_empty_physical_field_removals(
2534    authorities: &[StoreApplicationAuthority],
2535    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2536    candidates: &[CandidateSchemaRevision],
2537) -> Result<(), InternalError> {
2538    for candidate in candidates {
2539        let (position, authority) = authorities
2540            .iter()
2541            .enumerate()
2542            .find(|(_, authority)| authority.path == candidate.store_path())
2543            .ok_or_else(InternalError::store_invariant)?;
2544        let current = current_bundles
2545            .get(position)
2546            .and_then(Option::as_ref)
2547            .ok_or_else(InternalError::store_invariant)?;
2548        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2549            let before = current
2550                .entity_snapshots()
2551                .get(entity_tag)
2552                .ok_or_else(InternalError::store_invariant)?;
2553            if before.row_layout() == after.row_layout() {
2554                continue;
2555            }
2556            if before.fields().len() != after.fields().len().saturating_add(1) {
2557                return Err(InternalError::store_unsupported());
2558            }
2559            require_exact_empty_entity(authority.handle, *entity_tag)?;
2560        }
2561    }
2562    Ok(())
2563}
2564
2565// Prove exact logical emptiness from the maintained cardinality authority.
2566// Missing cardinality is corrupt state, not an empty domain or an unsupported
2567// user transition.
2568fn require_exact_empty_entity(
2569    store: StoreHandle,
2570    entity_tag: EntityTag,
2571) -> Result<(), InternalError> {
2572    require_exact_empty_entity_count(store.with_data(|data| data.exact_entity_count(entity_tag)))
2573}
2574
2575fn require_exact_empty_entity_count(count: Option<u64>) -> Result<(), InternalError> {
2576    let count = count.ok_or_else(InternalError::store_corruption)?;
2577    if count != 0 {
2578        return Err(InternalError::store_unsupported());
2579    }
2580
2581    Ok(())
2582}
2583
2584fn generated_row_local_constraint_proofs(
2585    authorities: &[StoreApplicationAuthority],
2586    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2587    candidates: &[CandidateSchemaRevision],
2588) -> Result<Vec<DirectGeneratedRowLocalProof>, InternalError> {
2589    let mut proofs = Vec::new();
2590    for (candidate_index, candidate) in candidates.iter().enumerate() {
2591        let (position, authority) = authorities
2592            .iter()
2593            .enumerate()
2594            .find(|(_, authority)| authority.path == candidate.store_path())
2595            .ok_or_else(InternalError::store_invariant)?;
2596        let current = current_bundles
2597            .get(position)
2598            .and_then(Option::as_ref)
2599            .ok_or_else(InternalError::store_invariant)?;
2600        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2601            let before = current
2602                .entity_snapshots()
2603                .get(entity_tag)
2604                .ok_or_else(InternalError::store_invariant)?;
2605            for constraint_id in added_generated_row_local_activations(before, after) {
2606                let historical_rows = authority
2607                    .handle
2608                    .with_data(|store| store.exact_entity_count(*entity_tag))
2609                    .ok_or_else(InternalError::store_corruption)?;
2610                proofs.push(DirectGeneratedRowLocalProof {
2611                    candidate_index,
2612                    store: authority.handle,
2613                    store_path: authority.path,
2614                    entity_tag: *entity_tag,
2615                    entity_path: after.entity_path().to_string(),
2616                    constraint_id,
2617                    historical_rows,
2618                });
2619            }
2620        }
2621    }
2622    Ok(proofs)
2623}
2624
2625fn added_generated_row_local_activations(
2626    before: &crate::db::schema::PersistedSchemaSnapshot,
2627    after: &crate::db::schema::PersistedSchemaSnapshot,
2628) -> Vec<ConstraintId> {
2629    after
2630        .constraint_activations()
2631        .iter()
2632        .filter(|candidate| {
2633            candidate.origin() == ConstraintOrigin::Generated
2634                && matches!(
2635                    candidate.kind(),
2636                    ConstraintActivationKind::Check { .. }
2637                        | ConstraintActivationKind::TargetedRule { .. }
2638                )
2639                && !before
2640                    .constraint_activations()
2641                    .iter()
2642                    .any(|accepted| accepted.id() == candidate.id())
2643        })
2644        .map(crate::db::schema::ConstraintActivationSnapshot::id)
2645        .collect()
2646}
2647
2648fn final_candidates_for_pending_row_local_constraint(
2649    candidates: &[CandidateSchemaRevision],
2650    pending: &PendingGeneratedRowLocalConstraint,
2651) -> Result<Vec<CandidateSchemaRevision>, InternalError> {
2652    let mut final_candidates = candidates.to_vec();
2653    let candidate = final_candidates
2654        .get(pending.proof.candidate_index)
2655        .cloned()
2656        .ok_or_else(InternalError::store_invariant)?;
2657    if candidate.store_path() != pending.proof.store_path {
2658        return Err(InternalError::store_invariant());
2659    }
2660    let mut snapshots = candidate.bundle().entity_snapshots().clone();
2661    let snapshot = snapshots
2662        .get(&pending.proof.entity_tag)
2663        .cloned()
2664        .ok_or_else(InternalError::store_invariant)?;
2665    let catalog = snapshot
2666        .constraint_catalog()
2667        .clone()
2668        .with_directly_validated_activation(pending.proof.constraint_id)
2669        .map_err(|_| InternalError::store_invariant())?;
2670    snapshots.insert(
2671        pending.proof.entity_tag,
2672        snapshot.with_constraint_catalog(catalog),
2673    );
2674    let final_revision = candidate
2675        .revision()
2676        .checked_next()
2677        .and_then(AcceptedSchemaRevision::checked_next)
2678        .ok_or_else(InternalError::store_unsupported)?;
2679    let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2680        final_revision,
2681        candidate.bundle().store_path(),
2682        candidate.bundle().enum_catalog().clone(),
2683        candidate.bundle().composite_catalog().clone(),
2684        candidate.bundle().source_bindings().clone(),
2685        snapshots,
2686    )?;
2687    final_candidates[pending.proof.candidate_index] = CandidateSchemaRevision::new(bundle)?;
2688    Ok(final_candidates)
2689}
2690
2691fn schema_change_progress_status(
2692    snapshot: &crate::db::schema::PersistedSchemaSnapshot,
2693    entity_tag: EntityTag,
2694    constraint_id: ConstraintId,
2695    progress: ConstraintValidationProgress,
2696) -> Result<SchemaChangeProgressStatus, InternalError> {
2697    match progress {
2698        ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
2699        ConstraintValidationProgress::Advanced {
2700            phase,
2701            rows_scanned,
2702        } => Ok(SchemaChangeProgressStatus::Advanced {
2703            phase: schema_change_validation_phase(phase),
2704            rows_scanned,
2705        }),
2706        ConstraintValidationProgress::Findings {
2707            receipt,
2708            phase,
2709            rows_scanned,
2710        } => {
2711            let activation = snapshot
2712                .constraint_catalog()
2713                .activation(constraint_id)
2714                .ok_or_else(InternalError::store_corruption)?;
2715            let fingerprint =
2716                crate::db::schema::accepted_schema_cache_fingerprint_for_persisted_snapshot(
2717                    snapshot,
2718                )?;
2719            let findings = receipt
2720                .findings()
2721                .iter()
2722                .map(|finding| {
2723                    constraint_validation_finding_output(
2724                        fingerprint,
2725                        entity_tag,
2726                        activation,
2727                        finding,
2728                    )
2729                })
2730                .collect::<Result<Vec<_>, InternalError>>()?;
2731            Ok(SchemaChangeProgressStatus::Findings {
2732                phase: schema_change_validation_phase(phase),
2733                rows_scanned,
2734                page_sequence: receipt.page_sequence(),
2735                findings,
2736            })
2737        }
2738        ConstraintValidationProgress::Restarted { rows_scanned } => {
2739            Ok(SchemaChangeProgressStatus::Restarted { rows_scanned })
2740        }
2741        ConstraintValidationProgress::Promoted { .. } => Ok(SchemaChangeProgressStatus::Applied),
2742    }
2743}
2744
2745const fn schema_change_validation_phase(
2746    phase: ConstraintValidationPhase,
2747) -> SchemaChangeValidationPhase {
2748    match phase {
2749        ConstraintValidationPhase::Forward => SchemaChangeValidationPhase::Forward,
2750        ConstraintValidationPhase::Verify => SchemaChangeValidationPhase::Verify,
2751    }
2752}
2753
2754fn finalize_schema_application<C: CanisterKind>(
2755    db: &Db<C>,
2756    record: &SchemaApplicationRecord,
2757    candidate_head: &ExpectedAcceptedHead,
2758    status: SchemaChangeProgressStatus,
2759) -> Result<SchemaChangeProgress, InternalError> {
2760    if schema_application_target(db)?.accepted_head() != candidate_head {
2761        return Err(InternalError::schema_application_conflict());
2762    }
2763    let receipt = SchemaChangeReceipt::new(
2764        record.receipt().database_identity(),
2765        record.receipt().submission_key().clone(),
2766        record.receipt().proposal_digest(),
2767        record.receipt().prior_head().clone(),
2768        SchemaChangeOutcome::Applied {
2769            accepted_head: candidate_head.clone(),
2770        },
2771    )?;
2772    let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
2773    let operation = SchemaApplicationRecordOp::replace(record, &terminal)?;
2774    publish_accepted_schema_candidates_with_application_record(Vec::new(), operation)?;
2775    Ok(SchemaChangeProgress::new(receipt, status))
2776}
2777
2778fn application_publications<'a>(
2779    authorities: &[StoreApplicationAuthority],
2780    current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2781    candidates: &'a [crate::db::schema::CandidateSchemaRevision],
2782) -> Result<Vec<AcceptedSchemaPublication<'a>>, InternalError> {
2783    candidates
2784        .iter()
2785        .map(|candidate| {
2786            let (position, authority) = authorities
2787                .iter()
2788                .enumerate()
2789                .find(|(_, authority)| authority.path == candidate.store_path())
2790                .ok_or_else(InternalError::store_invariant)?;
2791            let expected_revision = current_bundles[position].as_ref().map_or(
2792                AcceptedSchemaRevision::NONE,
2793                crate::db::schema::AcceptedSchemaRevisionBundle::revision,
2794            );
2795            Ok(AcceptedSchemaPublication::new(
2796                authority.path,
2797                authority.handle,
2798                expected_revision,
2799                candidate,
2800            ))
2801        })
2802        .collect()
2803}
2804
2805fn application_authorities<C: CanisterKind>(db: &Db<C>) -> Vec<StoreApplicationAuthority> {
2806    let mut authorities = db.with_store_registry(|registry| {
2807        registry
2808            .iter()
2809            .map(|(path, handle)| StoreApplicationAuthority { path, handle })
2810            .collect::<Vec<_>>()
2811    });
2812    authorities.sort_unstable_by(|left, right| left.path.cmp(right.path));
2813    authorities
2814}
2815
2816fn accepted_head_after_candidates(
2817    authorities: &[StoreApplicationAuthority],
2818    candidates: &[crate::db::schema::CandidateSchemaRevision],
2819) -> Result<ExpectedAcceptedHead, InternalError> {
2820    let heads = authorities
2821        .iter()
2822        .map(|authority| {
2823            let candidate = candidates
2824                .iter()
2825                .find(|candidate| candidate.store_path() == authority.path);
2826            let head = match candidate {
2827                Some(candidate) => Some(AcceptedStoreHead {
2828                    revision: candidate.revision().get(),
2829                    fingerprint: candidate.root().fingerprint().as_bytes(),
2830                }),
2831                None => authority
2832                    .handle
2833                    .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
2834                    .map(|selection| AcceptedStoreHead {
2835                        revision: selection.root().revision().get(),
2836                        fingerprint: selection.root().fingerprint().as_bytes(),
2837                    }),
2838            };
2839            Ok((authority.path, head))
2840        })
2841        .collect::<Result<Vec<_>, InternalError>>()?;
2842    Ok(derive_accepted_head(heads.as_slice()))
2843}
2844
2845fn derive_database_identity(
2846    incarnation: [u8; 16],
2847    stores: &[StoreApplicationAuthority],
2848) -> TargetDatabaseIdentity {
2849    let mut hasher = new_hash_sha256_prefixed(DATABASE_TARGET_FINGERPRINT_PROFILE);
2850    hasher.update(incarnation);
2851    write_hash_len_u32(&mut hasher, stores.len());
2852    for store in stores {
2853        write_store_authority(&mut hasher, store);
2854    }
2855    TargetDatabaseIdentity::from_bytes(finalize_hash_sha256(hasher))
2856}
2857
2858fn derive_store_identity(
2859    database_identity: TargetDatabaseIdentity,
2860    store: &StoreApplicationAuthority,
2861) -> TargetStoreIdentity {
2862    let mut hasher = new_hash_sha256_prefixed(STORE_TARGET_FINGERPRINT_PROFILE);
2863    hasher.update(database_identity.to_bytes());
2864    write_store_authority(&mut hasher, store);
2865    TargetStoreIdentity::from_bytes(finalize_hash_sha256(hasher))
2866}
2867
2868fn derive_accepted_head(stores: &[(&str, Option<AcceptedStoreHead>)]) -> ExpectedAcceptedHead {
2869    let Some(revision) = stores
2870        .iter()
2871        .filter_map(|(_, head)| head.map(|head| head.revision))
2872        .max()
2873    else {
2874        return ExpectedAcceptedHead::Empty;
2875    };
2876
2877    let mut hasher = new_hash_sha256_prefixed(ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE);
2878    write_hash_len_u32(&mut hasher, stores.len());
2879    for (path, head) in stores {
2880        write_hash_str_u32(&mut hasher, path);
2881        match head {
2882            None => write_hash_tag_u8(&mut hasher, 0),
2883            Some(head) => {
2884                write_hash_tag_u8(&mut hasher, 1);
2885                write_hash_u64(&mut hasher, head.revision);
2886                hasher.update(head.fingerprint);
2887            }
2888        }
2889    }
2890
2891    ExpectedAcceptedHead::Exact {
2892        revision,
2893        fingerprint: ExpectedSchemaFingerprint::from_bytes(finalize_hash_sha256(hasher)),
2894    }
2895}
2896
2897pub(in crate::db) fn generated_schema_reconciled(
2898    registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2899    incarnation: DatabaseIncarnationId,
2900    submission_key: &str,
2901) -> Result<(bool, ExpectedAcceptedHead), InternalError> {
2902    let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
2903        .map_err(|_| InternalError::store_invariant())?;
2904    let (database_identity, accepted_head) = generated_schema_authority(registry, incarnation)?;
2905    let reconciled = generated_submission_is_reconciled(database_identity, &submission_key)?;
2906    Ok((reconciled, accepted_head))
2907}
2908
2909pub(in crate::db) fn generated_schema_is_reconciled(
2910    registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2911    incarnation: DatabaseIncarnationId,
2912    submission_key: &str,
2913) -> Result<bool, InternalError> {
2914    let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
2915        .map_err(|_| InternalError::store_invariant())?;
2916    let database_identity = generated_database_identity(registry, incarnation);
2917    generated_submission_is_reconciled(database_identity, &submission_key)
2918}
2919
2920fn generated_submission_is_reconciled(
2921    database_identity: TargetDatabaseIdentity,
2922    submission_key: &SchemaSubmissionKey,
2923) -> Result<bool, InternalError> {
2924    Ok(
2925        load_schema_application_record_read_only(database_identity, submission_key)?.is_some_and(
2926            |record| match record.receipt().outcome() {
2927                // The generated submission key is derived from the complete
2928                // generated fragment and migration plan. A terminal receipt proves
2929                // that exact source was applied in this database incarnation.
2930                // Compatible SQL DDL may subsequently advance the accepted head
2931                // while intentionally preserving generated-owned identities and
2932                // semantics; exact submission replay returns the original receipt
2933                // and cannot rebind it to that later DDL-owned head.
2934                SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Applied { .. } => true,
2935                SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => false,
2936            },
2937        ),
2938    )
2939}
2940
2941pub(in crate::db) fn generated_schema_authority(
2942    registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2943    incarnation: DatabaseIncarnationId,
2944) -> Result<(TargetDatabaseIdentity, ExpectedAcceptedHead), InternalError> {
2945    let database_identity = generated_database_identity(registry, incarnation);
2946    let mut stores = store_application_authorities(registry);
2947    stores.sort_unstable_by(|left, right| left.path.cmp(right.path));
2948    let heads = stores
2949        .iter()
2950        .map(|store| {
2951            let head = store
2952                .handle
2953                .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
2954                .map(|selection| AcceptedStoreHead {
2955                    revision: selection.root().revision().get(),
2956                    fingerprint: selection.root().fingerprint().as_bytes(),
2957                });
2958            Ok((store.path, head))
2959        })
2960        .collect::<Result<Vec<_>, InternalError>>()?;
2961    let accepted_head = derive_accepted_head(heads.as_slice());
2962    Ok((database_identity, accepted_head))
2963}
2964
2965fn generated_database_identity(
2966    registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2967    incarnation: DatabaseIncarnationId,
2968) -> TargetDatabaseIdentity {
2969    let registry_key = std::ptr::from_ref(registry).cast::<()>() as usize;
2970    let cached = GENERATED_DATABASE_IDENTITY
2971        .with(Cell::get)
2972        .and_then(|entry| {
2973            (entry.registry == registry_key && entry.incarnation == incarnation)
2974                .then_some(entry.identity)
2975        });
2976    if let Some(identity) = cached {
2977        return identity;
2978    }
2979
2980    let mut stores = store_application_authorities(registry);
2981    stores.sort_unstable_by(|left, right| left.path.cmp(right.path));
2982    let identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
2983    GENERATED_DATABASE_IDENTITY.set(Some(GeneratedDatabaseIdentityCacheEntry {
2984        registry: registry_key,
2985        incarnation,
2986        identity,
2987    }));
2988    identity
2989}
2990
2991fn store_application_authorities(
2992    registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2993) -> Vec<StoreApplicationAuthority> {
2994    registry.with(|registry| {
2995        registry
2996            .iter()
2997            .map(|(path, handle)| StoreApplicationAuthority { path, handle })
2998            .collect()
2999    })
3000}
3001
3002fn write_store_authority(hasher: &mut sha2::Sha256, store: &StoreApplicationAuthority) {
3003    write_hash_str_u32(hasher, store.path);
3004    write_storage_capabilities(hasher, store.handle);
3005    for allocation in [
3006        store.handle.data_allocation(),
3007        store.handle.index_allocation(),
3008        store.handle.schema_allocation(),
3009        store.handle.journal_allocation(),
3010    ] {
3011        write_allocation_identity(hasher, allocation);
3012    }
3013}
3014
3015fn write_storage_capabilities(hasher: &mut sha2::Sha256, store: StoreHandle) {
3016    let capabilities = store.storage_capabilities();
3017    write_hash_tag_u8(
3018        hasher,
3019        match capabilities.storage_mode() {
3020            StoreRuntimeStorageMode::Heap => 0,
3021            StoreRuntimeStorageMode::Journaled => 1,
3022        },
3023    );
3024    write_hash_tag_u8(
3025        hasher,
3026        match capabilities.allocation_identity() {
3027            StoreAllocationIdentityCapability::Present => 0,
3028            StoreAllocationIdentityCapability::Absent => 1,
3029        },
3030    );
3031    write_hash_tag_u8(
3032        hasher,
3033        match capabilities.durability() {
3034            StoreDurability::Durable => 0,
3035            StoreDurability::Volatile => 1,
3036        },
3037    );
3038    write_hash_tag_u8(
3039        hasher,
3040        match capabilities.recovery() {
3041            StoreRecoveryCapability::StableBasePlusJournalReplay => 0,
3042            StoreRecoveryCapability::None => 1,
3043        },
3044    );
3045    write_hash_tag_u8(
3046        hasher,
3047        match capabilities.commit_participation() {
3048            StoreCommitParticipation::Durable => 0,
3049            StoreCommitParticipation::LiveOnly => 1,
3050        },
3051    );
3052    write_hash_tag_u8(
3053        hasher,
3054        match capabilities.schema_metadata() {
3055            StoreSchemaMetadataCapability::LiveRebuiltMetadata => 0,
3056            StoreSchemaMetadataCapability::CanonicalStableHistoryPlusJournalTail => 1,
3057        },
3058    );
3059    write_hash_tag_u8(
3060        hasher,
3061        match capabilities.relation_source() {
3062            StoreRelationSourceCapability::DurableSource => 0,
3063            StoreRelationSourceCapability::LiveSource => 1,
3064        },
3065    );
3066    write_hash_tag_u8(
3067        hasher,
3068        match capabilities.relation_target() {
3069            StoreRelationTargetCapability::DurableTarget => 0,
3070            StoreRelationTargetCapability::VolatileTarget => 1,
3071        },
3072    );
3073}
3074
3075fn write_allocation_identity(
3076    hasher: &mut sha2::Sha256,
3077    allocation: Option<StoreAllocationIdentity>,
3078) {
3079    match allocation {
3080        None => write_hash_tag_u8(hasher, 0),
3081        Some(allocation) => {
3082            write_hash_tag_u8(hasher, 1);
3083            write_hash_tag_u8(hasher, allocation.memory_id());
3084            write_hash_str_u32(hasher, allocation.stable_key());
3085        }
3086    }
3087}
3088
3089#[cfg(test)]
3090mod tests {
3091    use super::{
3092        AcceptedSchemaPublication, AcceptedStoreHead, DirectGeneratedRowLocalProof,
3093        PendingGeneratedRowLocalConstraint, abort_schema_application,
3094        aborted_generated_row_local_candidate, accepted_head_after_candidates,
3095        application_authorities, apply_schema, continue_schema_application, derive_accepted_head,
3096        derive_schema_change_job_id, final_candidates_for_pending_row_local_constraint,
3097        generated_database_identity, include_identity_state_count, lower_existing_schema_proposal,
3098        lower_initial_schema_proposal, publish_accepted_schema_candidates_with_application_record,
3099        require_exact_empty_entity_count, schema_application_target,
3100    };
3101    use crate::{
3102        db::{
3103            DatabaseStartupState, Db, GeneratedStartupDriverStep,
3104            commit::{RecoveryProgress, continue_recovery, forget_recovered_domain_for_tests},
3105            data::DataStore,
3106            drive_generated_startup_recovery_page,
3107            index::IndexStore,
3108            journal::JournalTailStore,
3109            observe_generated_startup_state,
3110            registry::{
3111                StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
3112                StoreRuntimeStorageCapabilities,
3113            },
3114            schema::{
3115                AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
3116                CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
3117                ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
3118                SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
3119                SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
3120            },
3121        },
3122        error::{ErrorClass, ErrorOrigin},
3123        testing::test_memory,
3124        traits::{CanisterKind, Path},
3125    };
3126    use ic_stable_structures::{DefaultMemoryImpl, memory_manager::VirtualMemory};
3127    use icydb_schema::{
3128        ConstraintFragment, ConstraintSourceKey, DeclaredEntityVersion, EntityFragment,
3129        EntitySourceKey, EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint,
3130        FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment,
3131        RuleSourceKey, ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName,
3132        SchemaProposal, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
3133        SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
3134        TypeSourceKey,
3135    };
3136    use std::cell::RefCell;
3137
3138    fn drive_startup_recovery_to_completion<C: CanisterKind>(db: &Db<C>) {
3139        for _ in 0..1_024 {
3140            match continue_recovery(db).expect("test startup recovery page should succeed") {
3141                RecoveryProgress::Complete => return,
3142                RecoveryProgress::Pending => {}
3143            }
3144        }
3145        panic!("test startup recovery should complete within 1,024 bounded pages");
3146    }
3147
3148    #[cfg(feature = "migration")]
3149    use crate::db::schema::SchemaChangeReceipt;
3150    use crate::{
3151        db::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell},
3152        value::InputValue,
3153    };
3154    #[cfg(feature = "migration")]
3155    use icydb_schema::{
3156        EntityMigration, IndexFragment, IndexKeyFragment, RelationDeleteAction, RelationFragment,
3157        SchemaMigrationPlan, SchemaMigrationTransform, SchemaProposalDigest,
3158    };
3159
3160    fn version_one() -> DeclaredEntityVersion {
3161        DeclaredEntityVersion::try_new(1).expect("fixture version should admit")
3162    }
3163
3164    const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
3165    const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
3166    #[cfg(feature = "migration")]
3167    const MIGRATION_STORE_PATH: &str = "schema_application_tests::MigrationStore";
3168    #[cfg(feature = "migration")]
3169    const MIGRATION_EXECUTION_STORE_PATH: &str =
3170        "schema_application_tests::MigrationExecutionStore";
3171    #[cfg(feature = "migration")]
3172    const MIGRATION_FINDING_STORE_PATH: &str = "schema_application_tests::MigrationFindingStore";
3173
3174    #[test]
3175    fn database_identity_state_capacity_combines_store_inventories_exactly() {
3176        let below = include_identity_state_count(0, 65_535)
3177            .expect("the first store inventory should remain below the database cap");
3178        let exact = include_identity_state_count(below, 1)
3179            .expect("the combined database boundary should admit");
3180        assert_eq!(exact, 65_536);
3181
3182        let error = include_identity_state_count(exact, 1)
3183            .expect_err("the next owner in another store must reject");
3184        assert_eq!(error.class(), ErrorClass::Unsupported);
3185        assert_eq!(error.origin(), ErrorOrigin::Identity);
3186    }
3187
3188    #[test]
3189    fn generated_database_identity_cache_is_bound_to_the_incarnation() {
3190        let first_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x41);
3191        let second_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x42);
3192        let first = generated_database_identity(&ABORT_REGISTRY, first_incarnation);
3193        assert_eq!(
3194            generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3195            first,
3196        );
3197        let second = generated_database_identity(&ABORT_REGISTRY, second_incarnation);
3198        assert_ne!(second, first);
3199        assert_eq!(
3200            generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3201            first,
3202        );
3203    }
3204
3205    #[test]
3206    fn exact_empty_entity_proof_distinguishes_corruption_from_non_empty_input() {
3207        let corrupt = require_exact_empty_entity_count(None)
3208            .expect_err("uninspectable cardinality must fail closed");
3209        assert_eq!(corrupt.class(), ErrorClass::Corruption);
3210
3211        let non_empty = require_exact_empty_entity_count(Some(1))
3212            .expect_err("non-empty cardinality must reject removal");
3213        assert_eq!(non_empty.class(), ErrorClass::Unsupported);
3214        assert!(require_exact_empty_entity_count(Some(0)).is_ok());
3215    }
3216
3217    thread_local! {
3218        static ABORT_DATA_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(180);
3219        static ABORT_INDEX_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(181);
3220        static ABORT_SCHEMA_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(182);
3221        static ABORT_JOURNAL_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(183);
3222        static ABORT_DATA: RefCell<DataStore> =
3223            ABORT_DATA_MEMORY.with(|memory| {
3224                RefCell::new(DataStore::init_journaled(memory.clone()))
3225            });
3226        static ABORT_INDEX: RefCell<IndexStore> =
3227            ABORT_INDEX_MEMORY.with(|memory| {
3228                RefCell::new(IndexStore::init_journaled(memory.clone()))
3229            });
3230        static ABORT_SCHEMA: RefCell<SchemaStore> =
3231            ABORT_SCHEMA_MEMORY.with(|memory| {
3232                RefCell::new(SchemaStore::init_journaled(memory.clone()))
3233            });
3234        static ABORT_JOURNAL: RefCell<JournalTailStore> =
3235            ABORT_JOURNAL_MEMORY.with(|memory| {
3236                RefCell::new(JournalTailStore::init(memory.clone()))
3237            });
3238        static ABORT_REGISTRY: StoreRegistry = {
3239            let mut registry = StoreRegistry::new();
3240            registry.register_journaled_store(
3241                ABORT_STORE_PATH,
3242                &ABORT_DATA,
3243                &ABORT_INDEX,
3244                &ABORT_SCHEMA,
3245                &ABORT_JOURNAL,
3246                StoreAllocationIdentities::new_journaled(
3247                    StoreAllocationIdentity::new(180, "icydb.test.application_abort.data.v1"),
3248                    StoreAllocationIdentity::new(181, "icydb.test.application_abort.index.v1"),
3249                    StoreAllocationIdentity::new(182, "icydb.test.application_abort.schema.v1"),
3250                    StoreAllocationIdentity::new(183, "icydb.test.application_abort.journal.v1"),
3251                ),
3252                StoreRuntimeStorageCapabilities::journaled(),
3253            ).expect("abort journaled store should register");
3254            registry
3255        };
3256    }
3257
3258    #[cfg(feature = "migration")]
3259    thread_local! {
3260        static MIGRATION_EXECUTION_DATA: RefCell<DataStore> =
3261            RefCell::new(DataStore::init_journaled(test_memory(210)));
3262        static MIGRATION_EXECUTION_INDEX: RefCell<IndexStore> =
3263            RefCell::new(IndexStore::init_journaled(test_memory(211)));
3264        static MIGRATION_EXECUTION_SCHEMA: RefCell<SchemaStore> =
3265            RefCell::new(SchemaStore::init_journaled(test_memory(212)));
3266        static MIGRATION_EXECUTION_JOURNAL: RefCell<JournalTailStore> =
3267            RefCell::new(JournalTailStore::init(test_memory(213)));
3268        static MIGRATION_EXECUTION_REGISTRY: StoreRegistry = {
3269            let mut registry = StoreRegistry::new();
3270            registry.register_journaled_store(
3271                MIGRATION_EXECUTION_STORE_PATH,
3272                &MIGRATION_EXECUTION_DATA,
3273                &MIGRATION_EXECUTION_INDEX,
3274                &MIGRATION_EXECUTION_SCHEMA,
3275                &MIGRATION_EXECUTION_JOURNAL,
3276                StoreAllocationIdentities::new_journaled(
3277                    StoreAllocationIdentity::new(210, "icydb.test.migration_execution.data.v1"),
3278                    StoreAllocationIdentity::new(211, "icydb.test.migration_execution.index.v1"),
3279                    StoreAllocationIdentity::new(212, "icydb.test.migration_execution.schema.v1"),
3280                    StoreAllocationIdentity::new(213, "icydb.test.migration_execution.journal.v1"),
3281                ),
3282                StoreRuntimeStorageCapabilities::journaled(),
3283            ).expect("migration execution store should register");
3284            registry
3285        };
3286    }
3287
3288    #[cfg(feature = "migration")]
3289    thread_local! {
3290        static MIGRATION_DATA: RefCell<DataStore> =
3291            RefCell::new(DataStore::init_journaled(test_memory(200)));
3292        static MIGRATION_INDEX: RefCell<IndexStore> =
3293            RefCell::new(IndexStore::init_journaled(test_memory(201)));
3294        static MIGRATION_SCHEMA: RefCell<SchemaStore> =
3295            RefCell::new(SchemaStore::init_journaled(test_memory(202)));
3296        static MIGRATION_JOURNAL: RefCell<JournalTailStore> =
3297            RefCell::new(JournalTailStore::init(test_memory(203)));
3298        static MIGRATION_REGISTRY: StoreRegistry = {
3299            let mut registry = StoreRegistry::new();
3300            registry.register_journaled_store(
3301                MIGRATION_STORE_PATH,
3302                &MIGRATION_DATA,
3303                &MIGRATION_INDEX,
3304                &MIGRATION_SCHEMA,
3305                &MIGRATION_JOURNAL,
3306                StoreAllocationIdentities::new_journaled(
3307                    StoreAllocationIdentity::new(200, "icydb.test.migration_validation.data.v1"),
3308                    StoreAllocationIdentity::new(201, "icydb.test.migration_validation.index.v1"),
3309                    StoreAllocationIdentity::new(202, "icydb.test.migration_validation.schema.v1"),
3310                    StoreAllocationIdentity::new(203, "icydb.test.migration_validation.journal.v1"),
3311                ),
3312                StoreRuntimeStorageCapabilities::journaled(),
3313            ).expect("migration validation store should register");
3314            registry
3315        };
3316    }
3317
3318    #[cfg(feature = "migration")]
3319    thread_local! {
3320        static MIGRATION_FINDING_DATA: RefCell<DataStore> =
3321            RefCell::new(DataStore::init_journaled(test_memory(206)));
3322        static MIGRATION_FINDING_INDEX: RefCell<IndexStore> =
3323            RefCell::new(IndexStore::init_journaled(test_memory(207)));
3324        static MIGRATION_FINDING_SCHEMA: RefCell<SchemaStore> =
3325            RefCell::new(SchemaStore::init_journaled(test_memory(208)));
3326        static MIGRATION_FINDING_JOURNAL: RefCell<JournalTailStore> =
3327            RefCell::new(JournalTailStore::init(test_memory(209)));
3328        static MIGRATION_FINDING_REGISTRY: StoreRegistry = {
3329            let mut registry = StoreRegistry::new();
3330            registry.register_journaled_store(
3331                MIGRATION_FINDING_STORE_PATH,
3332                &MIGRATION_FINDING_DATA,
3333                &MIGRATION_FINDING_INDEX,
3334                &MIGRATION_FINDING_SCHEMA,
3335                &MIGRATION_FINDING_JOURNAL,
3336                StoreAllocationIdentities::new_journaled(
3337                    StoreAllocationIdentity::new(206, "icydb.test.migration_finding.data.v1"),
3338                    StoreAllocationIdentity::new(207, "icydb.test.migration_finding.index.v1"),
3339                    StoreAllocationIdentity::new(208, "icydb.test.migration_finding.schema.v1"),
3340                    StoreAllocationIdentity::new(209, "icydb.test.migration_finding.journal.v1"),
3341                ),
3342                StoreRuntimeStorageCapabilities::journaled(),
3343            ).expect("migration finding store should register");
3344            registry
3345        };
3346    }
3347
3348    thread_local! {
3349        static EVOLUTION_DATA: RefCell<DataStore> =
3350            RefCell::new(DataStore::init_journaled(test_memory(192)));
3351        static EVOLUTION_INDEX: RefCell<IndexStore> =
3352            RefCell::new(IndexStore::init_journaled(test_memory(193)));
3353        static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
3354            RefCell::new(SchemaStore::init_journaled(test_memory(194)));
3355        static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
3356            RefCell::new(JournalTailStore::init(test_memory(195)));
3357        static EVOLUTION_REGISTRY: StoreRegistry = {
3358            let mut registry = StoreRegistry::new();
3359            registry.register_journaled_store(
3360                EVOLUTION_STORE_PATH,
3361                &EVOLUTION_DATA,
3362                &EVOLUTION_INDEX,
3363                &EVOLUTION_SCHEMA,
3364                &EVOLUTION_JOURNAL,
3365                StoreAllocationIdentities::new_journaled(
3366                    StoreAllocationIdentity::new(192, "icydb.test.rule_evolution.data.v1"),
3367                    StoreAllocationIdentity::new(193, "icydb.test.rule_evolution.index.v1"),
3368                    StoreAllocationIdentity::new(194, "icydb.test.rule_evolution.schema.v1"),
3369                    StoreAllocationIdentity::new(195, "icydb.test.rule_evolution.journal.v1"),
3370                ),
3371                StoreRuntimeStorageCapabilities::journaled(),
3372            ).expect("rule-evolution journaled store should register");
3373            registry
3374        };
3375    }
3376
3377    struct AbortCanister;
3378
3379    impl Path for AbortCanister {
3380        const PATH: &'static str = "schema_application_tests::AbortCanister";
3381    }
3382
3383    impl CanisterKind for AbortCanister {
3384        const COMMIT_MEMORY_ID: u8 = 184;
3385        const COMMIT_STABLE_KEY: &'static str = "icydb.test.application_abort.commit.v1";
3386        const STARTUP_MEMORY_ID: u8 = 186;
3387        const STARTUP_STABLE_KEY: &'static str = "icydb.test.application_abort.startup.control.v1";
3388        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 185;
3389        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3390            "icydb.test.application_abort.integrity.v1";
3391    }
3392
3393    struct EvolutionCanister;
3394
3395    impl Path for EvolutionCanister {
3396        const PATH: &'static str = "schema_application_tests::EvolutionCanister";
3397    }
3398
3399    impl CanisterKind for EvolutionCanister {
3400        const COMMIT_MEMORY_ID: u8 = 196;
3401        const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule_evolution.commit.v1";
3402        const STARTUP_MEMORY_ID: u8 = 198;
3403        const STARTUP_STABLE_KEY: &'static str = "icydb.test.rule_evolution.startup.control.v1";
3404        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 197;
3405        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3406            "icydb.test.rule_evolution.integrity.v1";
3407    }
3408
3409    #[cfg(feature = "migration")]
3410    struct MigrationCanister;
3411
3412    #[cfg(feature = "migration")]
3413    impl Path for MigrationCanister {
3414        const PATH: &'static str = "schema_application_tests::MigrationCanister";
3415    }
3416
3417    #[cfg(feature = "migration")]
3418    impl CanisterKind for MigrationCanister {
3419        const COMMIT_MEMORY_ID: u8 = 204;
3420        const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_validation.commit.v1";
3421        const STARTUP_MEMORY_ID: u8 = 206;
3422        const STARTUP_STABLE_KEY: &'static str =
3423            "icydb.test.migration_validation.startup.control.v1";
3424        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 205;
3425        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3426            "icydb.test.migration_validation.integrity.v1";
3427    }
3428
3429    #[cfg(feature = "migration")]
3430    struct MigrationExecutionCanister;
3431
3432    #[cfg(feature = "migration")]
3433    impl Path for MigrationExecutionCanister {
3434        const PATH: &'static str = "schema_application_tests::MigrationExecutionCanister";
3435    }
3436
3437    #[cfg(feature = "migration")]
3438    impl CanisterKind for MigrationExecutionCanister {
3439        const COMMIT_MEMORY_ID: u8 = 214;
3440        const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_execution.commit.v1";
3441        const STARTUP_MEMORY_ID: u8 = 216;
3442        const STARTUP_STABLE_KEY: &'static str =
3443            "icydb.test.migration_execution.startup.control.v1";
3444        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 215;
3445        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3446            "icydb.test.migration_execution.integrity.v1";
3447    }
3448
3449    #[cfg(feature = "migration")]
3450    struct MigrationFindingCanister;
3451
3452    #[cfg(feature = "migration")]
3453    impl Path for MigrationFindingCanister {
3454        const PATH: &'static str = "schema_application_tests::MigrationFindingCanister";
3455    }
3456
3457    #[cfg(feature = "migration")]
3458    impl CanisterKind for MigrationFindingCanister {
3459        const COMMIT_MEMORY_ID: u8 = 210;
3460        const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_finding.commit.v1";
3461        const STARTUP_MEMORY_ID: u8 = 212;
3462        const STARTUP_STABLE_KEY: &'static str = "icydb.test.migration_finding.startup.control.v1";
3463        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 211;
3464        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3465            "icydb.test.migration_finding.integrity.v1";
3466    }
3467
3468    fn name(value: &str) -> SchemaName {
3469        SchemaName::try_new(value).expect("test schema name should admit")
3470    }
3471
3472    fn generated_check_proposal(
3473        expected_head: ExpectedAcceptedHead,
3474        submission_key: &str,
3475        include_check: bool,
3476        database: TargetDatabaseIdentity,
3477        store: TargetStoreIdentity,
3478    ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3479        let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
3480        let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3481        let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
3482        let check_source =
3483            ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
3484        let check = SourceCheckExpr::try_new(vec![
3485            SourceCheckInstruction::Field(score_source),
3486            SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
3487            SourceCheckInstruction::GreaterThanOrEqual,
3488        ])
3489        .expect("check expression should admit");
3490        let constraints = include_check
3491            .then(|| ConstraintFragment::check(name("score_non_negative"), check))
3492            .into_iter()
3493            .collect();
3494        let entity = EntityFragment::try_new(
3495            name("Item"),
3496            version_one(),
3497            vec![
3498                FieldFragment::new(
3499                    name("id"),
3500                    FieldType::Scalar(ScalarType::Nat64),
3501                    false,
3502                    FieldInsertPolicy::Required,
3503                    None,
3504                ),
3505                FieldFragment::new(
3506                    name("score"),
3507                    FieldType::Scalar(ScalarType::Int64),
3508                    false,
3509                    FieldInsertPolicy::Required,
3510                    None,
3511                ),
3512            ],
3513            vec![id_source],
3514            Vec::new(),
3515            Vec::new(),
3516            constraints,
3517        )
3518        .expect("entity should admit");
3519        let proposal = SchemaProposal::try_compose(
3520            vec![SchemaCapability::ACCEPTED_CHECKS],
3521            database,
3522            SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3523            expected_head,
3524            vec![
3525                SchemaFragment::try_new(vec![entity], Vec::new())
3526                    .expect("schema fragment should admit"),
3527            ],
3528            vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3529            Vec::new(),
3530            None,
3531        )
3532        .expect("schema proposal should compose");
3533        (proposal, entity_source, check_source)
3534    }
3535
3536    #[cfg(feature = "migration")]
3537    #[derive(Clone, Copy, Eq, PartialEq)]
3538    enum ValidationMigrationShape {
3539        Clean,
3540        AllFindingFamilies,
3541    }
3542
3543    #[cfg(feature = "migration")]
3544    #[expect(
3545        clippy::too_many_lines,
3546        reason = "the fixture keeps both predecessor and candidate source contracts adjacent"
3547    )]
3548    fn validation_migration_proposal(
3549        shape: ValidationMigrationShape,
3550        current: bool,
3551        expected_head: ExpectedAcceptedHead,
3552        database: TargetDatabaseIdentity,
3553        store: TargetStoreIdentity,
3554    ) -> SchemaProposal {
3555        let entity_source = EntitySourceKey::try_new("MigratingItem")
3556            .expect("migration entity source should admit");
3557        let old_value =
3558            FieldSourceKey::try_new("old_value").expect("predecessor field source should admit");
3559        let current_value =
3560            FieldSourceKey::try_new("value").expect("candidate field source should admit");
3561        let target_entity = EntitySourceKey::try_new("MigrationTarget")
3562            .expect("migration target source should admit");
3563        let target_id = FieldSourceKey::try_new("id").expect("target id source should admit");
3564        let constraint = SourceCheckExpr::try_new(vec![
3565            SourceCheckInstruction::Field(current_value.clone()),
3566            SourceCheckInstruction::Literal(ScalarLiteral::Nat(8)),
3567            SourceCheckInstruction::LessThanOrEqual,
3568        ])
3569        .expect("candidate check should admit");
3570        let findings = shape == ValidationMigrationShape::AllFindingFamilies;
3571        let entity = EntityFragment::try_new(
3572            name("MigratingItem"),
3573            DeclaredEntityVersion::try_new(if current { 2 } else { 1 })
3574                .expect("migration version should admit"),
3575            vec![
3576                FieldFragment::new(
3577                    name("id"),
3578                    FieldType::Scalar(ScalarType::Nat64),
3579                    false,
3580                    FieldInsertPolicy::Required,
3581                    None,
3582                ),
3583                FieldFragment::new(
3584                    name(if current { "value" } else { "old_value" }),
3585                    FieldType::Scalar(if current {
3586                        ScalarType::Nat8
3587                    } else {
3588                        ScalarType::Int64
3589                    }),
3590                    false,
3591                    FieldInsertPolicy::Required,
3592                    None,
3593                ),
3594            ],
3595            vec![FieldSourceKey::try_new("id").expect("id source should admit")],
3596            current
3597                .then(|| {
3598                    IndexFragment::try_new(
3599                        name("value_unique"),
3600                        vec![IndexKeyFragment::Field(current_value.clone())],
3601                        true,
3602                        None,
3603                    )
3604                    .expect("candidate index should admit")
3605                })
3606                .into_iter()
3607                .collect(),
3608            (current && findings)
3609                .then(|| {
3610                    RelationFragment::try_new(
3611                        name("value_target"),
3612                        vec![current_value.clone()],
3613                        target_entity.clone(),
3614                        vec![target_id.clone()],
3615                        RelationDeleteAction::Restrict,
3616                    )
3617                    .expect("candidate relation should admit")
3618                })
3619                .into_iter()
3620                .collect(),
3621            (current && findings)
3622                .then(|| ConstraintFragment::check(name("value_at_most_eight"), constraint))
3623                .into_iter()
3624                .collect(),
3625        )
3626        .expect("migration entity should admit");
3627        let target = EntityFragment::try_new(
3628            name("MigrationTarget"),
3629            version_one(),
3630            vec![FieldFragment::new(
3631                name("id"),
3632                FieldType::Scalar(ScalarType::Nat8),
3633                false,
3634                FieldInsertPolicy::Required,
3635                None,
3636            )],
3637            vec![target_id],
3638            Vec::new(),
3639            Vec::new(),
3640            Vec::new(),
3641        )
3642        .expect("migration relation target should admit");
3643        let migration = current.then(|| {
3644            SchemaMigrationPlan::try_new(vec![
3645                EntityMigration::try_new(
3646                    entity_source.clone(),
3647                    DeclaredEntityVersion::try_new(1).expect("predecessor should admit"),
3648                    None,
3649                    Vec::new(),
3650                    vec![SchemaMigrationTransform::CheckedCast {
3651                        from: old_value.clone(),
3652                        to: current_value,
3653                        target: ScalarType::Nat8,
3654                    }],
3655                )
3656                .expect("migration transition should admit"),
3657            ])
3658            .expect("migration plan should admit")
3659        });
3660        let mut capabilities = Vec::new();
3661        if current && findings {
3662            capabilities.extend([
3663                SchemaCapability::ACCEPTED_CHECKS,
3664                SchemaCapability::SECONDARY_INDEXES,
3665                SchemaCapability::RESTRICTIVE_RELATIONS,
3666            ]);
3667        }
3668        if migration.is_some() {
3669            capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
3670        }
3671        let mut entities = vec![entity];
3672        let mut assignments = vec![EntityStoreAssignment::new(entity_source.clone(), store)];
3673        if findings {
3674            entities.push(target);
3675            assignments.push(EntityStoreAssignment::new(target_entity, store));
3676        }
3677        SchemaProposal::try_compose(
3678            capabilities,
3679            database,
3680            SchemaSubmissionKey::try_new(if current {
3681                "migration-validation-v2"
3682            } else {
3683                "migration-validation-v1"
3684            })
3685            .expect("submission should admit"),
3686            expected_head,
3687            vec![
3688                SchemaFragment::try_new(entities, Vec::new())
3689                    .expect("migration fragment should admit"),
3690            ],
3691            assignments,
3692            current
3693                .then_some(icydb_schema::SchemaRemoval::Field {
3694                    entity: entity_source,
3695                    field: old_value,
3696                })
3697                .into_iter()
3698                .collect(),
3699            migration,
3700        )
3701        .expect("migration proposal should compose")
3702    }
3703
3704    fn targeted_rule_proposal(
3705        expected_head: ExpectedAcceptedHead,
3706        submission_key: &str,
3707        operation: SourceRuleOperation,
3708        database: TargetDatabaseIdentity,
3709        store: TargetStoreIdentity,
3710    ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3711        let entity_source =
3712            EntitySourceKey::try_new("Measured").expect("entity source should admit");
3713        let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3714        let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
3715        let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
3716        let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
3717        let constraint_source =
3718            ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
3719        let entity = EntityFragment::try_new(
3720            name("Measured"),
3721            version_one(),
3722            vec![
3723                FieldFragment::new(
3724                    name("id"),
3725                    FieldType::Scalar(ScalarType::Nat64),
3726                    false,
3727                    FieldInsertPolicy::Required,
3728                    None,
3729                ),
3730                FieldFragment::new(
3731                    name("value"),
3732                    FieldType::Named(value_type.clone()),
3733                    false,
3734                    FieldInsertPolicy::Required,
3735                    None,
3736                ),
3737            ],
3738            vec![id_source],
3739            Vec::new(),
3740            Vec::new(),
3741            vec![ConstraintFragment::targeted_rule(
3742                TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
3743            )],
3744        )
3745        .expect("targeted entity should admit");
3746        let proposal = SchemaProposal::try_compose(
3747            vec![SchemaCapability::ACCEPTED_CHECKS],
3748            database,
3749            SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3750            expected_head,
3751            vec![
3752                SchemaFragment::try_new(
3753                    vec![entity],
3754                    vec![NamedTypeFragment::newtype(
3755                        name("Measure"),
3756                        FieldType::Scalar(ScalarType::Nat8),
3757                    )],
3758                )
3759                .expect("schema fragment should admit"),
3760            ],
3761            vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3762            Vec::new(),
3763            None,
3764        )
3765        .expect("schema proposal should compose");
3766        (proposal, entity_source, constraint_source)
3767    }
3768
3769    #[test]
3770    fn database_head_is_empty_only_when_every_store_root_is_absent() {
3771        assert_eq!(
3772            derive_accepted_head(&[("test::A", None), ("test::B", None)]),
3773            ExpectedAcceptedHead::Empty,
3774        );
3775    }
3776
3777    #[test]
3778    fn database_head_covers_store_path_revision_fingerprint_and_absence() {
3779        let first = derive_accepted_head(&[
3780            (
3781                "test::A",
3782                Some(AcceptedStoreHead {
3783                    revision: 3,
3784                    fingerprint: [0x11; 32],
3785                }),
3786            ),
3787            ("test::B", None),
3788        ]);
3789        let changed_fingerprint = derive_accepted_head(&[
3790            (
3791                "test::A",
3792                Some(AcceptedStoreHead {
3793                    revision: 3,
3794                    fingerprint: [0x12; 32],
3795                }),
3796            ),
3797            ("test::B", None),
3798        ]);
3799        let changed_absence = derive_accepted_head(&[
3800            (
3801                "test::A",
3802                Some(AcceptedStoreHead {
3803                    revision: 3,
3804                    fingerprint: [0x11; 32],
3805                }),
3806            ),
3807            (
3808                "test::B",
3809                Some(AcceptedStoreHead {
3810                    revision: 1,
3811                    fingerprint: [0x22; 32],
3812                }),
3813            ),
3814        ]);
3815
3816        assert_ne!(first, changed_fingerprint);
3817        assert_ne!(first, changed_absence);
3818        assert!(matches!(
3819            first,
3820            ExpectedAcceptedHead::Exact { revision: 3, .. }
3821        ));
3822    }
3823
3824    #[test]
3825    #[allow(
3826        clippy::too_many_lines,
3827        reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
3828    )]
3829    fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
3830        let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
3831        let store = TargetStoreIdentity::from_bytes([0x72; 32]);
3832        let (initial, entity_source, _) = generated_check_proposal(
3833            ExpectedAcceptedHead::Empty,
3834            "abort-initial",
3835            false,
3836            database,
3837            store,
3838        );
3839        let initial_candidate = lower_initial_schema_proposal(
3840            &initial,
3841            &[ProposalStoreTarget {
3842                path: "abort::Store",
3843                identity: store,
3844            }],
3845        )
3846        .expect("initial proposal should lower")
3847        .pop()
3848        .expect("initial proposal should produce one candidate");
3849        let (with_check, _, check_source) = generated_check_proposal(
3850            ExpectedAcceptedHead::Exact {
3851                revision: 1,
3852                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
3853            },
3854            "abort-add-check",
3855            true,
3856            database,
3857            store,
3858        );
3859        let pending_candidate = lower_existing_schema_proposal(
3860            &with_check,
3861            &[ExistingProposalStore {
3862                path: "abort::Store",
3863                identity: store,
3864                bundle: initial_candidate.bundle(),
3865            }],
3866        )
3867        .expect("generated check should lower")
3868        .pop()
3869        .expect("generated check should produce one candidate");
3870        let entity_tag = pending_candidate
3871            .bundle()
3872            .source_bindings_for_tests()
3873            .entity(&entity_source)
3874            .expect("entity source should remain bound");
3875        let constraint_id = pending_candidate
3876            .bundle()
3877            .source_bindings_for_tests()
3878            .constraint(entity_tag, &check_source)
3879            .expect("generated check source should bind");
3880        let pending_snapshot = pending_candidate
3881            .bundle()
3882            .entity_snapshots()
3883            .get(&entity_tag)
3884            .expect("pending entity should exist");
3885        let activation = pending_snapshot
3886            .constraint_catalog()
3887            .activation(constraint_id)
3888            .expect("generated check should remain an activation");
3889        assert_eq!(activation.origin(), ConstraintOrigin::Generated);
3890
3891        let aborted = aborted_generated_row_local_candidate(
3892            pending_candidate.bundle(),
3893            entity_tag,
3894            constraint_id,
3895        )
3896        .expect("generated check abort should build one catalog-native candidate");
3897        let aborted_snapshot = aborted
3898            .bundle()
3899            .entity_snapshots()
3900            .get(&entity_tag)
3901            .expect("aborted entity should remain");
3902        assert!(
3903            aborted_snapshot
3904                .constraint_catalog()
3905                .activation(constraint_id)
3906                .is_none(),
3907        );
3908        assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
3909        assert!(
3910            aborted
3911                .bundle()
3912                .source_bindings_for_tests()
3913                .constraint(entity_tag, &check_source)
3914                .is_none(),
3915        );
3916
3917        let reproposed = lower_existing_schema_proposal(
3918            &with_check,
3919            &[ExistingProposalStore {
3920                path: "abort::Store",
3921                identity: store,
3922                bundle: aborted.bundle(),
3923            }],
3924        )
3925        .expect("aborted generated check should be independently reproposable")
3926        .pop()
3927        .expect("reproposal should produce one candidate");
3928        let replacement_id = reproposed
3929            .bundle()
3930            .source_bindings_for_tests()
3931            .constraint(entity_tag, &check_source)
3932            .expect("reproposal should bind a fresh constraint identity");
3933        assert!(
3934            replacement_id > constraint_id,
3935            "aborted accepted IDs must remain retired",
3936        );
3937    }
3938
3939    #[test]
3940    fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
3941        let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
3942        let store = TargetStoreIdentity::from_bytes([0x82; 32]);
3943        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
3944            ExpectedAcceptedHead::Empty,
3945            "targeted-abort-initial",
3946            SourceRuleOperation::NumericRangeInclusive {
3947                min: ScalarLiteral::Nat(0),
3948                max: ScalarLiteral::Nat(10),
3949            },
3950            database,
3951            store,
3952        );
3953        let initial_candidate = lower_initial_schema_proposal(
3954            &initial,
3955            &[ProposalStoreTarget {
3956                path: "abort::TargetedStore",
3957                identity: store,
3958            }],
3959        )
3960        .expect("initial targeted proposal should lower")
3961        .pop()
3962        .expect("initial targeted proposal should produce one candidate");
3963        let initial_bundle = initial_candidate.bundle();
3964        let entity_tag = initial_bundle
3965            .source_bindings_for_tests()
3966            .entity(&entity_source)
3967            .expect("entity source should bind");
3968        let constraint_id = initial_bundle
3969            .source_bindings_for_tests()
3970            .constraint(entity_tag, &constraint_source)
3971            .expect("targeted source should bind");
3972        let high_water = initial_bundle.entity_snapshots()[&entity_tag]
3973            .constraint_id_allocator()
3974            .high_water();
3975        let (edited, _, _) = targeted_rule_proposal(
3976            ExpectedAcceptedHead::Exact {
3977                revision: initial_bundle.revision().get(),
3978                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
3979            },
3980            "targeted-abort-edit",
3981            SourceRuleOperation::NumericMaximumInclusive {
3982                value: ScalarLiteral::Nat(8),
3983            },
3984            database,
3985            store,
3986        );
3987        let staged = lower_existing_schema_proposal(
3988            &edited,
3989            &[ExistingProposalStore {
3990                path: "abort::TargetedStore",
3991                identity: store,
3992                bundle: initial_bundle,
3993            }],
3994        )
3995        .expect("targeted semantic edit should stage")
3996        .pop()
3997        .expect("targeted semantic edit should produce one candidate");
3998        let aborted =
3999            aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
4000                .expect("targeted semantic edit should abort through catalog authority");
4001        let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
4002
4003        assert!(
4004            snapshot
4005                .constraint_catalog()
4006                .activation(constraint_id)
4007                .is_none()
4008        );
4009        assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
4010        assert_eq!(
4011            aborted
4012                .bundle()
4013                .source_bindings_for_tests()
4014                .constraint(entity_tag, &constraint_source),
4015            Some(constraint_id),
4016        );
4017        assert!(snapshot.constraints().iter().any(|constraint| {
4018            constraint.id() == constraint_id
4019                && matches!(
4020                    constraint.kind(),
4021                    AcceptedConstraintKind::TargetedRule { operation, .. }
4022                        if matches!(
4023                            operation.as_ref(),
4024                            AcceptedRuleOperation::NumericRangeInclusive { .. }
4025                        )
4026                )
4027        }));
4028    }
4029
4030    #[test]
4031    #[allow(
4032        clippy::too_many_lines,
4033        reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
4034    )]
4035    fn generated_startup_driver_resumes_pending_activation_and_promotes_without_source_model() {
4036        let db = Db::<EvolutionCanister>::new(
4037            &EVOLUTION_REGISTRY,
4038            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4039        );
4040        drive_startup_recovery_to_completion(&db);
4041        let empty_target =
4042            schema_application_target(&db).expect("empty evolution target should issue");
4043        let store_identity = empty_target
4044            .stores()
4045            .first()
4046            .expect("evolution store should register")
4047            .identity();
4048        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
4049            empty_target.accepted_head().clone(),
4050            "targeted-recovery-initial",
4051            SourceRuleOperation::NumericRangeInclusive {
4052                min: ScalarLiteral::Nat(0),
4053                max: ScalarLiteral::Nat(10),
4054            },
4055            empty_target.database_identity(),
4056            store_identity,
4057        );
4058        assert!(matches!(
4059            apply_schema(&db, &initial)
4060                .expect("initial targeted proposal should publish")
4061                .outcome(),
4062            SchemaChangeOutcome::Applied { .. },
4063        ));
4064
4065        let direct_target =
4066            schema_application_target(&db).expect("direct evolution target should issue");
4067        let (direct_edit, _, _) = targeted_rule_proposal(
4068            direct_target.accepted_head().clone(),
4069            "targeted-direct-edit",
4070            SourceRuleOperation::NumericMaximumInclusive {
4071                value: ScalarLiteral::Nat(8),
4072            },
4073            direct_target.database_identity(),
4074            store_identity,
4075        );
4076        assert!(matches!(
4077            apply_schema(&db, &direct_edit)
4078                .expect("empty-domain semantic edit should publish directly")
4079                .outcome(),
4080            SchemaChangeOutcome::Applied { .. },
4081        ));
4082        let store = db
4083            .store_handle(EVOLUTION_STORE_PATH)
4084            .expect("evolution store should resolve");
4085        let direct = store
4086            .with_schema(SchemaStore::current_accepted_schema_bundle)
4087            .expect("directly edited bundle should remain readable")
4088            .expect("directly edited bundle should exist");
4089        let entity_tag = direct
4090            .source_bindings_for_tests()
4091            .entity(&entity_source)
4092            .expect("entity source should remain bound");
4093        let constraint_id = direct
4094            .source_bindings_for_tests()
4095            .constraint(entity_tag, &constraint_source)
4096            .expect("direct edit should preserve constraint identity");
4097        assert!(
4098            direct.entity_snapshots()[&entity_tag]
4099                .constraint_catalog()
4100                .activation(constraint_id)
4101                .is_none()
4102        );
4103        assert!(
4104            direct.entity_snapshots()[&entity_tag]
4105                .constraints()
4106                .iter()
4107                .any(|constraint| {
4108                    constraint.id() == constraint_id
4109                        && matches!(
4110                            constraint.kind(),
4111                            AcceptedConstraintKind::TargetedRule { operation, .. }
4112                                if matches!(
4113                                    operation.as_ref(),
4114                                    AcceptedRuleOperation::NumericMaximumInclusive { .. }
4115                                )
4116                        )
4117                })
4118        );
4119
4120        let target = schema_application_target(&db).expect("staged evolution target should issue");
4121        let (edited, _, _) = targeted_rule_proposal(
4122            target.accepted_head().clone(),
4123            "targeted-recovery-edit",
4124            SourceRuleOperation::MultipleOf {
4125                divisor: ScalarLiteral::Nat(2),
4126            },
4127            target.database_identity(),
4128            store_identity,
4129        );
4130        let current = store
4131            .with_schema(SchemaStore::current_accepted_schema_bundle)
4132            .expect("accepted evolution bundle should remain readable")
4133            .expect("directly edited evolution bundle should exist");
4134        let staged = lower_existing_schema_proposal(
4135            &edited,
4136            &[ExistingProposalStore {
4137                path: EVOLUTION_STORE_PATH,
4138                identity: store_identity,
4139                bundle: &current,
4140            }],
4141        )
4142        .expect("targeted edit should stage")
4143        .pop()
4144        .expect("targeted edit should produce one candidate");
4145        assert_eq!(
4146            staged
4147                .bundle()
4148                .source_bindings_for_tests()
4149                .constraint(entity_tag, &constraint_source),
4150            Some(constraint_id),
4151        );
4152        let proof = DirectGeneratedRowLocalProof {
4153            candidate_index: 0,
4154            store,
4155            store_path: EVOLUTION_STORE_PATH,
4156            entity_tag,
4157            entity_path: staged.bundle().entity_snapshots()[&entity_tag]
4158                .entity_path()
4159                .to_string(),
4160            constraint_id,
4161            historical_rows: 0,
4162        };
4163        let final_candidates = final_candidates_for_pending_row_local_constraint(
4164            std::slice::from_ref(&staged),
4165            &PendingGeneratedRowLocalConstraint { proof },
4166        )
4167        .expect("final semantic replacement should derive without source input");
4168        let authorities = application_authorities(&db);
4169        let candidate_head =
4170            accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
4171                .expect("final candidate head should derive");
4172        let digest = edited.digest().expect("proposal digest should derive");
4173        let job_id = derive_schema_change_job_id(
4174            target.database_identity(),
4175            edited.submission_key(),
4176            digest,
4177            target.accepted_head(),
4178        )
4179        .expect("job identity should derive");
4180        let receipt = crate::db::schema::SchemaChangeReceipt::new(
4181            target.database_identity(),
4182            edited.submission_key().clone(),
4183            digest,
4184            target.accepted_head().clone(),
4185            SchemaChangeOutcome::Pending {
4186                job: SchemaChangeJob::new(job_id),
4187                candidate_head,
4188            },
4189        )
4190        .expect("pending replacement receipt should admit");
4191        let record = SchemaApplicationRecord::new(
4192            receipt,
4193            vec![
4194                SchemaChangeActivation::new(
4195                    store_identity,
4196                    entity_tag.value(),
4197                    constraint_id.get(),
4198                )
4199                .expect("replacement activation should admit"),
4200            ],
4201        )
4202        .expect("pending replacement record should admit");
4203        let operation =
4204            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4205        publish_accepted_schema_candidates_with_application_record(
4206            vec![AcceptedSchemaPublication::new(
4207                EVOLUTION_STORE_PATH,
4208                store,
4209                current.revision(),
4210                &staged,
4211            )],
4212            operation,
4213        )
4214        .expect("staged replacement and record should publish atomically");
4215
4216        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4217        drive_startup_recovery_to_completion(&db);
4218
4219        let recovered = store
4220            .with_schema(SchemaStore::current_accepted_schema_bundle)
4221            .expect("recovered staged bundle should decode")
4222            .expect("recovered staged bundle should exist");
4223        let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
4224        let validating_catalog = recovered_snapshot
4225            .constraint_catalog()
4226            .clone()
4227            .with_validation_started(constraint_id)
4228            .expect("recovered replacement should enter validation");
4229        let mut validating_snapshots = recovered.entity_snapshots().clone();
4230        validating_snapshots.insert(
4231            entity_tag,
4232            recovered_snapshot.with_constraint_catalog(validating_catalog),
4233        );
4234        let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
4235            recovered
4236                .revision()
4237                .checked_next()
4238                .expect("validation revision should remain available"),
4239            recovered.store_path(),
4240            recovered.enum_catalog().clone(),
4241            recovered.composite_catalog().clone(),
4242            recovered.source_bindings_for_tests().clone(),
4243            validating_snapshots,
4244        )
4245        .expect("validating replacement bundle should close");
4246        let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
4247            .expect("validating replacement candidate should encode");
4248        let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
4249            .constraint_catalog()
4250            .activation(constraint_id)
4251            .expect("validating replacement activation should remain present");
4252        let validation_job = ConstraintValidationJob::start(
4253            entity_tag,
4254            validating_candidate.bundle().entity_snapshots()[&entity_tag]
4255                .entity_path()
4256                .to_string(),
4257            validating_activation,
4258            None,
4259        )
4260        .expect("validating replacement job should derive from accepted state");
4261        store
4262            .with_schema(|schema| {
4263                schema.validate_live_activation_transition(validating_candidate.bundle())?;
4264                schema.validate_constraint_validation_job_closure_with_change(
4265                    validating_candidate.bundle(),
4266                    Some(&validation_job),
4267                    None,
4268                )
4269            })
4270            .expect("validating replacement transition and job should close");
4271        let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4272        let startup_session =
4273            crate::db::DbSession::<EvolutionCanister>::new(&EVOLUTION_REGISTRY, &startup_root);
4274
4275        assert_eq!(
4276            drive_generated_startup_recovery_page(
4277                &startup_session,
4278                &EVOLUTION_REGISTRY,
4279                edited.submission_key().as_str(),
4280            )
4281            .expect("generated startup should begin pending validation"),
4282            GeneratedStartupDriverStep::Recovering,
4283        );
4284        let mut terminal = false;
4285        for _ in 0..8 {
4286            match drive_generated_startup_recovery_page(
4287                &startup_session,
4288                &EVOLUTION_REGISTRY,
4289                edited.submission_key().as_str(),
4290            )
4291            .expect("generated startup should advance pending validation")
4292            {
4293                GeneratedStartupDriverStep::Recovering => {}
4294                GeneratedStartupDriverStep::Terminal => {
4295                    terminal = true;
4296                    break;
4297                }
4298                GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4299                    panic!("an exact pending receipt must resume instead of being resubmitted")
4300                }
4301            }
4302        }
4303        assert!(
4304            terminal,
4305            "empty historical domain should promote within bounded startup steps"
4306        );
4307        assert_eq!(
4308            observe_generated_startup_state::<EvolutionCanister>(
4309                &EVOLUTION_REGISTRY,
4310                edited.submission_key().as_str(),
4311            ),
4312            Ok(DatabaseStartupState::Ready),
4313        );
4314        let applied = super::exact_schema_application_receipt(
4315            &edited,
4316            edited
4317                .digest()
4318                .expect("proposal digest should remain stable"),
4319        )
4320        .expect("terminal generated receipt should remain readable")
4321        .expect("terminal generated receipt should remain present");
4322        assert!(matches!(
4323            applied.outcome(),
4324            SchemaChangeOutcome::Applied { .. }
4325        ));
4326        let promoted = store
4327            .with_schema(SchemaStore::current_accepted_schema_bundle)
4328            .expect("promoted bundle should remain readable")
4329            .expect("promoted bundle should exist");
4330        let snapshot = &promoted.entity_snapshots()[&entity_tag];
4331        assert!(
4332            snapshot
4333                .constraint_catalog()
4334                .activation(constraint_id)
4335                .is_none()
4336        );
4337        assert_eq!(
4338            promoted
4339                .source_bindings_for_tests()
4340                .constraint(entity_tag, &constraint_source),
4341            Some(constraint_id),
4342        );
4343        assert!(snapshot.constraints().iter().any(|constraint| {
4344            constraint.id() == constraint_id
4345                && matches!(
4346                    constraint.kind(),
4347                    AcceptedConstraintKind::TargetedRule { operation, .. }
4348                        if matches!(
4349                            operation.as_ref(),
4350                            AcceptedRuleOperation::MultipleOf { .. }
4351                        )
4352                )
4353        }));
4354    }
4355
4356    #[test]
4357    #[allow(
4358        clippy::too_many_lines,
4359        reason = "the durable pending job, startup failure, and retained finding assertions form one scenario"
4360    )]
4361    fn generated_startup_driver_persists_e223_for_a_retained_historical_finding() {
4362        let db = Db::<AbortCanister>::new(
4363            &ABORT_REGISTRY,
4364            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4365        );
4366        drive_startup_recovery_to_completion(&db);
4367        let empty_target =
4368            schema_application_target(&db).expect("empty application target should issue");
4369        let store_identity = empty_target
4370            .stores()
4371            .first()
4372            .expect("abort store should be registered")
4373            .identity();
4374        let (initial, _, _) = generated_check_proposal(
4375            empty_target.accepted_head().clone(),
4376            "startup-finding-initial",
4377            false,
4378            empty_target.database_identity(),
4379            store_identity,
4380        );
4381        apply_schema(&db, &initial).expect("initial generated schema should publish");
4382
4383        let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4384        let session = DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &root);
4385        let rows = (1..=257)
4386            .map(|id| DynamicMutation::Insert {
4387                entity: "Item".to_string(),
4388                patch: DynamicStructuralPatch::new(vec![
4389                    (
4390                        "id".to_string(),
4391                        DynamicWriteCell::Value(InputValue::Nat64(id)),
4392                    ),
4393                    (
4394                        "score".to_string(),
4395                        DynamicWriteCell::Value(InputValue::Int64(if id == 257 { -1 } else { 1 })),
4396                    ),
4397                ]),
4398            })
4399            .collect();
4400        session
4401            .execute_trusted_dynamic_mutation_batch(rows)
4402            .expect("historical finding fixture rows should commit as one legal batch");
4403
4404        let target =
4405            schema_application_target(&db).expect("existing application target should issue");
4406        let (with_check, _, _) = generated_check_proposal(
4407            target.accepted_head().clone(),
4408            "startup-finding-pending",
4409            true,
4410            target.database_identity(),
4411            store_identity,
4412        );
4413        let pending = apply_schema(&db, &with_check)
4414            .expect("the first clean page should admit durable continuation");
4415        let SchemaChangeOutcome::Pending { job, .. } = pending.outcome() else {
4416            panic!("a 257-row domain must exceed the 256-row direct proof page")
4417        };
4418
4419        let mut terminal = false;
4420        for _ in 0..8 {
4421            match drive_generated_startup_recovery_page(
4422                &session,
4423                &ABORT_REGISTRY,
4424                with_check.submission_key().as_str(),
4425            )
4426            .expect("generated startup should retain a typed finding failure")
4427            {
4428                GeneratedStartupDriverStep::Recovering => {}
4429                GeneratedStartupDriverStep::Terminal => {
4430                    terminal = true;
4431                    break;
4432                }
4433                GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4434                    panic!("an exact pending receipt must not be resubmitted")
4435                }
4436            }
4437        }
4438        assert!(terminal, "the retained finding should become terminal");
4439        let failure = observe_generated_startup_state::<AbortCanister>(
4440            &ABORT_REGISTRY,
4441            with_check.submission_key().as_str(),
4442        )
4443        .expect_err("the retained finding must remain durably observable");
4444        assert_eq!(
4445            failure.kind(),
4446            crate::db::StartupFailureKind::SchemaReconciliation,
4447        );
4448        assert_eq!(
4449            failure.diagnostic().error_code(),
4450            icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION,
4451        );
4452        assert_eq!(
4453            ABORT_DATA.with(|store| store.borrow().len()),
4454            257,
4455            "terminal startup publication must not change historical rows",
4456        );
4457        assert!(matches!(
4458            continue_schema_application(&db, job.id(), None)
4459                .expect("the retained finding page should replay exactly")
4460                .status(),
4461            SchemaChangeProgressStatus::Findings { findings, .. } if !findings.is_empty(),
4462        ));
4463    }
4464
4465    #[test]
4466    #[allow(
4467        clippy::too_many_lines,
4468        reason = "the journaled abort, replay, and recovery assertions form one scenario"
4469    )]
4470    fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
4471        let db = Db::<AbortCanister>::new(
4472            &ABORT_REGISTRY,
4473            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4474        );
4475        drive_startup_recovery_to_completion(&db);
4476        let empty_target =
4477            schema_application_target(&db).expect("empty application target should issue");
4478        let store_identity = empty_target
4479            .stores()
4480            .first()
4481            .expect("abort store should be registered")
4482            .identity();
4483        let (initial, entity_source, _) = generated_check_proposal(
4484            empty_target.accepted_head().clone(),
4485            "abort-runtime-initial",
4486            false,
4487            empty_target.database_identity(),
4488            store_identity,
4489        );
4490        assert!(matches!(
4491            apply_schema(&db, &initial)
4492                .expect("initial application should publish")
4493                .outcome(),
4494            SchemaChangeOutcome::Applied { .. },
4495        ));
4496
4497        let target =
4498            schema_application_target(&db).expect("existing application target should issue");
4499        let (with_check, _, check_source) = generated_check_proposal(
4500            target.accepted_head().clone(),
4501            "abort-runtime-pending",
4502            true,
4503            target.database_identity(),
4504            store_identity,
4505        );
4506        let store = db
4507            .store_handle(ABORT_STORE_PATH)
4508            .expect("abort store should resolve");
4509        let current = store
4510            .with_schema(SchemaStore::current_accepted_schema_bundle)
4511            .expect("accepted bundle should remain readable")
4512            .expect("initial accepted bundle should exist");
4513        let pending_candidate = lower_existing_schema_proposal(
4514            &with_check,
4515            &[ExistingProposalStore {
4516                path: ABORT_STORE_PATH,
4517                identity: store_identity,
4518                bundle: &current,
4519            }],
4520        )
4521        .expect("pending generated check should lower")
4522        .pop()
4523        .expect("pending generated check should produce one candidate");
4524        let entity_tag = pending_candidate
4525            .bundle()
4526            .source_bindings_for_tests()
4527            .entity(&entity_source)
4528            .expect("entity source should bind");
4529        let constraint_id = pending_candidate
4530            .bundle()
4531            .source_bindings_for_tests()
4532            .constraint(entity_tag, &check_source)
4533            .expect("generated check source should bind");
4534        let digest = with_check.digest().expect("proposal digest should derive");
4535        let job_id = derive_schema_change_job_id(
4536            target.database_identity(),
4537            with_check.submission_key(),
4538            digest,
4539            target.accepted_head(),
4540        )
4541        .expect("job identity should derive");
4542        let receipt = crate::db::schema::SchemaChangeReceipt::new(
4543            target.database_identity(),
4544            with_check.submission_key().clone(),
4545            digest,
4546            target.accepted_head().clone(),
4547            SchemaChangeOutcome::Pending {
4548                job: SchemaChangeJob::new(job_id),
4549                candidate_head: ExpectedAcceptedHead::Exact {
4550                    revision: pending_candidate.revision().get().saturating_add(2),
4551                    fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
4552                },
4553            },
4554        )
4555        .expect("pending receipt should admit");
4556        let record = SchemaApplicationRecord::new(
4557            receipt,
4558            vec![
4559                SchemaChangeActivation::new(
4560                    store_identity,
4561                    entity_tag.value(),
4562                    constraint_id.get(),
4563                )
4564                .expect("application activation should admit"),
4565            ],
4566        )
4567        .expect("pending application record should admit");
4568        let operation =
4569            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4570        publish_accepted_schema_candidates_with_application_record(
4571            vec![AcceptedSchemaPublication::new(
4572                ABORT_STORE_PATH,
4573                store,
4574                current.revision(),
4575                &pending_candidate,
4576            )],
4577            operation,
4578        )
4579        .expect("pending candidate and record should publish atomically");
4580
4581        let started = continue_schema_application(&db, job_id, None)
4582            .expect("first continuation should durably start validation");
4583        assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4584        let progress =
4585            abort_schema_application(&db, job_id, None).expect("pending application should abort");
4586        assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
4587        assert!(matches!(
4588            progress.receipt().outcome(),
4589            SchemaChangeOutcome::Aborted { .. },
4590        ));
4591        let replay =
4592            abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
4593        assert_eq!(replay, progress);
4594        assert_eq!(
4595            continue_schema_application(&db, job_id, None)
4596                .expect("continuation after abort should replay terminal state"),
4597            progress,
4598        );
4599
4600        let aborted = store
4601            .with_schema(SchemaStore::current_accepted_schema_bundle)
4602            .expect("accepted bundle should remain readable")
4603            .expect("aborted accepted bundle should exist");
4604        assert!(
4605            aborted
4606                .entity_snapshots()
4607                .get(&entity_tag)
4608                .expect("entity should remain after abort")
4609                .constraint_catalog()
4610                .activation(constraint_id)
4611                .is_none(),
4612        );
4613        assert!(
4614            aborted
4615                .source_bindings_for_tests()
4616                .constraint(entity_tag, &check_source)
4617                .is_none(),
4618        );
4619        assert!(
4620            store
4621                .with_schema(|schema| {
4622                    schema.constraint_validation_job(entity_tag, constraint_id)
4623                })
4624                .expect("validation-job storage should remain readable")
4625                .is_none(),
4626        );
4627
4628        ABORT_DATA.with(|store| {
4629            ABORT_DATA_MEMORY.with(|memory| {
4630                *store.borrow_mut() = DataStore::init_journaled(memory.clone());
4631            });
4632        });
4633        ABORT_INDEX.with(|store| {
4634            ABORT_INDEX_MEMORY.with(|memory| {
4635                *store.borrow_mut() = IndexStore::init_journaled(memory.clone());
4636            });
4637        });
4638        ABORT_SCHEMA.with(|store| {
4639            ABORT_SCHEMA_MEMORY.with(|memory| {
4640                *store.borrow_mut() = SchemaStore::init_journaled(memory.clone());
4641            });
4642        });
4643        ABORT_JOURNAL.with(|store| {
4644            ABORT_JOURNAL_MEMORY.with(|memory| {
4645                *store.borrow_mut() = JournalTailStore::init(memory.clone());
4646            });
4647        });
4648        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4649        drive_startup_recovery_to_completion(&db);
4650        assert_eq!(
4651            abort_schema_application(&db, job_id, None)
4652                .expect("recovered terminal abort should replay"),
4653            progress,
4654        );
4655        assert!(
4656            store
4657                .with_schema(|schema| {
4658                    schema.constraint_validation_job(entity_tag, constraint_id)
4659                })
4660                .expect("recovered validation-job storage should remain readable")
4661                .is_none(),
4662        );
4663        let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4664        let startup_session =
4665            crate::db::DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &startup_root);
4666        ABORT_JOURNAL.with(|journal| {
4667            let journal = journal.borrow();
4668            assert!(
4669                journal
4670                    .validate_current_tail_authority()
4671                    .expect("recovered abort tail control should remain readable")
4672                    .is_empty(),
4673                "recovered abort tail control should close exactly",
4674            );
4675        });
4676        assert_eq!(
4677            drive_generated_startup_recovery_page(
4678                &startup_session,
4679                &ABORT_REGISTRY,
4680                with_check.submission_key().as_str(),
4681            )
4682            .expect("an exact aborted generated submission should publish terminal startup state"),
4683            GeneratedStartupDriverStep::Terminal,
4684        );
4685        let failure = observe_generated_startup_state::<AbortCanister>(
4686            &ABORT_REGISTRY,
4687            with_check.submission_key().as_str(),
4688        )
4689        .expect_err("an aborted generated submission must not remain retryable forever");
4690        assert_eq!(
4691            failure.kind(),
4692            crate::db::StartupFailureKind::SchemaReconciliation,
4693        );
4694        assert_eq!(
4695            failure.diagnostic().error_code(),
4696            icydb_diagnostic_code::ErrorCode::RUNTIME_CONFLICT,
4697        );
4698    }
4699
4700    #[cfg(feature = "migration")]
4701    #[test]
4702    fn migration_planning_failures_retain_typed_public_classification() {
4703        use super::schema_migration_planning_error;
4704        use crate::db::schema::migration_planner::SchemaMigrationPlanningError;
4705        use icydb_diagnostic_code::{DiagnosticDetail, SchemaMigrationCode};
4706
4707        for (error, reason) in [
4708            (
4709                SchemaMigrationPlanningError::Unadopted,
4710                SchemaMigrationCode::Unadopted,
4711            ),
4712            (
4713                SchemaMigrationPlanningError::MissingMigration,
4714                SchemaMigrationCode::MissingMigration,
4715            ),
4716            (
4717                SchemaMigrationPlanningError::VersionGap,
4718                SchemaMigrationCode::VersionGap,
4719            ),
4720            (
4721                SchemaMigrationPlanningError::Downgrade,
4722                SchemaMigrationCode::Downgrade,
4723            ),
4724            (
4725                SchemaMigrationPlanningError::EmptyEntityVersionBump,
4726                SchemaMigrationCode::EmptyEntityVersionBump,
4727            ),
4728            (
4729                SchemaMigrationPlanningError::StaleAcceptedHead,
4730                SchemaMigrationCode::StaleAcceptedHead,
4731            ),
4732            (
4733                SchemaMigrationPlanningError::UnknownFromObject,
4734                SchemaMigrationCode::UnknownFromObject,
4735            ),
4736            (
4737                SchemaMigrationPlanningError::UnknownToObject,
4738                SchemaMigrationCode::UnknownToObject,
4739            ),
4740            (
4741                SchemaMigrationPlanningError::KindMismatch,
4742                SchemaMigrationCode::KindMismatch,
4743            ),
4744            (
4745                SchemaMigrationPlanningError::IdentityConflict,
4746                SchemaMigrationCode::IdentityConflict,
4747            ),
4748            (
4749                SchemaMigrationPlanningError::UnexplainedSchemaDifference,
4750                SchemaMigrationCode::UnexplainedSchemaDifference,
4751            ),
4752            (
4753                SchemaMigrationPlanningError::UnsupportedTransform,
4754                SchemaMigrationCode::UnsupportedTransform,
4755            ),
4756            (
4757                SchemaMigrationPlanningError::RekeyedCatalogInvalid,
4758                SchemaMigrationCode::CandidateMismatch,
4759            ),
4760            (
4761                SchemaMigrationPlanningError::CandidateMismatch,
4762                SchemaMigrationCode::CandidateMismatch,
4763            ),
4764            (
4765                SchemaMigrationPlanningError::CorruptLineage,
4766                SchemaMigrationCode::ProgressCorrupt,
4767            ),
4768        ] {
4769            let diagnostic = schema_migration_planning_error(error).diagnostic();
4770            assert_eq!(
4771                diagnostic.detail(),
4772                Some(&DiagnosticDetail::SchemaMigration { reason }),
4773            );
4774            assert_eq!(diagnostic.code(), reason.diagnostic_code());
4775        }
4776    }
4777
4778    #[cfg(feature = "migration")]
4779    #[test]
4780    #[expect(
4781        clippy::too_many_lines,
4782        reason = "the validation replay, staging, and unchanged-row assertions form one scenario"
4783    )]
4784    fn physical_migration_validation_is_bounded_staged_and_does_not_rewrite_rows() {
4785        use std::convert::Infallible;
4786
4787        use super::{defer_generated_schema_application_for_prepared_migration, migrate_schema};
4788        use crate::db::{
4789            data::StoreVisit,
4790            index::{IndexEntryValue, IndexId, IndexKey, IndexKeyKind},
4791            key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
4792            schema::{SchemaMigrationCommand, SchemaMigrationPhase},
4793        };
4794        use crate::types::EntityTag;
4795
4796        let db = Db::<MigrationCanister>::new(
4797            &MIGRATION_REGISTRY,
4798            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4799        );
4800        drive_startup_recovery_to_completion(&db);
4801        let initial_target = schema_application_target(&db).expect("initial target should issue");
4802        let store_identity = initial_target
4803            .stores()
4804            .first()
4805            .expect("migration store should exist")
4806            .identity();
4807        let initial = validation_migration_proposal(
4808            ValidationMigrationShape::Clean,
4809            false,
4810            initial_target.accepted_head().clone(),
4811            initial_target.database_identity(),
4812            store_identity,
4813        );
4814        apply_schema(&db, &initial).expect("initial schema should publish");
4815
4816        let session = DbSession::<MigrationCanister>::new(
4817            &MIGRATION_REGISTRY,
4818            &crate::db::RequestExecutionRoot::__new_runtime_root(),
4819        );
4820        for (id, value) in [(1, 7), (2, 8)] {
4821            session
4822                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4823                    entity: "MigratingItem".to_string(),
4824                    patch: DynamicStructuralPatch::new(vec![
4825                        (
4826                            "id".to_string(),
4827                            DynamicWriteCell::Value(InputValue::Nat64(id)),
4828                        ),
4829                        (
4830                            "old_value".to_string(),
4831                            DynamicWriteCell::Value(InputValue::Int64(value)),
4832                        ),
4833                    ]),
4834                })
4835                .expect("predecessor row should insert");
4836        }
4837        let store = db
4838            .store_handle(MIGRATION_STORE_PATH)
4839            .expect("migration store should resolve");
4840        let row_bytes = || {
4841            store.with_data(|data| {
4842                let mut rows = Vec::new();
4843                let result: Result<(), Infallible> = data.visit_entries(|key, row| {
4844                    rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
4845                    Ok(StoreVisit::Continue)
4846                });
4847                result.expect("infallible row visit should complete");
4848                rows
4849            })
4850        };
4851        let before_rows = row_bytes();
4852
4853        let target = schema_application_target(&db).expect("migration target should issue");
4854        let proposal = validation_migration_proposal(
4855            ValidationMigrationShape::Clean,
4856            true,
4857            target.accepted_head().clone(),
4858            target.database_identity(),
4859            store_identity,
4860        );
4861        let plan = proposal
4862            .migration()
4863            .expect("migration plan should exist")
4864            .digest();
4865        let command = || SchemaMigrationCommand::Advance {
4866            expected_database: target.database_identity(),
4867            expected_head: target.accepted_head().clone(),
4868            expected_plan: plan,
4869            acknowledged_finding_page: None,
4870        };
4871        assert_eq!(
4872            migrate_schema(&db, &proposal, command())
4873                .unwrap_or_else(|error| {
4874                    panic!(
4875                        "physical migration should prepare: {:?}",
4876                        error.diagnostic()
4877                    )
4878                })
4879                .phase(),
4880            SchemaMigrationPhase::Prepared,
4881        );
4882        assert_eq!(
4883            migrate_schema(&db, &proposal, command())
4884                .expect("physical migration should enter validation")
4885                .phase(),
4886            SchemaMigrationPhase::Validating,
4887        );
4888        let record = super::load_schema_migration_record()
4889            .expect("migration record should remain readable")
4890            .expect("validating migration record should exist");
4891        let planned = super::recompile_active_physical_migration(&db, &proposal, &record)
4892            .expect("the exact active plan should recompile");
4893        for _ in 0..2 {
4894            let page = super::validate_migration_page(&db, &planned, record.progress())
4895                .expect("the same validation page should remain replayable");
4896            let (progress, staged, exhausted) = page.into_parts();
4897            assert!(progress.findings().is_empty());
4898            assert!(exhausted);
4899            super::stage_migration_index_entries(staged)
4900                .expect("staging before a cursor marker should be idempotent");
4901        }
4902        assert_eq!(
4903            store.with_index(IndexStore::len),
4904            2,
4905            "replaying an uncheckpointed page must retain one exact staged key per row",
4906        );
4907        let ready =
4908            migrate_schema(&db, &proposal, command()).expect("bounded validation should complete");
4909        assert_eq!(ready.phase(), SchemaMigrationPhase::ReadyToRewrite);
4910        assert_eq!(ready.rows_validated(), 2);
4911        assert!(ready.findings().is_empty());
4912        assert_eq!(row_bytes(), before_rows, "validation must not rewrite rows");
4913        assert_eq!(
4914            store.with_index(IndexStore::len),
4915            2,
4916            "the isolated candidate unique generation should be durably staged",
4917        );
4918        store.with_index_mut(|index| {
4919            for ordinal in 0..513_u64 {
4920                let component = ordinal.to_be_bytes();
4921                let key = IndexKey::new_from_components_with_primary_key_value(
4922                    &IndexId::new(EntityTag::new(2), 0),
4923                    IndexKeyKind::User,
4924                    &[component],
4925                    &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
4926                )
4927                .expect("unrelated abort-scan key should build")
4928                .to_raw()
4929                .expect("unrelated abort-scan key should encode");
4930                index.insert(key, IndexEntryValue::presence());
4931            }
4932        });
4933        let abort = || SchemaMigrationCommand::Abort {
4934            expected_database: target.database_identity(),
4935            expected_head: target.accepted_head().clone(),
4936            expected_plan: plan,
4937        };
4938        let cleaning = migrate_schema(&db, &proposal, abort())
4939            .expect("the first bounded abort cleanup page should publish");
4940        assert_eq!(cleaning.phase(), SchemaMigrationPhase::ReadyToRewrite);
4941        assert_eq!(store.with_index(IndexStore::len), 513);
4942        let aborted =
4943            migrate_schema(&db, &proposal, abort()).expect("pre-rewrite migration should abort");
4944        assert_eq!(aborted.phase(), SchemaMigrationPhase::Aborted);
4945        assert_eq!(
4946            store.with_index(IndexStore::len),
4947            513,
4948            "abort must remove only planner-invisible candidate generations",
4949        );
4950        assert_eq!(
4951            row_bytes(),
4952            before_rows,
4953            "abort must retain predecessor rows"
4954        );
4955        assert!(
4956            !defer_generated_schema_application_for_prepared_migration(&db, &proposal)
4957                .expect("terminal aborted record must not block generated startup"),
4958        );
4959    }
4960
4961    #[cfg(feature = "migration")]
4962    #[test]
4963    #[expect(
4964        clippy::too_many_lines,
4965        reason = "the interrupted rewrite, recovery, final proof, and publication form one scenario"
4966    )]
4967    fn physical_migration_rewrite_recovers_and_publishes_one_complete_candidate() {
4968        use super::{
4969            defer_generated_schema_application_for_prepared_migration, migrate_schema,
4970            schema_migration_status_for_target,
4971        };
4972        use crate::db::{
4973            data::{CanonicalSlotReader, DecodedDataStoreKey, StoreVisit, StructuralSlotReader},
4974            schema::{
4975                MigrationRewriteInterruption, SchemaMigrationCommand, SchemaMigrationPhase,
4976                ensure_schema_migration_ready_for_ordinary_operations,
4977                interrupt_next_migration_rewrite_at,
4978            },
4979        };
4980        use crate::error::InternalError;
4981
4982        let db = Db::<MigrationExecutionCanister>::new(
4983            &MIGRATION_EXECUTION_REGISTRY,
4984            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4985        );
4986        drive_startup_recovery_to_completion(&db);
4987        let initial_target = schema_application_target(&db).expect("initial target should issue");
4988        let store_identity = initial_target
4989            .stores()
4990            .first()
4991            .expect("migration execution store should exist")
4992            .identity();
4993        let initial = validation_migration_proposal(
4994            ValidationMigrationShape::Clean,
4995            false,
4996            initial_target.accepted_head().clone(),
4997            initial_target.database_identity(),
4998            store_identity,
4999        );
5000        apply_schema(&db, &initial).expect("initial schema should publish");
5001        let session = DbSession::<MigrationExecutionCanister>::new(
5002            &MIGRATION_EXECUTION_REGISTRY,
5003            &crate::db::RequestExecutionRoot::__new_runtime_root(),
5004        );
5005        for (id, value) in [(1, 7), (2, 8), (3, 9)] {
5006            session
5007                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5008                    entity: "MigratingItem".to_string(),
5009                    patch: DynamicStructuralPatch::new(vec![
5010                        (
5011                            "id".to_string(),
5012                            DynamicWriteCell::Value(InputValue::Nat64(id)),
5013                        ),
5014                        (
5015                            "old_value".to_string(),
5016                            DynamicWriteCell::Value(InputValue::Int64(value)),
5017                        ),
5018                    ]),
5019                })
5020                .expect("predecessor row should insert");
5021        }
5022        let target = schema_application_target(&db).expect("migration target should issue");
5023        let proposal = validation_migration_proposal(
5024            ValidationMigrationShape::Clean,
5025            true,
5026            target.accepted_head().clone(),
5027            target.database_identity(),
5028            store_identity,
5029        );
5030        let plan = proposal
5031            .migration()
5032            .expect("migration plan should exist")
5033            .digest();
5034        let command = || SchemaMigrationCommand::Advance {
5035            expected_database: target.database_identity(),
5036            expected_head: target.accepted_head().clone(),
5037            expected_plan: plan,
5038            acknowledged_finding_page: None,
5039        };
5040        for expected in [
5041            SchemaMigrationPhase::Prepared,
5042            SchemaMigrationPhase::Validating,
5043            SchemaMigrationPhase::ReadyToRewrite,
5044            SchemaMigrationPhase::RewritingRows,
5045        ] {
5046            assert_eq!(
5047                migrate_schema(&db, &proposal, command())
5048                    .expect("migration phase should advance")
5049                    .phase(),
5050                expected,
5051            );
5052        }
5053
5054        for interruption in [
5055            MigrationRewriteInterruption::MarkerPersisted,
5056            MigrationRewriteInterruption::JournalPublished,
5057            MigrationRewriteInterruption::PhysicalApplied,
5058        ] {
5059            interrupt_next_migration_rewrite_at(interruption);
5060            migrate_schema(&db, &proposal, command())
5061                .expect_err("injected interruption should retain the rewrite marker");
5062
5063            forget_recovered_domain_for_tests(&db)
5064                .expect("upgrade should reset recovery ownership");
5065            drive_startup_recovery_to_completion(&db);
5066        }
5067
5068        let rebuilding = schema_migration_status_for_target(
5069            &db,
5070            &proposal,
5071            &schema_application_target(&db).expect("recovered target should issue"),
5072        )
5073        .expect("recovered status should remain readable");
5074        assert_eq!(rebuilding.phase(), SchemaMigrationPhase::RebuildingIndexes);
5075        assert_eq!(rebuilding.rows_rewritten(), 3);
5076        assert_eq!(
5077            migrate_schema(&db, &proposal, command())
5078                .expect("derived generations should complete")
5079                .phase(),
5080            SchemaMigrationPhase::FinalValidation,
5081        );
5082        assert_eq!(
5083            migrate_schema(&db, &proposal, command())
5084                .expect("final validation should complete")
5085                .phase(),
5086            SchemaMigrationPhase::Publishing,
5087        );
5088        let applied = migrate_schema(&db, &proposal, command())
5089            .expect("candidate publication should complete atomically");
5090        assert_eq!(applied.phase(), SchemaMigrationPhase::Applied);
5091        assert_eq!(applied.rows_rewritten(), 3);
5092        assert_eq!(applied.indexes_rebuilt(), 1);
5093        assert_ne!(applied.accepted_head(), target.accepted_head());
5094        let terminal_target = schema_application_target(&db).expect("terminal target should issue");
5095        let terminal_proposal = validation_migration_proposal(
5096            ValidationMigrationShape::Clean,
5097            true,
5098            terminal_target.accepted_head().clone(),
5099            terminal_target.database_identity(),
5100            store_identity,
5101        );
5102        assert!(
5103            !defer_generated_schema_application_for_prepared_migration(&db, &terminal_proposal,)
5104                .expect("terminal record must not block generated startup"),
5105        );
5106
5107        let store = db
5108            .store_handle(MIGRATION_EXECUTION_STORE_PATH)
5109            .expect("migration execution store should resolve");
5110        let runtime = db
5111            .accepted_runtime_entity_for_path("MigratingItem")
5112            .expect("published candidate entity should resolve");
5113        let selection = store
5114            .with_schema(|schema| {
5115                schema.current_accepted_catalog_selection(
5116                    runtime.entity_tag(),
5117                    runtime.entity_path(),
5118                    runtime.store_path(),
5119                )
5120            })
5121            .expect("candidate selection should remain readable")
5122            .expect("candidate selection should exist");
5123        let contract = crate::db::data::AcceptedStructuralRowAuthority::from_catalog_selection(
5124            runtime.entity_path(),
5125            &selection,
5126        )
5127        .expect("candidate row authority should compile")
5128        .into_row_contract();
5129        let mut values = Vec::new();
5130        store
5131            .with_data(|data| {
5132                data.visit_entries(|key, row| {
5133                    let decoded = DecodedDataStoreKey::try_from_raw(key)
5134                        .expect("rewritten key should decode");
5135                    let reader =
5136                        StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
5137                            row, &contract,
5138                        )
5139                        .expect("rewritten row should use the candidate layout");
5140                    reader
5141                        .validate_primary_key(&decoded)
5142                        .expect("rewritten row and key should remain bound");
5143                    values.push(
5144                        reader
5145                            .required_value_by_contract(1)
5146                            .expect("candidate value slot should decode"),
5147                    );
5148                    Ok::<StoreVisit, InternalError>(StoreVisit::Continue)
5149                })
5150            })
5151            .expect("rewritten row scan should complete");
5152        assert_eq!(
5153            values,
5154            vec![
5155                crate::value::Value::Nat64(7),
5156                crate::value::Value::Nat64(8),
5157                crate::value::Value::Nat64(9),
5158            ],
5159        );
5160        assert_eq!(store.with_index(IndexStore::len), 3);
5161        let accepted = store
5162            .with_schema(SchemaStore::current_accepted_schema_bundle)
5163            .expect("published candidate bundle should remain readable")
5164            .expect("published candidate bundle should exist");
5165        let entity_source = EntitySourceKey::try_new("MigratingItem")
5166            .expect("migration entity source should admit");
5167        let entity_tag = accepted
5168            .source_bindings_for_tests()
5169            .entity(&entity_source)
5170            .expect("candidate entity source should remain bound");
5171        let old_value =
5172            FieldSourceKey::try_new("old_value").expect("predecessor source should admit");
5173        let current_value =
5174            FieldSourceKey::try_new("value").expect("candidate source should admit");
5175        assert_eq!(
5176            accepted
5177                .source_bindings_for_tests()
5178                .field(entity_tag, &old_value),
5179            None,
5180        );
5181        assert!(
5182            accepted
5183                .source_bindings_for_tests()
5184                .field(entity_tag, &current_value)
5185                .is_some(),
5186        );
5187        ensure_schema_migration_ready_for_ordinary_operations()
5188            .expect("terminal publication must clear the database-wide gate");
5189    }
5190
5191    #[cfg(feature = "migration")]
5192    #[test]
5193    #[expect(
5194        clippy::too_many_lines,
5195        reason = "all four finding families share one ordered historical scan fixture"
5196    )]
5197    fn physical_migration_validation_reports_every_typed_finding_family_without_writes() {
5198        use std::convert::Infallible;
5199
5200        use super::migrate_schema;
5201        use crate::db::{
5202            data::StoreVisit,
5203            schema::{SchemaMigrationCommand, SchemaMigrationFindingKind, SchemaMigrationPhase},
5204        };
5205
5206        let db = Db::<MigrationFindingCanister>::new(
5207            &MIGRATION_FINDING_REGISTRY,
5208            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5209        );
5210        drive_startup_recovery_to_completion(&db);
5211        let initial_target = schema_application_target(&db).expect("initial target should issue");
5212        let store_identity = initial_target
5213            .stores()
5214            .first()
5215            .expect("migration finding store should exist")
5216            .identity();
5217        let initial = validation_migration_proposal(
5218            ValidationMigrationShape::AllFindingFamilies,
5219            false,
5220            initial_target.accepted_head().clone(),
5221            initial_target.database_identity(),
5222            store_identity,
5223        );
5224        apply_schema(&db, &initial).expect("initial finding schema should publish");
5225
5226        let session = DbSession::<MigrationFindingCanister>::new(
5227            &MIGRATION_FINDING_REGISTRY,
5228            &crate::db::RequestExecutionRoot::__new_runtime_root(),
5229        );
5230        session
5231            .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5232                entity: "MigrationTarget".to_string(),
5233                patch: DynamicStructuralPatch::new(vec![(
5234                    "id".to_string(),
5235                    DynamicWriteCell::Value(InputValue::Nat64(7)),
5236                )]),
5237            })
5238            .expect("relation target should insert");
5239        for (id, value) in [(1, 9), (2, 8), (3, 7), (4, 7), (5, 300)] {
5240            session
5241                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5242                    entity: "MigratingItem".to_string(),
5243                    patch: DynamicStructuralPatch::new(vec![
5244                        (
5245                            "id".to_string(),
5246                            DynamicWriteCell::Value(InputValue::Nat64(id)),
5247                        ),
5248                        (
5249                            "old_value".to_string(),
5250                            DynamicWriteCell::Value(InputValue::Int64(value)),
5251                        ),
5252                    ]),
5253                })
5254                .expect("predecessor finding row should insert");
5255        }
5256        let store = db
5257            .store_handle(MIGRATION_FINDING_STORE_PATH)
5258            .expect("migration finding store should resolve");
5259        let row_bytes = || {
5260            store.with_data(|data| {
5261                let mut rows = Vec::new();
5262                let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5263                    rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5264                    Ok(StoreVisit::Continue)
5265                });
5266                result.expect("infallible row visit should complete");
5267                rows
5268            })
5269        };
5270        let before_rows = row_bytes();
5271
5272        let target = schema_application_target(&db).expect("migration target should issue");
5273        let proposal = validation_migration_proposal(
5274            ValidationMigrationShape::AllFindingFamilies,
5275            true,
5276            target.accepted_head().clone(),
5277            target.database_identity(),
5278            store_identity,
5279        );
5280        let plan = proposal
5281            .migration()
5282            .expect("migration plan should exist")
5283            .digest();
5284        let command = || SchemaMigrationCommand::Advance {
5285            expected_database: target.database_identity(),
5286            expected_head: target.accepted_head().clone(),
5287            expected_plan: plan,
5288            acknowledged_finding_page: None,
5289        };
5290        assert_eq!(
5291            migrate_schema(&db, &proposal, command())
5292                .expect("finding migration should prepare")
5293                .phase(),
5294            SchemaMigrationPhase::Prepared,
5295        );
5296        assert_eq!(
5297            migrate_schema(&db, &proposal, command())
5298                .expect("finding migration should enter validation")
5299                .phase(),
5300            SchemaMigrationPhase::Validating,
5301        );
5302        let rejected =
5303            migrate_schema(&db, &proposal, command()).expect("validation should report findings");
5304        assert_eq!(rejected.phase(), SchemaMigrationPhase::Rejected);
5305        assert_eq!(rejected.rows_validated(), 5);
5306        assert_eq!(
5307            rejected
5308                .findings()
5309                .iter()
5310                .map(crate::db::schema::SchemaMigrationFinding::kind)
5311                .collect::<Vec<_>>(),
5312            vec![
5313                SchemaMigrationFindingKind::Constraint,
5314                SchemaMigrationFindingKind::Relation,
5315                SchemaMigrationFindingKind::UniqueIndex,
5316                SchemaMigrationFindingKind::Transform,
5317            ],
5318        );
5319        assert_eq!(
5320            row_bytes(),
5321            before_rows,
5322            "rejected validation must not rewrite accepted rows"
5323        );
5324        assert_eq!(
5325            store.with_index(IndexStore::len),
5326            0,
5327            "a rejected page must not publish any staged generation"
5328        );
5329    }
5330
5331    #[cfg(feature = "migration")]
5332    #[test]
5333    fn exact_migration_retry_binds_the_terminal_head_not_the_predecessor_head() {
5334        use super::exact_migration_replay_target;
5335
5336        let db = Db::<EvolutionCanister>::new(
5337            &EVOLUTION_REGISTRY,
5338            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5339        );
5340        drive_startup_recovery_to_completion(&db);
5341        let initial_target = schema_application_target(&db).expect("initial target should issue");
5342        let (proposal, _, _) = generated_check_proposal(
5343            initial_target.accepted_head().clone(),
5344            "migration-retry-initial",
5345            false,
5346            initial_target.database_identity(),
5347            initial_target
5348                .stores()
5349                .first()
5350                .expect("test store should exist")
5351                .identity(),
5352        );
5353        apply_schema(&db, &proposal).expect("initial schema should publish");
5354        let current_target = schema_application_target(&db).expect("current target should issue");
5355        assert_ne!(
5356            current_target.accepted_head(),
5357            initial_target.accepted_head(),
5358        );
5359
5360        let receipt = SchemaChangeReceipt::new(
5361            current_target.database_identity(),
5362            SchemaSubmissionKey::try_new("migration/retry")
5363                .expect("migration submission should admit"),
5364            SchemaProposalDigest::from_bytes([0x77; 32]),
5365            initial_target.accepted_head().clone(),
5366            SchemaChangeOutcome::Applied {
5367                accepted_head: current_target.accepted_head().clone(),
5368            },
5369        )
5370        .expect("terminal migration receipt should admit");
5371        let record = SchemaApplicationRecord::new(receipt, Vec::new())
5372            .expect("terminal migration record should admit");
5373
5374        assert_eq!(
5375            exact_migration_replay_target(&db, current_target.database_identity(), &record,)
5376                .expect("exact retry should resolve the terminal target"),
5377            current_target,
5378        );
5379    }
5380}