Skip to main content

icydb_core/db/schema/
application.rs

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