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