Skip to main content

icydb_core/db/schema/
application_receipt.rs

1//! Module: db::schema::application_receipt
2//! Responsibility: define durable schema-application receipt and job identity contracts.
3//! Does not own: proposal lowering, accepted-schema mutation, or stable-memory access.
4//! Boundary: application admission/result -> bounded current-form durable record.
5
6use crate::db::codec::{
7    finalize_hash_sha256, new_hash_sha256_prefixed, write_hash_str_u32, write_hash_tag_u8,
8    write_hash_u64,
9};
10use crate::error::{ConstraintValidationFindingOutput, InternalError};
11
12use icydb_schema::{
13    ExpectedAcceptedHead, SchemaProposalDigest, SchemaSubmissionKey, TargetDatabaseIdentity,
14    TargetStoreIdentity,
15};
16use serde::Deserialize;
17use sha2::Digest;
18
19const SCHEMA_CHANGE_JOB_ID_PROFILE: &[u8] = b"icydb.schema-application.job-id.v1";
20pub(in crate::db::schema) const MAX_SCHEMA_CHANGE_ACTIVATIONS: usize = 512;
21
22///
23/// SchemaChangeJobId
24///
25/// Opaque identity for one admitted resumable schema change.
26///
27
28#[derive(
29    serde::Serialize, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd,
30)]
31#[serde(transparent)]
32pub struct SchemaChangeJobId([u8; 32]);
33
34impl SchemaChangeJobId {
35    pub(in crate::db::schema) fn from_bytes(bytes: [u8; 32]) -> Result<Self, InternalError> {
36        if bytes == [0; 32] {
37            return Err(InternalError::store_corruption());
38        }
39        Ok(Self(bytes))
40    }
41
42    /// Return the opaque job identity bytes.
43    #[must_use]
44    pub const fn to_bytes(self) -> [u8; 32] {
45        self.0
46    }
47
48    fn validate(self) -> Result<(), InternalError> {
49        if self.0 == [0; 32] {
50            return Err(InternalError::store_corruption());
51        }
52        Ok(())
53    }
54}
55
56///
57/// SchemaChangeJob
58///
59/// Queryable resumable work attached to a pending schema receipt.
60///
61
62#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
63pub struct SchemaChangeJob {
64    id: SchemaChangeJobId,
65}
66
67impl SchemaChangeJob {
68    pub(in crate::db) const fn new(id: SchemaChangeJobId) -> Self {
69        Self { id }
70    }
71
72    /// Return the opaque durable job identity.
73    #[must_use]
74    pub const fn id(self) -> SchemaChangeJobId {
75        self.id
76    }
77}
78
79///
80/// SchemaChangeValidationPhase
81///
82/// Public phase of the canonical proof owned by one schema-change job.
83///
84
85#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
86pub enum SchemaChangeValidationPhase {
87    /// Classify every historical row and capture the completed-domain revision.
88    Forward,
89    /// Recheck the same domain against the unchanged captured revision.
90    Verify,
91}
92
93///
94/// SchemaChangeProgressStatus
95///
96/// Result of one bounded schema-change continuation call.
97///
98
99#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
100pub enum SchemaChangeProgressStatus {
101    /// The durable validation job was created.
102    Started,
103    /// One clean bounded page advanced.
104    Advanced {
105        /// Current canonical proof phase.
106        phase: SchemaChangeValidationPhase,
107        /// Cumulative rows classified by this activation.
108        rows_scanned: u64,
109    },
110    /// One bounded finding page remains retained until acknowledged.
111    Findings {
112        /// Current canonical proof phase.
113        phase: SchemaChangeValidationPhase,
114        /// Cumulative rows classified by this activation.
115        rows_scanned: u64,
116        /// Exact sequence required to acknowledge this retained page.
117        page_sequence: u64,
118        /// Bounded accepted-constraint diagnostics for this page.
119        findings: Vec<ConstraintValidationFindingOutput>,
120    },
121    /// Verify authority drifted and the canonical proof restarted at Forward.
122    Restarted {
123        /// Cumulative rows classified by this activation.
124        rows_scanned: u64,
125    },
126    /// Every activation completed and the durable receipt became terminal.
127    Applied,
128    /// The authorized owner aborted the pending activation.
129    Aborted,
130}
131
132///
133/// SchemaChangeProgress
134///
135/// Durable application receipt paired with one derived bounded progress result.
136///
137
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
139pub struct SchemaChangeProgress {
140    receipt: SchemaChangeReceipt,
141    status: SchemaChangeProgressStatus,
142}
143
144impl SchemaChangeProgress {
145    pub(in crate::db) const fn new(
146        receipt: SchemaChangeReceipt,
147        status: SchemaChangeProgressStatus,
148    ) -> Self {
149        Self { receipt, status }
150    }
151
152    /// Borrow the durable application receipt after this continuation.
153    #[must_use]
154    pub const fn receipt(&self) -> &SchemaChangeReceipt {
155        &self.receipt
156    }
157
158    /// Borrow the bounded progress result.
159    #[must_use]
160    pub const fn status(&self) -> &SchemaChangeProgressStatus {
161        &self.status
162    }
163}
164
165///
166/// SchemaChangeOutcome
167///
168/// Durable terminal or resumable outcome of one admitted proposal.
169///
170
171#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
172pub enum SchemaChangeOutcome {
173    /// The exact proposal was already reflected by the accepted head.
174    NoOp {
175        /// Accepted head retained by the no-op.
176        accepted_head: ExpectedAcceptedHead,
177    },
178    /// The proposal published a new accepted head atomically.
179    Applied {
180        /// Newly accepted database-wide head.
181        accepted_head: ExpectedAcceptedHead,
182    },
183    /// Bounded activation work remains under one durable job.
184    Pending {
185        /// Resumable job identity.
186        job: SchemaChangeJob,
187        /// Candidate head reserved by the pending work.
188        candidate_head: ExpectedAcceptedHead,
189    },
190    /// The authorized owner aborted a pending activation.
191    Aborted {
192        /// Accepted database-wide head after retiring the activation.
193        accepted_head: ExpectedAcceptedHead,
194    },
195}
196
197///
198/// SchemaChangeReceipt
199///
200/// Durable idempotency and result record for one schema submission.
201///
202
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
204pub struct SchemaChangeReceipt {
205    database_identity: TargetDatabaseIdentity,
206    submission_key: SchemaSubmissionKey,
207    proposal_digest: SchemaProposalDigest,
208    prior_head: ExpectedAcceptedHead,
209    outcome: SchemaChangeOutcome,
210}
211
212impl SchemaChangeReceipt {
213    pub(in crate::db) fn new(
214        database_identity: TargetDatabaseIdentity,
215        submission_key: SchemaSubmissionKey,
216        proposal_digest: SchemaProposalDigest,
217        prior_head: ExpectedAcceptedHead,
218        outcome: SchemaChangeOutcome,
219    ) -> Result<Self, InternalError> {
220        let receipt = Self {
221            database_identity,
222            submission_key,
223            proposal_digest,
224            prior_head,
225            outcome,
226        };
227        receipt.validate()?;
228        Ok(receipt)
229    }
230
231    /// Return the target database identity admitted with the proposal.
232    #[must_use]
233    pub const fn database_identity(&self) -> TargetDatabaseIdentity {
234        self.database_identity
235    }
236
237    /// Borrow the immutable caller submission key.
238    #[must_use]
239    pub const fn submission_key(&self) -> &SchemaSubmissionKey {
240        &self.submission_key
241    }
242
243    /// Return the canonical proposal digest.
244    #[must_use]
245    pub const fn proposal_digest(&self) -> SchemaProposalDigest {
246        self.proposal_digest
247    }
248
249    /// Borrow the exact accepted head observed before admission.
250    #[must_use]
251    pub const fn prior_head(&self) -> &ExpectedAcceptedHead {
252        &self.prior_head
253    }
254
255    /// Borrow the durable terminal or pending outcome.
256    #[must_use]
257    pub const fn outcome(&self) -> &SchemaChangeOutcome {
258        &self.outcome
259    }
260
261    pub(in crate::db) fn is_exact_submission(
262        &self,
263        database_identity: TargetDatabaseIdentity,
264        submission_key: &SchemaSubmissionKey,
265        proposal_digest: SchemaProposalDigest,
266        prior_head: &ExpectedAcceptedHead,
267    ) -> bool {
268        self.database_identity == database_identity
269            && &self.submission_key == submission_key
270            && self.proposal_digest == proposal_digest
271            && &self.prior_head == prior_head
272    }
273
274    pub(in crate::db) fn validate(&self) -> Result<(), InternalError> {
275        if self.database_identity.to_bytes() == [0; 32]
276            || self.proposal_digest.to_bytes() == [0; 32]
277        {
278            return Err(InternalError::store_corruption());
279        }
280        validate_head(&self.prior_head, true)?;
281        match &self.outcome {
282            SchemaChangeOutcome::NoOp { accepted_head } => {
283                validate_head(accepted_head, true)?;
284                if accepted_head != &self.prior_head {
285                    return Err(InternalError::store_corruption());
286                }
287            }
288            SchemaChangeOutcome::Applied { accepted_head }
289            | SchemaChangeOutcome::Aborted { accepted_head } => {
290                validate_head(accepted_head, false)?;
291                if accepted_head == &self.prior_head {
292                    return Err(InternalError::store_corruption());
293                }
294            }
295            SchemaChangeOutcome::Pending {
296                job,
297                candidate_head,
298            } => {
299                job.id.validate()?;
300                validate_head(candidate_head, false)?;
301                if candidate_head == &self.prior_head
302                    || job.id
303                        != derive_schema_change_job_id(
304                            self.database_identity,
305                            &self.submission_key,
306                            self.proposal_digest,
307                            &self.prior_head,
308                        )?
309                {
310                    return Err(InternalError::store_corruption());
311                }
312            }
313        }
314        Ok(())
315    }
316}
317
318///
319/// SchemaChangeActivation
320///
321/// Minimal generated-check identity carried by a pending application job.
322///
323
324#[derive(Clone, Debug, Eq, PartialEq)]
325pub(in crate::db) struct SchemaChangeActivation {
326    store: TargetStoreIdentity,
327    entity_tag: u64,
328    constraint_id: u32,
329}
330
331impl SchemaChangeActivation {
332    pub(in crate::db) fn new(
333        store: TargetStoreIdentity,
334        entity_tag: u64,
335        constraint_id: u32,
336    ) -> Result<Self, InternalError> {
337        if entity_tag == 0 || constraint_id == 0 {
338            return Err(InternalError::store_invariant());
339        }
340        Ok(Self {
341            store,
342            entity_tag,
343            constraint_id,
344        })
345    }
346
347    pub(in crate::db) const fn store(&self) -> TargetStoreIdentity {
348        self.store
349    }
350
351    pub(in crate::db) const fn entity_tag(&self) -> u64 {
352        self.entity_tag
353    }
354
355    pub(in crate::db) const fn constraint_id(&self) -> u32 {
356        self.constraint_id
357    }
358
359    fn validate(&self) -> Result<(), InternalError> {
360        if self.store.to_bytes() == [0; 32] || self.entity_tag == 0 || self.constraint_id == 0 {
361            return Err(InternalError::store_corruption());
362        }
363        Ok(())
364    }
365}
366
367///
368/// SchemaApplicationRecord
369///
370/// Canonical durable receipt plus the exact activations owned by a pending
371/// job. Terminal receipts cannot retain activation state.
372///
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub(in crate::db) struct SchemaApplicationRecord {
376    receipt: SchemaChangeReceipt,
377    activations: Vec<SchemaChangeActivation>,
378}
379
380impl SchemaApplicationRecord {
381    pub(in crate::db) fn new(
382        receipt: SchemaChangeReceipt,
383        activations: Vec<SchemaChangeActivation>,
384    ) -> Result<Self, InternalError> {
385        let record = Self {
386            receipt,
387            activations,
388        };
389        record.validate()?;
390        Ok(record)
391    }
392
393    pub(in crate::db) const fn receipt(&self) -> &SchemaChangeReceipt {
394        &self.receipt
395    }
396
397    pub(in crate::db) const fn activations(&self) -> &[SchemaChangeActivation] {
398        self.activations.as_slice()
399    }
400
401    pub(in crate::db) fn validate(&self) -> Result<(), InternalError> {
402        self.receipt.validate()?;
403        if self.activations.len() > MAX_SCHEMA_CHANGE_ACTIVATIONS
404            || self.activations.windows(2).any(|pair| {
405                (pair[0].store, pair[0].entity_tag, pair[0].constraint_id)
406                    >= (pair[1].store, pair[1].entity_tag, pair[1].constraint_id)
407            })
408            || self
409                .activations
410                .iter()
411                .any(|activation| activation.validate().is_err())
412        {
413            return Err(InternalError::store_corruption());
414        }
415
416        let pending = matches!(self.receipt.outcome(), SchemaChangeOutcome::Pending { .. });
417        if pending == self.activations.is_empty() {
418            return Err(InternalError::store_corruption());
419        }
420        Ok(())
421    }
422}
423
424pub(in crate::db) fn derive_schema_change_job_id(
425    database_identity: TargetDatabaseIdentity,
426    submission_key: &SchemaSubmissionKey,
427    proposal_digest: SchemaProposalDigest,
428    prior_head: &ExpectedAcceptedHead,
429) -> Result<SchemaChangeJobId, InternalError> {
430    validate_head(prior_head, true)?;
431    let mut hasher = new_hash_sha256_prefixed(SCHEMA_CHANGE_JOB_ID_PROFILE);
432    hasher.update(database_identity.to_bytes());
433    write_hash_str_u32(&mut hasher, submission_key.as_str());
434    hasher.update(proposal_digest.to_bytes());
435    write_head(&mut hasher, prior_head);
436    SchemaChangeJobId::from_bytes(finalize_hash_sha256(hasher))
437}
438
439fn validate_head(head: &ExpectedAcceptedHead, empty_allowed: bool) -> Result<(), InternalError> {
440    match head {
441        ExpectedAcceptedHead::Empty if empty_allowed => Ok(()),
442        ExpectedAcceptedHead::Exact { revision: 0, .. } | ExpectedAcceptedHead::Empty => {
443            Err(InternalError::store_corruption())
444        }
445        ExpectedAcceptedHead::Exact { fingerprint, .. } if fingerprint.to_bytes() == [0; 32] => {
446            Err(InternalError::store_corruption())
447        }
448        ExpectedAcceptedHead::Exact { .. } => Ok(()),
449    }
450}
451
452fn write_head(hasher: &mut sha2::Sha256, head: &ExpectedAcceptedHead) {
453    match head {
454        ExpectedAcceptedHead::Empty => write_hash_tag_u8(hasher, 0),
455        ExpectedAcceptedHead::Exact {
456            revision,
457            fingerprint,
458        } => {
459            write_hash_tag_u8(hasher, 1);
460            write_hash_u64(hasher, *revision);
461            hasher.update(fingerprint.to_bytes());
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::{
469        SchemaApplicationRecord, SchemaChangeActivation, SchemaChangeJob, SchemaChangeOutcome,
470        SchemaChangeReceipt, derive_schema_change_job_id,
471    };
472    use icydb_schema::{
473        ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposalDigest, SchemaSubmissionKey,
474        TargetDatabaseIdentity, TargetStoreIdentity,
475    };
476
477    #[test]
478    fn schema_change_job_identity_covers_the_complete_idempotency_tuple() {
479        let database = TargetDatabaseIdentity::from_bytes([0x11; 32]);
480        let submission =
481            SchemaSubmissionKey::try_new("job-id").expect("submission key should admit");
482        let digest = SchemaProposalDigest::from_bytes([0x22; 32]);
483        let empty = derive_schema_change_job_id(
484            database,
485            &submission,
486            digest,
487            &ExpectedAcceptedHead::Empty,
488        )
489        .expect("empty-head identity should derive");
490        let exact = derive_schema_change_job_id(
491            database,
492            &submission,
493            digest,
494            &ExpectedAcceptedHead::Exact {
495                revision: 1,
496                fingerprint: ExpectedSchemaFingerprint::from_bytes([0x33; 32]),
497            },
498        )
499        .expect("exact-head identity should derive");
500
501        assert_ne!(empty, exact);
502    }
503
504    #[test]
505    fn pending_and_terminal_record_state_is_exact() {
506        let database = TargetDatabaseIdentity::from_bytes([0x11; 32]);
507        let submission =
508            SchemaSubmissionKey::try_new("state").expect("submission key should admit");
509        let digest = SchemaProposalDigest::from_bytes([0x22; 32]);
510        let head = ExpectedAcceptedHead::Empty;
511        let job = SchemaChangeJob::new(
512            derive_schema_change_job_id(database, &submission, digest, &head)
513                .expect("job identity should derive"),
514        );
515        let pending = SchemaChangeReceipt::new(
516            database,
517            submission.clone(),
518            digest,
519            head.clone(),
520            SchemaChangeOutcome::Pending {
521                job,
522                candidate_head: ExpectedAcceptedHead::Exact {
523                    revision: 1,
524                    fingerprint: ExpectedSchemaFingerprint::from_bytes([0x33; 32]),
525                },
526            },
527        )
528        .expect("pending receipt should admit");
529        assert!(
530            SchemaApplicationRecord::new(pending.clone(), Vec::new()).is_err(),
531            "pending records require exact activation ownership",
532        );
533        let activation =
534            SchemaChangeActivation::new(TargetStoreIdentity::from_bytes([0x44; 32]), 1, 1)
535                .expect("activation should admit");
536        SchemaApplicationRecord::new(pending, vec![activation])
537            .expect("pending record with activation should admit");
538
539        let terminal = SchemaChangeReceipt::new(
540            database,
541            submission,
542            digest,
543            head,
544            SchemaChangeOutcome::Aborted {
545                accepted_head: ExpectedAcceptedHead::Exact {
546                    revision: 1,
547                    fingerprint: ExpectedSchemaFingerprint::from_bytes([0x55; 32]),
548                },
549            },
550        )
551        .expect("terminal receipt should admit");
552        assert!(
553            SchemaApplicationRecord::new(
554                terminal,
555                vec![
556                    SchemaChangeActivation::new(TargetStoreIdentity::from_bytes([0x44; 32]), 1, 1,)
557                        .expect("activation should admit"),
558                ],
559            )
560            .is_err(),
561            "terminal records cannot retain activation state",
562        );
563    }
564
565    #[test]
566    fn schema_change_receipt_outcome_heads_have_exact_temporal_closure() {
567        let database = TargetDatabaseIdentity::from_bytes([0x11; 32]);
568        let digest = SchemaProposalDigest::from_bytes([0x22; 32]);
569        let prior = ExpectedAcceptedHead::Exact {
570            revision: 7,
571            fingerprint: ExpectedSchemaFingerprint::from_bytes([0x33; 32]),
572        };
573        let changed = ExpectedAcceptedHead::Exact {
574            revision: 8,
575            fingerprint: ExpectedSchemaFingerprint::from_bytes([0x44; 32]),
576        };
577
578        SchemaChangeReceipt::new(
579            database,
580            SchemaSubmissionKey::try_new("noop").expect("submission key should admit"),
581            digest,
582            prior.clone(),
583            SchemaChangeOutcome::NoOp {
584                accepted_head: prior.clone(),
585            },
586        )
587        .expect("no-op must retain the exact prior head");
588        assert!(
589            SchemaChangeReceipt::new(
590                database,
591                SchemaSubmissionKey::try_new("invalid-noop").expect("submission key should admit"),
592                digest,
593                prior.clone(),
594                SchemaChangeOutcome::NoOp {
595                    accepted_head: changed.clone(),
596                },
597            )
598            .is_err(),
599        );
600        assert!(
601            SchemaChangeReceipt::new(
602                database,
603                SchemaSubmissionKey::try_new("invalid-applied")
604                    .expect("submission key should admit"),
605                digest,
606                prior.clone(),
607                SchemaChangeOutcome::Applied {
608                    accepted_head: prior.clone(),
609                },
610            )
611            .is_err(),
612        );
613        SchemaChangeReceipt::new(
614            database,
615            SchemaSubmissionKey::try_new("applied").expect("submission key should admit"),
616            digest,
617            prior.clone(),
618            SchemaChangeOutcome::Applied {
619                accepted_head: changed.clone(),
620            },
621        )
622        .expect("applied receipt must identify a different exact head");
623        assert!(
624            SchemaChangeReceipt::new(
625                database,
626                SchemaSubmissionKey::try_new("invalid-abort").expect("submission key should admit"),
627                digest,
628                prior.clone(),
629                SchemaChangeOutcome::Aborted {
630                    accepted_head: prior.clone(),
631                },
632            )
633            .is_err(),
634        );
635        SchemaChangeReceipt::new(
636            database,
637            SchemaSubmissionKey::try_new("aborted").expect("submission key should admit"),
638            digest,
639            prior,
640            SchemaChangeOutcome::Aborted {
641                accepted_head: changed,
642            },
643        )
644        .expect("aborted receipt must retain the post-abort accepted head");
645    }
646}