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, database_incarnation_id, ensure_recovered,
15            publish_accepted_schema_candidates_with_application_record,
16            publish_generated_row_local_abort_with_application_record,
17        },
18        data::DataStore,
19        index::{IndexState, IndexStore},
20        registry::{
21            StoreAllocationIdentity, StoreAllocationIdentityCapability, StoreCommitParticipation,
22            StoreDurability, StoreHandle, StoreRecoveryCapability, StoreRelationSourceCapability,
23            StoreRelationTargetCapability, StoreRuntimeStorageMode, StoreSchemaMetadataCapability,
24        },
25        relation::prove_empty_reverse_relation_domain,
26        schema::{
27            AcceptedSchemaRevision, AcceptedSchemaRevisionBundle, CandidateSchemaRevision,
28            ConstraintActivationKind, ConstraintActivationState, ConstraintId, ConstraintOrigin,
29            ConstraintValidationPhase, ConstraintValidationProgress, ExistingProposalStore,
30            MAX_IDENTITY_STATE_RECORDS_PER_DATABASE, ProposalStoreTarget, SchemaApplicationRecord,
31            SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob, SchemaChangeJobId,
32            SchemaChangeOutcome, SchemaChangeProgress, SchemaChangeProgressStatus,
33            SchemaChangeReceipt, SchemaChangeValidationPhase, StagedUserIndexDomainError,
34            UnpublishedRowLocalValidation, advance_accepted_row_local_constraint_activation,
35            constraint_validation_finding_diagnostic, derive_schema_change_job_id,
36            lower_existing_schema_proposal, lower_initial_schema_proposal,
37            prove_empty_user_index_domain, validate_unpublished_row_local_candidate_bounded,
38            with_schema_application_store,
39        },
40    },
41    error::InternalError,
42    traits::CanisterKind,
43    types::EntityTag,
44};
45use candid::CandidType;
46use icydb_schema::{
47    ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposal, SchemaProposalDigest,
48    SchemaSubmissionKey, TargetDatabaseIdentity, TargetStoreIdentity,
49};
50use serde::Deserialize;
51use sha2::Digest;
52
53const DATABASE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.database.v1";
54const STORE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.store.v1";
55const ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE: &[u8] = b"icydb.accepted-schema.database-head.v1";
56
57///
58/// SchemaApplicationStore
59///
60/// One registered store path paired with the opaque routing token accepted by
61/// the current database incarnation.
62///
63
64#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
65pub struct SchemaApplicationStore {
66    path: String,
67    identity: TargetStoreIdentity,
68}
69
70impl SchemaApplicationStore {
71    /// Borrow the registered store path.
72    #[must_use]
73    pub const fn path(&self) -> &str {
74        self.path.as_str()
75    }
76
77    /// Return the opaque routing identity for this store.
78    #[must_use]
79    pub const fn identity(&self) -> TargetStoreIdentity {
80        self.identity
81    }
82}
83
84///
85/// SchemaApplicationTarget
86///
87/// Point-in-time optimistic application context issued from recovered runtime
88/// authority. Callers compose proposals against these opaque identities and
89/// this exact database-wide accepted head.
90///
91
92#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
93pub struct SchemaApplicationTarget {
94    database_identity: TargetDatabaseIdentity,
95    accepted_head: ExpectedAcceptedHead,
96    stores: Vec<SchemaApplicationStore>,
97}
98
99impl SchemaApplicationTarget {
100    /// Return the opaque current database identity.
101    #[must_use]
102    pub const fn database_identity(&self) -> TargetDatabaseIdentity {
103        self.database_identity
104    }
105
106    /// Borrow the exact optimistic accepted head.
107    #[must_use]
108    pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
109        &self.accepted_head
110    }
111
112    /// Borrow registered stores in canonical path order.
113    #[must_use]
114    pub const fn stores(&self) -> &[SchemaApplicationStore] {
115        self.stores.as_slice()
116    }
117}
118
119///
120/// StoreApplicationAuthority
121///
122/// Canonically ordered registry facts used to derive opaque proposal routing
123/// identities without exposing physical allocation details.
124///
125
126#[derive(Clone, Copy)]
127struct StoreApplicationAuthority {
128    path: &'static str,
129    handle: StoreHandle,
130}
131
132/// Catalog authority resolved for one pending generated row-local abort.
133struct PendingApplicationAbort {
134    authority: StoreApplicationAuthority,
135    current: AcceptedSchemaRevisionBundle,
136    entity_tag: EntityTag,
137    constraint_id: ConstraintId,
138    remove_validation_job: bool,
139}
140
141///
142/// AcceptedStoreHead
143///
144/// Exact store-local root facts contributing to the database-wide optimistic
145/// accepted head. Absence is represented explicitly by the enclosing option.
146///
147
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149struct AcceptedStoreHead {
150    revision: u64,
151    fingerprint: [u8; 32],
152}
153
154/// One new generated row-local activation awaiting direct bounded proof.
155#[derive(Clone)]
156struct DirectGeneratedRowLocalProof {
157    candidate_index: usize,
158    store: StoreHandle,
159    store_path: &'static str,
160    entity_tag: crate::types::EntityTag,
161    entity_path: String,
162    constraint_id: ConstraintId,
163    historical_rows: u64,
164}
165
166/// One generated row-local constraint whose proof requires durable continuation.
167#[derive(Clone)]
168struct PendingGeneratedRowLocalConstraint {
169    proof: DirectGeneratedRowLocalProof,
170}
171
172/// Catalog-native application staging retained until marker publication.
173struct LoweredApplication {
174    current_bundles: Vec<Option<AcceptedSchemaRevisionBundle>>,
175    candidates: Vec<CandidateSchemaRevision>,
176    pending: Option<PendingGeneratedRowLocalConstraint>,
177}
178
179/// Issue the current proposal-application target from recovered authority.
180pub(in crate::db) fn schema_application_target<C: CanisterKind>(
181    db: &Db<C>,
182) -> Result<SchemaApplicationTarget, InternalError> {
183    ensure_recovered(db)?;
184    let incarnation = database_incarnation_id()?;
185    let mut stores = db.with_store_registry(|registry| {
186        registry
187            .iter()
188            .map(|(path, handle)| StoreApplicationAuthority { path, handle })
189            .collect::<Vec<_>>()
190    });
191    stores.sort_by(|left, right| left.path.cmp(right.path));
192
193    let database_identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
194    let mut accepted_heads = Vec::with_capacity(stores.len());
195    let mut application_stores = Vec::with_capacity(stores.len());
196    for store in &stores {
197        let root = store
198            .handle
199            .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
200            .map(|selection| AcceptedStoreHead {
201                revision: selection.root().revision().get(),
202                fingerprint: selection.root().fingerprint().as_bytes(),
203            });
204        accepted_heads.push((store.path, root));
205        application_stores.push(SchemaApplicationStore {
206            path: store.path.to_string(),
207            identity: derive_store_identity(database_identity, store),
208        });
209    }
210
211    Ok(SchemaApplicationTarget {
212        database_identity,
213        accepted_head: derive_accepted_head(accepted_heads.as_slice()),
214        stores: application_stores,
215    })
216}
217
218/// Load one durable schema-application receipt by its exact idempotency
219/// identity.
220pub(in crate::db) fn schema_application_receipt<C: CanisterKind>(
221    db: &Db<C>,
222    database_identity: TargetDatabaseIdentity,
223    submission_key: &SchemaSubmissionKey,
224) -> Result<Option<SchemaChangeReceipt>, InternalError> {
225    ensure_recovered(db)?;
226    with_schema_application_store(|store| {
227        store
228            .load(database_identity, submission_key)
229            .map(|record| record.map(|record| record.receipt().clone()))
230    })
231}
232
233fn exact_schema_application_receipt(
234    proposal: &SchemaProposal,
235    proposal_digest: SchemaProposalDigest,
236) -> Result<Option<SchemaChangeReceipt>, InternalError> {
237    let Some(record) = with_schema_application_store(|store| {
238        store.load(proposal.target_database(), proposal.submission_key())
239    })?
240    else {
241        return Ok(None);
242    };
243    let receipt = record.receipt();
244    if !receipt.is_exact_submission(
245        proposal.target_database(),
246        proposal.submission_key(),
247        proposal_digest,
248        proposal.expected_head(),
249    ) {
250        return Err(InternalError::schema_application_conflict());
251    }
252    Ok(Some(receipt.clone()))
253}
254
255/// Advance one durable pending schema application by at most one canonical
256/// 0.211 validation step.
257pub(in crate::db) fn continue_schema_application<C: CanisterKind>(
258    db: &Db<C>,
259    job_id: SchemaChangeJobId,
260    acknowledged_receipt: Option<u64>,
261) -> Result<SchemaChangeProgress, InternalError> {
262    ensure_recovered(db)?;
263    let record = with_schema_application_store(|store| store.load_job(job_id))?
264        .ok_or_else(InternalError::schema_application_conflict)?;
265    let target = schema_application_target(db)?;
266    if target.database_identity() != record.receipt().database_identity() {
267        return Err(InternalError::schema_application_conflict());
268    }
269    let candidate_head = match record.receipt().outcome() {
270        SchemaChangeOutcome::Pending {
271            job,
272            candidate_head,
273        } if job.id() == job_id => candidate_head,
274        SchemaChangeOutcome::Applied { .. } => {
275            return Ok(SchemaChangeProgress::new(
276                record.receipt().clone(),
277                SchemaChangeProgressStatus::Applied,
278            ));
279        }
280        SchemaChangeOutcome::Aborted { .. } => {
281            return Ok(SchemaChangeProgress::new(
282                record.receipt().clone(),
283                SchemaChangeProgressStatus::Aborted,
284            ));
285        }
286        _ => return Err(InternalError::store_corruption()),
287    };
288    let [activation] = record.activations() else {
289        return Err(InternalError::store_corruption());
290    };
291    let authorities = application_authorities(db);
292    let authority = authorities
293        .iter()
294        .find(|authority| {
295            derive_store_identity(target.database_identity(), authority) == activation.store()
296        })
297        .ok_or_else(InternalError::store_corruption)?;
298    let entity_tag = EntityTag::new(activation.entity_tag());
299    let constraint_id = ConstraintId::new(activation.constraint_id())
300        .ok_or_else(InternalError::store_corruption)?;
301    let bundle = authority
302        .handle
303        .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
304        .ok_or_else(InternalError::store_corruption)?;
305    if bundle.store_path() != authority.path {
306        return Err(InternalError::store_corruption());
307    }
308    let snapshot = bundle
309        .entity_snapshots()
310        .get(&entity_tag)
311        .ok_or_else(InternalError::store_corruption)?;
312
313    let accepted = snapshot
314        .constraint_catalog()
315        .constraints()
316        .iter()
317        .any(|constraint| {
318            constraint.id() == constraint_id
319                && constraint.origin() == ConstraintOrigin::Generated
320                && matches!(
321                    constraint.kind(),
322                    crate::db::schema::AcceptedConstraintKind::Check { .. }
323                        | crate::db::schema::AcceptedConstraintKind::TargetedRule { .. }
324                )
325        });
326    let pending = snapshot.constraint_catalog().activation(constraint_id);
327    if accepted && pending.is_none() {
328        return finalize_schema_application(
329            db,
330            &record,
331            candidate_head,
332            SchemaChangeProgressStatus::Applied,
333        );
334    }
335    let pending = pending.ok_or_else(InternalError::store_corruption)?;
336    if pending.origin() != ConstraintOrigin::Generated
337        || !matches!(
338            pending.kind(),
339            ConstraintActivationKind::Check { .. } | ConstraintActivationKind::TargetedRule { .. }
340        )
341    {
342        return Err(InternalError::store_corruption());
343    }
344    let entity_path = snapshot.entity_path().to_string();
345    let progress = advance_accepted_row_local_constraint_activation(
346        authority.handle,
347        authority.path,
348        entity_tag,
349        entity_path.as_str(),
350        constraint_id,
351        acknowledged_receipt,
352    )?;
353    let status = schema_change_progress_status(snapshot, constraint_id, progress)?;
354    if status == SchemaChangeProgressStatus::Applied {
355        finalize_schema_application(db, &record, candidate_head, status)
356    } else {
357        Ok(SchemaChangeProgress::new(record.receipt().clone(), status))
358    }
359}
360
361/// Abort one pending generated row-local application.
362///
363/// A retained finding page must be acknowledged by exact sequence before the
364/// activation and its validation job can be retired. Terminal outcomes replay
365/// without mutating accepted authority.
366pub(in crate::db) fn abort_schema_application<C: CanisterKind>(
367    db: &Db<C>,
368    job_id: SchemaChangeJobId,
369    acknowledged_receipt: Option<u64>,
370) -> Result<SchemaChangeProgress, InternalError> {
371    ensure_recovered(db)?;
372    let record = with_schema_application_store(|store| store.load_job(job_id))?
373        .ok_or_else(InternalError::schema_application_conflict)?;
374    let target = schema_application_target(db)?;
375    if target.database_identity() != record.receipt().database_identity() {
376        return Err(InternalError::schema_application_conflict());
377    }
378    match record.receipt().outcome() {
379        SchemaChangeOutcome::Applied { .. } => {
380            return Ok(SchemaChangeProgress::new(
381                record.receipt().clone(),
382                SchemaChangeProgressStatus::Applied,
383            ));
384        }
385        SchemaChangeOutcome::Aborted { .. } => {
386            return Ok(SchemaChangeProgress::new(
387                record.receipt().clone(),
388                SchemaChangeProgressStatus::Aborted,
389            ));
390        }
391        SchemaChangeOutcome::Pending { job, .. } if job.id() == job_id => {}
392        SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Pending { .. } => {
393            return Err(InternalError::store_corruption());
394        }
395    }
396
397    let authorities = application_authorities(db);
398    let abort = prepare_pending_application_abort(
399        target.database_identity(),
400        &record,
401        authorities.as_slice(),
402        acknowledged_receipt,
403    )?;
404    let candidate = aborted_generated_row_local_candidate(
405        &abort.current,
406        abort.entity_tag,
407        abort.constraint_id,
408    )?;
409    let accepted_head =
410        accepted_head_after_candidates(authorities.as_slice(), std::slice::from_ref(&candidate))?;
411    let receipt = SchemaChangeReceipt::new(
412        record.receipt().database_identity(),
413        record.receipt().submission_key().clone(),
414        record.receipt().proposal_digest(),
415        record.receipt().prior_head().clone(),
416        SchemaChangeOutcome::Aborted { accepted_head },
417    )?;
418    let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
419    let operation = SchemaApplicationRecordOp::replace(&record, &terminal)?;
420    if abort.remove_validation_job {
421        publish_generated_row_local_abort_with_application_record(
422            abort.authority.path,
423            abort.authority.handle,
424            abort.current.revision(),
425            &candidate,
426            abort.entity_tag,
427            abort.constraint_id,
428            operation,
429        )?;
430    } else {
431        publish_accepted_schema_candidates_with_application_record(
432            vec![AcceptedSchemaPublication::new(
433                abort.authority.path,
434                abort.authority.handle,
435                abort.current.revision(),
436                &candidate,
437            )],
438            operation,
439        )?;
440    }
441    Ok(SchemaChangeProgress::new(
442        receipt,
443        SchemaChangeProgressStatus::Aborted,
444    ))
445}
446
447fn prepare_pending_application_abort(
448    database_identity: TargetDatabaseIdentity,
449    record: &SchemaApplicationRecord,
450    authorities: &[StoreApplicationAuthority],
451    acknowledged_receipt: Option<u64>,
452) -> Result<PendingApplicationAbort, InternalError> {
453    let [activation] = record.activations() else {
454        return Err(InternalError::store_corruption());
455    };
456    let authority = authorities
457        .iter()
458        .copied()
459        .find(|authority| derive_store_identity(database_identity, authority) == activation.store())
460        .ok_or_else(InternalError::store_corruption)?;
461    let entity_tag = EntityTag::new(activation.entity_tag());
462    let constraint_id = ConstraintId::new(activation.constraint_id())
463        .ok_or_else(InternalError::store_corruption)?;
464    let current = authority
465        .handle
466        .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
467        .ok_or_else(InternalError::store_corruption)?;
468    if current.store_path() != authority.path {
469        return Err(InternalError::store_corruption());
470    }
471    let pending = current
472        .entity_snapshots()
473        .get(&entity_tag)
474        .and_then(|snapshot| snapshot.constraint_catalog().activation(constraint_id))
475        .filter(|pending| {
476            pending.origin() == ConstraintOrigin::Generated
477                && matches!(
478                    pending.kind(),
479                    ConstraintActivationKind::Check { .. }
480                        | ConstraintActivationKind::TargetedRule { .. }
481                )
482        })
483        .ok_or_else(InternalError::store_corruption)?;
484    let remove_validation_job = pending_generated_row_local_job_retirement(
485        authority,
486        entity_tag,
487        constraint_id,
488        pending.state(),
489        acknowledged_receipt,
490    )?;
491    Ok(PendingApplicationAbort {
492        authority,
493        current,
494        entity_tag,
495        constraint_id,
496        remove_validation_job,
497    })
498}
499
500fn pending_generated_row_local_job_retirement(
501    authority: StoreApplicationAuthority,
502    entity_tag: EntityTag,
503    constraint_id: ConstraintId,
504    state: ConstraintActivationState,
505    acknowledged_receipt: Option<u64>,
506) -> Result<bool, InternalError> {
507    let job = authority
508        .handle
509        .with_schema(|store| store.constraint_validation_job(entity_tag, constraint_id))?;
510    match state {
511        ConstraintActivationState::EnforcingNewWrites => {
512            if acknowledged_receipt.is_some() || job.is_some() {
513                return Err(InternalError::schema_application_conflict());
514            }
515            Ok(false)
516        }
517        ConstraintActivationState::Validating => {
518            let mut job = job.ok_or_else(InternalError::store_corruption)?;
519            if !job.acknowledge_receipt(acknowledged_receipt) {
520                return Err(InternalError::schema_application_conflict());
521            }
522            Ok(true)
523        }
524    }
525}
526
527fn aborted_generated_row_local_candidate(
528    current: &AcceptedSchemaRevisionBundle,
529    entity_tag: EntityTag,
530    constraint_id: ConstraintId,
531) -> Result<CandidateSchemaRevision, InternalError> {
532    let snapshot = current
533        .entity_snapshots()
534        .get(&entity_tag)
535        .cloned()
536        .ok_or_else(InternalError::store_corruption)?;
537    let _activation = snapshot
538        .constraint_catalog()
539        .activation(constraint_id)
540        .filter(|activation| {
541            activation.origin() == ConstraintOrigin::Generated
542                && matches!(
543                    activation.kind(),
544                    ConstraintActivationKind::Check { .. }
545                        | ConstraintActivationKind::TargetedRule { .. }
546                )
547        })
548        .ok_or_else(InternalError::store_corruption)?;
549    let catalog = snapshot
550        .constraint_catalog()
551        .clone()
552        .with_aborted_activation(constraint_id)
553        .map_err(|_| InternalError::store_invariant())?;
554    let accepted_identity_remains = catalog
555        .constraints()
556        .iter()
557        .any(|constraint| constraint.id() == constraint_id);
558    let mut snapshots = current.entity_snapshots().clone();
559    snapshots.insert(entity_tag, snapshot.with_constraint_catalog(catalog));
560    let mut source_bindings = current.source_bindings().clone();
561    if !accepted_identity_remains {
562        source_bindings.remove_constraint_identity(entity_tag, constraint_id)?;
563    }
564    let revision = current
565        .revision()
566        .checked_next()
567        .ok_or_else(InternalError::store_unsupported)?;
568    let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
569        revision,
570        current.store_path(),
571        current.enum_catalog().clone(),
572        current.composite_catalog().clone(),
573        source_bindings,
574        snapshots,
575    )?;
576    CandidateSchemaRevision::new(bundle)
577}
578
579/// Apply one exact source-keyed schema proposal through catalog-native
580/// accepted candidates and the durable application-receipt boundary.
581pub(in crate::db) fn apply_schema<C: CanisterKind>(
582    db: &Db<C>,
583    proposal: &SchemaProposal,
584) -> Result<SchemaChangeReceipt, InternalError> {
585    ensure_recovered(db)?;
586    let proposal_digest = proposal
587        .digest()
588        .map_err(|_| InternalError::store_unsupported())?;
589    if let Some(receipt) = exact_schema_application_receipt(proposal, proposal_digest)? {
590        return Ok(receipt);
591    }
592
593    let target = schema_application_target(db)?;
594    if target.database_identity() != proposal.target_database()
595        || target.accepted_head() != proposal.expected_head()
596    {
597        return Err(InternalError::schema_application_conflict());
598    }
599
600    let authorities = application_authorities(db);
601    let LoweredApplication {
602        current_bundles,
603        candidates,
604        pending,
605    } = lower_application_candidates(&target, proposal, authorities.as_slice())?;
606    validate_database_identity_state_capacity(
607        authorities.as_slice(),
608        candidates.as_slice(),
609        database_incarnation_id()?,
610    )?;
611    let accepted_head = if let Some(pending) = pending.as_ref() {
612        let final_candidates =
613            final_candidates_for_pending_row_local_constraint(&candidates, pending)?;
614        accepted_head_after_candidates(authorities.as_slice(), final_candidates.as_slice())?
615    } else if candidates.is_empty() {
616        target.accepted_head().clone()
617    } else {
618        accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?
619    };
620    let outcome = if pending.is_some() {
621        let job_id = derive_schema_change_job_id(
622            target.database_identity(),
623            proposal.submission_key(),
624            proposal_digest,
625            target.accepted_head(),
626        )?;
627        SchemaChangeOutcome::Pending {
628            job: SchemaChangeJob::new(job_id),
629            candidate_head: accepted_head,
630        }
631    } else if candidates.is_empty() {
632        SchemaChangeOutcome::NoOp { accepted_head }
633    } else {
634        SchemaChangeOutcome::Applied { accepted_head }
635    };
636    let receipt = SchemaChangeReceipt::new(
637        target.database_identity(),
638        proposal.submission_key().clone(),
639        proposal_digest,
640        target.accepted_head().clone(),
641        outcome,
642    )?;
643    let activations = match pending {
644        Some(pending) => {
645            let authority = authorities
646                .iter()
647                .find(|authority| authority.path == pending.proof.store_path)
648                .ok_or_else(InternalError::store_invariant)?;
649            vec![SchemaChangeActivation::new(
650                derive_store_identity(target.database_identity(), authority),
651                pending.proof.entity_tag.value(),
652                pending.proof.constraint_id.get(),
653            )?]
654        }
655        None => Vec::new(),
656    };
657    let record = SchemaApplicationRecord::new(receipt.clone(), activations)?;
658    let operation = SchemaApplicationRecordOp::insert(&record)?;
659    let publications =
660        application_publications(authorities.as_slice(), &current_bundles, &candidates)?;
661    publish_accepted_schema_candidates_with_application_record(publications, operation)?;
662    Ok(receipt)
663}
664
665fn lower_application_candidates(
666    target: &SchemaApplicationTarget,
667    proposal: &SchemaProposal,
668    authorities: &[StoreApplicationAuthority],
669) -> Result<LoweredApplication, InternalError> {
670    let current_bundles = authorities
671        .iter()
672        .map(|authority| {
673            authority
674                .handle
675                .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
676        })
677        .collect::<Result<Vec<_>, InternalError>>()?;
678    let initial_application = matches!(target.accepted_head(), ExpectedAcceptedHead::Empty);
679    let mut candidates = match target.accepted_head() {
680        ExpectedAcceptedHead::Empty => {
681            let stores = authorities
682                .iter()
683                .map(|authority| ProposalStoreTarget {
684                    path: authority.path,
685                    identity: derive_store_identity(target.database_identity(), authority),
686                })
687                .collect::<Vec<_>>();
688            lower_initial_schema_proposal(proposal, stores.as_slice())?
689        }
690        ExpectedAcceptedHead::Exact { .. }
691            if proposal.fragments().is_empty() && proposal.removals().is_empty() =>
692        {
693            Vec::new()
694        }
695        ExpectedAcceptedHead::Exact { .. } => {
696            let stores = authorities
697                .iter()
698                .zip(&current_bundles)
699                .filter_map(|(authority, bundle)| {
700                    bundle.as_ref().map(|bundle| ExistingProposalStore {
701                        path: authority.path,
702                        identity: derive_store_identity(target.database_identity(), authority),
703                        bundle,
704                    })
705                })
706                .collect::<Vec<_>>();
707            lower_existing_schema_proposal(proposal, stores.as_slice())?
708        }
709    };
710    let pending = if initial_application {
711        preflight_initial_application(authorities, &candidates)?;
712        None
713    } else {
714        preflight_existing_application(authorities, &current_bundles, &mut candidates)?
715    };
716    Ok(LoweredApplication {
717        current_bundles,
718        candidates,
719        pending,
720    })
721}
722
723fn validate_database_identity_state_capacity(
724    authorities: &[StoreApplicationAuthority],
725    candidates: &[CandidateSchemaRevision],
726    incarnation: crate::db::integrity::DatabaseIncarnationId,
727) -> Result<(), InternalError> {
728    let mut total = 0usize;
729    for authority in authorities {
730        let count = match candidates
731            .iter()
732            .find(|candidate| candidate.store_path() == authority.path)
733        {
734            Some(candidate) => authority.handle.with_schema(|store| {
735                store.projected_identity_state_count(incarnation, candidate)
736            })?,
737            None => authority
738                .handle
739                .with_schema(|store| store.identity_state_inventory_for_integrity(incarnation))?
740                .len(),
741        };
742        total = include_identity_state_count(total, count)?;
743    }
744    Ok(())
745}
746
747fn include_identity_state_count(total: usize, count: usize) -> Result<usize, InternalError> {
748    let total = total
749        .checked_add(count)
750        .ok_or_else(InternalError::identity_state_capacity_exhausted)?;
751    if total > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
752        return Err(InternalError::identity_state_capacity_exhausted());
753    }
754    Ok(total)
755}
756
757fn preflight_initial_application(
758    authorities: &[StoreApplicationAuthority],
759    candidates: &[crate::db::schema::CandidateSchemaRevision],
760) -> Result<(), InternalError> {
761    for candidate in candidates {
762        let authority = authorities
763            .iter()
764            .find(|authority| authority.path == candidate.store_path())
765            .ok_or_else(InternalError::store_invariant)?;
766        if authority.handle.with_data(DataStore::len) != 0
767            || authority.handle.index_state() != IndexState::Ready
768            || !authority.handle.with_index(IndexStore::is_empty)
769        {
770            return Err(InternalError::store_unsupported());
771        }
772    }
773    Ok(())
774}
775
776/// Complete generated row-local additions only after a bounded exact proof.
777///
778/// Empty domains use maintained exact cardinality. At most one non-empty
779/// activation may consume the canonical 0.211 exact scan budget. A journaled
780/// proof that exceeds that page becomes one durable pending application;
781/// volatile or additional non-empty proofs reject before publication.
782fn preflight_existing_application(
783    authorities: &[StoreApplicationAuthority],
784    current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
785    candidates: &mut [CandidateSchemaRevision],
786) -> Result<Option<PendingGeneratedRowLocalConstraint>, InternalError> {
787    require_empty_physical_entity_removal(authorities, current_bundles, candidates)?;
788    require_empty_physical_field_removals(authorities, current_bundles, candidates)?;
789    require_empty_physical_index_removals(authorities, current_bundles, candidates)?;
790    require_empty_physical_relation_removals(authorities, current_bundles, candidates)?;
791    let proofs = generated_row_local_constraint_proofs(authorities, current_bundles, candidates)?;
792    if proofs
793        .iter()
794        .filter(|proof| proof.historical_rows != 0)
795        .count()
796        > 1
797    {
798        return Err(InternalError::store_unsupported());
799    }
800
801    let mut pending = None;
802    for candidate_index in 0..candidates.len() {
803        let candidate = candidates
804            .get(candidate_index)
805            .cloned()
806            .ok_or_else(InternalError::store_invariant)?;
807        let candidate_proofs = proofs
808            .iter()
809            .filter(|proof| proof.candidate_index == candidate_index)
810            .collect::<Vec<_>>();
811        if candidate_proofs.is_empty() {
812            continue;
813        }
814
815        let mut snapshots = candidate.bundle().entity_snapshots().clone();
816        for proof in candidate_proofs {
817            let mut promote = true;
818            if proof.historical_rows != 0 {
819                match validate_unpublished_row_local_candidate_bounded(
820                    proof.store,
821                    proof.store_path,
822                    proof.entity_tag,
823                    proof.entity_path.as_str(),
824                    &candidate,
825                    proof.constraint_id,
826                )? {
827                    UnpublishedRowLocalValidation::Complete { .. } => {}
828                    UnpublishedRowLocalValidation::Incomplete => {
829                        if proof.store.storage_capabilities().recovery()
830                            != StoreRecoveryCapability::StableBasePlusJournalReplay
831                            || pending.is_some()
832                        {
833                            return Err(InternalError::store_unsupported());
834                        }
835                        pending = Some(PendingGeneratedRowLocalConstraint {
836                            proof: (*proof).clone(),
837                        });
838                        promote = false;
839                    }
840                }
841            }
842            if !promote {
843                continue;
844            }
845            let snapshot = snapshots
846                .get(&proof.entity_tag)
847                .cloned()
848                .ok_or_else(InternalError::store_invariant)?;
849            let catalog = snapshot
850                .constraint_catalog()
851                .clone()
852                .with_directly_validated_activation(proof.constraint_id)
853                .map_err(|_| InternalError::store_invariant())?;
854            snapshots.insert(proof.entity_tag, snapshot.with_constraint_catalog(catalog));
855        }
856        let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
857            candidate.revision(),
858            candidate.bundle().store_path(),
859            candidate.bundle().enum_catalog().clone(),
860            candidate.bundle().composite_catalog().clone(),
861            candidate.bundle().source_bindings().clone(),
862            snapshots,
863        )?;
864        candidates[candidate_index] = CandidateSchemaRevision::new(bundle)?;
865    }
866    Ok(pending)
867}
868
869/// Prove one exact generated entity removal has no retained logical or
870/// physical authority.
871///
872/// The source row domain, every user-index generation, and every outgoing
873/// reverse-relation generation must be empty. The accepted-after topology must
874/// also contain no retained relation targeting the removed entity.
875fn require_empty_physical_entity_removal(
876    authorities: &[StoreApplicationAuthority],
877    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
878    candidates: &[CandidateSchemaRevision],
879) -> Result<(), InternalError> {
880    let mut removed_entity = None;
881    for candidate in candidates {
882        let (position, source_authority) = authorities
883            .iter()
884            .enumerate()
885            .find(|(_, authority)| authority.path == candidate.store_path())
886            .ok_or_else(InternalError::store_invariant)?;
887        let current = current_bundles
888            .get(position)
889            .and_then(Option::as_ref)
890            .ok_or_else(InternalError::store_invariant)?;
891        let removed = current
892            .entity_snapshots()
893            .iter()
894            .filter(|(entity_tag, _)| {
895                !candidate
896                    .bundle()
897                    .entity_snapshots()
898                    .contains_key(entity_tag)
899            })
900            .collect::<Vec<_>>();
901        if removed.is_empty() {
902            continue;
903        }
904        let [(entity_tag, snapshot)] = removed.as_slice() else {
905            return Err(InternalError::store_unsupported());
906        };
907        let entity_tag = **entity_tag;
908        let snapshot = *snapshot;
909        if removed_entity.is_some()
910            || current.entity_snapshots().len()
911                != candidate
912                    .bundle()
913                    .entity_snapshots()
914                    .len()
915                    .saturating_add(1)
916        {
917            return Err(InternalError::store_unsupported());
918        }
919        require_exact_empty_entity(source_authority.handle, entity_tag)?;
920        source_authority
921            .handle
922            .with_index(|store| prove_empty_user_index_domain(store, entity_tag))
923            .map_err(StagedUserIndexDomainError::into_internal_error)?;
924        for relation in snapshot.relations() {
925            let target_store = accepted_entity_store_for_path(
926                authorities,
927                current_bundles,
928                relation.target_path(),
929            )?;
930            target_store.with_index(|store| {
931                prove_empty_reverse_relation_domain(store, entity_tag, snapshot, relation)
932            })?;
933        }
934        removed_entity = Some(snapshot.entity_path());
935    }
936
937    let Some(removed_path) = removed_entity else {
938        return Ok(());
939    };
940    for (position, authority) in authorities.iter().enumerate() {
941        let after = candidates
942            .iter()
943            .find(|candidate| candidate.store_path() == authority.path)
944            .map(CandidateSchemaRevision::bundle)
945            .or_else(|| current_bundles.get(position).and_then(Option::as_ref));
946        let Some(after) = after else {
947            continue;
948        };
949        if after
950            .entity_snapshots()
951            .values()
952            .flat_map(crate::db::schema::PersistedSchemaSnapshot::relations)
953            .any(|relation| relation.target_path() == removed_path)
954        {
955            return Err(InternalError::store_unsupported());
956        }
957    }
958    Ok(())
959}
960
961/// Prove that every removed relation has neither source rows nor surviving
962/// entries in its exact target-owned reverse physical generation.
963fn require_empty_physical_relation_removals(
964    authorities: &[StoreApplicationAuthority],
965    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
966    candidates: &[CandidateSchemaRevision],
967) -> Result<(), InternalError> {
968    for candidate in candidates {
969        let (position, source_authority) = authorities
970            .iter()
971            .enumerate()
972            .find(|(_, authority)| authority.path == candidate.store_path())
973            .ok_or_else(InternalError::store_invariant)?;
974        let current = current_bundles
975            .get(position)
976            .and_then(Option::as_ref)
977            .ok_or_else(InternalError::store_invariant)?;
978        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
979            let before = current
980                .entity_snapshots()
981                .get(entity_tag)
982                .ok_or_else(InternalError::store_invariant)?;
983            let removed = before
984                .relations()
985                .iter()
986                .filter(|relation| {
987                    !after
988                        .relations()
989                        .iter()
990                        .any(|candidate| candidate.id() == relation.id())
991                })
992                .collect::<Vec<_>>();
993            if removed.is_empty() {
994                continue;
995            }
996            let added = after.relations().iter().any(|relation| {
997                !before
998                    .relations()
999                    .iter()
1000                    .any(|accepted| accepted.id() == relation.id())
1001            });
1002            let [removed] = removed.as_slice() else {
1003                return Err(InternalError::store_unsupported());
1004            };
1005            if added || before.relations().len() != after.relations().len().saturating_add(1) {
1006                return Err(InternalError::store_unsupported());
1007            }
1008            require_exact_empty_entity(source_authority.handle, *entity_tag)?;
1009            let target_store = accepted_entity_store_for_path(
1010                authorities,
1011                current_bundles,
1012                removed.target_path(),
1013            )?;
1014            target_store.with_index(|store| {
1015                prove_empty_reverse_relation_domain(store, *entity_tag, before, removed)
1016            })?;
1017        }
1018    }
1019    Ok(())
1020}
1021
1022fn accepted_entity_store_for_path(
1023    authorities: &[StoreApplicationAuthority],
1024    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1025    entity_path: &str,
1026) -> Result<StoreHandle, InternalError> {
1027    let mut resolved = None;
1028    for (position, bundle) in current_bundles.iter().enumerate() {
1029        let Some(bundle) = bundle else {
1030            continue;
1031        };
1032        if !bundle
1033            .entity_snapshots()
1034            .values()
1035            .any(|snapshot| snapshot.entity_path() == entity_path)
1036        {
1037            continue;
1038        }
1039        if resolved.is_some() {
1040            return Err(InternalError::store_invariant());
1041        }
1042        resolved = authorities.get(position).map(|authority| authority.handle);
1043    }
1044    resolved.ok_or_else(InternalError::store_unsupported)
1045}
1046
1047/// Prove that every dense index-removal candidate has neither authoritative
1048/// rows nor stale physical user-index state. The staged replacement is empty
1049/// by construction and is discarded before schema-only publication.
1050fn require_empty_physical_index_removals(
1051    authorities: &[StoreApplicationAuthority],
1052    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1053    candidates: &[CandidateSchemaRevision],
1054) -> Result<(), InternalError> {
1055    for candidate in candidates {
1056        let (position, authority) = authorities
1057            .iter()
1058            .enumerate()
1059            .find(|(_, authority)| authority.path == candidate.store_path())
1060            .ok_or_else(InternalError::store_invariant)?;
1061        let current = current_bundles
1062            .get(position)
1063            .and_then(Option::as_ref)
1064            .ok_or_else(InternalError::store_invariant)?;
1065        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
1066            let before = current
1067                .entity_snapshots()
1068                .get(entity_tag)
1069                .ok_or_else(InternalError::store_invariant)?;
1070            if before.indexes().len() == after.indexes().len() {
1071                continue;
1072            }
1073            if before.indexes().len() != after.indexes().len().saturating_add(1) {
1074                return Err(InternalError::store_unsupported());
1075            }
1076            require_exact_empty_entity(authority.handle, *entity_tag)?;
1077            authority
1078                .handle
1079                .with_index(|store| prove_empty_user_index_domain(store, *entity_tag))
1080                .map_err(StagedUserIndexDomainError::into_internal_error)?;
1081        }
1082    }
1083    Ok(())
1084}
1085
1086/// Prove that every dense field-removal candidate has no historical row to
1087/// rewrite. Missing or corrupt maintained cardinality fails closed.
1088fn require_empty_physical_field_removals(
1089    authorities: &[StoreApplicationAuthority],
1090    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1091    candidates: &[CandidateSchemaRevision],
1092) -> Result<(), InternalError> {
1093    for candidate in candidates {
1094        let (position, authority) = authorities
1095            .iter()
1096            .enumerate()
1097            .find(|(_, authority)| authority.path == candidate.store_path())
1098            .ok_or_else(InternalError::store_invariant)?;
1099        let current = current_bundles
1100            .get(position)
1101            .and_then(Option::as_ref)
1102            .ok_or_else(InternalError::store_invariant)?;
1103        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
1104            let before = current
1105                .entity_snapshots()
1106                .get(entity_tag)
1107                .ok_or_else(InternalError::store_invariant)?;
1108            if before.row_layout() == after.row_layout() {
1109                continue;
1110            }
1111            if before.fields().len() != after.fields().len().saturating_add(1) {
1112                return Err(InternalError::store_unsupported());
1113            }
1114            require_exact_empty_entity(authority.handle, *entity_tag)?;
1115        }
1116    }
1117    Ok(())
1118}
1119
1120// Prove exact logical emptiness from the maintained cardinality authority.
1121// Missing cardinality is corrupt state, not an empty domain or an unsupported
1122// user transition.
1123fn require_exact_empty_entity(
1124    store: StoreHandle,
1125    entity_tag: EntityTag,
1126) -> Result<(), InternalError> {
1127    require_exact_empty_entity_count(store.with_data(|data| data.exact_entity_count(entity_tag)))
1128}
1129
1130fn require_exact_empty_entity_count(count: Option<u64>) -> Result<(), InternalError> {
1131    let count = count.ok_or_else(InternalError::store_corruption)?;
1132    if count != 0 {
1133        return Err(InternalError::store_unsupported());
1134    }
1135
1136    Ok(())
1137}
1138
1139fn generated_row_local_constraint_proofs(
1140    authorities: &[StoreApplicationAuthority],
1141    current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
1142    candidates: &[CandidateSchemaRevision],
1143) -> Result<Vec<DirectGeneratedRowLocalProof>, InternalError> {
1144    let mut proofs = Vec::new();
1145    for (candidate_index, candidate) in candidates.iter().enumerate() {
1146        let (position, authority) = authorities
1147            .iter()
1148            .enumerate()
1149            .find(|(_, authority)| authority.path == candidate.store_path())
1150            .ok_or_else(InternalError::store_invariant)?;
1151        let current = current_bundles
1152            .get(position)
1153            .and_then(Option::as_ref)
1154            .ok_or_else(InternalError::store_invariant)?;
1155        for (entity_tag, after) in candidate.bundle().entity_snapshots() {
1156            let before = current
1157                .entity_snapshots()
1158                .get(entity_tag)
1159                .ok_or_else(InternalError::store_invariant)?;
1160            for constraint_id in added_generated_row_local_activations(before, after) {
1161                let historical_rows = authority
1162                    .handle
1163                    .with_data(|store| store.exact_entity_count(*entity_tag))
1164                    .ok_or_else(InternalError::store_corruption)?;
1165                proofs.push(DirectGeneratedRowLocalProof {
1166                    candidate_index,
1167                    store: authority.handle,
1168                    store_path: authority.path,
1169                    entity_tag: *entity_tag,
1170                    entity_path: after.entity_path().to_string(),
1171                    constraint_id,
1172                    historical_rows,
1173                });
1174            }
1175        }
1176    }
1177    Ok(proofs)
1178}
1179
1180fn added_generated_row_local_activations(
1181    before: &crate::db::schema::PersistedSchemaSnapshot,
1182    after: &crate::db::schema::PersistedSchemaSnapshot,
1183) -> Vec<ConstraintId> {
1184    after
1185        .constraint_activations()
1186        .iter()
1187        .filter(|candidate| {
1188            candidate.origin() == ConstraintOrigin::Generated
1189                && matches!(
1190                    candidate.kind(),
1191                    ConstraintActivationKind::Check { .. }
1192                        | ConstraintActivationKind::TargetedRule { .. }
1193                )
1194                && !before
1195                    .constraint_activations()
1196                    .iter()
1197                    .any(|accepted| accepted.id() == candidate.id())
1198        })
1199        .map(crate::db::schema::ConstraintActivationSnapshot::id)
1200        .collect()
1201}
1202
1203fn final_candidates_for_pending_row_local_constraint(
1204    candidates: &[CandidateSchemaRevision],
1205    pending: &PendingGeneratedRowLocalConstraint,
1206) -> Result<Vec<CandidateSchemaRevision>, InternalError> {
1207    let mut final_candidates = candidates.to_vec();
1208    let candidate = final_candidates
1209        .get(pending.proof.candidate_index)
1210        .cloned()
1211        .ok_or_else(InternalError::store_invariant)?;
1212    if candidate.store_path() != pending.proof.store_path {
1213        return Err(InternalError::store_invariant());
1214    }
1215    let mut snapshots = candidate.bundle().entity_snapshots().clone();
1216    let snapshot = snapshots
1217        .get(&pending.proof.entity_tag)
1218        .cloned()
1219        .ok_or_else(InternalError::store_invariant)?;
1220    let catalog = snapshot
1221        .constraint_catalog()
1222        .clone()
1223        .with_directly_validated_activation(pending.proof.constraint_id)
1224        .map_err(|_| InternalError::store_invariant())?;
1225    snapshots.insert(
1226        pending.proof.entity_tag,
1227        snapshot.with_constraint_catalog(catalog),
1228    );
1229    let final_revision = candidate
1230        .revision()
1231        .checked_next()
1232        .and_then(AcceptedSchemaRevision::checked_next)
1233        .ok_or_else(InternalError::store_unsupported)?;
1234    let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
1235        final_revision,
1236        candidate.bundle().store_path(),
1237        candidate.bundle().enum_catalog().clone(),
1238        candidate.bundle().composite_catalog().clone(),
1239        candidate.bundle().source_bindings().clone(),
1240        snapshots,
1241    )?;
1242    final_candidates[pending.proof.candidate_index] = CandidateSchemaRevision::new(bundle)?;
1243    Ok(final_candidates)
1244}
1245
1246fn schema_change_progress_status(
1247    snapshot: &crate::db::schema::PersistedSchemaSnapshot,
1248    constraint_id: ConstraintId,
1249    progress: ConstraintValidationProgress,
1250) -> Result<SchemaChangeProgressStatus, InternalError> {
1251    match progress {
1252        ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
1253        ConstraintValidationProgress::Advanced {
1254            phase,
1255            rows_scanned,
1256        } => Ok(SchemaChangeProgressStatus::Advanced {
1257            phase: schema_change_validation_phase(phase),
1258            rows_scanned,
1259        }),
1260        ConstraintValidationProgress::Findings {
1261            receipt,
1262            phase,
1263            rows_scanned,
1264        } => {
1265            let activation = snapshot
1266                .constraint_catalog()
1267                .activation(constraint_id)
1268                .ok_or_else(InternalError::store_corruption)?;
1269            let findings = receipt
1270                .findings()
1271                .iter()
1272                .map(|finding| {
1273                    let primary_key = finding
1274                        .primary_key()
1275                        .encoded_primary_key_bytes()
1276                        .ok_or_else(InternalError::store_invariant)?;
1277                    constraint_validation_finding_diagnostic(
1278                        snapshot,
1279                        activation,
1280                        snapshot.entity_path(),
1281                        primary_key,
1282                        finding,
1283                    )
1284                })
1285                .collect::<Result<Vec<_>, InternalError>>()?;
1286            Ok(SchemaChangeProgressStatus::Findings {
1287                phase: schema_change_validation_phase(phase),
1288                rows_scanned,
1289                page_sequence: receipt.page_sequence(),
1290                findings,
1291            })
1292        }
1293        ConstraintValidationProgress::Restarted { rows_scanned } => {
1294            Ok(SchemaChangeProgressStatus::Restarted { rows_scanned })
1295        }
1296        ConstraintValidationProgress::Promoted { .. } => Ok(SchemaChangeProgressStatus::Applied),
1297    }
1298}
1299
1300const fn schema_change_validation_phase(
1301    phase: ConstraintValidationPhase,
1302) -> SchemaChangeValidationPhase {
1303    match phase {
1304        ConstraintValidationPhase::Forward => SchemaChangeValidationPhase::Forward,
1305        ConstraintValidationPhase::Verify => SchemaChangeValidationPhase::Verify,
1306    }
1307}
1308
1309fn finalize_schema_application<C: CanisterKind>(
1310    db: &Db<C>,
1311    record: &SchemaApplicationRecord,
1312    candidate_head: &ExpectedAcceptedHead,
1313    status: SchemaChangeProgressStatus,
1314) -> Result<SchemaChangeProgress, InternalError> {
1315    if schema_application_target(db)?.accepted_head() != candidate_head {
1316        return Err(InternalError::schema_application_conflict());
1317    }
1318    let receipt = SchemaChangeReceipt::new(
1319        record.receipt().database_identity(),
1320        record.receipt().submission_key().clone(),
1321        record.receipt().proposal_digest(),
1322        record.receipt().prior_head().clone(),
1323        SchemaChangeOutcome::Applied {
1324            accepted_head: candidate_head.clone(),
1325        },
1326    )?;
1327    let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
1328    let operation = SchemaApplicationRecordOp::replace(record, &terminal)?;
1329    publish_accepted_schema_candidates_with_application_record(Vec::new(), operation)?;
1330    Ok(SchemaChangeProgress::new(receipt, status))
1331}
1332
1333fn application_publications<'a>(
1334    authorities: &[StoreApplicationAuthority],
1335    current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
1336    candidates: &'a [crate::db::schema::CandidateSchemaRevision],
1337) -> Result<Vec<AcceptedSchemaPublication<'a>>, InternalError> {
1338    candidates
1339        .iter()
1340        .map(|candidate| {
1341            let (position, authority) = authorities
1342                .iter()
1343                .enumerate()
1344                .find(|(_, authority)| authority.path == candidate.store_path())
1345                .ok_or_else(InternalError::store_invariant)?;
1346            let expected_revision = current_bundles[position].as_ref().map_or(
1347                AcceptedSchemaRevision::NONE,
1348                crate::db::schema::AcceptedSchemaRevisionBundle::revision,
1349            );
1350            Ok(AcceptedSchemaPublication::new(
1351                authority.path,
1352                authority.handle,
1353                expected_revision,
1354                candidate,
1355            ))
1356        })
1357        .collect()
1358}
1359
1360fn application_authorities<C: CanisterKind>(db: &Db<C>) -> Vec<StoreApplicationAuthority> {
1361    let mut authorities = db.with_store_registry(|registry| {
1362        registry
1363            .iter()
1364            .map(|(path, handle)| StoreApplicationAuthority { path, handle })
1365            .collect::<Vec<_>>()
1366    });
1367    authorities.sort_by(|left, right| left.path.cmp(right.path));
1368    authorities
1369}
1370
1371fn accepted_head_after_candidates(
1372    authorities: &[StoreApplicationAuthority],
1373    candidates: &[crate::db::schema::CandidateSchemaRevision],
1374) -> Result<ExpectedAcceptedHead, InternalError> {
1375    let heads = authorities
1376        .iter()
1377        .map(|authority| {
1378            let candidate = candidates
1379                .iter()
1380                .find(|candidate| candidate.store_path() == authority.path);
1381            let head = match candidate {
1382                Some(candidate) => Some(AcceptedStoreHead {
1383                    revision: candidate.revision().get(),
1384                    fingerprint: candidate.root().fingerprint().as_bytes(),
1385                }),
1386                None => authority
1387                    .handle
1388                    .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
1389                    .map(|selection| AcceptedStoreHead {
1390                        revision: selection.root().revision().get(),
1391                        fingerprint: selection.root().fingerprint().as_bytes(),
1392                    }),
1393            };
1394            Ok((authority.path, head))
1395        })
1396        .collect::<Result<Vec<_>, InternalError>>()?;
1397    Ok(derive_accepted_head(heads.as_slice()))
1398}
1399
1400fn derive_database_identity(
1401    incarnation: [u8; 16],
1402    stores: &[StoreApplicationAuthority],
1403) -> TargetDatabaseIdentity {
1404    let mut hasher = new_hash_sha256_prefixed(DATABASE_TARGET_FINGERPRINT_PROFILE);
1405    hasher.update(incarnation);
1406    write_hash_len_u32(&mut hasher, stores.len());
1407    for store in stores {
1408        write_store_authority(&mut hasher, store);
1409    }
1410    TargetDatabaseIdentity::from_bytes(finalize_hash_sha256(hasher))
1411}
1412
1413fn derive_store_identity(
1414    database_identity: TargetDatabaseIdentity,
1415    store: &StoreApplicationAuthority,
1416) -> TargetStoreIdentity {
1417    let mut hasher = new_hash_sha256_prefixed(STORE_TARGET_FINGERPRINT_PROFILE);
1418    hasher.update(database_identity.to_bytes());
1419    write_store_authority(&mut hasher, store);
1420    TargetStoreIdentity::from_bytes(finalize_hash_sha256(hasher))
1421}
1422
1423fn derive_accepted_head(stores: &[(&str, Option<AcceptedStoreHead>)]) -> ExpectedAcceptedHead {
1424    let Some(revision) = stores
1425        .iter()
1426        .filter_map(|(_, head)| head.map(|head| head.revision))
1427        .max()
1428    else {
1429        return ExpectedAcceptedHead::Empty;
1430    };
1431
1432    let mut hasher = new_hash_sha256_prefixed(ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE);
1433    write_hash_len_u32(&mut hasher, stores.len());
1434    for (path, head) in stores {
1435        write_hash_str_u32(&mut hasher, path);
1436        match head {
1437            None => write_hash_tag_u8(&mut hasher, 0),
1438            Some(head) => {
1439                write_hash_tag_u8(&mut hasher, 1);
1440                write_hash_u64(&mut hasher, head.revision);
1441                hasher.update(head.fingerprint);
1442            }
1443        }
1444    }
1445
1446    ExpectedAcceptedHead::Exact {
1447        revision,
1448        fingerprint: ExpectedSchemaFingerprint::from_bytes(finalize_hash_sha256(hasher)),
1449    }
1450}
1451
1452fn write_store_authority(hasher: &mut sha2::Sha256, store: &StoreApplicationAuthority) {
1453    write_hash_str_u32(hasher, store.path);
1454    write_storage_capabilities(hasher, store.handle);
1455    for allocation in [
1456        store.handle.data_allocation(),
1457        store.handle.index_allocation(),
1458        store.handle.schema_allocation(),
1459        store.handle.journal_allocation(),
1460    ] {
1461        write_allocation_identity(hasher, allocation);
1462    }
1463}
1464
1465fn write_storage_capabilities(hasher: &mut sha2::Sha256, store: StoreHandle) {
1466    let capabilities = store.storage_capabilities();
1467    write_hash_tag_u8(
1468        hasher,
1469        match capabilities.storage_mode() {
1470            StoreRuntimeStorageMode::Heap => 0,
1471            StoreRuntimeStorageMode::Journaled => 1,
1472        },
1473    );
1474    write_hash_tag_u8(
1475        hasher,
1476        match capabilities.allocation_identity() {
1477            StoreAllocationIdentityCapability::Present => 0,
1478            StoreAllocationIdentityCapability::Absent => 1,
1479        },
1480    );
1481    write_hash_tag_u8(
1482        hasher,
1483        match capabilities.durability() {
1484            StoreDurability::Durable => 0,
1485            StoreDurability::Volatile => 1,
1486        },
1487    );
1488    write_hash_tag_u8(
1489        hasher,
1490        match capabilities.recovery() {
1491            StoreRecoveryCapability::StableBasePlusJournalReplay => 0,
1492            StoreRecoveryCapability::None => 1,
1493        },
1494    );
1495    write_hash_tag_u8(
1496        hasher,
1497        match capabilities.commit_participation() {
1498            StoreCommitParticipation::Durable => 0,
1499            StoreCommitParticipation::LiveOnly => 1,
1500        },
1501    );
1502    write_hash_tag_u8(
1503        hasher,
1504        match capabilities.schema_metadata() {
1505            StoreSchemaMetadataCapability::LiveRebuiltMetadata => 0,
1506            StoreSchemaMetadataCapability::CanonicalStableHistoryPlusJournalTail => 1,
1507        },
1508    );
1509    write_hash_tag_u8(
1510        hasher,
1511        match capabilities.relation_source() {
1512            StoreRelationSourceCapability::DurableSource => 0,
1513            StoreRelationSourceCapability::LiveSource => 1,
1514        },
1515    );
1516    write_hash_tag_u8(
1517        hasher,
1518        match capabilities.relation_target() {
1519            StoreRelationTargetCapability::DurableTarget => 0,
1520            StoreRelationTargetCapability::VolatileTarget => 1,
1521        },
1522    );
1523}
1524
1525fn write_allocation_identity(
1526    hasher: &mut sha2::Sha256,
1527    allocation: Option<StoreAllocationIdentity>,
1528) {
1529    match allocation {
1530        None => write_hash_tag_u8(hasher, 0),
1531        Some(allocation) => {
1532            write_hash_tag_u8(hasher, 1);
1533            write_hash_tag_u8(hasher, allocation.memory_id());
1534            write_hash_str_u32(hasher, allocation.stable_key());
1535        }
1536    }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541    use super::{
1542        AcceptedSchemaPublication, AcceptedStoreHead, DirectGeneratedRowLocalProof,
1543        PendingGeneratedRowLocalConstraint, abort_schema_application,
1544        aborted_generated_row_local_candidate, accepted_head_after_candidates,
1545        application_authorities, apply_schema, continue_schema_application, derive_accepted_head,
1546        derive_schema_change_job_id, ensure_recovered,
1547        final_candidates_for_pending_row_local_constraint, include_identity_state_count,
1548        lower_existing_schema_proposal, lower_initial_schema_proposal,
1549        publish_accepted_schema_candidates_with_application_record,
1550        require_exact_empty_entity_count, schema_application_target,
1551    };
1552    use crate::{
1553        db::{
1554            Db,
1555            commit::forget_recovered_domain_for_tests,
1556            data::DataStore,
1557            index::IndexStore,
1558            journal::JournalTailStore,
1559            registry::{
1560                StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
1561                StoreRuntimeStorageCapabilities,
1562            },
1563            schema::{
1564                AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
1565                CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
1566                ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
1567                SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
1568                SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
1569            },
1570        },
1571        error::{ErrorClass, ErrorOrigin},
1572        testing::test_memory,
1573        traits::{CanisterKind, Path},
1574    };
1575    use icydb_schema::{
1576        ConstraintFragment, ConstraintSourceKey, EntityFragment, EntitySourceKey,
1577        EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint, FieldFragment,
1578        FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment, RuleSourceKey,
1579        ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName, SchemaProposal,
1580        SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction, SourceRuleOperation,
1581        TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment, TypeSourceKey,
1582    };
1583    use std::cell::RefCell;
1584
1585    const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
1586    const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
1587
1588    #[test]
1589    fn database_identity_state_capacity_combines_store_inventories_exactly() {
1590        let below = include_identity_state_count(0, 65_535)
1591            .expect("the first store inventory should remain below the database cap");
1592        let exact = include_identity_state_count(below, 1)
1593            .expect("the combined database boundary should admit");
1594        assert_eq!(exact, 65_536);
1595
1596        let error = include_identity_state_count(exact, 1)
1597            .expect_err("the next owner in another store must reject");
1598        assert_eq!(error.class(), ErrorClass::Unsupported);
1599        assert_eq!(error.origin(), ErrorOrigin::Identity);
1600    }
1601
1602    #[test]
1603    fn exact_empty_entity_proof_distinguishes_corruption_from_non_empty_input() {
1604        let corrupt = require_exact_empty_entity_count(None)
1605            .expect_err("uninspectable cardinality must fail closed");
1606        assert_eq!(corrupt.class(), ErrorClass::Corruption);
1607
1608        let non_empty = require_exact_empty_entity_count(Some(1))
1609            .expect_err("non-empty cardinality must reject removal");
1610        assert_eq!(non_empty.class(), ErrorClass::Unsupported);
1611        assert!(require_exact_empty_entity_count(Some(0)).is_ok());
1612    }
1613
1614    thread_local! {
1615        static ABORT_DATA: RefCell<DataStore> =
1616            RefCell::new(DataStore::init_journaled(test_memory(180)));
1617        static ABORT_INDEX: RefCell<IndexStore> =
1618            RefCell::new(IndexStore::init_journaled(test_memory(181)));
1619        static ABORT_SCHEMA: RefCell<SchemaStore> =
1620            RefCell::new(SchemaStore::init_journaled(test_memory(182)));
1621        static ABORT_JOURNAL: RefCell<JournalTailStore> =
1622            RefCell::new(JournalTailStore::init(test_memory(183)));
1623        static ABORT_REGISTRY: StoreRegistry = {
1624            let mut registry = StoreRegistry::new();
1625            registry.register_journaled_store(
1626                ABORT_STORE_PATH,
1627                &ABORT_DATA,
1628                &ABORT_INDEX,
1629                &ABORT_SCHEMA,
1630                &ABORT_JOURNAL,
1631                StoreAllocationIdentities::new_journaled(
1632                    StoreAllocationIdentity::new(180, "icydb.test.application-abort.data.v1"),
1633                    StoreAllocationIdentity::new(181, "icydb.test.application-abort.index.v1"),
1634                    StoreAllocationIdentity::new(182, "icydb.test.application-abort.schema.v1"),
1635                    StoreAllocationIdentity::new(183, "icydb.test.application-abort.journal.v1"),
1636                ),
1637                StoreRuntimeStorageCapabilities::journaled(),
1638            ).expect("abort journaled store should register");
1639            registry
1640        };
1641    }
1642
1643    thread_local! {
1644        static EVOLUTION_DATA: RefCell<DataStore> =
1645            RefCell::new(DataStore::init_journaled(test_memory(192)));
1646        static EVOLUTION_INDEX: RefCell<IndexStore> =
1647            RefCell::new(IndexStore::init_journaled(test_memory(193)));
1648        static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
1649            RefCell::new(SchemaStore::init_journaled(test_memory(194)));
1650        static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
1651            RefCell::new(JournalTailStore::init(test_memory(195)));
1652        static EVOLUTION_REGISTRY: StoreRegistry = {
1653            let mut registry = StoreRegistry::new();
1654            registry.register_journaled_store(
1655                EVOLUTION_STORE_PATH,
1656                &EVOLUTION_DATA,
1657                &EVOLUTION_INDEX,
1658                &EVOLUTION_SCHEMA,
1659                &EVOLUTION_JOURNAL,
1660                StoreAllocationIdentities::new_journaled(
1661                    StoreAllocationIdentity::new(192, "icydb.test.rule-evolution.data.v1"),
1662                    StoreAllocationIdentity::new(193, "icydb.test.rule-evolution.index.v1"),
1663                    StoreAllocationIdentity::new(194, "icydb.test.rule-evolution.schema.v1"),
1664                    StoreAllocationIdentity::new(195, "icydb.test.rule-evolution.journal.v1"),
1665                ),
1666                StoreRuntimeStorageCapabilities::journaled(),
1667            ).expect("rule-evolution journaled store should register");
1668            registry
1669        };
1670    }
1671
1672    struct AbortCanister;
1673
1674    impl Path for AbortCanister {
1675        const PATH: &'static str = "schema_application_tests::AbortCanister";
1676    }
1677
1678    impl CanisterKind for AbortCanister {
1679        const COMMIT_MEMORY_ID: u8 = 184;
1680        const COMMIT_STABLE_KEY: &'static str = "icydb.test.application-abort.commit.v1";
1681        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 185;
1682        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1683            "icydb.test.application-abort.integrity.v1";
1684    }
1685
1686    struct EvolutionCanister;
1687
1688    impl Path for EvolutionCanister {
1689        const PATH: &'static str = "schema_application_tests::EvolutionCanister";
1690    }
1691
1692    impl CanisterKind for EvolutionCanister {
1693        const COMMIT_MEMORY_ID: u8 = 196;
1694        const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule-evolution.commit.v1";
1695        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 197;
1696        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1697            "icydb.test.rule-evolution.integrity.v1";
1698    }
1699
1700    fn name(value: &str) -> SchemaName {
1701        SchemaName::try_new(value).expect("test schema name should admit")
1702    }
1703
1704    fn generated_check_proposal(
1705        expected_head: ExpectedAcceptedHead,
1706        submission_key: &str,
1707        include_check: bool,
1708        database: TargetDatabaseIdentity,
1709        store: TargetStoreIdentity,
1710    ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
1711        let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
1712        let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
1713        let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
1714        let check_source =
1715            ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
1716        let check = SourceCheckExpr::try_new(vec![
1717            SourceCheckInstruction::Field(score_source),
1718            SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
1719            SourceCheckInstruction::GreaterThanOrEqual,
1720        ])
1721        .expect("check expression should admit");
1722        let constraints = include_check
1723            .then(|| ConstraintFragment::check(name("score_non_negative"), check))
1724            .into_iter()
1725            .collect();
1726        let entity = EntityFragment::try_new(
1727            name("Item"),
1728            vec![
1729                FieldFragment::new(
1730                    name("id"),
1731                    FieldType::Scalar(ScalarType::Nat64),
1732                    false,
1733                    FieldInsertPolicy::Required,
1734                    None,
1735                ),
1736                FieldFragment::new(
1737                    name("score"),
1738                    FieldType::Scalar(ScalarType::Int64),
1739                    false,
1740                    FieldInsertPolicy::Required,
1741                    None,
1742                ),
1743            ],
1744            vec![id_source],
1745            Vec::new(),
1746            Vec::new(),
1747            constraints,
1748        )
1749        .expect("entity should admit");
1750        let proposal = SchemaProposal::try_compose(
1751            vec![SchemaCapability::ACCEPTED_CHECKS],
1752            database,
1753            SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
1754            expected_head,
1755            vec![
1756                SchemaFragment::try_new(vec![entity], Vec::new())
1757                    .expect("schema fragment should admit"),
1758            ],
1759            vec![EntityStoreAssignment::new(entity_source.clone(), store)],
1760            Vec::new(),
1761        )
1762        .expect("schema proposal should compose");
1763        (proposal, entity_source, check_source)
1764    }
1765
1766    fn targeted_rule_proposal(
1767        expected_head: ExpectedAcceptedHead,
1768        submission_key: &str,
1769        operation: SourceRuleOperation,
1770        database: TargetDatabaseIdentity,
1771        store: TargetStoreIdentity,
1772    ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
1773        let entity_source =
1774            EntitySourceKey::try_new("Measured").expect("entity source should admit");
1775        let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
1776        let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
1777        let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
1778        let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
1779        let constraint_source =
1780            ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
1781        let entity = EntityFragment::try_new(
1782            name("Measured"),
1783            vec![
1784                FieldFragment::new(
1785                    name("id"),
1786                    FieldType::Scalar(ScalarType::Nat64),
1787                    false,
1788                    FieldInsertPolicy::Required,
1789                    None,
1790                ),
1791                FieldFragment::new(
1792                    name("value"),
1793                    FieldType::Named(value_type.clone()),
1794                    false,
1795                    FieldInsertPolicy::Required,
1796                    None,
1797                ),
1798            ],
1799            vec![id_source],
1800            Vec::new(),
1801            Vec::new(),
1802            vec![ConstraintFragment::targeted_rule(
1803                TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
1804            )],
1805        )
1806        .expect("targeted entity should admit");
1807        let proposal = SchemaProposal::try_compose(
1808            vec![SchemaCapability::ACCEPTED_CHECKS],
1809            database,
1810            SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
1811            expected_head,
1812            vec![
1813                SchemaFragment::try_new(
1814                    vec![entity],
1815                    vec![NamedTypeFragment::newtype(
1816                        name("Measure"),
1817                        FieldType::Scalar(ScalarType::Nat8),
1818                    )],
1819                )
1820                .expect("schema fragment should admit"),
1821            ],
1822            vec![EntityStoreAssignment::new(entity_source.clone(), store)],
1823            Vec::new(),
1824        )
1825        .expect("schema proposal should compose");
1826        (proposal, entity_source, constraint_source)
1827    }
1828
1829    #[test]
1830    fn database_head_is_empty_only_when_every_store_root_is_absent() {
1831        assert_eq!(
1832            derive_accepted_head(&[("test::A", None), ("test::B", None)]),
1833            ExpectedAcceptedHead::Empty,
1834        );
1835    }
1836
1837    #[test]
1838    fn database_head_covers_store_path_revision_fingerprint_and_absence() {
1839        let first = derive_accepted_head(&[
1840            (
1841                "test::A",
1842                Some(AcceptedStoreHead {
1843                    revision: 3,
1844                    fingerprint: [0x11; 32],
1845                }),
1846            ),
1847            ("test::B", None),
1848        ]);
1849        let changed_fingerprint = derive_accepted_head(&[
1850            (
1851                "test::A",
1852                Some(AcceptedStoreHead {
1853                    revision: 3,
1854                    fingerprint: [0x12; 32],
1855                }),
1856            ),
1857            ("test::B", None),
1858        ]);
1859        let changed_absence = derive_accepted_head(&[
1860            (
1861                "test::A",
1862                Some(AcceptedStoreHead {
1863                    revision: 3,
1864                    fingerprint: [0x11; 32],
1865                }),
1866            ),
1867            (
1868                "test::B",
1869                Some(AcceptedStoreHead {
1870                    revision: 1,
1871                    fingerprint: [0x22; 32],
1872                }),
1873            ),
1874        ]);
1875
1876        assert_ne!(first, changed_fingerprint);
1877        assert_ne!(first, changed_absence);
1878        assert!(matches!(
1879            first,
1880            ExpectedAcceptedHead::Exact { revision: 3, .. }
1881        ));
1882    }
1883
1884    #[test]
1885    #[allow(
1886        clippy::too_many_lines,
1887        reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
1888    )]
1889    fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
1890        let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
1891        let store = TargetStoreIdentity::from_bytes([0x72; 32]);
1892        let (initial, entity_source, _) = generated_check_proposal(
1893            ExpectedAcceptedHead::Empty,
1894            "abort-initial",
1895            false,
1896            database,
1897            store,
1898        );
1899        let initial_candidate = lower_initial_schema_proposal(
1900            &initial,
1901            &[ProposalStoreTarget {
1902                path: "abort::Store",
1903                identity: store,
1904            }],
1905        )
1906        .expect("initial proposal should lower")
1907        .pop()
1908        .expect("initial proposal should produce one candidate");
1909        let (with_check, _, check_source) = generated_check_proposal(
1910            ExpectedAcceptedHead::Exact {
1911                revision: 1,
1912                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
1913            },
1914            "abort-add-check",
1915            true,
1916            database,
1917            store,
1918        );
1919        let pending_candidate = lower_existing_schema_proposal(
1920            &with_check,
1921            &[ExistingProposalStore {
1922                path: "abort::Store",
1923                identity: store,
1924                bundle: initial_candidate.bundle(),
1925            }],
1926        )
1927        .expect("generated check should lower")
1928        .pop()
1929        .expect("generated check should produce one candidate");
1930        let entity_tag = pending_candidate
1931            .bundle()
1932            .source_bindings_for_tests()
1933            .entity(&entity_source)
1934            .expect("entity source should remain bound");
1935        let constraint_id = pending_candidate
1936            .bundle()
1937            .source_bindings_for_tests()
1938            .constraint(entity_tag, &check_source)
1939            .expect("generated check source should bind");
1940        let pending_snapshot = pending_candidate
1941            .bundle()
1942            .entity_snapshots()
1943            .get(&entity_tag)
1944            .expect("pending entity should exist");
1945        let activation = pending_snapshot
1946            .constraint_catalog()
1947            .activation(constraint_id)
1948            .expect("generated check should remain an activation");
1949        assert_eq!(activation.origin(), ConstraintOrigin::Generated);
1950
1951        let aborted = aborted_generated_row_local_candidate(
1952            pending_candidate.bundle(),
1953            entity_tag,
1954            constraint_id,
1955        )
1956        .expect("generated check abort should build one catalog-native candidate");
1957        let aborted_snapshot = aborted
1958            .bundle()
1959            .entity_snapshots()
1960            .get(&entity_tag)
1961            .expect("aborted entity should remain");
1962        assert!(
1963            aborted_snapshot
1964                .constraint_catalog()
1965                .activation(constraint_id)
1966                .is_none(),
1967        );
1968        assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
1969        assert!(
1970            aborted
1971                .bundle()
1972                .source_bindings_for_tests()
1973                .constraint(entity_tag, &check_source)
1974                .is_none(),
1975        );
1976
1977        let reproposed = lower_existing_schema_proposal(
1978            &with_check,
1979            &[ExistingProposalStore {
1980                path: "abort::Store",
1981                identity: store,
1982                bundle: aborted.bundle(),
1983            }],
1984        )
1985        .expect("aborted generated check should be independently reproposable")
1986        .pop()
1987        .expect("reproposal should produce one candidate");
1988        let replacement_id = reproposed
1989            .bundle()
1990            .source_bindings_for_tests()
1991            .constraint(entity_tag, &check_source)
1992            .expect("reproposal should bind a fresh constraint identity");
1993        assert!(
1994            replacement_id > constraint_id,
1995            "aborted accepted IDs must remain retired",
1996        );
1997    }
1998
1999    #[test]
2000    fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
2001        let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
2002        let store = TargetStoreIdentity::from_bytes([0x82; 32]);
2003        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
2004            ExpectedAcceptedHead::Empty,
2005            "targeted-abort-initial",
2006            SourceRuleOperation::NumericRangeInclusive {
2007                min: ScalarLiteral::Nat(0),
2008                max: ScalarLiteral::Nat(10),
2009            },
2010            database,
2011            store,
2012        );
2013        let initial_candidate = lower_initial_schema_proposal(
2014            &initial,
2015            &[ProposalStoreTarget {
2016                path: "abort::TargetedStore",
2017                identity: store,
2018            }],
2019        )
2020        .expect("initial targeted proposal should lower")
2021        .pop()
2022        .expect("initial targeted proposal should produce one candidate");
2023        let initial_bundle = initial_candidate.bundle();
2024        let entity_tag = initial_bundle
2025            .source_bindings_for_tests()
2026            .entity(&entity_source)
2027            .expect("entity source should bind");
2028        let constraint_id = initial_bundle
2029            .source_bindings_for_tests()
2030            .constraint(entity_tag, &constraint_source)
2031            .expect("targeted source should bind");
2032        let high_water = initial_bundle.entity_snapshots()[&entity_tag]
2033            .constraint_id_allocator()
2034            .high_water();
2035        let (edited, _, _) = targeted_rule_proposal(
2036            ExpectedAcceptedHead::Exact {
2037                revision: initial_bundle.revision().get(),
2038                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
2039            },
2040            "targeted-abort-edit",
2041            SourceRuleOperation::NumericMaximumInclusive {
2042                value: ScalarLiteral::Nat(8),
2043            },
2044            database,
2045            store,
2046        );
2047        let staged = lower_existing_schema_proposal(
2048            &edited,
2049            &[ExistingProposalStore {
2050                path: "abort::TargetedStore",
2051                identity: store,
2052                bundle: initial_bundle,
2053            }],
2054        )
2055        .expect("targeted semantic edit should stage")
2056        .pop()
2057        .expect("targeted semantic edit should produce one candidate");
2058        let aborted =
2059            aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
2060                .expect("targeted semantic edit should abort through catalog authority");
2061        let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
2062
2063        assert!(
2064            snapshot
2065                .constraint_catalog()
2066                .activation(constraint_id)
2067                .is_none()
2068        );
2069        assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
2070        assert_eq!(
2071            aborted
2072                .bundle()
2073                .source_bindings_for_tests()
2074                .constraint(entity_tag, &constraint_source),
2075            Some(constraint_id),
2076        );
2077        assert!(snapshot.constraints().iter().any(|constraint| {
2078            constraint.id() == constraint_id
2079                && matches!(
2080                    constraint.kind(),
2081                    AcceptedConstraintKind::TargetedRule { operation, .. }
2082                        if matches!(
2083                            operation.as_ref(),
2084                            AcceptedRuleOperation::NumericRangeInclusive { .. }
2085                        )
2086                )
2087        }));
2088    }
2089
2090    #[test]
2091    #[allow(
2092        clippy::too_many_lines,
2093        reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
2094    )]
2095    fn targeted_rule_edit_activation_recovers_and_promotes_without_source_model() {
2096        let db = Db::<EvolutionCanister>::new(&EVOLUTION_REGISTRY);
2097        let empty_target =
2098            schema_application_target(&db).expect("empty evolution target should issue");
2099        let store_identity = empty_target
2100            .stores()
2101            .first()
2102            .expect("evolution store should register")
2103            .identity();
2104        let (initial, entity_source, constraint_source) = targeted_rule_proposal(
2105            empty_target.accepted_head().clone(),
2106            "targeted-recovery-initial",
2107            SourceRuleOperation::NumericRangeInclusive {
2108                min: ScalarLiteral::Nat(0),
2109                max: ScalarLiteral::Nat(10),
2110            },
2111            empty_target.database_identity(),
2112            store_identity,
2113        );
2114        assert!(matches!(
2115            apply_schema(&db, &initial)
2116                .expect("initial targeted proposal should publish")
2117                .outcome(),
2118            SchemaChangeOutcome::Applied { .. },
2119        ));
2120
2121        let direct_target =
2122            schema_application_target(&db).expect("direct evolution target should issue");
2123        let (direct_edit, _, _) = targeted_rule_proposal(
2124            direct_target.accepted_head().clone(),
2125            "targeted-direct-edit",
2126            SourceRuleOperation::NumericMaximumInclusive {
2127                value: ScalarLiteral::Nat(8),
2128            },
2129            direct_target.database_identity(),
2130            store_identity,
2131        );
2132        assert!(matches!(
2133            apply_schema(&db, &direct_edit)
2134                .expect("empty-domain semantic edit should publish directly")
2135                .outcome(),
2136            SchemaChangeOutcome::Applied { .. },
2137        ));
2138        let store = db
2139            .store_handle(EVOLUTION_STORE_PATH)
2140            .expect("evolution store should resolve");
2141        let direct = store
2142            .with_schema(SchemaStore::current_accepted_schema_bundle)
2143            .expect("directly edited bundle should remain readable")
2144            .expect("directly edited bundle should exist");
2145        let entity_tag = direct
2146            .source_bindings_for_tests()
2147            .entity(&entity_source)
2148            .expect("entity source should remain bound");
2149        let constraint_id = direct
2150            .source_bindings_for_tests()
2151            .constraint(entity_tag, &constraint_source)
2152            .expect("direct edit should preserve constraint identity");
2153        assert!(
2154            direct.entity_snapshots()[&entity_tag]
2155                .constraint_catalog()
2156                .activation(constraint_id)
2157                .is_none()
2158        );
2159        assert!(
2160            direct.entity_snapshots()[&entity_tag]
2161                .constraints()
2162                .iter()
2163                .any(|constraint| {
2164                    constraint.id() == constraint_id
2165                        && matches!(
2166                            constraint.kind(),
2167                            AcceptedConstraintKind::TargetedRule { operation, .. }
2168                                if matches!(
2169                                    operation.as_ref(),
2170                                    AcceptedRuleOperation::NumericMaximumInclusive { .. }
2171                                )
2172                        )
2173                })
2174        );
2175
2176        let target = schema_application_target(&db).expect("staged evolution target should issue");
2177        let (edited, _, _) = targeted_rule_proposal(
2178            target.accepted_head().clone(),
2179            "targeted-recovery-edit",
2180            SourceRuleOperation::MultipleOf {
2181                divisor: ScalarLiteral::Nat(2),
2182            },
2183            target.database_identity(),
2184            store_identity,
2185        );
2186        let current = store
2187            .with_schema(SchemaStore::current_accepted_schema_bundle)
2188            .expect("accepted evolution bundle should remain readable")
2189            .expect("directly edited evolution bundle should exist");
2190        let staged = lower_existing_schema_proposal(
2191            &edited,
2192            &[ExistingProposalStore {
2193                path: EVOLUTION_STORE_PATH,
2194                identity: store_identity,
2195                bundle: &current,
2196            }],
2197        )
2198        .expect("targeted edit should stage")
2199        .pop()
2200        .expect("targeted edit should produce one candidate");
2201        assert_eq!(
2202            staged
2203                .bundle()
2204                .source_bindings_for_tests()
2205                .constraint(entity_tag, &constraint_source),
2206            Some(constraint_id),
2207        );
2208        let proof = DirectGeneratedRowLocalProof {
2209            candidate_index: 0,
2210            store,
2211            store_path: EVOLUTION_STORE_PATH,
2212            entity_tag,
2213            entity_path: staged.bundle().entity_snapshots()[&entity_tag]
2214                .entity_path()
2215                .to_string(),
2216            constraint_id,
2217            historical_rows: 0,
2218        };
2219        let final_candidates = final_candidates_for_pending_row_local_constraint(
2220            std::slice::from_ref(&staged),
2221            &PendingGeneratedRowLocalConstraint { proof },
2222        )
2223        .expect("final semantic replacement should derive without source input");
2224        let authorities = application_authorities(&db);
2225        let candidate_head =
2226            accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
2227                .expect("final candidate head should derive");
2228        let digest = edited.digest().expect("proposal digest should derive");
2229        let job_id = derive_schema_change_job_id(
2230            target.database_identity(),
2231            edited.submission_key(),
2232            digest,
2233            target.accepted_head(),
2234        )
2235        .expect("job identity should derive");
2236        let receipt = crate::db::schema::SchemaChangeReceipt::new(
2237            target.database_identity(),
2238            edited.submission_key().clone(),
2239            digest,
2240            target.accepted_head().clone(),
2241            SchemaChangeOutcome::Pending {
2242                job: SchemaChangeJob::new(job_id),
2243                candidate_head,
2244            },
2245        )
2246        .expect("pending replacement receipt should admit");
2247        let record = SchemaApplicationRecord::new(
2248            receipt,
2249            vec![
2250                SchemaChangeActivation::new(
2251                    store_identity,
2252                    entity_tag.value(),
2253                    constraint_id.get(),
2254                )
2255                .expect("replacement activation should admit"),
2256            ],
2257        )
2258        .expect("pending replacement record should admit");
2259        let operation =
2260            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
2261        publish_accepted_schema_candidates_with_application_record(
2262            vec![AcceptedSchemaPublication::new(
2263                EVOLUTION_STORE_PATH,
2264                store,
2265                current.revision(),
2266                &staged,
2267            )],
2268            operation,
2269        )
2270        .expect("staged replacement and record should publish atomically");
2271
2272        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
2273        ensure_recovered(&db).expect("recovery should restore the staged accepted activation");
2274
2275        let recovered = store
2276            .with_schema(SchemaStore::current_accepted_schema_bundle)
2277            .expect("recovered staged bundle should decode")
2278            .expect("recovered staged bundle should exist");
2279        let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
2280        let validating_catalog = recovered_snapshot
2281            .constraint_catalog()
2282            .clone()
2283            .with_validation_started(constraint_id)
2284            .expect("recovered replacement should enter validation");
2285        let mut validating_snapshots = recovered.entity_snapshots().clone();
2286        validating_snapshots.insert(
2287            entity_tag,
2288            recovered_snapshot.with_constraint_catalog(validating_catalog),
2289        );
2290        let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2291            recovered
2292                .revision()
2293                .checked_next()
2294                .expect("validation revision should remain available"),
2295            recovered.store_path(),
2296            recovered.enum_catalog().clone(),
2297            recovered.composite_catalog().clone(),
2298            recovered.source_bindings_for_tests().clone(),
2299            validating_snapshots,
2300        )
2301        .expect("validating replacement bundle should close");
2302        let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
2303            .expect("validating replacement candidate should encode");
2304        let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
2305            .constraint_catalog()
2306            .activation(constraint_id)
2307            .expect("validating replacement activation should remain present");
2308        let validation_job = ConstraintValidationJob::start(
2309            entity_tag,
2310            validating_candidate.bundle().entity_snapshots()[&entity_tag]
2311                .entity_path()
2312                .to_string(),
2313            validating_activation,
2314            None,
2315        )
2316        .expect("validating replacement job should derive from accepted state");
2317        store
2318            .with_schema(|schema| {
2319                schema.validate_live_activation_transition(validating_candidate.bundle())?;
2320                schema.validate_constraint_validation_job_closure_with_change(
2321                    validating_candidate.bundle(),
2322                    Some(&validation_job),
2323                    None,
2324                )
2325            })
2326            .expect("validating replacement transition and job should close");
2327
2328        let started = continue_schema_application(&db, job_id, None).unwrap_or_else(|error| {
2329            panic!(
2330                "recovered replacement should begin validation: {:?}/{:?}",
2331                error.class(),
2332                error.origin(),
2333            )
2334        });
2335        assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
2336        let mut applied = None;
2337        for _ in 0..8 {
2338            let progress = continue_schema_application(&db, job_id, None)
2339                .expect("replacement validation should advance from durable authority");
2340            if progress.status() == &SchemaChangeProgressStatus::Applied {
2341                applied = Some(progress);
2342                break;
2343            }
2344        }
2345        let applied = applied.expect("empty historical domain should promote within bounded steps");
2346        assert!(matches!(
2347            applied.receipt().outcome(),
2348            SchemaChangeOutcome::Applied { .. }
2349        ));
2350        let promoted = store
2351            .with_schema(SchemaStore::current_accepted_schema_bundle)
2352            .expect("promoted bundle should remain readable")
2353            .expect("promoted bundle should exist");
2354        let snapshot = &promoted.entity_snapshots()[&entity_tag];
2355        assert!(
2356            snapshot
2357                .constraint_catalog()
2358                .activation(constraint_id)
2359                .is_none()
2360        );
2361        assert_eq!(
2362            promoted
2363                .source_bindings_for_tests()
2364                .constraint(entity_tag, &constraint_source),
2365            Some(constraint_id),
2366        );
2367        assert!(snapshot.constraints().iter().any(|constraint| {
2368            constraint.id() == constraint_id
2369                && matches!(
2370                    constraint.kind(),
2371                    AcceptedConstraintKind::TargetedRule { operation, .. }
2372                        if matches!(
2373                            operation.as_ref(),
2374                            AcceptedRuleOperation::MultipleOf { .. }
2375                        )
2376                )
2377        }));
2378    }
2379
2380    #[test]
2381    #[allow(
2382        clippy::too_many_lines,
2383        reason = "the journaled abort, replay, and recovery assertions form one scenario"
2384    )]
2385    fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
2386        let db = Db::<AbortCanister>::new(&ABORT_REGISTRY);
2387        let empty_target =
2388            schema_application_target(&db).expect("empty application target should issue");
2389        let store_identity = empty_target
2390            .stores()
2391            .first()
2392            .expect("abort store should be registered")
2393            .identity();
2394        let (initial, entity_source, _) = generated_check_proposal(
2395            empty_target.accepted_head().clone(),
2396            "abort-runtime-initial",
2397            false,
2398            empty_target.database_identity(),
2399            store_identity,
2400        );
2401        assert!(matches!(
2402            apply_schema(&db, &initial)
2403                .expect("initial application should publish")
2404                .outcome(),
2405            SchemaChangeOutcome::Applied { .. },
2406        ));
2407
2408        let target =
2409            schema_application_target(&db).expect("existing application target should issue");
2410        let (with_check, _, check_source) = generated_check_proposal(
2411            target.accepted_head().clone(),
2412            "abort-runtime-pending",
2413            true,
2414            target.database_identity(),
2415            store_identity,
2416        );
2417        let store = db
2418            .store_handle(ABORT_STORE_PATH)
2419            .expect("abort store should resolve");
2420        let current = store
2421            .with_schema(SchemaStore::current_accepted_schema_bundle)
2422            .expect("accepted bundle should remain readable")
2423            .expect("initial accepted bundle should exist");
2424        let pending_candidate = lower_existing_schema_proposal(
2425            &with_check,
2426            &[ExistingProposalStore {
2427                path: ABORT_STORE_PATH,
2428                identity: store_identity,
2429                bundle: &current,
2430            }],
2431        )
2432        .expect("pending generated check should lower")
2433        .pop()
2434        .expect("pending generated check should produce one candidate");
2435        let entity_tag = pending_candidate
2436            .bundle()
2437            .source_bindings_for_tests()
2438            .entity(&entity_source)
2439            .expect("entity source should bind");
2440        let constraint_id = pending_candidate
2441            .bundle()
2442            .source_bindings_for_tests()
2443            .constraint(entity_tag, &check_source)
2444            .expect("generated check source should bind");
2445        let digest = with_check.digest().expect("proposal digest should derive");
2446        let job_id = derive_schema_change_job_id(
2447            target.database_identity(),
2448            with_check.submission_key(),
2449            digest,
2450            target.accepted_head(),
2451        )
2452        .expect("job identity should derive");
2453        let receipt = crate::db::schema::SchemaChangeReceipt::new(
2454            target.database_identity(),
2455            with_check.submission_key().clone(),
2456            digest,
2457            target.accepted_head().clone(),
2458            SchemaChangeOutcome::Pending {
2459                job: SchemaChangeJob::new(job_id),
2460                candidate_head: ExpectedAcceptedHead::Exact {
2461                    revision: pending_candidate.revision().get().saturating_add(2),
2462                    fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
2463                },
2464            },
2465        )
2466        .expect("pending receipt should admit");
2467        let record = SchemaApplicationRecord::new(
2468            receipt,
2469            vec![
2470                SchemaChangeActivation::new(
2471                    store_identity,
2472                    entity_tag.value(),
2473                    constraint_id.get(),
2474                )
2475                .expect("application activation should admit"),
2476            ],
2477        )
2478        .expect("pending application record should admit");
2479        let operation =
2480            SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
2481        publish_accepted_schema_candidates_with_application_record(
2482            vec![AcceptedSchemaPublication::new(
2483                ABORT_STORE_PATH,
2484                store,
2485                current.revision(),
2486                &pending_candidate,
2487            )],
2488            operation,
2489        )
2490        .expect("pending candidate and record should publish atomically");
2491
2492        let started = continue_schema_application(&db, job_id, None)
2493            .expect("first continuation should durably start validation");
2494        assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
2495        let progress =
2496            abort_schema_application(&db, job_id, None).expect("pending application should abort");
2497        assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
2498        assert!(matches!(
2499            progress.receipt().outcome(),
2500            SchemaChangeOutcome::Aborted { .. },
2501        ));
2502        let replay =
2503            abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
2504        assert_eq!(replay, progress);
2505        assert_eq!(
2506            continue_schema_application(&db, job_id, None)
2507                .expect("continuation after abort should replay terminal state"),
2508            progress,
2509        );
2510
2511        let aborted = store
2512            .with_schema(SchemaStore::current_accepted_schema_bundle)
2513            .expect("accepted bundle should remain readable")
2514            .expect("aborted accepted bundle should exist");
2515        assert!(
2516            aborted
2517                .entity_snapshots()
2518                .get(&entity_tag)
2519                .expect("entity should remain after abort")
2520                .constraint_catalog()
2521                .activation(constraint_id)
2522                .is_none(),
2523        );
2524        assert!(
2525            aborted
2526                .source_bindings_for_tests()
2527                .constraint(entity_tag, &check_source)
2528                .is_none(),
2529        );
2530        assert!(
2531            store
2532                .with_schema(|schema| {
2533                    schema.constraint_validation_job(entity_tag, constraint_id)
2534                })
2535                .expect("validation-job storage should remain readable")
2536                .is_none(),
2537        );
2538
2539        ABORT_DATA.with(|store| {
2540            *store.borrow_mut() = DataStore::init_journaled(test_memory(180));
2541        });
2542        ABORT_INDEX.with(|store| {
2543            *store.borrow_mut() = IndexStore::init_journaled(test_memory(181));
2544        });
2545        ABORT_SCHEMA.with(|store| {
2546            *store.borrow_mut() = SchemaStore::init_journaled(test_memory(182));
2547        });
2548        ABORT_JOURNAL.with(|store| {
2549            *store.borrow_mut() = JournalTailStore::init(test_memory(183));
2550        });
2551        forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
2552        ensure_recovered(&db).expect("recovery should retain the terminal abort");
2553        assert_eq!(
2554            abort_schema_application(&db, job_id, None)
2555                .expect("recovered terminal abort should replay"),
2556            progress,
2557        );
2558        assert!(
2559            store
2560                .with_schema(|schema| {
2561                    schema.constraint_validation_job(entity_tag, constraint_id)
2562                })
2563                .expect("recovered validation-job storage should remain readable")
2564                .is_none(),
2565        );
2566    }
2567}