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