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