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