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.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                    .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::{
3105                RecoveryProgress, continue_recovery, database_incarnation_id,
3106                forget_recovered_domain_for_tests,
3107            },
3108            data::DataStore,
3109            drive_generated_startup_recovery_page,
3110            index::IndexStore,
3111            journal::JournalTailStore,
3112            observe_generated_startup_state,
3113            registry::{
3114                StoreAllocationIdentities, StoreAllocationIdentity, StoreHandle, StoreRegistry,
3115                StoreRuntimeStorageCapabilities,
3116            },
3117            schema::{
3118                AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
3119                CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
3120                ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
3121                SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
3122                SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
3123                cardinality_build::{
3124                    CardinalityBuildAuthority, CardinalityGenerationPageOutcome,
3125                    drive_cardinality_generation_page,
3126                },
3127            },
3128        },
3129        error::{ErrorClass, ErrorOrigin},
3130        testing::test_memory,
3131        traits::{CanisterKind, Path},
3132    };
3133    use ic_stable_structures::{DefaultMemoryImpl, memory_manager::VirtualMemory};
3134    use icydb_schema::{
3135        ConstraintFragment, ConstraintSourceKey, DeclaredEntityVersion, EntityFragment,
3136        EntitySourceKey, EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint,
3137        FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment,
3138        RuleSourceKey, ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName,
3139        SchemaProposal, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
3140        SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
3141        TypeSourceKey,
3142    };
3143    use std::cell::RefCell;
3144
3145    fn drive_startup_recovery_to_completion<C: CanisterKind>(db: &Db<C>) {
3146        for _ in 0..1_024 {
3147            match continue_recovery(db).expect("test startup recovery page should succeed") {
3148                RecoveryProgress::Complete => return,
3149                RecoveryProgress::Pending => {}
3150            }
3151        }
3152        panic!("test startup recovery should complete within 1,024 bounded pages");
3153    }
3154
3155    fn drive_cardinality_to_ready(store: StoreHandle) {
3156        let journal = store
3157            .journal_tail_store()
3158            .expect("cardinality fixture store should be journaled");
3159        for _ in 0..8 {
3160            let outcome = store
3161                .with_data(|data| {
3162                    store.with_index(|index| {
3163                        store.with_schema_mut(|schema| {
3164                            drive_cardinality_generation_page(data, index, schema, |schema| {
3165                                CardinalityBuildAuthority::derive(
3166                                    schema,
3167                                    database_incarnation_id()?,
3168                                    store.allocation_identities(),
3169                                    journal.with_borrow(JournalTailStore::fold_watermark)?,
3170                                )
3171                            })
3172                        })
3173                    })
3174                })
3175                .expect("bounded cardinality generation should advance");
3176            if outcome == CardinalityGenerationPageOutcome::Quiescent {
3177                return;
3178            }
3179        }
3180        panic!("cardinality generation should become Ready within eight bounded pages");
3181    }
3182
3183    #[cfg(feature = "migration")]
3184    use crate::db::schema::SchemaChangeReceipt;
3185    use crate::{
3186        db::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell},
3187        value::InputValue,
3188    };
3189    #[cfg(feature = "migration")]
3190    use icydb_schema::{
3191        EntityMigration, IndexFragment, IndexKeyFragment, RelationDeleteAction, RelationFragment,
3192        SchemaMigrationPlan, SchemaMigrationTransform, SchemaProposalDigest,
3193    };
3194
3195    fn version_one() -> DeclaredEntityVersion {
3196        DeclaredEntityVersion::try_new(1).expect("fixture version should admit")
3197    }
3198
3199    const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
3200    const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
3201    #[cfg(feature = "migration")]
3202    const MIGRATION_STORE_PATH: &str = "schema_application_tests::MigrationStore";
3203    #[cfg(feature = "migration")]
3204    const MIGRATION_EXECUTION_STORE_PATH: &str =
3205        "schema_application_tests::MigrationExecutionStore";
3206    #[cfg(feature = "migration")]
3207    const MIGRATION_FINDING_STORE_PATH: &str = "schema_application_tests::MigrationFindingStore";
3208
3209    #[test]
3210    fn database_identity_state_capacity_combines_store_inventories_exactly() {
3211        let below = include_identity_state_count(0, 65_535)
3212            .expect("the first store inventory should remain below the database cap");
3213        let exact = include_identity_state_count(below, 1)
3214            .expect("the combined database boundary should admit");
3215        assert_eq!(exact, 65_536);
3216
3217        let error = include_identity_state_count(exact, 1)
3218            .expect_err("the next owner in another store must reject");
3219        assert_eq!(error.class(), ErrorClass::Unsupported);
3220        assert_eq!(error.origin(), ErrorOrigin::Identity);
3221    }
3222
3223    #[test]
3224    fn generated_database_identity_cache_is_bound_to_the_incarnation() {
3225        let first_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x41);
3226        let second_incarnation = crate::db::DatabaseIncarnationId::for_tests(0x42);
3227        let first = generated_database_identity(&ABORT_REGISTRY, first_incarnation);
3228        assert_eq!(
3229            generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3230            first,
3231        );
3232        let second = generated_database_identity(&ABORT_REGISTRY, second_incarnation);
3233        assert_ne!(second, first);
3234        assert_eq!(
3235            generated_database_identity(&ABORT_REGISTRY, first_incarnation),
3236            first,
3237        );
3238    }
3239
3240    #[test]
3241    fn exact_empty_entity_proof_distinguishes_corruption_from_non_empty_input() {
3242        let corrupt = require_exact_empty_entity_count(None)
3243            .expect_err("uninspectable cardinality must fail closed");
3244        assert_eq!(corrupt.class(), ErrorClass::Corruption);
3245
3246        let non_empty = require_exact_empty_entity_count(Some(1))
3247            .expect_err("non-empty cardinality must reject removal");
3248        assert_eq!(non_empty.class(), ErrorClass::Unsupported);
3249        assert!(require_exact_empty_entity_count(Some(0)).is_ok());
3250    }
3251
3252    thread_local! {
3253        static ABORT_DATA_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(180);
3254        static ABORT_INDEX_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(181);
3255        static ABORT_SCHEMA_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(182);
3256        static ABORT_JOURNAL_MEMORY: VirtualMemory<DefaultMemoryImpl> = test_memory(183);
3257        static ABORT_DATA: RefCell<DataStore> =
3258            ABORT_DATA_MEMORY.with(|memory| {
3259                RefCell::new(DataStore::init_journaled(memory.clone()))
3260            });
3261        static ABORT_INDEX: RefCell<IndexStore> =
3262            ABORT_INDEX_MEMORY.with(|memory| {
3263                RefCell::new(IndexStore::init_journaled(memory.clone()))
3264            });
3265        static ABORT_SCHEMA: RefCell<SchemaStore> =
3266            ABORT_SCHEMA_MEMORY.with(|memory| {
3267                RefCell::new(SchemaStore::init_journaled(memory.clone()))
3268            });
3269        static ABORT_JOURNAL: RefCell<JournalTailStore> =
3270            ABORT_JOURNAL_MEMORY.with(|memory| {
3271                RefCell::new(JournalTailStore::init(memory.clone()))
3272            });
3273        static ABORT_REGISTRY: StoreRegistry = {
3274            let mut registry = StoreRegistry::new();
3275            registry.register_journaled_store(
3276                ABORT_STORE_PATH,
3277                &ABORT_DATA,
3278                &ABORT_INDEX,
3279                &ABORT_SCHEMA,
3280                &ABORT_JOURNAL,
3281                StoreAllocationIdentities::new_journaled(
3282                    StoreAllocationIdentity::new(180, "icydb.test.application_abort.data.v1"),
3283                    StoreAllocationIdentity::new(181, "icydb.test.application_abort.index.v1"),
3284                    StoreAllocationIdentity::new(182, "icydb.test.application_abort.schema.v1"),
3285                    StoreAllocationIdentity::new(183, "icydb.test.application_abort.journal.v1"),
3286                ),
3287                StoreRuntimeStorageCapabilities::journaled(),
3288            ).expect("abort journaled store should register");
3289            registry
3290        };
3291    }
3292
3293    #[cfg(feature = "migration")]
3294    thread_local! {
3295        static MIGRATION_EXECUTION_DATA: RefCell<DataStore> =
3296            RefCell::new(DataStore::init_journaled(test_memory(210)));
3297        static MIGRATION_EXECUTION_INDEX: RefCell<IndexStore> =
3298            RefCell::new(IndexStore::init_journaled(test_memory(211)));
3299        static MIGRATION_EXECUTION_SCHEMA: RefCell<SchemaStore> =
3300            RefCell::new(SchemaStore::init_journaled(test_memory(212)));
3301        static MIGRATION_EXECUTION_JOURNAL: RefCell<JournalTailStore> =
3302            RefCell::new(JournalTailStore::init(test_memory(213)));
3303        static MIGRATION_EXECUTION_REGISTRY: StoreRegistry = {
3304            let mut registry = StoreRegistry::new();
3305            registry.register_journaled_store(
3306                MIGRATION_EXECUTION_STORE_PATH,
3307                &MIGRATION_EXECUTION_DATA,
3308                &MIGRATION_EXECUTION_INDEX,
3309                &MIGRATION_EXECUTION_SCHEMA,
3310                &MIGRATION_EXECUTION_JOURNAL,
3311                StoreAllocationIdentities::new_journaled(
3312                    StoreAllocationIdentity::new(210, "icydb.test.migration_execution.data.v1"),
3313                    StoreAllocationIdentity::new(211, "icydb.test.migration_execution.index.v1"),
3314                    StoreAllocationIdentity::new(212, "icydb.test.migration_execution.schema.v1"),
3315                    StoreAllocationIdentity::new(213, "icydb.test.migration_execution.journal.v1"),
3316                ),
3317                StoreRuntimeStorageCapabilities::journaled(),
3318            ).expect("migration execution store should register");
3319            registry
3320        };
3321    }
3322
3323    #[cfg(feature = "migration")]
3324    thread_local! {
3325        static MIGRATION_DATA: RefCell<DataStore> =
3326            RefCell::new(DataStore::init_journaled(test_memory(200)));
3327        static MIGRATION_INDEX: RefCell<IndexStore> =
3328            RefCell::new(IndexStore::init_journaled(test_memory(201)));
3329        static MIGRATION_SCHEMA: RefCell<SchemaStore> =
3330            RefCell::new(SchemaStore::init_journaled(test_memory(202)));
3331        static MIGRATION_JOURNAL: RefCell<JournalTailStore> =
3332            RefCell::new(JournalTailStore::init(test_memory(203)));
3333        static MIGRATION_REGISTRY: StoreRegistry = {
3334            let mut registry = StoreRegistry::new();
3335            registry.register_journaled_store(
3336                MIGRATION_STORE_PATH,
3337                &MIGRATION_DATA,
3338                &MIGRATION_INDEX,
3339                &MIGRATION_SCHEMA,
3340                &MIGRATION_JOURNAL,
3341                StoreAllocationIdentities::new_journaled(
3342                    StoreAllocationIdentity::new(200, "icydb.test.migration_validation.data.v1"),
3343                    StoreAllocationIdentity::new(201, "icydb.test.migration_validation.index.v1"),
3344                    StoreAllocationIdentity::new(202, "icydb.test.migration_validation.schema.v1"),
3345                    StoreAllocationIdentity::new(203, "icydb.test.migration_validation.journal.v1"),
3346                ),
3347                StoreRuntimeStorageCapabilities::journaled(),
3348            ).expect("migration validation store should register");
3349            registry
3350        };
3351    }
3352
3353    #[cfg(feature = "migration")]
3354    thread_local! {
3355        static MIGRATION_FINDING_DATA: RefCell<DataStore> =
3356            RefCell::new(DataStore::init_journaled(test_memory(206)));
3357        static MIGRATION_FINDING_INDEX: RefCell<IndexStore> =
3358            RefCell::new(IndexStore::init_journaled(test_memory(207)));
3359        static MIGRATION_FINDING_SCHEMA: RefCell<SchemaStore> =
3360            RefCell::new(SchemaStore::init_journaled(test_memory(208)));
3361        static MIGRATION_FINDING_JOURNAL: RefCell<JournalTailStore> =
3362            RefCell::new(JournalTailStore::init(test_memory(209)));
3363        static MIGRATION_FINDING_REGISTRY: StoreRegistry = {
3364            let mut registry = StoreRegistry::new();
3365            registry.register_journaled_store(
3366                MIGRATION_FINDING_STORE_PATH,
3367                &MIGRATION_FINDING_DATA,
3368                &MIGRATION_FINDING_INDEX,
3369                &MIGRATION_FINDING_SCHEMA,
3370                &MIGRATION_FINDING_JOURNAL,
3371                StoreAllocationIdentities::new_journaled(
3372                    StoreAllocationIdentity::new(206, "icydb.test.migration_finding.data.v1"),
3373                    StoreAllocationIdentity::new(207, "icydb.test.migration_finding.index.v1"),
3374                    StoreAllocationIdentity::new(208, "icydb.test.migration_finding.schema.v1"),
3375                    StoreAllocationIdentity::new(209, "icydb.test.migration_finding.journal.v1"),
3376                ),
3377                StoreRuntimeStorageCapabilities::journaled(),
3378            ).expect("migration finding store should register");
3379            registry
3380        };
3381    }
3382
3383    thread_local! {
3384        static EVOLUTION_DATA: RefCell<DataStore> =
3385            RefCell::new(DataStore::init_journaled(test_memory(192)));
3386        static EVOLUTION_INDEX: RefCell<IndexStore> =
3387            RefCell::new(IndexStore::init_journaled(test_memory(193)));
3388        static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
3389            RefCell::new(SchemaStore::init_journaled(test_memory(194)));
3390        static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
3391            RefCell::new(JournalTailStore::init(test_memory(195)));
3392        static EVOLUTION_REGISTRY: StoreRegistry = {
3393            let mut registry = StoreRegistry::new();
3394            registry.register_journaled_store(
3395                EVOLUTION_STORE_PATH,
3396                &EVOLUTION_DATA,
3397                &EVOLUTION_INDEX,
3398                &EVOLUTION_SCHEMA,
3399                &EVOLUTION_JOURNAL,
3400                StoreAllocationIdentities::new_journaled(
3401                    StoreAllocationIdentity::new(192, "icydb.test.rule_evolution.data.v1"),
3402                    StoreAllocationIdentity::new(193, "icydb.test.rule_evolution.index.v1"),
3403                    StoreAllocationIdentity::new(194, "icydb.test.rule_evolution.schema.v1"),
3404                    StoreAllocationIdentity::new(195, "icydb.test.rule_evolution.journal.v1"),
3405                ),
3406                StoreRuntimeStorageCapabilities::journaled(),
3407            ).expect("rule-evolution journaled store should register");
3408            registry
3409        };
3410    }
3411
3412    struct AbortCanister;
3413
3414    impl Path for AbortCanister {
3415        const PATH: &'static str = "schema_application_tests::AbortCanister";
3416    }
3417
3418    impl CanisterKind for AbortCanister {
3419        const COMMIT_MEMORY_ID: u8 = 184;
3420        const COMMIT_STABLE_KEY: &'static str = "icydb.test.application_abort.commit.v1";
3421        const STARTUP_MEMORY_ID: u8 = 186;
3422        const STARTUP_STABLE_KEY: &'static str = "icydb.test.application_abort.startup.control.v1";
3423        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 185;
3424        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3425            "icydb.test.application_abort.integrity.v1";
3426    }
3427
3428    struct EvolutionCanister;
3429
3430    impl Path for EvolutionCanister {
3431        const PATH: &'static str = "schema_application_tests::EvolutionCanister";
3432    }
3433
3434    impl CanisterKind for EvolutionCanister {
3435        const COMMIT_MEMORY_ID: u8 = 196;
3436        const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule_evolution.commit.v1";
3437        const STARTUP_MEMORY_ID: u8 = 198;
3438        const STARTUP_STABLE_KEY: &'static str = "icydb.test.rule_evolution.startup.control.v1";
3439        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 197;
3440        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3441            "icydb.test.rule_evolution.integrity.v1";
3442    }
3443
3444    #[cfg(feature = "migration")]
3445    struct MigrationCanister;
3446
3447    #[cfg(feature = "migration")]
3448    impl Path for MigrationCanister {
3449        const PATH: &'static str = "schema_application_tests::MigrationCanister";
3450    }
3451
3452    #[cfg(feature = "migration")]
3453    impl CanisterKind for MigrationCanister {
3454        const COMMIT_MEMORY_ID: u8 = 204;
3455        const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_validation.commit.v1";
3456        const STARTUP_MEMORY_ID: u8 = 206;
3457        const STARTUP_STABLE_KEY: &'static str =
3458            "icydb.test.migration_validation.startup.control.v1";
3459        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 205;
3460        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3461            "icydb.test.migration_validation.integrity.v1";
3462    }
3463
3464    #[cfg(feature = "migration")]
3465    struct MigrationExecutionCanister;
3466
3467    #[cfg(feature = "migration")]
3468    impl Path for MigrationExecutionCanister {
3469        const PATH: &'static str = "schema_application_tests::MigrationExecutionCanister";
3470    }
3471
3472    #[cfg(feature = "migration")]
3473    impl CanisterKind for MigrationExecutionCanister {
3474        const COMMIT_MEMORY_ID: u8 = 214;
3475        const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_execution.commit.v1";
3476        const STARTUP_MEMORY_ID: u8 = 216;
3477        const STARTUP_STABLE_KEY: &'static str =
3478            "icydb.test.migration_execution.startup.control.v1";
3479        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 215;
3480        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3481            "icydb.test.migration_execution.integrity.v1";
3482    }
3483
3484    #[cfg(feature = "migration")]
3485    struct MigrationFindingCanister;
3486
3487    #[cfg(feature = "migration")]
3488    impl Path for MigrationFindingCanister {
3489        const PATH: &'static str = "schema_application_tests::MigrationFindingCanister";
3490    }
3491
3492    #[cfg(feature = "migration")]
3493    impl CanisterKind for MigrationFindingCanister {
3494        const COMMIT_MEMORY_ID: u8 = 210;
3495        const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration_finding.commit.v1";
3496        const STARTUP_MEMORY_ID: u8 = 212;
3497        const STARTUP_STABLE_KEY: &'static str = "icydb.test.migration_finding.startup.control.v1";
3498        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 211;
3499        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3500            "icydb.test.migration_finding.integrity.v1";
3501    }
3502
3503    fn name(value: &str) -> SchemaName {
3504        SchemaName::try_new(value).expect("test schema name should admit")
3505    }
3506
3507    fn generated_check_proposal(
3508        expected_head: ExpectedAcceptedHead,
3509        submission_key: &str,
3510        include_check: bool,
3511        database: TargetDatabaseIdentity,
3512        store: TargetStoreIdentity,
3513    ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3514        let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
3515        let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3516        let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
3517        let check_source =
3518            ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
3519        let check = SourceCheckExpr::try_new(vec![
3520            SourceCheckInstruction::Field(score_source),
3521            SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
3522            SourceCheckInstruction::GreaterThanOrEqual,
3523        ])
3524        .expect("check expression should admit");
3525        let constraints = include_check
3526            .then(|| ConstraintFragment::check(name("score_non_negative"), check))
3527            .into_iter()
3528            .collect();
3529        let entity = EntityFragment::try_new(
3530            name("Item"),
3531            version_one(),
3532            vec![
3533                FieldFragment::new(
3534                    name("id"),
3535                    FieldType::Scalar(ScalarType::Nat64),
3536                    false,
3537                    FieldInsertPolicy::Required,
3538                    None,
3539                ),
3540                FieldFragment::new(
3541                    name("score"),
3542                    FieldType::Scalar(ScalarType::Int64),
3543                    false,
3544                    FieldInsertPolicy::Required,
3545                    None,
3546                ),
3547            ],
3548            vec![id_source],
3549            Vec::new(),
3550            Vec::new(),
3551            constraints,
3552        )
3553        .expect("entity should admit");
3554        let proposal = SchemaProposal::try_compose(
3555            vec![SchemaCapability::ACCEPTED_CHECKS],
3556            database,
3557            SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3558            expected_head,
3559            vec![
3560                SchemaFragment::try_new(vec![entity], Vec::new())
3561                    .expect("schema fragment should admit"),
3562            ],
3563            vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3564            Vec::new(),
3565            None,
3566        )
3567        .expect("schema proposal should compose");
3568        (proposal, entity_source, check_source)
3569    }
3570
3571    #[cfg(feature = "migration")]
3572    #[derive(Clone, Copy, Eq, PartialEq)]
3573    enum ValidationMigrationShape {
3574        Clean,
3575        AllFindingFamilies,
3576    }
3577
3578    #[cfg(feature = "migration")]
3579    #[expect(
3580        clippy::too_many_lines,
3581        reason = "the fixture keeps both predecessor and candidate source contracts adjacent"
3582    )]
3583    fn validation_migration_proposal(
3584        shape: ValidationMigrationShape,
3585        current: bool,
3586        expected_head: ExpectedAcceptedHead,
3587        database: TargetDatabaseIdentity,
3588        store: TargetStoreIdentity,
3589    ) -> SchemaProposal {
3590        let entity_source = EntitySourceKey::try_new("MigratingItem")
3591            .expect("migration entity source should admit");
3592        let old_value =
3593            FieldSourceKey::try_new("old_value").expect("predecessor field source should admit");
3594        let current_value =
3595            FieldSourceKey::try_new("value").expect("candidate field source should admit");
3596        let target_entity = EntitySourceKey::try_new("MigrationTarget")
3597            .expect("migration target source should admit");
3598        let target_id = FieldSourceKey::try_new("id").expect("target id source should admit");
3599        let constraint = SourceCheckExpr::try_new(vec![
3600            SourceCheckInstruction::Field(current_value.clone()),
3601            SourceCheckInstruction::Literal(ScalarLiteral::Nat(8)),
3602            SourceCheckInstruction::LessThanOrEqual,
3603        ])
3604        .expect("candidate check should admit");
3605        let findings = shape == ValidationMigrationShape::AllFindingFamilies;
3606        let entity = EntityFragment::try_new(
3607            name("MigratingItem"),
3608            DeclaredEntityVersion::try_new(if current { 2 } else { 1 })
3609                .expect("migration version should admit"),
3610            vec![
3611                FieldFragment::new(
3612                    name("id"),
3613                    FieldType::Scalar(ScalarType::Nat64),
3614                    false,
3615                    FieldInsertPolicy::Required,
3616                    None,
3617                ),
3618                FieldFragment::new(
3619                    name(if current { "value" } else { "old_value" }),
3620                    FieldType::Scalar(if current {
3621                        ScalarType::Nat8
3622                    } else {
3623                        ScalarType::Int64
3624                    }),
3625                    false,
3626                    FieldInsertPolicy::Required,
3627                    None,
3628                ),
3629            ],
3630            vec![FieldSourceKey::try_new("id").expect("id source should admit")],
3631            current
3632                .then(|| {
3633                    IndexFragment::try_new(
3634                        name("value_unique"),
3635                        vec![IndexKeyFragment::Field(current_value.clone())],
3636                        true,
3637                        None,
3638                    )
3639                    .expect("candidate index should admit")
3640                })
3641                .into_iter()
3642                .collect(),
3643            (current && findings)
3644                .then(|| {
3645                    RelationFragment::try_new(
3646                        name("value_target"),
3647                        vec![current_value.clone()],
3648                        target_entity.clone(),
3649                        vec![target_id.clone()],
3650                        RelationDeleteAction::Restrict,
3651                    )
3652                    .expect("candidate relation should admit")
3653                })
3654                .into_iter()
3655                .collect(),
3656            (current && findings)
3657                .then(|| ConstraintFragment::check(name("value_at_most_eight"), constraint))
3658                .into_iter()
3659                .collect(),
3660        )
3661        .expect("migration entity should admit");
3662        let target = EntityFragment::try_new(
3663            name("MigrationTarget"),
3664            version_one(),
3665            vec![FieldFragment::new(
3666                name("id"),
3667                FieldType::Scalar(ScalarType::Nat8),
3668                false,
3669                FieldInsertPolicy::Required,
3670                None,
3671            )],
3672            vec![target_id],
3673            Vec::new(),
3674            Vec::new(),
3675            Vec::new(),
3676        )
3677        .expect("migration relation target should admit");
3678        let migration = current.then(|| {
3679            SchemaMigrationPlan::try_new(vec![
3680                EntityMigration::try_new(
3681                    entity_source.clone(),
3682                    DeclaredEntityVersion::try_new(1).expect("predecessor should admit"),
3683                    None,
3684                    Vec::new(),
3685                    vec![SchemaMigrationTransform::CheckedCast {
3686                        from: old_value.clone(),
3687                        to: current_value,
3688                        target: ScalarType::Nat8,
3689                    }],
3690                )
3691                .expect("migration transition should admit"),
3692            ])
3693            .expect("migration plan should admit")
3694        });
3695        let mut capabilities = Vec::new();
3696        if current && findings {
3697            capabilities.extend([
3698                SchemaCapability::ACCEPTED_CHECKS,
3699                SchemaCapability::SECONDARY_INDEXES,
3700                SchemaCapability::RESTRICTIVE_RELATIONS,
3701            ]);
3702        }
3703        if migration.is_some() {
3704            capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
3705        }
3706        let mut entities = vec![entity];
3707        let mut assignments = vec![EntityStoreAssignment::new(entity_source.clone(), store)];
3708        if findings {
3709            entities.push(target);
3710            assignments.push(EntityStoreAssignment::new(target_entity, store));
3711        }
3712        SchemaProposal::try_compose(
3713            capabilities,
3714            database,
3715            SchemaSubmissionKey::try_new(if current {
3716                "migration-validation-v2"
3717            } else {
3718                "migration-validation-v1"
3719            })
3720            .expect("submission should admit"),
3721            expected_head,
3722            vec![
3723                SchemaFragment::try_new(entities, Vec::new())
3724                    .expect("migration fragment should admit"),
3725            ],
3726            assignments,
3727            current
3728                .then_some(icydb_schema::SchemaRemoval::Field {
3729                    entity: entity_source,
3730                    field: old_value,
3731                })
3732                .into_iter()
3733                .collect(),
3734            migration,
3735        )
3736        .expect("migration proposal should compose")
3737    }
3738
3739    fn targeted_rule_proposal(
3740        expected_head: ExpectedAcceptedHead,
3741        submission_key: &str,
3742        operation: SourceRuleOperation,
3743        database: TargetDatabaseIdentity,
3744        store: TargetStoreIdentity,
3745    ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3746        let entity_source =
3747            EntitySourceKey::try_new("Measured").expect("entity source should admit");
3748        let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3749        let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
3750        let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
3751        let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
3752        let constraint_source =
3753            ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
3754        let entity = EntityFragment::try_new(
3755            name("Measured"),
3756            version_one(),
3757            vec![
3758                FieldFragment::new(
3759                    name("id"),
3760                    FieldType::Scalar(ScalarType::Nat64),
3761                    false,
3762                    FieldInsertPolicy::Required,
3763                    None,
3764                ),
3765                FieldFragment::new(
3766                    name("value"),
3767                    FieldType::Named(value_type.clone()),
3768                    false,
3769                    FieldInsertPolicy::Required,
3770                    None,
3771                ),
3772            ],
3773            vec![id_source],
3774            Vec::new(),
3775            Vec::new(),
3776            vec![ConstraintFragment::targeted_rule(
3777                TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
3778            )],
3779        )
3780        .expect("targeted entity should admit");
3781        let proposal = SchemaProposal::try_compose(
3782            vec![SchemaCapability::ACCEPTED_CHECKS],
3783            database,
3784            SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3785            expected_head,
3786            vec![
3787                SchemaFragment::try_new(
3788                    vec![entity],
3789                    vec![NamedTypeFragment::newtype(
3790                        name("Measure"),
3791                        FieldType::Scalar(ScalarType::Nat8),
3792                    )],
3793                )
3794                .expect("schema fragment should admit"),
3795            ],
3796            vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3797            Vec::new(),
3798            None,
3799        )
3800        .expect("schema proposal should compose");
3801        (proposal, entity_source, constraint_source)
3802    }
3803
3804    #[test]
3805    fn database_head_is_empty_only_when_every_store_root_is_absent() {
3806        assert_eq!(
3807            derive_accepted_head(&[("test::A", None), ("test::B", None)]),
3808            ExpectedAcceptedHead::Empty,
3809        );
3810    }
3811
3812    #[test]
3813    fn database_head_covers_store_path_revision_fingerprint_and_absence() {
3814        let first = derive_accepted_head(&[
3815            (
3816                "test::A",
3817                Some(AcceptedStoreHead {
3818                    revision: 3,
3819                    fingerprint: [0x11; 32],
3820                }),
3821            ),
3822            ("test::B", None),
3823        ]);
3824        let changed_fingerprint = derive_accepted_head(&[
3825            (
3826                "test::A",
3827                Some(AcceptedStoreHead {
3828                    revision: 3,
3829                    fingerprint: [0x12; 32],
3830                }),
3831            ),
3832            ("test::B", None),
3833        ]);
3834        let changed_absence = derive_accepted_head(&[
3835            (
3836                "test::A",
3837                Some(AcceptedStoreHead {
3838                    revision: 3,
3839                    fingerprint: [0x11; 32],
3840                }),
3841            ),
3842            (
3843                "test::B",
3844                Some(AcceptedStoreHead {
3845                    revision: 1,
3846                    fingerprint: [0x22; 32],
3847                }),
3848            ),
3849        ]);
3850
3851        assert_ne!(first, changed_fingerprint);
3852        assert_ne!(first, changed_absence);
3853        assert!(matches!(
3854            first,
3855            ExpectedAcceptedHead::Exact { revision: 3, .. }
3856        ));
3857    }
3858
3859    #[test]
3860    #[allow(
3861        clippy::too_many_lines,
3862        reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
3863    )]
3864    fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
3865        let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
3866        let store = TargetStoreIdentity::from_bytes([0x72; 32]);
3867        let (initial, entity_source, _) = generated_check_proposal(
3868            ExpectedAcceptedHead::Empty,
3869            "abort-initial",
3870            false,
3871            database,
3872            store,
3873        );
3874        let initial_candidate = lower_initial_schema_proposal(
3875            &initial,
3876            &[ProposalStoreTarget {
3877                path: "abort::Store",
3878                identity: store,
3879            }],
3880        )
3881        .expect("initial proposal should lower")
3882        .pop()
3883        .expect("initial proposal should produce one candidate");
3884        let (with_check, _, check_source) = generated_check_proposal(
3885            ExpectedAcceptedHead::Exact {
3886                revision: 1,
3887                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
3888            },
3889            "abort-add-check",
3890            true,
3891            database,
3892            store,
3893        );
3894        let pending_candidate = lower_existing_schema_proposal(
3895            &with_check,
3896            &[ExistingProposalStore {
3897                path: "abort::Store",
3898                identity: store,
3899                bundle: initial_candidate.bundle(),
3900            }],
3901        )
3902        .expect("generated check should lower")
3903        .pop()
3904        .expect("generated check should produce one candidate");
3905        let entity_tag = pending_candidate
3906            .bundle()
3907            .source_bindings_for_tests()
3908            .entity(&entity_source)
3909            .expect("entity source should remain bound");
3910        let constraint_id = pending_candidate
3911            .bundle()
3912            .source_bindings_for_tests()
3913            .constraint(entity_tag, &check_source)
3914            .expect("generated check source should bind");
3915        let pending_snapshot = pending_candidate
3916            .bundle()
3917            .entity_snapshots()
3918            .get(&entity_tag)
3919            .expect("pending entity should exist");
3920        let activation = pending_snapshot
3921            .constraint_catalog()
3922            .activation(constraint_id)
3923            .expect("generated check should remain an activation");
3924        assert_eq!(activation.origin(), ConstraintOrigin::Generated);
3925
3926        let aborted = aborted_generated_row_local_candidate(
3927            pending_candidate.bundle(),
3928            entity_tag,
3929            constraint_id,
3930        )
3931        .expect("generated check abort should build one catalog-native candidate");
3932        let aborted_snapshot = aborted
3933            .bundle()
3934            .entity_snapshots()
3935            .get(&entity_tag)
3936            .expect("aborted entity should remain");
3937        assert!(
3938            aborted_snapshot
3939                .constraint_catalog()
3940                .activation(constraint_id)
3941                .is_none(),
3942        );
3943        assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
3944        assert!(
3945            aborted
3946                .bundle()
3947                .source_bindings_for_tests()
3948                .constraint(entity_tag, &check_source)
3949                .is_none(),
3950        );
3951
3952        let reproposed = lower_existing_schema_proposal(
3953            &with_check,
3954            &[ExistingProposalStore {
3955                path: "abort::Store",
3956                identity: store,
3957                bundle: aborted.bundle(),
3958            }],
3959        )
3960        .expect("aborted generated check should be independently reproposable")
3961        .pop()
3962        .expect("reproposal should produce one candidate");
3963        let replacement_id = reproposed
3964            .bundle()
3965            .source_bindings_for_tests()
3966            .constraint(entity_tag, &check_source)
3967            .expect("reproposal should bind a fresh constraint identity");
3968        assert!(
3969            replacement_id > constraint_id,
3970            "aborted accepted IDs must remain retired",
3971        );
3972    }
3973
3974    #[test]
3975    fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
3976        let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
3977        let store = TargetStoreIdentity::from_bytes([0x82; 32]);
3978        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
3979            ExpectedAcceptedHead::Empty,
3980            "targeted-abort-initial",
3981            SourceRuleOperation::NumericRangeInclusive {
3982                min: ScalarLiteral::Nat(0),
3983                max: ScalarLiteral::Nat(10),
3984            },
3985            database,
3986            store,
3987        );
3988        let initial_candidate = lower_initial_schema_proposal(
3989            &initial,
3990            &[ProposalStoreTarget {
3991                path: "abort::TargetedStore",
3992                identity: store,
3993            }],
3994        )
3995        .expect("initial targeted proposal should lower")
3996        .pop()
3997        .expect("initial targeted proposal should produce one candidate");
3998        let initial_bundle = initial_candidate.bundle();
3999        let entity_tag = initial_bundle
4000            .source_bindings_for_tests()
4001            .entity(&entity_source)
4002            .expect("entity source should bind");
4003        let constraint_id = initial_bundle
4004            .source_bindings_for_tests()
4005            .constraint(entity_tag, &constraint_source)
4006            .expect("targeted source should bind");
4007        let high_water = initial_bundle.entity_snapshots()[&entity_tag]
4008            .constraint_id_allocator()
4009            .high_water();
4010        let (edited, _, _) = targeted_rule_proposal(
4011            ExpectedAcceptedHead::Exact {
4012                revision: initial_bundle.revision().get(),
4013                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
4014            },
4015            "targeted-abort-edit",
4016            SourceRuleOperation::NumericMaximumInclusive {
4017                value: ScalarLiteral::Nat(8),
4018            },
4019            database,
4020            store,
4021        );
4022        let staged = lower_existing_schema_proposal(
4023            &edited,
4024            &[ExistingProposalStore {
4025                path: "abort::TargetedStore",
4026                identity: store,
4027                bundle: initial_bundle,
4028            }],
4029        )
4030        .expect("targeted semantic edit should stage")
4031        .pop()
4032        .expect("targeted semantic edit should produce one candidate");
4033        let aborted =
4034            aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
4035                .expect("targeted semantic edit should abort through catalog authority");
4036        let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
4037
4038        assert!(
4039            snapshot
4040                .constraint_catalog()
4041                .activation(constraint_id)
4042                .is_none()
4043        );
4044        assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
4045        assert_eq!(
4046            aborted
4047                .bundle()
4048                .source_bindings_for_tests()
4049                .constraint(entity_tag, &constraint_source),
4050            Some(constraint_id),
4051        );
4052        assert!(snapshot.constraints().iter().any(|constraint| {
4053            constraint.id() == constraint_id
4054                && matches!(
4055                    constraint.kind(),
4056                    AcceptedConstraintKind::TargetedRule { operation, .. }
4057                        if matches!(
4058                            operation.as_ref(),
4059                            AcceptedRuleOperation::NumericRangeInclusive { .. }
4060                        )
4061                )
4062        }));
4063    }
4064
4065    #[test]
4066    #[allow(
4067        clippy::too_many_lines,
4068        reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
4069    )]
4070    fn generated_startup_driver_resumes_pending_activation_and_promotes_without_source_model() {
4071        let db = Db::<EvolutionCanister>::new(
4072            &EVOLUTION_REGISTRY,
4073            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4074        );
4075        drive_startup_recovery_to_completion(&db);
4076        let empty_target =
4077            schema_application_target(&db).expect("empty evolution target should issue");
4078        let store_identity = empty_target
4079            .stores()
4080            .first()
4081            .expect("evolution store should register")
4082            .identity();
4083        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
4084            empty_target.accepted_head().clone(),
4085            "targeted-recovery-initial",
4086            SourceRuleOperation::NumericRangeInclusive {
4087                min: ScalarLiteral::Nat(0),
4088                max: ScalarLiteral::Nat(10),
4089            },
4090            empty_target.database_identity(),
4091            store_identity,
4092        );
4093        assert!(matches!(
4094            apply_schema(&db, &initial)
4095                .expect("initial targeted proposal should publish")
4096                .outcome(),
4097            SchemaChangeOutcome::Applied { .. },
4098        ));
4099        drive_startup_recovery_to_completion(&db);
4100        drive_cardinality_to_ready(
4101            db.store_handle(EVOLUTION_STORE_PATH)
4102                .expect("evolution store should resolve"),
4103        );
4104
4105        let direct_target =
4106            schema_application_target(&db).expect("direct evolution target should issue");
4107        let (direct_edit, _, _) = targeted_rule_proposal(
4108            direct_target.accepted_head().clone(),
4109            "targeted-direct-edit",
4110            SourceRuleOperation::NumericMaximumInclusive {
4111                value: ScalarLiteral::Nat(8),
4112            },
4113            direct_target.database_identity(),
4114            store_identity,
4115        );
4116        assert!(matches!(
4117            apply_schema(&db, &direct_edit)
4118                .expect("empty-domain semantic edit should publish directly")
4119                .outcome(),
4120            SchemaChangeOutcome::Applied { .. },
4121        ));
4122        let store = db
4123            .store_handle(EVOLUTION_STORE_PATH)
4124            .expect("evolution store should resolve");
4125        let direct = store
4126            .with_schema(SchemaStore::current_accepted_schema_bundle)
4127            .expect("directly edited bundle should remain readable")
4128            .expect("directly edited bundle should exist");
4129        let entity_tag = direct
4130            .source_bindings_for_tests()
4131            .entity(&entity_source)
4132            .expect("entity source should remain bound");
4133        let constraint_id = direct
4134            .source_bindings_for_tests()
4135            .constraint(entity_tag, &constraint_source)
4136            .expect("direct edit should preserve constraint identity");
4137        assert!(
4138            direct.entity_snapshots()[&entity_tag]
4139                .constraint_catalog()
4140                .activation(constraint_id)
4141                .is_none()
4142        );
4143        assert!(
4144            direct.entity_snapshots()[&entity_tag]
4145                .constraints()
4146                .iter()
4147                .any(|constraint| {
4148                    constraint.id() == constraint_id
4149                        && matches!(
4150                            constraint.kind(),
4151                            AcceptedConstraintKind::TargetedRule { operation, .. }
4152                                if matches!(
4153                                    operation.as_ref(),
4154                                    AcceptedRuleOperation::NumericMaximumInclusive { .. }
4155                                )
4156                        )
4157                })
4158        );
4159
4160        let target = schema_application_target(&db).expect("staged evolution target should issue");
4161        let (edited, _, _) = targeted_rule_proposal(
4162            target.accepted_head().clone(),
4163            "targeted-recovery-edit",
4164            SourceRuleOperation::MultipleOf {
4165                divisor: ScalarLiteral::Nat(2),
4166            },
4167            target.database_identity(),
4168            store_identity,
4169        );
4170        let current = store
4171            .with_schema(SchemaStore::current_accepted_schema_bundle)
4172            .expect("accepted evolution bundle should remain readable")
4173            .expect("directly edited evolution bundle should exist");
4174        let staged = lower_existing_schema_proposal(
4175            &edited,
4176            &[ExistingProposalStore {
4177                path: EVOLUTION_STORE_PATH,
4178                identity: store_identity,
4179                bundle: &current,
4180            }],
4181        )
4182        .expect("targeted edit should stage")
4183        .pop()
4184        .expect("targeted edit should produce one candidate");
4185        assert_eq!(
4186            staged
4187                .bundle()
4188                .source_bindings_for_tests()
4189                .constraint(entity_tag, &constraint_source),
4190            Some(constraint_id),
4191        );
4192        let proof = DirectGeneratedRowLocalProof {
4193            candidate_index: 0,
4194            store,
4195            store_path: EVOLUTION_STORE_PATH,
4196            entity_tag,
4197            entity_path: staged.bundle().entity_snapshots()[&entity_tag]
4198                .entity_path()
4199                .to_string(),
4200            constraint_id,
4201            historical_rows: 0,
4202        };
4203        let final_candidates = final_candidates_for_pending_row_local_constraint(
4204            std::slice::from_ref(&staged),
4205            &PendingGeneratedRowLocalConstraint { proof },
4206        )
4207        .expect("final semantic replacement should derive without source input");
4208        let authorities = application_authorities(&db);
4209        let candidate_head =
4210            accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
4211                .expect("final candidate head should derive");
4212        let digest = edited.digest().expect("proposal digest should derive");
4213        let job_id = derive_schema_change_job_id(
4214            target.database_identity(),
4215            edited.submission_key(),
4216            digest,
4217            target.accepted_head(),
4218        )
4219        .expect("job identity should derive");
4220        let receipt = crate::db::schema::SchemaChangeReceipt::new(
4221            target.database_identity(),
4222            edited.submission_key().clone(),
4223            digest,
4224            target.accepted_head().clone(),
4225            SchemaChangeOutcome::Pending {
4226                job: SchemaChangeJob::new(job_id),
4227                candidate_head,
4228            },
4229        )
4230        .expect("pending replacement receipt should admit");
4231        let record = SchemaApplicationRecord::new(
4232            receipt,
4233            vec![
4234                SchemaChangeActivation::new(
4235                    store_identity,
4236                    entity_tag.value(),
4237                    constraint_id.get(),
4238                )
4239                .expect("replacement activation should admit"),
4240            ],
4241        )
4242        .expect("pending replacement record should admit");
4243        let operation =
4244            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4245        publish_accepted_schema_candidates_with_application_record(
4246            vec![AcceptedSchemaPublication::new(
4247                EVOLUTION_STORE_PATH,
4248                store,
4249                current.revision(),
4250                &staged,
4251            )],
4252            operation,
4253        )
4254        .expect("staged replacement and record should publish atomically");
4255
4256        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4257        drive_startup_recovery_to_completion(&db);
4258
4259        let recovered = store
4260            .with_schema(SchemaStore::current_accepted_schema_bundle)
4261            .expect("recovered staged bundle should decode")
4262            .expect("recovered staged bundle should exist");
4263        let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
4264        let validating_catalog = recovered_snapshot
4265            .constraint_catalog()
4266            .clone()
4267            .with_validation_started(constraint_id)
4268            .expect("recovered replacement should enter validation");
4269        let mut validating_snapshots = recovered.entity_snapshots().clone();
4270        validating_snapshots.insert(
4271            entity_tag,
4272            recovered_snapshot.with_constraint_catalog(validating_catalog),
4273        );
4274        let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
4275            recovered
4276                .revision()
4277                .checked_next()
4278                .expect("validation revision should remain available"),
4279            recovered.store_path(),
4280            recovered.enum_catalog().clone(),
4281            recovered.composite_catalog().clone(),
4282            recovered.source_bindings_for_tests().clone(),
4283            validating_snapshots,
4284        )
4285        .expect("validating replacement bundle should close");
4286        let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
4287            .expect("validating replacement candidate should encode");
4288        let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
4289            .constraint_catalog()
4290            .activation(constraint_id)
4291            .expect("validating replacement activation should remain present");
4292        let validation_job = ConstraintValidationJob::start(
4293            entity_tag,
4294            validating_candidate.bundle().entity_snapshots()[&entity_tag]
4295                .entity_path()
4296                .to_string(),
4297            validating_activation,
4298            None,
4299        )
4300        .expect("validating replacement job should derive from accepted state");
4301        store
4302            .with_schema(|schema| {
4303                schema.validate_live_activation_transition(validating_candidate.bundle())?;
4304                schema.validate_constraint_validation_job_closure_with_change(
4305                    validating_candidate.bundle(),
4306                    Some(&validation_job),
4307                    None,
4308                )
4309            })
4310            .expect("validating replacement transition and job should close");
4311        let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4312        let startup_session =
4313            crate::db::DbSession::<EvolutionCanister>::new(&EVOLUTION_REGISTRY, &startup_root);
4314
4315        assert_eq!(
4316            drive_generated_startup_recovery_page(
4317                &startup_session,
4318                &EVOLUTION_REGISTRY,
4319                edited.submission_key().as_str(),
4320            )
4321            .expect("generated startup should begin pending validation"),
4322            GeneratedStartupDriverStep::Recovering,
4323        );
4324        let mut terminal = false;
4325        for _ in 0..8 {
4326            match drive_generated_startup_recovery_page(
4327                &startup_session,
4328                &EVOLUTION_REGISTRY,
4329                edited.submission_key().as_str(),
4330            )
4331            .expect("generated startup should advance pending validation")
4332            {
4333                GeneratedStartupDriverStep::Recovering => {}
4334                GeneratedStartupDriverStep::Terminal => {
4335                    terminal = true;
4336                    break;
4337                }
4338                GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4339                    panic!("an exact pending receipt must resume instead of being resubmitted")
4340                }
4341            }
4342        }
4343        assert!(
4344            terminal,
4345            "empty historical domain should promote within bounded startup steps"
4346        );
4347        assert_eq!(
4348            observe_generated_startup_state::<EvolutionCanister>(
4349                &EVOLUTION_REGISTRY,
4350                edited.submission_key().as_str(),
4351            ),
4352            Ok(DatabaseStartupState::Ready),
4353        );
4354        let applied = super::exact_schema_application_receipt(
4355            &edited,
4356            edited
4357                .digest()
4358                .expect("proposal digest should remain stable"),
4359        )
4360        .expect("terminal generated receipt should remain readable")
4361        .expect("terminal generated receipt should remain present");
4362        assert!(matches!(
4363            applied.outcome(),
4364            SchemaChangeOutcome::Applied { .. }
4365        ));
4366        let promoted = store
4367            .with_schema(SchemaStore::current_accepted_schema_bundle)
4368            .expect("promoted bundle should remain readable")
4369            .expect("promoted bundle should exist");
4370        let snapshot = &promoted.entity_snapshots()[&entity_tag];
4371        assert!(
4372            snapshot
4373                .constraint_catalog()
4374                .activation(constraint_id)
4375                .is_none()
4376        );
4377        assert_eq!(
4378            promoted
4379                .source_bindings_for_tests()
4380                .constraint(entity_tag, &constraint_source),
4381            Some(constraint_id),
4382        );
4383        assert!(snapshot.constraints().iter().any(|constraint| {
4384            constraint.id() == constraint_id
4385                && matches!(
4386                    constraint.kind(),
4387                    AcceptedConstraintKind::TargetedRule { operation, .. }
4388                        if matches!(
4389                            operation.as_ref(),
4390                            AcceptedRuleOperation::MultipleOf { .. }
4391                        )
4392                )
4393        }));
4394    }
4395
4396    #[test]
4397    #[allow(
4398        clippy::too_many_lines,
4399        reason = "the durable pending job, startup failure, and retained finding assertions form one scenario"
4400    )]
4401    fn generated_startup_driver_persists_e223_for_a_retained_historical_finding() {
4402        let db = Db::<AbortCanister>::new(
4403            &ABORT_REGISTRY,
4404            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4405        );
4406        drive_startup_recovery_to_completion(&db);
4407        let empty_target =
4408            schema_application_target(&db).expect("empty application target should issue");
4409        let store_identity = empty_target
4410            .stores()
4411            .first()
4412            .expect("abort store should be registered")
4413            .identity();
4414        let (initial, _, _) = generated_check_proposal(
4415            empty_target.accepted_head().clone(),
4416            "startup-finding-initial",
4417            false,
4418            empty_target.database_identity(),
4419            store_identity,
4420        );
4421        apply_schema(&db, &initial).expect("initial generated schema should publish");
4422
4423        let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4424        let session = DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &root);
4425        let rows = (1..=257)
4426            .map(|id| DynamicMutation::Insert {
4427                entity: "Item".to_string(),
4428                patch: DynamicStructuralPatch::new(vec![
4429                    (
4430                        "id".to_string(),
4431                        DynamicWriteCell::Value(InputValue::Nat64(id)),
4432                    ),
4433                    (
4434                        "score".to_string(),
4435                        DynamicWriteCell::Value(InputValue::Int64(if id == 257 { -1 } else { 1 })),
4436                    ),
4437                ]),
4438            })
4439            .collect();
4440        session
4441            .execute_trusted_dynamic_mutation_batch(rows)
4442            .expect("historical finding fixture rows should commit as one legal batch");
4443        drive_startup_recovery_to_completion(&db);
4444        drive_cardinality_to_ready(
4445            db.store_handle(ABORT_STORE_PATH)
4446                .expect("abort store should resolve"),
4447        );
4448
4449        let target =
4450            schema_application_target(&db).expect("existing application target should issue");
4451        let (with_check, _, _) = generated_check_proposal(
4452            target.accepted_head().clone(),
4453            "startup-finding-pending",
4454            true,
4455            target.database_identity(),
4456            store_identity,
4457        );
4458        let pending = apply_schema(&db, &with_check)
4459            .expect("the first clean page should admit durable continuation");
4460        let SchemaChangeOutcome::Pending { job, .. } = pending.outcome() else {
4461            panic!("a 257-row domain must exceed the 256-row direct proof page")
4462        };
4463
4464        let mut terminal = false;
4465        for _ in 0..8 {
4466            match drive_generated_startup_recovery_page(
4467                &session,
4468                &ABORT_REGISTRY,
4469                with_check.submission_key().as_str(),
4470            )
4471            .expect("generated startup should retain a typed finding failure")
4472            {
4473                GeneratedStartupDriverStep::Recovering => {}
4474                GeneratedStartupDriverStep::Terminal => {
4475                    terminal = true;
4476                    break;
4477                }
4478                GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4479                    panic!("an exact pending receipt must not be resubmitted")
4480                }
4481            }
4482        }
4483        assert!(terminal, "the retained finding should become terminal");
4484        let failure = observe_generated_startup_state::<AbortCanister>(
4485            &ABORT_REGISTRY,
4486            with_check.submission_key().as_str(),
4487        )
4488        .expect_err("the retained finding must remain durably observable");
4489        assert_eq!(
4490            failure.kind(),
4491            crate::db::StartupFailureKind::SchemaReconciliation,
4492        );
4493        assert_eq!(
4494            failure.diagnostic().error_code(),
4495            icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION,
4496        );
4497        assert_eq!(
4498            ABORT_DATA.with(|store| store.borrow().len()),
4499            257,
4500            "terminal startup publication must not change historical rows",
4501        );
4502        assert!(matches!(
4503            continue_schema_application(&db, job.id(), None)
4504                .expect("the retained finding page should replay exactly")
4505                .status(),
4506            SchemaChangeProgressStatus::Findings { findings, .. } if !findings.is_empty(),
4507        ));
4508    }
4509
4510    #[test]
4511    #[allow(
4512        clippy::too_many_lines,
4513        reason = "the journaled abort, replay, and recovery assertions form one scenario"
4514    )]
4515    fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
4516        let db = Db::<AbortCanister>::new(
4517            &ABORT_REGISTRY,
4518            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4519        );
4520        drive_startup_recovery_to_completion(&db);
4521        let empty_target =
4522            schema_application_target(&db).expect("empty application target should issue");
4523        let store_identity = empty_target
4524            .stores()
4525            .first()
4526            .expect("abort store should be registered")
4527            .identity();
4528        let (initial, entity_source, _) = generated_check_proposal(
4529            empty_target.accepted_head().clone(),
4530            "abort-runtime-initial",
4531            false,
4532            empty_target.database_identity(),
4533            store_identity,
4534        );
4535        assert!(matches!(
4536            apply_schema(&db, &initial)
4537                .expect("initial application should publish")
4538                .outcome(),
4539            SchemaChangeOutcome::Applied { .. },
4540        ));
4541
4542        let target =
4543            schema_application_target(&db).expect("existing application target should issue");
4544        let (with_check, _, check_source) = generated_check_proposal(
4545            target.accepted_head().clone(),
4546            "abort-runtime-pending",
4547            true,
4548            target.database_identity(),
4549            store_identity,
4550        );
4551        let store = db
4552            .store_handle(ABORT_STORE_PATH)
4553            .expect("abort store should resolve");
4554        let current = store
4555            .with_schema(SchemaStore::current_accepted_schema_bundle)
4556            .expect("accepted bundle should remain readable")
4557            .expect("initial accepted bundle should exist");
4558        let pending_candidate = lower_existing_schema_proposal(
4559            &with_check,
4560            &[ExistingProposalStore {
4561                path: ABORT_STORE_PATH,
4562                identity: store_identity,
4563                bundle: &current,
4564            }],
4565        )
4566        .expect("pending generated check should lower")
4567        .pop()
4568        .expect("pending generated check should produce one candidate");
4569        let entity_tag = pending_candidate
4570            .bundle()
4571            .source_bindings_for_tests()
4572            .entity(&entity_source)
4573            .expect("entity source should bind");
4574        let constraint_id = pending_candidate
4575            .bundle()
4576            .source_bindings_for_tests()
4577            .constraint(entity_tag, &check_source)
4578            .expect("generated check source should bind");
4579        let digest = with_check.digest().expect("proposal digest should derive");
4580        let job_id = derive_schema_change_job_id(
4581            target.database_identity(),
4582            with_check.submission_key(),
4583            digest,
4584            target.accepted_head(),
4585        )
4586        .expect("job identity should derive");
4587        let receipt = crate::db::schema::SchemaChangeReceipt::new(
4588            target.database_identity(),
4589            with_check.submission_key().clone(),
4590            digest,
4591            target.accepted_head().clone(),
4592            SchemaChangeOutcome::Pending {
4593                job: SchemaChangeJob::new(job_id),
4594                candidate_head: ExpectedAcceptedHead::Exact {
4595                    revision: pending_candidate.revision().get().saturating_add(2),
4596                    fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
4597                },
4598            },
4599        )
4600        .expect("pending receipt should admit");
4601        let record = SchemaApplicationRecord::new(
4602            receipt,
4603            vec![
4604                SchemaChangeActivation::new(
4605                    store_identity,
4606                    entity_tag.value(),
4607                    constraint_id.get(),
4608                )
4609                .expect("application activation should admit"),
4610            ],
4611        )
4612        .expect("pending application record should admit");
4613        let operation =
4614            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4615        publish_accepted_schema_candidates_with_application_record(
4616            vec![AcceptedSchemaPublication::new(
4617                ABORT_STORE_PATH,
4618                store,
4619                current.revision(),
4620                &pending_candidate,
4621            )],
4622            operation,
4623        )
4624        .expect("pending candidate and record should publish atomically");
4625
4626        let started = continue_schema_application(&db, job_id, None)
4627            .expect("first continuation should durably start validation");
4628        assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4629        let progress =
4630            abort_schema_application(&db, job_id, None).expect("pending application should abort");
4631        assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
4632        assert!(matches!(
4633            progress.receipt().outcome(),
4634            SchemaChangeOutcome::Aborted { .. },
4635        ));
4636        let replay =
4637            abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
4638        assert_eq!(replay, progress);
4639        assert_eq!(
4640            continue_schema_application(&db, job_id, None)
4641                .expect("continuation after abort should replay terminal state"),
4642            progress,
4643        );
4644
4645        let aborted = store
4646            .with_schema(SchemaStore::current_accepted_schema_bundle)
4647            .expect("accepted bundle should remain readable")
4648            .expect("aborted accepted bundle should exist");
4649        assert!(
4650            aborted
4651                .entity_snapshots()
4652                .get(&entity_tag)
4653                .expect("entity should remain after abort")
4654                .constraint_catalog()
4655                .activation(constraint_id)
4656                .is_none(),
4657        );
4658        assert!(
4659            aborted
4660                .source_bindings_for_tests()
4661                .constraint(entity_tag, &check_source)
4662                .is_none(),
4663        );
4664        assert!(
4665            store
4666                .with_schema(|schema| {
4667                    schema.constraint_validation_job(entity_tag, constraint_id)
4668                })
4669                .expect("validation-job storage should remain readable")
4670                .is_none(),
4671        );
4672
4673        ABORT_DATA.with(|store| {
4674            ABORT_DATA_MEMORY.with(|memory| {
4675                *store.borrow_mut() = DataStore::init_journaled(memory.clone());
4676            });
4677        });
4678        ABORT_INDEX.with(|store| {
4679            ABORT_INDEX_MEMORY.with(|memory| {
4680                *store.borrow_mut() = IndexStore::init_journaled(memory.clone());
4681            });
4682        });
4683        ABORT_SCHEMA.with(|store| {
4684            ABORT_SCHEMA_MEMORY.with(|memory| {
4685                *store.borrow_mut() = SchemaStore::init_journaled(memory.clone());
4686            });
4687        });
4688        ABORT_JOURNAL.with(|store| {
4689            ABORT_JOURNAL_MEMORY.with(|memory| {
4690                *store.borrow_mut() = JournalTailStore::init(memory.clone());
4691            });
4692        });
4693        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4694        drive_startup_recovery_to_completion(&db);
4695        assert_eq!(
4696            abort_schema_application(&db, job_id, None)
4697                .expect("recovered terminal abort should replay"),
4698            progress,
4699        );
4700        assert!(
4701            store
4702                .with_schema(|schema| {
4703                    schema.constraint_validation_job(entity_tag, constraint_id)
4704                })
4705                .expect("recovered validation-job storage should remain readable")
4706                .is_none(),
4707        );
4708        let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4709        let startup_session =
4710            crate::db::DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &startup_root);
4711        ABORT_JOURNAL.with(|journal| {
4712            let journal = journal.borrow();
4713            assert!(
4714                journal
4715                    .validate_current_tail_authority()
4716                    .expect("recovered abort tail control should remain readable")
4717                    .is_empty(),
4718                "recovered abort tail control should close exactly",
4719            );
4720        });
4721        assert_eq!(
4722            drive_generated_startup_recovery_page(
4723                &startup_session,
4724                &ABORT_REGISTRY,
4725                with_check.submission_key().as_str(),
4726            )
4727            .expect("an exact aborted generated submission should publish terminal startup state"),
4728            GeneratedStartupDriverStep::Terminal,
4729        );
4730        let failure = observe_generated_startup_state::<AbortCanister>(
4731            &ABORT_REGISTRY,
4732            with_check.submission_key().as_str(),
4733        )
4734        .expect_err("an aborted generated submission must not remain retryable forever");
4735        assert_eq!(
4736            failure.kind(),
4737            crate::db::StartupFailureKind::SchemaReconciliation,
4738        );
4739        assert_eq!(
4740            failure.diagnostic().error_code(),
4741            icydb_diagnostic_code::ErrorCode::RUNTIME_CONFLICT,
4742        );
4743    }
4744
4745    #[cfg(feature = "migration")]
4746    #[test]
4747    fn migration_planning_failures_retain_typed_public_classification() {
4748        use super::schema_migration_planning_error;
4749        use crate::db::schema::migration_planner::SchemaMigrationPlanningError;
4750        use icydb_diagnostic_code::{DiagnosticDetail, SchemaMigrationCode};
4751
4752        for (error, reason) in [
4753            (
4754                SchemaMigrationPlanningError::Unadopted,
4755                SchemaMigrationCode::Unadopted,
4756            ),
4757            (
4758                SchemaMigrationPlanningError::MissingMigration,
4759                SchemaMigrationCode::MissingMigration,
4760            ),
4761            (
4762                SchemaMigrationPlanningError::VersionGap,
4763                SchemaMigrationCode::VersionGap,
4764            ),
4765            (
4766                SchemaMigrationPlanningError::Downgrade,
4767                SchemaMigrationCode::Downgrade,
4768            ),
4769            (
4770                SchemaMigrationPlanningError::EmptyEntityVersionBump,
4771                SchemaMigrationCode::EmptyEntityVersionBump,
4772            ),
4773            (
4774                SchemaMigrationPlanningError::StaleAcceptedHead,
4775                SchemaMigrationCode::StaleAcceptedHead,
4776            ),
4777            (
4778                SchemaMigrationPlanningError::UnknownFromObject,
4779                SchemaMigrationCode::UnknownFromObject,
4780            ),
4781            (
4782                SchemaMigrationPlanningError::UnknownToObject,
4783                SchemaMigrationCode::UnknownToObject,
4784            ),
4785            (
4786                SchemaMigrationPlanningError::KindMismatch,
4787                SchemaMigrationCode::KindMismatch,
4788            ),
4789            (
4790                SchemaMigrationPlanningError::IdentityConflict,
4791                SchemaMigrationCode::IdentityConflict,
4792            ),
4793            (
4794                SchemaMigrationPlanningError::UnexplainedSchemaDifference,
4795                SchemaMigrationCode::UnexplainedSchemaDifference,
4796            ),
4797            (
4798                SchemaMigrationPlanningError::UnsupportedTransform,
4799                SchemaMigrationCode::UnsupportedTransform,
4800            ),
4801            (
4802                SchemaMigrationPlanningError::RekeyedCatalogInvalid,
4803                SchemaMigrationCode::CandidateMismatch,
4804            ),
4805            (
4806                SchemaMigrationPlanningError::CandidateMismatch,
4807                SchemaMigrationCode::CandidateMismatch,
4808            ),
4809            (
4810                SchemaMigrationPlanningError::CorruptLineage,
4811                SchemaMigrationCode::ProgressCorrupt,
4812            ),
4813        ] {
4814            let diagnostic = schema_migration_planning_error(error).diagnostic();
4815            assert_eq!(
4816                diagnostic.detail(),
4817                Some(&DiagnosticDetail::SchemaMigration { reason }),
4818            );
4819            assert_eq!(diagnostic.code(), reason.diagnostic_code());
4820        }
4821    }
4822
4823    #[cfg(feature = "migration")]
4824    #[test]
4825    #[expect(
4826        clippy::too_many_lines,
4827        reason = "the validation replay, staging, and unchanged-row assertions form one scenario"
4828    )]
4829    fn physical_migration_validation_is_bounded_staged_and_does_not_rewrite_rows() {
4830        use std::convert::Infallible;
4831
4832        use super::{defer_generated_schema_application_for_prepared_migration, migrate_schema};
4833        use crate::db::{
4834            data::StoreVisit,
4835            index::{IndexEntryValue, IndexId, IndexKey, IndexKeyKind},
4836            key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
4837            schema::{SchemaMigrationCommand, SchemaMigrationPhase},
4838        };
4839        use crate::types::EntityTag;
4840
4841        let db = Db::<MigrationCanister>::new(
4842            &MIGRATION_REGISTRY,
4843            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4844        );
4845        drive_startup_recovery_to_completion(&db);
4846        let initial_target = schema_application_target(&db).expect("initial target should issue");
4847        let store_identity = initial_target
4848            .stores()
4849            .first()
4850            .expect("migration store should exist")
4851            .identity();
4852        let initial = validation_migration_proposal(
4853            ValidationMigrationShape::Clean,
4854            false,
4855            initial_target.accepted_head().clone(),
4856            initial_target.database_identity(),
4857            store_identity,
4858        );
4859        apply_schema(&db, &initial).expect("initial schema should publish");
4860
4861        let session = DbSession::<MigrationCanister>::new(
4862            &MIGRATION_REGISTRY,
4863            &crate::db::RequestExecutionRoot::__new_runtime_root(),
4864        );
4865        for (id, value) in [(1, 7), (2, 8)] {
4866            session
4867                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4868                    entity: "MigratingItem".to_string(),
4869                    patch: DynamicStructuralPatch::new(vec![
4870                        (
4871                            "id".to_string(),
4872                            DynamicWriteCell::Value(InputValue::Nat64(id)),
4873                        ),
4874                        (
4875                            "old_value".to_string(),
4876                            DynamicWriteCell::Value(InputValue::Int64(value)),
4877                        ),
4878                    ]),
4879                })
4880                .expect("predecessor row should insert");
4881        }
4882        let store = db
4883            .store_handle(MIGRATION_STORE_PATH)
4884            .expect("migration store should resolve");
4885        let row_bytes = || {
4886            store.with_data(|data| {
4887                let mut rows = Vec::new();
4888                let result: Result<(), Infallible> = data.visit_entries(|key, row| {
4889                    rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
4890                    Ok(StoreVisit::Continue)
4891                });
4892                result.expect("infallible row visit should complete");
4893                rows
4894            })
4895        };
4896        let before_rows = row_bytes();
4897
4898        let target = schema_application_target(&db).expect("migration target should issue");
4899        let proposal = validation_migration_proposal(
4900            ValidationMigrationShape::Clean,
4901            true,
4902            target.accepted_head().clone(),
4903            target.database_identity(),
4904            store_identity,
4905        );
4906        let plan = proposal
4907            .migration()
4908            .expect("migration plan should exist")
4909            .digest();
4910        let command = || SchemaMigrationCommand::Advance {
4911            expected_database: target.database_identity(),
4912            expected_head: target.accepted_head().clone(),
4913            expected_plan: plan,
4914            acknowledged_finding_page: None,
4915        };
4916        assert_eq!(
4917            migrate_schema(&db, &proposal, command())
4918                .unwrap_or_else(|error| {
4919                    panic!(
4920                        "physical migration should prepare: {:?}",
4921                        error.diagnostic()
4922                    )
4923                })
4924                .phase(),
4925            SchemaMigrationPhase::Prepared,
4926        );
4927        assert_eq!(
4928            migrate_schema(&db, &proposal, command())
4929                .expect("physical migration should enter validation")
4930                .phase(),
4931            SchemaMigrationPhase::Validating,
4932        );
4933        let record = super::load_schema_migration_record()
4934            .expect("migration record should remain readable")
4935            .expect("validating migration record should exist");
4936        let planned = super::recompile_active_physical_migration(&db, &proposal, &record)
4937            .expect("the exact active plan should recompile");
4938        for _ in 0..2 {
4939            let page = super::validate_migration_page(&db, &planned, record.progress())
4940                .expect("the same validation page should remain replayable");
4941            let (progress, staged, exhausted) = page.into_parts();
4942            assert!(progress.findings().is_empty());
4943            assert!(exhausted);
4944            super::stage_migration_index_entries(staged)
4945                .expect("staging before a cursor marker should be idempotent");
4946        }
4947        assert_eq!(
4948            store.with_index(IndexStore::len),
4949            2,
4950            "replaying an uncheckpointed page must retain one exact staged key per row",
4951        );
4952        let ready =
4953            migrate_schema(&db, &proposal, command()).expect("bounded validation should complete");
4954        assert_eq!(ready.phase(), SchemaMigrationPhase::ReadyToRewrite);
4955        assert_eq!(ready.rows_validated(), 2);
4956        assert!(ready.findings().is_empty());
4957        assert_eq!(row_bytes(), before_rows, "validation must not rewrite rows");
4958        assert_eq!(
4959            store.with_index(IndexStore::len),
4960            2,
4961            "the isolated candidate unique generation should be durably staged",
4962        );
4963        store.with_index_mut(|index| {
4964            for ordinal in 0..513_u64 {
4965                let component = ordinal.to_be_bytes();
4966                let key = IndexKey::new_from_components_with_primary_key_value(
4967                    &IndexId::new(EntityTag::new(2), 0),
4968                    IndexKeyKind::User,
4969                    &[component],
4970                    &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
4971                )
4972                .expect("unrelated abort-scan key should build")
4973                .to_raw()
4974                .expect("unrelated abort-scan key should encode");
4975                index.insert(key, IndexEntryValue::presence());
4976            }
4977        });
4978        let abort = || SchemaMigrationCommand::Abort {
4979            expected_database: target.database_identity(),
4980            expected_head: target.accepted_head().clone(),
4981            expected_plan: plan,
4982        };
4983        let cleaning = migrate_schema(&db, &proposal, abort())
4984            .expect("the first bounded abort cleanup page should publish");
4985        assert_eq!(cleaning.phase(), SchemaMigrationPhase::ReadyToRewrite);
4986        assert_eq!(store.with_index(IndexStore::len), 513);
4987        let aborted =
4988            migrate_schema(&db, &proposal, abort()).expect("pre-rewrite migration should abort");
4989        assert_eq!(aborted.phase(), SchemaMigrationPhase::Aborted);
4990        assert_eq!(
4991            store.with_index(IndexStore::len),
4992            513,
4993            "abort must remove only planner-invisible candidate generations",
4994        );
4995        assert_eq!(
4996            row_bytes(),
4997            before_rows,
4998            "abort must retain predecessor rows"
4999        );
5000        assert!(
5001            !defer_generated_schema_application_for_prepared_migration(&db, &proposal)
5002                .expect("terminal aborted record must not block generated startup"),
5003        );
5004    }
5005
5006    #[cfg(feature = "migration")]
5007    #[test]
5008    #[expect(
5009        clippy::too_many_lines,
5010        reason = "the interrupted rewrite, recovery, final proof, and publication form one scenario"
5011    )]
5012    fn physical_migration_rewrite_recovers_and_publishes_one_complete_candidate() {
5013        use super::{
5014            defer_generated_schema_application_for_prepared_migration, migrate_schema,
5015            schema_migration_status_for_target,
5016        };
5017        use crate::db::{
5018            data::{CanonicalSlotReader, DecodedDataStoreKey, StoreVisit, StructuralSlotReader},
5019            schema::{
5020                MigrationRewriteInterruption, SchemaMigrationCommand, SchemaMigrationPhase,
5021                ensure_schema_migration_ready_for_ordinary_operations,
5022                interrupt_next_migration_rewrite_at,
5023            },
5024        };
5025        use crate::error::InternalError;
5026
5027        let db = Db::<MigrationExecutionCanister>::new(
5028            &MIGRATION_EXECUTION_REGISTRY,
5029            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5030        );
5031        drive_startup_recovery_to_completion(&db);
5032        let initial_target = schema_application_target(&db).expect("initial target should issue");
5033        let store_identity = initial_target
5034            .stores()
5035            .first()
5036            .expect("migration execution store should exist")
5037            .identity();
5038        let initial = validation_migration_proposal(
5039            ValidationMigrationShape::Clean,
5040            false,
5041            initial_target.accepted_head().clone(),
5042            initial_target.database_identity(),
5043            store_identity,
5044        );
5045        apply_schema(&db, &initial).expect("initial schema should publish");
5046        let session = DbSession::<MigrationExecutionCanister>::new(
5047            &MIGRATION_EXECUTION_REGISTRY,
5048            &crate::db::RequestExecutionRoot::__new_runtime_root(),
5049        );
5050        for (id, value) in [(1, 7), (2, 8), (3, 9)] {
5051            session
5052                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5053                    entity: "MigratingItem".to_string(),
5054                    patch: DynamicStructuralPatch::new(vec![
5055                        (
5056                            "id".to_string(),
5057                            DynamicWriteCell::Value(InputValue::Nat64(id)),
5058                        ),
5059                        (
5060                            "old_value".to_string(),
5061                            DynamicWriteCell::Value(InputValue::Int64(value)),
5062                        ),
5063                    ]),
5064                })
5065                .expect("predecessor row should insert");
5066        }
5067        let target = schema_application_target(&db).expect("migration target should issue");
5068        let proposal = validation_migration_proposal(
5069            ValidationMigrationShape::Clean,
5070            true,
5071            target.accepted_head().clone(),
5072            target.database_identity(),
5073            store_identity,
5074        );
5075        let plan = proposal
5076            .migration()
5077            .expect("migration plan should exist")
5078            .digest();
5079        let command = || SchemaMigrationCommand::Advance {
5080            expected_database: target.database_identity(),
5081            expected_head: target.accepted_head().clone(),
5082            expected_plan: plan,
5083            acknowledged_finding_page: None,
5084        };
5085        for expected in [
5086            SchemaMigrationPhase::Prepared,
5087            SchemaMigrationPhase::Validating,
5088            SchemaMigrationPhase::ReadyToRewrite,
5089            SchemaMigrationPhase::RewritingRows,
5090        ] {
5091            assert_eq!(
5092                migrate_schema(&db, &proposal, command())
5093                    .expect("migration phase should advance")
5094                    .phase(),
5095                expected,
5096            );
5097        }
5098
5099        for interruption in [
5100            MigrationRewriteInterruption::MarkerPersisted,
5101            MigrationRewriteInterruption::JournalPublished,
5102            MigrationRewriteInterruption::PhysicalApplied,
5103        ] {
5104            interrupt_next_migration_rewrite_at(interruption);
5105            migrate_schema(&db, &proposal, command())
5106                .expect_err("injected interruption should retain the rewrite marker");
5107
5108            forget_recovered_domain_for_tests(&db)
5109                .expect("upgrade should reset recovery ownership");
5110            drive_startup_recovery_to_completion(&db);
5111        }
5112
5113        let rebuilding = schema_migration_status_for_target(
5114            &db,
5115            &proposal,
5116            &schema_application_target(&db).expect("recovered target should issue"),
5117        )
5118        .expect("recovered status should remain readable");
5119        assert_eq!(rebuilding.phase(), SchemaMigrationPhase::RebuildingIndexes);
5120        assert_eq!(rebuilding.rows_rewritten(), 3);
5121        assert_eq!(
5122            migrate_schema(&db, &proposal, command())
5123                .expect("derived generations should complete")
5124                .phase(),
5125            SchemaMigrationPhase::FinalValidation,
5126        );
5127        assert_eq!(
5128            migrate_schema(&db, &proposal, command())
5129                .expect("final validation should complete")
5130                .phase(),
5131            SchemaMigrationPhase::Publishing,
5132        );
5133        let applied = migrate_schema(&db, &proposal, command())
5134            .expect("candidate publication should complete atomically");
5135        assert_eq!(applied.phase(), SchemaMigrationPhase::Applied);
5136        assert_eq!(applied.rows_rewritten(), 3);
5137        assert_eq!(applied.indexes_rebuilt(), 1);
5138        assert_ne!(applied.accepted_head(), target.accepted_head());
5139        let terminal_target = schema_application_target(&db).expect("terminal target should issue");
5140        let terminal_proposal = validation_migration_proposal(
5141            ValidationMigrationShape::Clean,
5142            true,
5143            terminal_target.accepted_head().clone(),
5144            terminal_target.database_identity(),
5145            store_identity,
5146        );
5147        assert!(
5148            !defer_generated_schema_application_for_prepared_migration(&db, &terminal_proposal,)
5149                .expect("terminal record must not block generated startup"),
5150        );
5151
5152        let store = db
5153            .store_handle(MIGRATION_EXECUTION_STORE_PATH)
5154            .expect("migration execution store should resolve");
5155        let runtime = db
5156            .accepted_runtime_entity_for_path("MigratingItem")
5157            .expect("published candidate entity should resolve");
5158        let selection = store
5159            .with_schema(|schema| {
5160                schema.current_accepted_catalog_selection(
5161                    runtime.entity_tag(),
5162                    runtime.entity_path(),
5163                    runtime.store_path(),
5164                )
5165            })
5166            .expect("candidate selection should remain readable")
5167            .expect("candidate selection should exist");
5168        let contract = crate::db::data::AcceptedStructuralRowAuthority::from_catalog_selection(
5169            runtime.entity_path(),
5170            &selection,
5171        )
5172        .expect("candidate row authority should compile")
5173        .into_row_contract();
5174        let mut values = Vec::new();
5175        store
5176            .with_data(|data| {
5177                data.visit_entries(|key, row| {
5178                    let decoded = DecodedDataStoreKey::try_from_raw(key)
5179                        .expect("rewritten key should decode");
5180                    let reader =
5181                        StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
5182                            row, &contract,
5183                        )
5184                        .expect("rewritten row should use the candidate layout");
5185                    reader
5186                        .validate_primary_key(&decoded)
5187                        .expect("rewritten row and key should remain bound");
5188                    values.push(
5189                        reader
5190                            .required_value_by_contract(1)
5191                            .expect("candidate value slot should decode"),
5192                    );
5193                    Ok::<StoreVisit, InternalError>(StoreVisit::Continue)
5194                })
5195            })
5196            .expect("rewritten row scan should complete");
5197        assert_eq!(
5198            values,
5199            vec![
5200                crate::value::Value::Nat64(7),
5201                crate::value::Value::Nat64(8),
5202                crate::value::Value::Nat64(9),
5203            ],
5204        );
5205        assert_eq!(store.with_index(IndexStore::len), 3);
5206        let accepted = store
5207            .with_schema(SchemaStore::current_accepted_schema_bundle)
5208            .expect("published candidate bundle should remain readable")
5209            .expect("published candidate bundle should exist");
5210        let entity_source = EntitySourceKey::try_new("MigratingItem")
5211            .expect("migration entity source should admit");
5212        let entity_tag = accepted
5213            .source_bindings_for_tests()
5214            .entity(&entity_source)
5215            .expect("candidate entity source should remain bound");
5216        let old_value =
5217            FieldSourceKey::try_new("old_value").expect("predecessor source should admit");
5218        let current_value =
5219            FieldSourceKey::try_new("value").expect("candidate source should admit");
5220        assert_eq!(
5221            accepted
5222                .source_bindings_for_tests()
5223                .field(entity_tag, &old_value),
5224            None,
5225        );
5226        assert!(
5227            accepted
5228                .source_bindings_for_tests()
5229                .field(entity_tag, &current_value)
5230                .is_some(),
5231        );
5232        ensure_schema_migration_ready_for_ordinary_operations()
5233            .expect("terminal publication must clear the database-wide gate");
5234    }
5235
5236    #[cfg(feature = "migration")]
5237    #[test]
5238    #[expect(
5239        clippy::too_many_lines,
5240        reason = "all four finding families share one ordered historical scan fixture"
5241    )]
5242    fn physical_migration_validation_reports_every_typed_finding_family_without_writes() {
5243        use std::convert::Infallible;
5244
5245        use super::migrate_schema;
5246        use crate::db::{
5247            data::StoreVisit,
5248            schema::{SchemaMigrationCommand, SchemaMigrationFindingKind, SchemaMigrationPhase},
5249        };
5250
5251        let db = Db::<MigrationFindingCanister>::new(
5252            &MIGRATION_FINDING_REGISTRY,
5253            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5254        );
5255        drive_startup_recovery_to_completion(&db);
5256        let initial_target = schema_application_target(&db).expect("initial target should issue");
5257        let store_identity = initial_target
5258            .stores()
5259            .first()
5260            .expect("migration finding store should exist")
5261            .identity();
5262        let initial = validation_migration_proposal(
5263            ValidationMigrationShape::AllFindingFamilies,
5264            false,
5265            initial_target.accepted_head().clone(),
5266            initial_target.database_identity(),
5267            store_identity,
5268        );
5269        apply_schema(&db, &initial).expect("initial finding schema should publish");
5270
5271        let session = DbSession::<MigrationFindingCanister>::new(
5272            &MIGRATION_FINDING_REGISTRY,
5273            &crate::db::RequestExecutionRoot::__new_runtime_root(),
5274        );
5275        session
5276            .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5277                entity: "MigrationTarget".to_string(),
5278                patch: DynamicStructuralPatch::new(vec![(
5279                    "id".to_string(),
5280                    DynamicWriteCell::Value(InputValue::Nat64(7)),
5281                )]),
5282            })
5283            .expect("relation target should insert");
5284        for (id, value) in [(1, 9), (2, 8), (3, 7), (4, 7), (5, 300)] {
5285            session
5286                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5287                    entity: "MigratingItem".to_string(),
5288                    patch: DynamicStructuralPatch::new(vec![
5289                        (
5290                            "id".to_string(),
5291                            DynamicWriteCell::Value(InputValue::Nat64(id)),
5292                        ),
5293                        (
5294                            "old_value".to_string(),
5295                            DynamicWriteCell::Value(InputValue::Int64(value)),
5296                        ),
5297                    ]),
5298                })
5299                .expect("predecessor finding row should insert");
5300        }
5301        let store = db
5302            .store_handle(MIGRATION_FINDING_STORE_PATH)
5303            .expect("migration finding store should resolve");
5304        let row_bytes = || {
5305            store.with_data(|data| {
5306                let mut rows = Vec::new();
5307                let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5308                    rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5309                    Ok(StoreVisit::Continue)
5310                });
5311                result.expect("infallible row visit should complete");
5312                rows
5313            })
5314        };
5315        let before_rows = row_bytes();
5316
5317        let target = schema_application_target(&db).expect("migration target should issue");
5318        let proposal = validation_migration_proposal(
5319            ValidationMigrationShape::AllFindingFamilies,
5320            true,
5321            target.accepted_head().clone(),
5322            target.database_identity(),
5323            store_identity,
5324        );
5325        let plan = proposal
5326            .migration()
5327            .expect("migration plan should exist")
5328            .digest();
5329        let command = || SchemaMigrationCommand::Advance {
5330            expected_database: target.database_identity(),
5331            expected_head: target.accepted_head().clone(),
5332            expected_plan: plan,
5333            acknowledged_finding_page: None,
5334        };
5335        assert_eq!(
5336            migrate_schema(&db, &proposal, command())
5337                .expect("finding migration should prepare")
5338                .phase(),
5339            SchemaMigrationPhase::Prepared,
5340        );
5341        assert_eq!(
5342            migrate_schema(&db, &proposal, command())
5343                .expect("finding migration should enter validation")
5344                .phase(),
5345            SchemaMigrationPhase::Validating,
5346        );
5347        let rejected =
5348            migrate_schema(&db, &proposal, command()).expect("validation should report findings");
5349        assert_eq!(rejected.phase(), SchemaMigrationPhase::Rejected);
5350        assert_eq!(rejected.rows_validated(), 5);
5351        assert_eq!(
5352            rejected
5353                .findings()
5354                .iter()
5355                .map(crate::db::schema::SchemaMigrationFinding::kind)
5356                .collect::<Vec<_>>(),
5357            vec![
5358                SchemaMigrationFindingKind::Constraint,
5359                SchemaMigrationFindingKind::Relation,
5360                SchemaMigrationFindingKind::UniqueIndex,
5361                SchemaMigrationFindingKind::Transform,
5362            ],
5363        );
5364        assert_eq!(
5365            row_bytes(),
5366            before_rows,
5367            "rejected validation must not rewrite accepted rows"
5368        );
5369        assert_eq!(
5370            store.with_index(IndexStore::len),
5371            0,
5372            "a rejected page must not publish any staged generation"
5373        );
5374    }
5375
5376    #[cfg(feature = "migration")]
5377    #[test]
5378    fn exact_migration_retry_binds_the_terminal_head_not_the_predecessor_head() {
5379        use super::exact_migration_replay_target;
5380
5381        let db = Db::<EvolutionCanister>::new(
5382            &EVOLUTION_REGISTRY,
5383            crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5384        );
5385        drive_startup_recovery_to_completion(&db);
5386        let initial_target = schema_application_target(&db).expect("initial target should issue");
5387        let (proposal, _, _) = generated_check_proposal(
5388            initial_target.accepted_head().clone(),
5389            "migration-retry-initial",
5390            false,
5391            initial_target.database_identity(),
5392            initial_target
5393                .stores()
5394                .first()
5395                .expect("test store should exist")
5396                .identity(),
5397        );
5398        apply_schema(&db, &proposal).expect("initial schema should publish");
5399        let current_target = schema_application_target(&db).expect("current target should issue");
5400        assert_ne!(
5401            current_target.accepted_head(),
5402            initial_target.accepted_head(),
5403        );
5404
5405        let receipt = SchemaChangeReceipt::new(
5406            current_target.database_identity(),
5407            SchemaSubmissionKey::try_new("migration/retry")
5408                .expect("migration submission should admit"),
5409            SchemaProposalDigest::from_bytes([0x77; 32]),
5410            initial_target.accepted_head().clone(),
5411            SchemaChangeOutcome::Applied {
5412                accepted_head: current_target.accepted_head().clone(),
5413            },
5414        )
5415        .expect("terminal migration receipt should admit");
5416        let record = SchemaApplicationRecord::new(receipt, Vec::new())
5417            .expect("terminal migration record should admit");
5418
5419        assert_eq!(
5420            exact_migration_replay_target(&db, current_target.database_identity(), &record,)
5421                .expect("exact retry should resolve the terminal target"),
5422            current_target,
5423        );
5424    }
5425}