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