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_diagnostic, 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/// 0.211 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, 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                // Patch 4 has no nonterminal job to abort. Patch 5 introduces
839                // the bounded pre-rewrite abort state machine.
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 0.211 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    constraint_id: ConstraintId,
2677    progress: ConstraintValidationProgress,
2678) -> Result<SchemaChangeProgressStatus, InternalError> {
2679    match progress {
2680        ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
2681        ConstraintValidationProgress::Advanced {
2682            phase,
2683            rows_scanned,
2684        } => Ok(SchemaChangeProgressStatus::Advanced {
2685            phase: schema_change_validation_phase(phase),
2686            rows_scanned,
2687        }),
2688        ConstraintValidationProgress::Findings {
2689            receipt,
2690            phase,
2691            rows_scanned,
2692        } => {
2693            let activation = snapshot
2694                .constraint_catalog()
2695                .activation(constraint_id)
2696                .ok_or_else(InternalError::store_corruption)?;
2697            let findings = receipt
2698                .findings()
2699                .iter()
2700                .map(|finding| {
2701                    let primary_key = finding
2702                        .primary_key()
2703                        .encoded_primary_key_bytes()
2704                        .ok_or_else(InternalError::store_invariant)?;
2705                    constraint_validation_finding_diagnostic(
2706                        snapshot,
2707                        activation,
2708                        snapshot.entity_path(),
2709                        primary_key,
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(&EVOLUTION_REGISTRY);
3864        let empty_target =
3865            schema_application_target(&db).expect("empty evolution target should issue");
3866        let store_identity = empty_target
3867            .stores()
3868            .first()
3869            .expect("evolution store should register")
3870            .identity();
3871        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
3872            empty_target.accepted_head().clone(),
3873            "targeted-recovery-initial",
3874            SourceRuleOperation::NumericRangeInclusive {
3875                min: ScalarLiteral::Nat(0),
3876                max: ScalarLiteral::Nat(10),
3877            },
3878            empty_target.database_identity(),
3879            store_identity,
3880        );
3881        assert!(matches!(
3882            apply_schema(&db, &initial)
3883                .expect("initial targeted proposal should publish")
3884                .outcome(),
3885            SchemaChangeOutcome::Applied { .. },
3886        ));
3887
3888        let direct_target =
3889            schema_application_target(&db).expect("direct evolution target should issue");
3890        let (direct_edit, _, _) = targeted_rule_proposal(
3891            direct_target.accepted_head().clone(),
3892            "targeted-direct-edit",
3893            SourceRuleOperation::NumericMaximumInclusive {
3894                value: ScalarLiteral::Nat(8),
3895            },
3896            direct_target.database_identity(),
3897            store_identity,
3898        );
3899        assert!(matches!(
3900            apply_schema(&db, &direct_edit)
3901                .expect("empty-domain semantic edit should publish directly")
3902                .outcome(),
3903            SchemaChangeOutcome::Applied { .. },
3904        ));
3905        let store = db
3906            .store_handle(EVOLUTION_STORE_PATH)
3907            .expect("evolution store should resolve");
3908        let direct = store
3909            .with_schema(SchemaStore::current_accepted_schema_bundle)
3910            .expect("directly edited bundle should remain readable")
3911            .expect("directly edited bundle should exist");
3912        let entity_tag = direct
3913            .source_bindings_for_tests()
3914            .entity(&entity_source)
3915            .expect("entity source should remain bound");
3916        let constraint_id = direct
3917            .source_bindings_for_tests()
3918            .constraint(entity_tag, &constraint_source)
3919            .expect("direct edit should preserve constraint identity");
3920        assert!(
3921            direct.entity_snapshots()[&entity_tag]
3922                .constraint_catalog()
3923                .activation(constraint_id)
3924                .is_none()
3925        );
3926        assert!(
3927            direct.entity_snapshots()[&entity_tag]
3928                .constraints()
3929                .iter()
3930                .any(|constraint| {
3931                    constraint.id() == constraint_id
3932                        && matches!(
3933                            constraint.kind(),
3934                            AcceptedConstraintKind::TargetedRule { operation, .. }
3935                                if matches!(
3936                                    operation.as_ref(),
3937                                    AcceptedRuleOperation::NumericMaximumInclusive { .. }
3938                                )
3939                        )
3940                })
3941        );
3942
3943        let target = schema_application_target(&db).expect("staged evolution target should issue");
3944        let (edited, _, _) = targeted_rule_proposal(
3945            target.accepted_head().clone(),
3946            "targeted-recovery-edit",
3947            SourceRuleOperation::MultipleOf {
3948                divisor: ScalarLiteral::Nat(2),
3949            },
3950            target.database_identity(),
3951            store_identity,
3952        );
3953        let current = store
3954            .with_schema(SchemaStore::current_accepted_schema_bundle)
3955            .expect("accepted evolution bundle should remain readable")
3956            .expect("directly edited evolution bundle should exist");
3957        let staged = lower_existing_schema_proposal(
3958            &edited,
3959            &[ExistingProposalStore {
3960                path: EVOLUTION_STORE_PATH,
3961                identity: store_identity,
3962                bundle: &current,
3963            }],
3964        )
3965        .expect("targeted edit should stage")
3966        .pop()
3967        .expect("targeted edit should produce one candidate");
3968        assert_eq!(
3969            staged
3970                .bundle()
3971                .source_bindings_for_tests()
3972                .constraint(entity_tag, &constraint_source),
3973            Some(constraint_id),
3974        );
3975        let proof = DirectGeneratedRowLocalProof {
3976            candidate_index: 0,
3977            store,
3978            store_path: EVOLUTION_STORE_PATH,
3979            entity_tag,
3980            entity_path: staged.bundle().entity_snapshots()[&entity_tag]
3981                .entity_path()
3982                .to_string(),
3983            constraint_id,
3984            historical_rows: 0,
3985        };
3986        let final_candidates = final_candidates_for_pending_row_local_constraint(
3987            std::slice::from_ref(&staged),
3988            &PendingGeneratedRowLocalConstraint { proof },
3989        )
3990        .expect("final semantic replacement should derive without source input");
3991        let authorities = application_authorities(&db);
3992        let candidate_head =
3993            accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
3994                .expect("final candidate head should derive");
3995        let digest = edited.digest().expect("proposal digest should derive");
3996        let job_id = derive_schema_change_job_id(
3997            target.database_identity(),
3998            edited.submission_key(),
3999            digest,
4000            target.accepted_head(),
4001        )
4002        .expect("job identity should derive");
4003        let receipt = crate::db::schema::SchemaChangeReceipt::new(
4004            target.database_identity(),
4005            edited.submission_key().clone(),
4006            digest,
4007            target.accepted_head().clone(),
4008            SchemaChangeOutcome::Pending {
4009                job: SchemaChangeJob::new(job_id),
4010                candidate_head,
4011            },
4012        )
4013        .expect("pending replacement receipt should admit");
4014        let record = SchemaApplicationRecord::new(
4015            receipt,
4016            vec![
4017                SchemaChangeActivation::new(
4018                    store_identity,
4019                    entity_tag.value(),
4020                    constraint_id.get(),
4021                )
4022                .expect("replacement activation should admit"),
4023            ],
4024        )
4025        .expect("pending replacement record should admit");
4026        let operation =
4027            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4028        publish_accepted_schema_candidates_with_application_record(
4029            vec![AcceptedSchemaPublication::new(
4030                EVOLUTION_STORE_PATH,
4031                store,
4032                current.revision(),
4033                &staged,
4034            )],
4035            operation,
4036        )
4037        .expect("staged replacement and record should publish atomically");
4038
4039        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4040        ensure_recovered(&db).expect("recovery should restore the staged accepted activation");
4041
4042        let recovered = store
4043            .with_schema(SchemaStore::current_accepted_schema_bundle)
4044            .expect("recovered staged bundle should decode")
4045            .expect("recovered staged bundle should exist");
4046        let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
4047        let validating_catalog = recovered_snapshot
4048            .constraint_catalog()
4049            .clone()
4050            .with_validation_started(constraint_id)
4051            .expect("recovered replacement should enter validation");
4052        let mut validating_snapshots = recovered.entity_snapshots().clone();
4053        validating_snapshots.insert(
4054            entity_tag,
4055            recovered_snapshot.with_constraint_catalog(validating_catalog),
4056        );
4057        let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
4058            recovered
4059                .revision()
4060                .checked_next()
4061                .expect("validation revision should remain available"),
4062            recovered.store_path(),
4063            recovered.enum_catalog().clone(),
4064            recovered.composite_catalog().clone(),
4065            recovered.source_bindings_for_tests().clone(),
4066            validating_snapshots,
4067        )
4068        .expect("validating replacement bundle should close");
4069        let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
4070            .expect("validating replacement candidate should encode");
4071        let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
4072            .constraint_catalog()
4073            .activation(constraint_id)
4074            .expect("validating replacement activation should remain present");
4075        let validation_job = ConstraintValidationJob::start(
4076            entity_tag,
4077            validating_candidate.bundle().entity_snapshots()[&entity_tag]
4078                .entity_path()
4079                .to_string(),
4080            validating_activation,
4081            None,
4082        )
4083        .expect("validating replacement job should derive from accepted state");
4084        store
4085            .with_schema(|schema| {
4086                schema.validate_live_activation_transition(validating_candidate.bundle())?;
4087                schema.validate_constraint_validation_job_closure_with_change(
4088                    validating_candidate.bundle(),
4089                    Some(&validation_job),
4090                    None,
4091                )
4092            })
4093            .expect("validating replacement transition and job should close");
4094
4095        let started = continue_schema_application(&db, job_id, None).unwrap_or_else(|error| {
4096            panic!(
4097                "recovered replacement should begin validation: {:?}/{:?}",
4098                error.class(),
4099                error.origin(),
4100            )
4101        });
4102        assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4103        let mut applied = None;
4104        for _ in 0..8 {
4105            let progress = continue_schema_application(&db, job_id, None)
4106                .expect("replacement validation should advance from durable authority");
4107            if progress.status() == &SchemaChangeProgressStatus::Applied {
4108                applied = Some(progress);
4109                break;
4110            }
4111        }
4112        let applied = applied.expect("empty historical domain should promote within bounded steps");
4113        assert!(matches!(
4114            applied.receipt().outcome(),
4115            SchemaChangeOutcome::Applied { .. }
4116        ));
4117        let promoted = store
4118            .with_schema(SchemaStore::current_accepted_schema_bundle)
4119            .expect("promoted bundle should remain readable")
4120            .expect("promoted bundle should exist");
4121        let snapshot = &promoted.entity_snapshots()[&entity_tag];
4122        assert!(
4123            snapshot
4124                .constraint_catalog()
4125                .activation(constraint_id)
4126                .is_none()
4127        );
4128        assert_eq!(
4129            promoted
4130                .source_bindings_for_tests()
4131                .constraint(entity_tag, &constraint_source),
4132            Some(constraint_id),
4133        );
4134        assert!(snapshot.constraints().iter().any(|constraint| {
4135            constraint.id() == constraint_id
4136                && matches!(
4137                    constraint.kind(),
4138                    AcceptedConstraintKind::TargetedRule { operation, .. }
4139                        if matches!(
4140                            operation.as_ref(),
4141                            AcceptedRuleOperation::MultipleOf { .. }
4142                        )
4143                )
4144        }));
4145    }
4146
4147    #[test]
4148    #[allow(
4149        clippy::too_many_lines,
4150        reason = "the journaled abort, replay, and recovery assertions form one scenario"
4151    )]
4152    fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
4153        let db = Db::<AbortCanister>::new(&ABORT_REGISTRY);
4154        let empty_target =
4155            schema_application_target(&db).expect("empty application target should issue");
4156        let store_identity = empty_target
4157            .stores()
4158            .first()
4159            .expect("abort store should be registered")
4160            .identity();
4161        let (initial, entity_source, _) = generated_check_proposal(
4162            empty_target.accepted_head().clone(),
4163            "abort-runtime-initial",
4164            false,
4165            empty_target.database_identity(),
4166            store_identity,
4167        );
4168        assert!(matches!(
4169            apply_schema(&db, &initial)
4170                .expect("initial application should publish")
4171                .outcome(),
4172            SchemaChangeOutcome::Applied { .. },
4173        ));
4174
4175        let target =
4176            schema_application_target(&db).expect("existing application target should issue");
4177        let (with_check, _, check_source) = generated_check_proposal(
4178            target.accepted_head().clone(),
4179            "abort-runtime-pending",
4180            true,
4181            target.database_identity(),
4182            store_identity,
4183        );
4184        let store = db
4185            .store_handle(ABORT_STORE_PATH)
4186            .expect("abort store should resolve");
4187        let current = store
4188            .with_schema(SchemaStore::current_accepted_schema_bundle)
4189            .expect("accepted bundle should remain readable")
4190            .expect("initial accepted bundle should exist");
4191        let pending_candidate = lower_existing_schema_proposal(
4192            &with_check,
4193            &[ExistingProposalStore {
4194                path: ABORT_STORE_PATH,
4195                identity: store_identity,
4196                bundle: &current,
4197            }],
4198        )
4199        .expect("pending generated check should lower")
4200        .pop()
4201        .expect("pending generated check should produce one candidate");
4202        let entity_tag = pending_candidate
4203            .bundle()
4204            .source_bindings_for_tests()
4205            .entity(&entity_source)
4206            .expect("entity source should bind");
4207        let constraint_id = pending_candidate
4208            .bundle()
4209            .source_bindings_for_tests()
4210            .constraint(entity_tag, &check_source)
4211            .expect("generated check source should bind");
4212        let digest = with_check.digest().expect("proposal digest should derive");
4213        let job_id = derive_schema_change_job_id(
4214            target.database_identity(),
4215            with_check.submission_key(),
4216            digest,
4217            target.accepted_head(),
4218        )
4219        .expect("job identity should derive");
4220        let receipt = crate::db::schema::SchemaChangeReceipt::new(
4221            target.database_identity(),
4222            with_check.submission_key().clone(),
4223            digest,
4224            target.accepted_head().clone(),
4225            SchemaChangeOutcome::Pending {
4226                job: SchemaChangeJob::new(job_id),
4227                candidate_head: ExpectedAcceptedHead::Exact {
4228                    revision: pending_candidate.revision().get().saturating_add(2),
4229                    fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
4230                },
4231            },
4232        )
4233        .expect("pending receipt should admit");
4234        let record = SchemaApplicationRecord::new(
4235            receipt,
4236            vec![
4237                SchemaChangeActivation::new(
4238                    store_identity,
4239                    entity_tag.value(),
4240                    constraint_id.get(),
4241                )
4242                .expect("application activation should admit"),
4243            ],
4244        )
4245        .expect("pending application record should admit");
4246        let operation =
4247            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4248        publish_accepted_schema_candidates_with_application_record(
4249            vec![AcceptedSchemaPublication::new(
4250                ABORT_STORE_PATH,
4251                store,
4252                current.revision(),
4253                &pending_candidate,
4254            )],
4255            operation,
4256        )
4257        .expect("pending candidate and record should publish atomically");
4258
4259        let started = continue_schema_application(&db, job_id, None)
4260            .expect("first continuation should durably start validation");
4261        assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4262        let progress =
4263            abort_schema_application(&db, job_id, None).expect("pending application should abort");
4264        assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
4265        assert!(matches!(
4266            progress.receipt().outcome(),
4267            SchemaChangeOutcome::Aborted { .. },
4268        ));
4269        let replay =
4270            abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
4271        assert_eq!(replay, progress);
4272        assert_eq!(
4273            continue_schema_application(&db, job_id, None)
4274                .expect("continuation after abort should replay terminal state"),
4275            progress,
4276        );
4277
4278        let aborted = store
4279            .with_schema(SchemaStore::current_accepted_schema_bundle)
4280            .expect("accepted bundle should remain readable")
4281            .expect("aborted accepted bundle should exist");
4282        assert!(
4283            aborted
4284                .entity_snapshots()
4285                .get(&entity_tag)
4286                .expect("entity should remain after abort")
4287                .constraint_catalog()
4288                .activation(constraint_id)
4289                .is_none(),
4290        );
4291        assert!(
4292            aborted
4293                .source_bindings_for_tests()
4294                .constraint(entity_tag, &check_source)
4295                .is_none(),
4296        );
4297        assert!(
4298            store
4299                .with_schema(|schema| {
4300                    schema.constraint_validation_job(entity_tag, constraint_id)
4301                })
4302                .expect("validation-job storage should remain readable")
4303                .is_none(),
4304        );
4305
4306        ABORT_DATA.with(|store| {
4307            *store.borrow_mut() = DataStore::init_journaled(test_memory(180));
4308        });
4309        ABORT_INDEX.with(|store| {
4310            *store.borrow_mut() = IndexStore::init_journaled(test_memory(181));
4311        });
4312        ABORT_SCHEMA.with(|store| {
4313            *store.borrow_mut() = SchemaStore::init_journaled(test_memory(182));
4314        });
4315        ABORT_JOURNAL.with(|store| {
4316            *store.borrow_mut() = JournalTailStore::init(test_memory(183));
4317        });
4318        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4319        ensure_recovered(&db).expect("recovery should retain the terminal abort");
4320        assert_eq!(
4321            abort_schema_application(&db, job_id, None)
4322                .expect("recovered terminal abort should replay"),
4323            progress,
4324        );
4325        assert!(
4326            store
4327                .with_schema(|schema| {
4328                    schema.constraint_validation_job(entity_tag, constraint_id)
4329                })
4330                .expect("recovered validation-job storage should remain readable")
4331                .is_none(),
4332        );
4333    }
4334
4335    #[cfg(feature = "migration")]
4336    #[test]
4337    fn migration_planning_failures_retain_typed_public_classification() {
4338        use super::schema_migration_planning_error;
4339        use crate::db::schema::migration_planner::SchemaMigrationPlanningError;
4340        use icydb_diagnostic_code::{DiagnosticDetail, SchemaMigrationCode};
4341
4342        for (error, reason) in [
4343            (
4344                SchemaMigrationPlanningError::Unadopted,
4345                SchemaMigrationCode::Unadopted,
4346            ),
4347            (
4348                SchemaMigrationPlanningError::MissingMigration,
4349                SchemaMigrationCode::MissingMigration,
4350            ),
4351            (
4352                SchemaMigrationPlanningError::VersionGap,
4353                SchemaMigrationCode::VersionGap,
4354            ),
4355            (
4356                SchemaMigrationPlanningError::Downgrade,
4357                SchemaMigrationCode::Downgrade,
4358            ),
4359            (
4360                SchemaMigrationPlanningError::EmptyEntityVersionBump,
4361                SchemaMigrationCode::EmptyEntityVersionBump,
4362            ),
4363            (
4364                SchemaMigrationPlanningError::StaleAcceptedHead,
4365                SchemaMigrationCode::StaleAcceptedHead,
4366            ),
4367            (
4368                SchemaMigrationPlanningError::UnknownFromObject,
4369                SchemaMigrationCode::UnknownFromObject,
4370            ),
4371            (
4372                SchemaMigrationPlanningError::UnknownToObject,
4373                SchemaMigrationCode::UnknownToObject,
4374            ),
4375            (
4376                SchemaMigrationPlanningError::KindMismatch,
4377                SchemaMigrationCode::KindMismatch,
4378            ),
4379            (
4380                SchemaMigrationPlanningError::IdentityConflict,
4381                SchemaMigrationCode::IdentityConflict,
4382            ),
4383            (
4384                SchemaMigrationPlanningError::UnexplainedSchemaDifference,
4385                SchemaMigrationCode::UnexplainedSchemaDifference,
4386            ),
4387            (
4388                SchemaMigrationPlanningError::UnsupportedTransform,
4389                SchemaMigrationCode::UnsupportedTransform,
4390            ),
4391            (
4392                SchemaMigrationPlanningError::RekeyedCatalogInvalid,
4393                SchemaMigrationCode::CandidateMismatch,
4394            ),
4395            (
4396                SchemaMigrationPlanningError::CandidateMismatch,
4397                SchemaMigrationCode::CandidateMismatch,
4398            ),
4399            (
4400                SchemaMigrationPlanningError::CorruptLineage,
4401                SchemaMigrationCode::ProgressCorrupt,
4402            ),
4403        ] {
4404            let diagnostic = schema_migration_planning_error(error).diagnostic();
4405            assert_eq!(
4406                diagnostic.detail(),
4407                Some(&DiagnosticDetail::SchemaMigration { reason }),
4408            );
4409            assert_eq!(diagnostic.code(), reason.diagnostic_code());
4410        }
4411    }
4412
4413    #[cfg(feature = "migration")]
4414    #[test]
4415    #[expect(
4416        clippy::too_many_lines,
4417        reason = "the validation replay, staging, and unchanged-row assertions form one scenario"
4418    )]
4419    fn physical_migration_validation_is_bounded_staged_and_does_not_rewrite_rows() {
4420        use std::convert::Infallible;
4421
4422        use super::migrate_schema;
4423        use crate::db::{
4424            data::StoreVisit,
4425            index::{IndexEntryValue, IndexId, IndexKey, IndexKeyKind},
4426            key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
4427            schema::{SchemaMigrationCommand, SchemaMigrationPhase},
4428        };
4429        use crate::types::EntityTag;
4430
4431        let db = Db::<MigrationCanister>::new(&MIGRATION_REGISTRY);
4432        ensure_recovered(&db).expect("migration database should initialize");
4433        let initial_target = schema_application_target(&db).expect("initial target should issue");
4434        let store_identity = initial_target
4435            .stores()
4436            .first()
4437            .expect("migration store should exist")
4438            .identity();
4439        let initial = validation_migration_proposal(
4440            ValidationMigrationShape::Clean,
4441            false,
4442            initial_target.accepted_head().clone(),
4443            initial_target.database_identity(),
4444            store_identity,
4445        );
4446        apply_schema(&db, &initial).expect("initial schema should publish");
4447
4448        let session = DbSession::<MigrationCanister>::new(&MIGRATION_REGISTRY);
4449        for (id, value) in [(1, 7), (2, 8)] {
4450            session
4451                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4452                    entity: "MigratingItem".to_string(),
4453                    patch: DynamicStructuralPatch::new(vec![
4454                        (
4455                            "id".to_string(),
4456                            DynamicWriteCell::Value(InputValue::Nat64(id)),
4457                        ),
4458                        (
4459                            "old_value".to_string(),
4460                            DynamicWriteCell::Value(InputValue::Int64(value)),
4461                        ),
4462                    ]),
4463                })
4464                .expect("predecessor row should insert");
4465        }
4466        let store = db
4467            .store_handle(MIGRATION_STORE_PATH)
4468            .expect("migration store should resolve");
4469        let row_bytes = || {
4470            store.with_data(|data| {
4471                let mut rows = Vec::new();
4472                let result: Result<(), Infallible> = data.visit_entries(|key, row| {
4473                    rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
4474                    Ok(StoreVisit::Continue)
4475                });
4476                result.expect("infallible row visit should complete");
4477                rows
4478            })
4479        };
4480        let before_rows = row_bytes();
4481
4482        let target = schema_application_target(&db).expect("migration target should issue");
4483        let proposal = validation_migration_proposal(
4484            ValidationMigrationShape::Clean,
4485            true,
4486            target.accepted_head().clone(),
4487            target.database_identity(),
4488            store_identity,
4489        );
4490        let plan = proposal
4491            .migration()
4492            .expect("migration plan should exist")
4493            .digest();
4494        let command = || SchemaMigrationCommand::Advance {
4495            expected_database: target.database_identity(),
4496            expected_head: target.accepted_head().clone(),
4497            expected_plan: plan,
4498            acknowledged_finding_page: None,
4499        };
4500        assert_eq!(
4501            migrate_schema(&db, &proposal, command())
4502                .unwrap_or_else(|error| {
4503                    panic!(
4504                        "physical migration should prepare: {:?}",
4505                        error.diagnostic()
4506                    )
4507                })
4508                .phase(),
4509            SchemaMigrationPhase::Prepared,
4510        );
4511        assert_eq!(
4512            migrate_schema(&db, &proposal, command())
4513                .expect("physical migration should enter validation")
4514                .phase(),
4515            SchemaMigrationPhase::Validating,
4516        );
4517        let record = super::load_schema_migration_record()
4518            .expect("migration record should remain readable")
4519            .expect("validating migration record should exist");
4520        let planned = super::recompile_active_physical_migration(&db, &proposal, &record)
4521            .expect("the exact active plan should recompile");
4522        for _ in 0..2 {
4523            let page = super::validate_migration_page(&db, &planned, record.progress())
4524                .expect("the same validation page should remain replayable");
4525            let (progress, staged, exhausted) = page.into_parts();
4526            assert!(progress.findings().is_empty());
4527            assert!(exhausted);
4528            super::stage_migration_index_entries(staged)
4529                .expect("staging before a cursor marker should be idempotent");
4530        }
4531        assert_eq!(
4532            store.with_index(IndexStore::len),
4533            2,
4534            "replaying an uncheckpointed page must retain one exact staged key per row",
4535        );
4536        let ready =
4537            migrate_schema(&db, &proposal, command()).expect("bounded validation should complete");
4538        assert_eq!(ready.phase(), SchemaMigrationPhase::ReadyToRewrite);
4539        assert_eq!(ready.rows_validated(), 2);
4540        assert!(ready.findings().is_empty());
4541        assert_eq!(row_bytes(), before_rows, "validation must not rewrite rows");
4542        assert_eq!(
4543            store.with_index(IndexStore::len),
4544            2,
4545            "the isolated candidate unique generation should be durably staged",
4546        );
4547        store.with_index_mut(|index| {
4548            for ordinal in 0..513_u64 {
4549                let component = ordinal.to_be_bytes();
4550                let key = IndexKey::new_from_components_with_primary_key_value(
4551                    &IndexId::new(EntityTag::new(2), 0),
4552                    IndexKeyKind::User,
4553                    &[component],
4554                    &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
4555                )
4556                .expect("unrelated abort-scan key should build")
4557                .to_raw()
4558                .expect("unrelated abort-scan key should encode");
4559                index.insert(key, IndexEntryValue::presence());
4560            }
4561        });
4562        let abort = || SchemaMigrationCommand::Abort {
4563            expected_database: target.database_identity(),
4564            expected_head: target.accepted_head().clone(),
4565            expected_plan: plan,
4566        };
4567        let cleaning = migrate_schema(&db, &proposal, abort())
4568            .expect("the first bounded abort cleanup page should publish");
4569        assert_eq!(cleaning.phase(), SchemaMigrationPhase::ReadyToRewrite);
4570        assert_eq!(store.with_index(IndexStore::len), 513);
4571        let aborted =
4572            migrate_schema(&db, &proposal, abort()).expect("pre-rewrite migration should abort");
4573        assert_eq!(aborted.phase(), SchemaMigrationPhase::Aborted);
4574        assert_eq!(
4575            store.with_index(IndexStore::len),
4576            513,
4577            "abort must remove only planner-invisible candidate generations",
4578        );
4579        assert_eq!(
4580            row_bytes(),
4581            before_rows,
4582            "abort must retain predecessor rows"
4583        );
4584    }
4585
4586    #[cfg(feature = "migration")]
4587    #[test]
4588    #[expect(
4589        clippy::too_many_lines,
4590        reason = "the interrupted rewrite, recovery, final proof, and publication form one scenario"
4591    )]
4592    fn physical_migration_rewrite_recovers_and_publishes_one_complete_candidate() {
4593        use super::{
4594            defer_generated_schema_application_for_prepared_migration, migrate_schema,
4595            schema_migration_status_for_target,
4596        };
4597        use crate::db::{
4598            data::{CanonicalSlotReader, DecodedDataStoreKey, StoreVisit, StructuralSlotReader},
4599            schema::{
4600                MigrationRewriteInterruption, SchemaMigrationCommand, SchemaMigrationPhase,
4601                ensure_schema_migration_ready_for_ordinary_operations,
4602                interrupt_next_migration_rewrite_at,
4603            },
4604        };
4605        use crate::error::InternalError;
4606
4607        let db = Db::<MigrationExecutionCanister>::new(&MIGRATION_EXECUTION_REGISTRY);
4608        ensure_recovered(&db).expect("migration execution database should initialize");
4609        let initial_target = schema_application_target(&db).expect("initial target should issue");
4610        let store_identity = initial_target
4611            .stores()
4612            .first()
4613            .expect("migration execution store should exist")
4614            .identity();
4615        let initial = validation_migration_proposal(
4616            ValidationMigrationShape::Clean,
4617            false,
4618            initial_target.accepted_head().clone(),
4619            initial_target.database_identity(),
4620            store_identity,
4621        );
4622        apply_schema(&db, &initial).expect("initial schema should publish");
4623        let session = DbSession::<MigrationExecutionCanister>::new(&MIGRATION_EXECUTION_REGISTRY);
4624        for (id, value) in [(1, 7), (2, 8), (3, 9)] {
4625            session
4626                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4627                    entity: "MigratingItem".to_string(),
4628                    patch: DynamicStructuralPatch::new(vec![
4629                        (
4630                            "id".to_string(),
4631                            DynamicWriteCell::Value(InputValue::Nat64(id)),
4632                        ),
4633                        (
4634                            "old_value".to_string(),
4635                            DynamicWriteCell::Value(InputValue::Int64(value)),
4636                        ),
4637                    ]),
4638                })
4639                .expect("predecessor row should insert");
4640        }
4641        let target = schema_application_target(&db).expect("migration target should issue");
4642        let proposal = validation_migration_proposal(
4643            ValidationMigrationShape::Clean,
4644            true,
4645            target.accepted_head().clone(),
4646            target.database_identity(),
4647            store_identity,
4648        );
4649        let plan = proposal
4650            .migration()
4651            .expect("migration plan should exist")
4652            .digest();
4653        let command = || SchemaMigrationCommand::Advance {
4654            expected_database: target.database_identity(),
4655            expected_head: target.accepted_head().clone(),
4656            expected_plan: plan,
4657            acknowledged_finding_page: None,
4658        };
4659        for expected in [
4660            SchemaMigrationPhase::Prepared,
4661            SchemaMigrationPhase::Validating,
4662            SchemaMigrationPhase::ReadyToRewrite,
4663            SchemaMigrationPhase::RewritingRows,
4664        ] {
4665            assert_eq!(
4666                migrate_schema(&db, &proposal, command())
4667                    .expect("migration phase should advance")
4668                    .phase(),
4669                expected,
4670            );
4671        }
4672
4673        for interruption in [
4674            MigrationRewriteInterruption::MarkerPersisted,
4675            MigrationRewriteInterruption::JournalPublished,
4676            MigrationRewriteInterruption::PhysicalApplied,
4677        ] {
4678            interrupt_next_migration_rewrite_at(interruption);
4679            migrate_schema(&db, &proposal, command())
4680                .expect_err("injected interruption should retain the rewrite marker");
4681
4682            forget_recovered_domain_for_tests(&db)
4683                .expect("upgrade should reset recovery ownership");
4684            ensure_recovered(&db).unwrap_or_else(|error| {
4685                panic!(
4686                    "recovery should finish the exact marker-bound rewrite page: {:?}",
4687                    error.diagnostic(),
4688                )
4689            });
4690        }
4691
4692        let rebuilding = schema_migration_status_for_target(
4693            &db,
4694            &proposal,
4695            &schema_application_target(&db).expect("recovered target should issue"),
4696        )
4697        .expect("recovered status should remain readable");
4698        assert_eq!(rebuilding.phase(), SchemaMigrationPhase::RebuildingIndexes);
4699        assert_eq!(rebuilding.rows_rewritten(), 3);
4700        assert_eq!(
4701            migrate_schema(&db, &proposal, command())
4702                .expect("derived generations should complete")
4703                .phase(),
4704            SchemaMigrationPhase::FinalValidation,
4705        );
4706        assert_eq!(
4707            migrate_schema(&db, &proposal, command())
4708                .expect("final validation should complete")
4709                .phase(),
4710            SchemaMigrationPhase::Publishing,
4711        );
4712        let applied = migrate_schema(&db, &proposal, command())
4713            .expect("candidate publication should complete atomically");
4714        assert_eq!(applied.phase(), SchemaMigrationPhase::Applied);
4715        assert_eq!(applied.rows_rewritten(), 3);
4716        assert_eq!(applied.indexes_rebuilt(), 1);
4717        assert_ne!(applied.accepted_head(), target.accepted_head());
4718        let terminal_target = schema_application_target(&db).expect("terminal target should issue");
4719        let terminal_proposal = validation_migration_proposal(
4720            ValidationMigrationShape::Clean,
4721            true,
4722            terminal_target.accepted_head().clone(),
4723            terminal_target.database_identity(),
4724            store_identity,
4725        );
4726        assert!(
4727            !defer_generated_schema_application_for_prepared_migration(&db, &terminal_proposal,)
4728                .expect("terminal record must not block generated startup"),
4729        );
4730
4731        let store = db
4732            .store_handle(MIGRATION_EXECUTION_STORE_PATH)
4733            .expect("migration execution store should resolve");
4734        let runtime = db
4735            .accepted_runtime_entity_for_path("MigratingItem")
4736            .expect("published candidate entity should resolve");
4737        let selection = store
4738            .with_schema(|schema| {
4739                schema.current_accepted_catalog_selection(
4740                    runtime.entity_tag(),
4741                    runtime.entity_path(),
4742                    runtime.store_path(),
4743                )
4744            })
4745            .expect("candidate selection should remain readable")
4746            .expect("candidate selection should exist");
4747        let contract = crate::db::data::AcceptedStructuralRowAuthority::from_catalog_selection(
4748            runtime.entity_path(),
4749            &selection,
4750        )
4751        .expect("candidate row authority should compile")
4752        .into_row_contract();
4753        let mut values = Vec::new();
4754        store
4755            .with_data(|data| {
4756                data.visit_entries(|key, row| {
4757                    let decoded = DecodedDataStoreKey::try_from_raw(key)
4758                        .expect("rewritten key should decode");
4759                    let reader =
4760                        StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
4761                            row, &contract,
4762                        )
4763                        .expect("rewritten row should use the candidate layout");
4764                    reader
4765                        .validate_primary_key(&decoded)
4766                        .expect("rewritten row and key should remain bound");
4767                    values.push(
4768                        reader
4769                            .required_value_by_contract(1)
4770                            .expect("candidate value slot should decode"),
4771                    );
4772                    Ok::<StoreVisit, InternalError>(StoreVisit::Continue)
4773                })
4774            })
4775            .expect("rewritten row scan should complete");
4776        assert_eq!(
4777            values,
4778            vec![
4779                crate::value::Value::Nat64(7),
4780                crate::value::Value::Nat64(8),
4781                crate::value::Value::Nat64(9),
4782            ],
4783        );
4784        assert_eq!(store.with_index(IndexStore::len), 3);
4785        let accepted = store
4786            .with_schema(SchemaStore::current_accepted_schema_bundle)
4787            .expect("published candidate bundle should remain readable")
4788            .expect("published candidate bundle should exist");
4789        let entity_source = EntitySourceKey::try_new("MigratingItem")
4790            .expect("migration entity source should admit");
4791        let entity_tag = accepted
4792            .source_bindings_for_tests()
4793            .entity(&entity_source)
4794            .expect("candidate entity source should remain bound");
4795        let old_value =
4796            FieldSourceKey::try_new("old_value").expect("predecessor source should admit");
4797        let current_value =
4798            FieldSourceKey::try_new("value").expect("candidate source should admit");
4799        assert_eq!(
4800            accepted
4801                .source_bindings_for_tests()
4802                .field(entity_tag, &old_value),
4803            None,
4804        );
4805        assert!(
4806            accepted
4807                .source_bindings_for_tests()
4808                .field(entity_tag, &current_value)
4809                .is_some(),
4810        );
4811        ensure_schema_migration_ready_for_ordinary_operations()
4812            .expect("terminal publication must clear the database-wide gate");
4813    }
4814
4815    #[cfg(feature = "migration")]
4816    #[test]
4817    #[expect(
4818        clippy::too_many_lines,
4819        reason = "all four finding families share one ordered historical scan fixture"
4820    )]
4821    fn physical_migration_validation_reports_every_typed_finding_family_without_writes() {
4822        use std::convert::Infallible;
4823
4824        use super::migrate_schema;
4825        use crate::db::{
4826            data::StoreVisit,
4827            schema::{SchemaMigrationCommand, SchemaMigrationFindingKind, SchemaMigrationPhase},
4828        };
4829
4830        let db = Db::<MigrationFindingCanister>::new(&MIGRATION_FINDING_REGISTRY);
4831        ensure_recovered(&db).expect("migration finding database should initialize");
4832        let initial_target = schema_application_target(&db).expect("initial target should issue");
4833        let store_identity = initial_target
4834            .stores()
4835            .first()
4836            .expect("migration finding store should exist")
4837            .identity();
4838        let initial = validation_migration_proposal(
4839            ValidationMigrationShape::AllFindingFamilies,
4840            false,
4841            initial_target.accepted_head().clone(),
4842            initial_target.database_identity(),
4843            store_identity,
4844        );
4845        apply_schema(&db, &initial).expect("initial finding schema should publish");
4846
4847        let session = DbSession::<MigrationFindingCanister>::new(&MIGRATION_FINDING_REGISTRY);
4848        session
4849            .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4850                entity: "MigrationTarget".to_string(),
4851                patch: DynamicStructuralPatch::new(vec![(
4852                    "id".to_string(),
4853                    DynamicWriteCell::Value(InputValue::Nat64(7)),
4854                )]),
4855            })
4856            .expect("relation target should insert");
4857        for (id, value) in [(1, 9), (2, 8), (3, 7), (4, 7), (5, 300)] {
4858            session
4859                .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4860                    entity: "MigratingItem".to_string(),
4861                    patch: DynamicStructuralPatch::new(vec![
4862                        (
4863                            "id".to_string(),
4864                            DynamicWriteCell::Value(InputValue::Nat64(id)),
4865                        ),
4866                        (
4867                            "old_value".to_string(),
4868                            DynamicWriteCell::Value(InputValue::Int64(value)),
4869                        ),
4870                    ]),
4871                })
4872                .expect("predecessor finding row should insert");
4873        }
4874        let store = db
4875            .store_handle(MIGRATION_FINDING_STORE_PATH)
4876            .expect("migration finding store should resolve");
4877        let row_bytes = || {
4878            store.with_data(|data| {
4879                let mut rows = Vec::new();
4880                let result: Result<(), Infallible> = data.visit_entries(|key, row| {
4881                    rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
4882                    Ok(StoreVisit::Continue)
4883                });
4884                result.expect("infallible row visit should complete");
4885                rows
4886            })
4887        };
4888        let before_rows = row_bytes();
4889
4890        let target = schema_application_target(&db).expect("migration target should issue");
4891        let proposal = validation_migration_proposal(
4892            ValidationMigrationShape::AllFindingFamilies,
4893            true,
4894            target.accepted_head().clone(),
4895            target.database_identity(),
4896            store_identity,
4897        );
4898        let plan = proposal
4899            .migration()
4900            .expect("migration plan should exist")
4901            .digest();
4902        let command = || SchemaMigrationCommand::Advance {
4903            expected_database: target.database_identity(),
4904            expected_head: target.accepted_head().clone(),
4905            expected_plan: plan,
4906            acknowledged_finding_page: None,
4907        };
4908        assert_eq!(
4909            migrate_schema(&db, &proposal, command())
4910                .expect("finding migration should prepare")
4911                .phase(),
4912            SchemaMigrationPhase::Prepared,
4913        );
4914        assert_eq!(
4915            migrate_schema(&db, &proposal, command())
4916                .expect("finding migration should enter validation")
4917                .phase(),
4918            SchemaMigrationPhase::Validating,
4919        );
4920        let rejected =
4921            migrate_schema(&db, &proposal, command()).expect("validation should report findings");
4922        assert_eq!(rejected.phase(), SchemaMigrationPhase::Rejected);
4923        assert_eq!(rejected.rows_validated(), 5);
4924        assert_eq!(
4925            rejected
4926                .findings()
4927                .iter()
4928                .map(crate::db::schema::SchemaMigrationFinding::kind)
4929                .collect::<Vec<_>>(),
4930            vec![
4931                SchemaMigrationFindingKind::Constraint,
4932                SchemaMigrationFindingKind::Relation,
4933                SchemaMigrationFindingKind::UniqueIndex,
4934                SchemaMigrationFindingKind::Transform,
4935            ],
4936        );
4937        assert_eq!(
4938            row_bytes(),
4939            before_rows,
4940            "rejected validation must not rewrite accepted rows"
4941        );
4942        assert_eq!(
4943            store.with_index(IndexStore::len),
4944            0,
4945            "a rejected page must not publish any staged generation"
4946        );
4947    }
4948
4949    #[cfg(feature = "migration")]
4950    #[test]
4951    fn exact_migration_retry_binds_the_terminal_head_not_the_predecessor_head() {
4952        use super::exact_migration_replay_target;
4953
4954        let db = Db::<EvolutionCanister>::new(&EVOLUTION_REGISTRY);
4955        ensure_recovered(&db).expect("test database should initialize");
4956        let initial_target = schema_application_target(&db).expect("initial target should issue");
4957        let (proposal, _, _) = generated_check_proposal(
4958            initial_target.accepted_head().clone(),
4959            "migration-retry-initial",
4960            false,
4961            initial_target.database_identity(),
4962            initial_target
4963                .stores()
4964                .first()
4965                .expect("test store should exist")
4966                .identity(),
4967        );
4968        apply_schema(&db, &proposal).expect("initial schema should publish");
4969        let current_target = schema_application_target(&db).expect("current target should issue");
4970        assert_ne!(
4971            current_target.accepted_head(),
4972            initial_target.accepted_head(),
4973        );
4974
4975        let receipt = SchemaChangeReceipt::new(
4976            current_target.database_identity(),
4977            SchemaSubmissionKey::try_new("migration/retry")
4978                .expect("migration submission should admit"),
4979            SchemaProposalDigest::from_bytes([0x77; 32]),
4980            initial_target.accepted_head().clone(),
4981            SchemaChangeOutcome::Applied {
4982                accepted_head: current_target.accepted_head().clone(),
4983            },
4984        )
4985        .expect("terminal migration receipt should admit");
4986        let record = SchemaApplicationRecord::new(receipt, Vec::new())
4987            .expect("terminal migration record should admit");
4988
4989        assert_eq!(
4990            exact_migration_replay_target(&db, current_target.database_identity(), &record,)
4991                .expect("exact retry should resolve the terminal target"),
4992            current_target,
4993        );
4994    }
4995}