Skip to main content

icydb_core/db/schema/
application.rs

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