Skip to main content

icydb_core/db/
integrity.rs

1//! Module: db::integrity
2//! Responsibility: bounded integrity-inspection result vocabulary and lifecycle identity.
3//! Does not own: accepted schema meaning, physical traversal, or inspection progress persistence.
4//! Boundary: database control + accepted inspection plan -> typed Quick inspection result.
5
6mod deep;
7mod derived;
8mod job;
9mod progress_codec;
10mod progress_store;
11mod proof;
12mod row;
13
14use crate::{
15    db::{
16        commit::ensure_recovery_admitted,
17        registry::{
18            StoreAllocationIdentities, StoreHandle, StoreRuntimeStorageCapabilities,
19            StoreRuntimeStorageMode,
20        },
21        schema::{
22            AcceptedInspectionPlan, IdentityStateLifecycle, MAX_IDENTITY_STATE_RECORDS_PER_DATABASE,
23        },
24    },
25    error::{ConstraintValuePath, ErrorClass, ErrorOrigin, InternalError},
26    traits::CanisterKind,
27};
28use candid::CandidType;
29use serde::Deserialize;
30use std::{
31    collections::BTreeMap,
32    sync::atomic::{AtomicU64, Ordering},
33};
34
35pub(in crate::db) use deep::{
36    abort_deep_integrity_job, continue_deep_integrity_job, run_next_integrity_retention_page,
37    start_deep_integrity_job,
38};
39pub(in crate::db) use derived::{
40    DerivedInspectionLimits, execute_index_integrity_page, execute_reverse_integrity_page,
41};
42pub use job::{
43    DeepIntegrityPage, DeepIntegrityPageStatus, IntegrityAbortReceipt, IntegrityAbortStatus,
44    IntegrityDeepError, IntegrityJobError, IntegrityJobId, IntegrityJobOwner, IntegrityJobReceipt,
45    IntegrityPendingTerminal, IntegritySubmissionKey, IntegrityTerminalOutcome,
46};
47pub(in crate::db) use job::{
48    IntegrityCheckpoint, IntegrityJob, IntegrityJobState, IntegrityReceiptEnvelope,
49    IntegrityReceiptReplayKey, MAX_INTEGRITY_IN_PROGRESS_PAGES,
50};
51#[cfg(any(feature = "sql", test))]
52pub(in crate::db) use progress_store::InsertMutationJobResult;
53#[cfg(feature = "sql")]
54pub(in crate::db) use progress_store::replace_mutation_progress_record_op;
55pub(in crate::db) use progress_store::{
56    MutationProgressRecordOp, apply_mutation_progress_record_op,
57    apply_preflighted_mutation_progress_record_op, preflight_mutation_progress_record_op,
58    verify_mutation_progress_record_op, with_mutation_progress_store,
59    with_resumable_progress_store,
60};
61pub use progress_store::{
62    ProgressJobFamily, ProgressJobInventory, ProgressJobInventoryRecord, ProgressJobLifecycle,
63};
64pub(in crate::db) use proof::{IntegrityProofVector, capture_integrity_proof_vector};
65pub(in crate::db) use row::{
66    PhysicalUnitCheckpoint, RowInspectionLimits, execute_row_integrity_page,
67};
68
69pub(in crate::db) const MAX_INTEGRITY_PATH_BYTES: usize = 4 * 1024;
70
71/// One authorization-bound typed integrity operation.
72///
73/// Entity-bearing variants pin the generated selector identity that the
74/// session must match against current accepted authority. Continuation and
75/// abort carry only the opaque job identity; private checkpoints never cross
76/// this boundary.
77
78#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
79pub enum IntegrityCheckRequest {
80    /// Execute one bounded metadata/control inspection.
81    Quick {
82        /// Accepted entity selector to resolve and verify.
83        entity: IntegrityEntityIdentity,
84    },
85    /// Create or replay one idempotent Deep job.
86    DeepStart {
87        /// Accepted entity selector to resolve and verify.
88        entity: IntegrityEntityIdentity,
89        /// Owner-scoped idempotency key.
90        submission_key: IntegritySubmissionKey,
91    },
92    /// Advance or replay one retained Deep job.
93    DeepContinue {
94        /// Opaque engine-issued job identity.
95        job_id: IntegrityJobId,
96        /// Sequence of the outstanding receipt being acknowledged.
97        acknowledged_sequence: u64,
98    },
99    /// Freeze one retained Deep job for replayable abort.
100    DeepAbort {
101        /// Opaque engine-issued job identity.
102        job_id: IntegrityJobId,
103    },
104}
105
106impl IntegrityCheckRequest {
107    /// Build one Deep continuation or exact replay request.
108    #[must_use]
109    pub const fn deep_continue(job_id: IntegrityJobId, acknowledged_sequence: u64) -> Self {
110        Self::DeepContinue {
111            job_id,
112            acknowledged_sequence,
113        }
114    }
115
116    /// Build one replayable Deep-abort request.
117    #[must_use]
118    pub const fn deep_abort(job_id: IntegrityJobId) -> Self {
119        Self::DeepAbort { job_id }
120    }
121}
122
123/// Typed result shared by trusted Rust and SQL integrity frontends.
124
125#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
126pub enum IntegrityCheckResult {
127    /// Bounded one-call Quick result.
128    Quick(QuickIntegrityResult),
129    /// Start, continuation, terminal, or abort Deep receipt.
130    Deep(IntegrityJobReceipt),
131}
132
133/// Resolve the canonical source-plus-relation store set for one inspection.
134fn resolve_integrity_participating_stores<C: CanisterKind>(
135    db: &crate::db::Db<C>,
136    plan: &AcceptedInspectionPlan,
137) -> Result<BTreeMap<String, StoreHandle>, InternalError> {
138    let identity = plan.identity();
139    let source_store = db.store_handle(identity.store_path())?;
140    let mut stores = BTreeMap::from([(identity.store_path().to_string(), source_store)]);
141    for relation in plan.relation_inspection() {
142        stores
143            .entry(relation.target_store_path().to_string())
144            .or_insert_with(|| relation.target_store());
145    }
146    Ok(stores)
147}
148
149fn validate_quick_integrity_control<C: CanisterKind>(
150    db: &crate::db::Db<C>,
151    plan: &AcceptedInspectionPlan,
152    incarnation: DatabaseIncarnationId,
153) -> Result<Vec<IntegrityFinding>, InternalError> {
154    let identity = plan.identity();
155    let participating_stores = resolve_integrity_participating_stores(db, plan)?;
156
157    // The session's accepted-root capture or the Deep job's proof capture has
158    // already validated the database-control envelope that supplied this
159    // incarnation. Deep persists the envelope hash across pages; Quick must
160    // not decode and hash those same stable bytes a second time.
161    proof::validate_integrity_allocation_registry()?;
162    validate_quick_identity_control(db, incarnation)?;
163    let mut findings = Vec::new();
164    for (store_path, store) in &participating_stores {
165        if let Some(finding) = validate_quick_store_control(plan, store_path, *store)? {
166            findings.push(finding);
167        }
168    }
169    for ordinal in 0..plan.index_inspection().len() {
170        let _domain = plan
171            .index_inspection()
172            .domain(ordinal, identity.entity_tag())?;
173    }
174
175    Ok(findings)
176}
177
178fn validate_quick_identity_control<C: CanisterKind>(
179    db: &crate::db::Db<C>,
180    incarnation: DatabaseIncarnationId,
181) -> Result<(), InternalError> {
182    let mut stores = db.with_store_registry(|registry| registry.iter().collect::<Vec<_>>());
183    icydb_schema::compact_sort_unstable_by(&mut stores, |left, right| left.0.cmp(right.0));
184
185    let mut owners = BTreeMap::new();
186    let mut state_count = 0usize;
187    for (store_path, store) in stores {
188        let states = store.with_schema(|schema_store| {
189            schema_store.identity_state_inventory_for_integrity(incarnation)
190        })?;
191        state_count = state_count
192            .checked_add(states.len())
193            .ok_or_else(InternalError::identity_state_corruption)?;
194        if state_count > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
195            return Err(InternalError::identity_state_corruption());
196        }
197
198        for state in states {
199            let owner = state.owner();
200            record_quick_identity_owner(&mut owners, store_path, &state)?;
201            if state.lifecycle() == IdentityStateLifecycle::Active {
202                let runtime_entity = db
203                    .accepted_runtime_entity_for_tag(owner.entity_tag())
204                    .map_err(|_| InternalError::identity_state_corruption())?;
205                if runtime_entity.store_path() != store_path {
206                    return Err(InternalError::identity_state_corruption());
207                }
208            }
209        }
210    }
211
212    Ok(())
213}
214
215fn record_quick_identity_owner<'a>(
216    owners: &mut BTreeMap<(crate::types::EntityTag, crate::db::schema::FieldId), &'a str>,
217    store_path: &'a str,
218    state: &crate::db::schema::IdentityState,
219) -> Result<(), InternalError> {
220    let owner = state.owner();
221    let key = (owner.entity_tag(), owner.field_id());
222    if owners.insert(key, store_path).is_some() {
223        return Err(InternalError::identity_state_corruption());
224    }
225    Ok(())
226}
227
228fn validate_quick_store_control(
229    plan: &AcceptedInspectionPlan,
230    store_path: &str,
231    store: StoreHandle,
232) -> Result<Option<IntegrityFinding>, InternalError> {
233    let capabilities = store.storage_capabilities();
234    let allocations = store.allocation_identities();
235    match capabilities.storage_mode() {
236        StoreRuntimeStorageMode::Heap => {
237            if capabilities != StoreRuntimeStorageCapabilities::heap()
238                || allocations != StoreAllocationIdentities::absent()
239                || store.journal_tail_store().is_some()
240            {
241                return Err(InternalError::store_invariant());
242            }
243            Ok(None)
244        }
245        StoreRuntimeStorageMode::Journaled => {
246            if capabilities != StoreRuntimeStorageCapabilities::journaled()
247                || !allocations.matches_storage_capabilities(capabilities)
248            {
249                return Err(InternalError::store_invariant());
250            }
251            let journal = store
252                .journal_tail_store()
253                .ok_or_else(InternalError::store_invariant)?
254                .with_borrow(crate::db::journal::JournalTailStore::proof_identity)?;
255            if !journal.is_well_formed() {
256                return Ok(Some(quick_journal_control_finding(plan, store_path)));
257            }
258            Ok(None)
259        }
260    }
261}
262
263fn quick_journal_control_finding(
264    plan: &AcceptedInspectionPlan,
265    store_path: &str,
266) -> IntegrityFinding {
267    let error = InternalError::store_corruption();
268    IntegrityFinding {
269        diagnostic_code: error.diagnostic_code().error_code().raw(),
270        class: IntegrityFindingClass::Corruption,
271        severity: IntegritySeverity::Error,
272        kind: IntegrityFindingKind::JournalControlMismatch,
273        entity: IntegrityEntityIdentity::from_plan(plan),
274        store_path: store_path.to_string(),
275        phase: IntegrityPhase::QuickMetadata,
276        verifier_family: IntegrityVerifierFamily::JournalEnvelope,
277        physical_key: Vec::new(),
278        primary_key: None,
279        field_paths: Vec::new(),
280        value_path: None,
281        constraint_id: None,
282        constraint_name: None,
283        schema_index_id: None,
284        relation_id: None,
285        expected: Some("well-formed-journal-control".to_string()),
286        observed: Some("inconsistent-journal-control".to_string()),
287    }
288}
289
290fn relation_field_paths(plan: &AcceptedInspectionPlan, relation_id: u32) -> Vec<String> {
291    let snapshot = plan.snapshot().persisted_snapshot();
292    let Some(relation) = snapshot
293        .relations()
294        .iter()
295        .find(|relation| relation.id().get() == relation_id)
296    else {
297        return Vec::new();
298    };
299
300    relation
301        .source()
302        .root_field_ids()
303        .iter()
304        .filter_map(|field_id| {
305            snapshot
306                .fields()
307                .iter()
308                .find(|field| field.id() == *field_id)
309                .map(|field| field.name().to_string())
310        })
311        .collect()
312}
313
314const MAX_QUICK_RETURNED_FINDINGS: usize = 64;
315#[cfg(target_arch = "wasm32")]
316const DATABASE_INCARNATION_DOMAIN: &[u8] = b"icydb.database-incarnation.v1";
317static DATABASE_INCARNATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
318
319/// Durable identity of one database lifecycle.
320///
321/// The identity is independent of accepted schema, row, index, relation, and
322/// journal revisions. Ordinary reopen preserves it. Any future restore,
323/// replacement, or import lane that can reuse those revisions must mint and
324/// publish a fresh identity before the restored database becomes available.
325#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)]
326pub struct DatabaseIncarnationId([u8; 16]);
327
328impl DatabaseIncarnationId {
329    /// Decode one current-form nonzero incarnation identity.
330    pub(crate) fn try_from_bytes(bytes: [u8; 16]) -> Result<Self, InternalError> {
331        if bytes == [0; 16] {
332            return Err(InternalError::database_incarnation_invalid());
333        }
334
335        Ok(Self(bytes))
336    }
337
338    /// Return the canonical persisted identity bytes.
339    #[must_use]
340    pub const fn to_bytes(self) -> [u8; 16] {
341        self.0
342    }
343
344    fn generate() -> Result<Self, InternalError> {
345        let sequence = DATABASE_INCARNATION_SEQUENCE
346            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
347                current.checked_add(1)
348            })
349            .map_err(|_| InternalError::database_incarnation_generation_failed())?
350            .checked_add(1)
351            .ok_or_else(InternalError::database_incarnation_generation_failed)?;
352
353        #[cfg(not(target_arch = "wasm32"))]
354        let bytes = {
355            let mut bytes = [0_u8; 16];
356            getrandom::fill(&mut bytes)
357                .map_err(|_| InternalError::database_incarnation_generation_failed())?;
358            bytes
359        };
360
361        #[cfg(target_arch = "wasm32")]
362        let bytes = {
363            use sha2::{Digest, Sha256};
364
365            let mut hasher = Sha256::new();
366            hasher.update(DATABASE_INCARNATION_DOMAIN);
367            hasher.update(ic_cdk::api::canister_self().as_slice());
368            hasher.update(ic_cdk::api::time().to_be_bytes());
369            hasher.update(sequence.to_be_bytes());
370            let digest = hasher.finalize();
371            let mut bytes = [0_u8; 16];
372            bytes.copy_from_slice(&digest[..16]);
373            bytes
374        };
375
376        let _ = sequence;
377        Self::try_from_bytes(bytes)
378    }
379
380    #[cfg(test)]
381    pub(crate) const fn for_tests(fill: u8) -> Self {
382        let mut bytes = [fill; 16];
383        if fill == 0 {
384            bytes[15] = 1;
385        }
386        Self(bytes)
387    }
388}
389
390/// Stable entity identity projected into integrity responses.
391#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
392pub struct IntegrityEntityIdentity {
393    entity_tag: u64,
394    entity_path: String,
395    store_path: String,
396}
397
398impl IntegrityEntityIdentity {
399    fn from_plan(plan: &AcceptedInspectionPlan) -> Self {
400        Self::from_accepted_identity(plan.identity_ref())
401    }
402
403    pub(in crate::db) fn from_accepted_identity(
404        identity: &crate::db::schema::AcceptedCatalogIdentity,
405    ) -> Self {
406        Self {
407            entity_tag: identity.entity_tag().value(),
408            entity_path: identity.entity_path().to_string(),
409            store_path: identity.store_path().to_string(),
410        }
411    }
412
413    pub(in crate::db) const fn validate(&self) -> Result<(), IntegrityJobError> {
414        if self.entity_tag == 0
415            || self.entity_path.is_empty()
416            || self.entity_path.len() > MAX_INTEGRITY_PATH_BYTES
417            || self.store_path.is_empty()
418            || self.store_path.len() > MAX_INTEGRITY_PATH_BYTES
419        {
420            return Err(IntegrityJobError::InvalidEntityIdentity);
421        }
422        Ok(())
423    }
424
425    /// Return the stable accepted entity tag.
426    #[must_use]
427    pub const fn entity_tag(&self) -> u64 {
428        self.entity_tag
429    }
430
431    /// Borrow the accepted entity path.
432    #[must_use]
433    pub const fn entity_path(&self) -> &str {
434        self.entity_path.as_str()
435    }
436
437    /// Borrow the accepted store path.
438    #[must_use]
439    pub const fn store_path(&self) -> &str {
440        self.store_path.as_str()
441    }
442}
443
444/// Broad machine-readable accepted-authority failure class.
445#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
446pub enum IntegrityAuthorityClass {
447    /// Accepted authority bytes or closure are corrupt.
448    Corruption,
449    /// Accepted authority uses an unsupported persisted form.
450    IncompatiblePersistedFormat,
451    /// Accepted authority violates an internal invariant.
452    InvariantViolation,
453    /// The selected entity or storage contract is unsupported.
454    Unsupported,
455    /// The engine could not complete accepted-authority inspection.
456    Internal,
457}
458
459/// Broad machine-readable integrity finding class.
460#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
461pub enum IntegrityFindingClass {
462    /// Accepted or physical bytes are corrupt.
463    Corruption,
464    /// Current-form persisted bytes cannot be decoded by this build.
465    IncompatiblePersistedFormat,
466    /// A required bounded proof could not be completed.
467    ResourceLimited,
468}
469
470/// Stable semantic family of one integrity finding.
471
472#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
473pub enum IntegrityFindingKind {
474    /// The physical data key is not a valid current key for its entity interval.
475    MalformedDataKey,
476
477    /// The maintained row envelope or slot table is malformed.
478    MalformedRow,
479
480    /// The row exceeds the maintained current raw-byte bound.
481    OversizedRow,
482
483    /// One active accepted field payload violates its exact field contract.
484    InvalidFieldValue,
485
486    /// The physical key and decoded primary-key field values disagree.
487    PrimaryKeyMismatch,
488
489    /// An Identity primary key is zero or has the wrong exact unsigned shape.
490    InvalidIdentityValue,
491
492    /// A live Identity primary key is above committed high-water.
493    IdentityHighWaterExceeded,
494
495    /// One validated accepted row-local constraint is violated.
496    ConstraintViolation,
497
498    /// One row-derived active forward-index witness is absent.
499    MissingIndexEntry,
500
501    /// One row-derived active forward-index witness has invalid value bytes.
502    DivergentIndexEntry,
503
504    /// One active forward-index entry has malformed key, identity, or value framing.
505    MalformedIndexEntry,
506
507    /// One active forward-index entry points at no authoritative source row.
508    OrphanIndexEntry,
509
510    /// One unique logical key has more than one physical row witness.
511    DuplicateUniqueIndexKey,
512
513    /// One accepted relation points to an absent target row.
514    MissingRelationTarget,
515
516    /// One expected active reverse-relation witness is absent.
517    MissingReverseRelationEntry,
518
519    /// One expected active reverse-relation witness has invalid value bytes.
520    DivergentReverseRelationEntry,
521
522    /// One active reverse-relation entry has malformed key, identity, or value framing.
523    MalformedReverseRelationEntry,
524
525    /// One active reverse-relation entry points at no authoritative source row.
526    OrphanReverseRelationEntry,
527
528    /// One durable journal batch is not a valid current-form envelope.
529    MalformedJournalBatch,
530
531    /// The durable journal tail omits one or more expected sequence values.
532    JournalSequenceGap,
533
534    /// Two durable journal batches carry the same logical batch identity.
535    DuplicateJournalBatchIdentity,
536
537    /// Bounded journal control records disagree without requiring tail traversal.
538    JournalControlMismatch,
539}
540
541/// Canonical Deep inspection phase.
542
543#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
544pub enum IntegrityPhase {
545    /// Bounded accepted metadata and control closure.
546    QuickMetadata,
547
548    /// Canonical physical row storage.
549    Rows,
550
551    /// Active forward-index storage.
552    IndexEntries,
553
554    /// Active source-owned reverse-relation storage.
555    ReverseRelations,
556
557    /// Durable journal tails.
558    JournalTails,
559
560    /// Final unchanged-proof-vector comparison.
561    FinalProofVectorCheck,
562}
563
564/// Deterministic verifier family within one physical inspection unit.
565
566#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
567pub enum IntegrityVerifierFamily {
568    /// Current physical data-key framing and identity.
569    DataKey,
570
571    /// Current row envelope, layout stamp, slot count, and table framing.
572    RowEnvelope,
573
574    /// One accepted field payload or frozen historical fill.
575    FieldValue,
576
577    /// Physical key versus accepted row primary-key fields.
578    PrimaryKey,
579
580    /// Accepted Identity owner and committed high-water.
581    IdentityState,
582
583    /// Validated accepted row-local constraints.
584    ValidatedConstraints,
585
586    /// One expected active forward-index witness.
587    ForwardIndex,
588
589    /// One physical active forward-index entry.
590    IndexEntry,
591
592    /// One unique-key multiplicity proof.
593    UniqueIndex,
594
595    /// One accepted relation's target and reverse witness projection.
596    Relation,
597
598    /// One physical active source-owned reverse-relation entry.
599    ReverseRelationEntry,
600
601    /// Current durable journal batch framing and sequence continuity.
602    JournalEnvelope,
603
604    /// Durable journal batch identity uniqueness.
605    JournalBatchIdentity,
606}
607
608/// Severity of one definite integrity finding.
609#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
610pub enum IntegritySeverity {
611    /// The finding identifies invalid maintained state.
612    Error,
613    /// The finding is an operator advisory and does not invalidate a clean proof.
614    Advisory,
615}
616
617/// One bounded machine-readable integrity finding.
618///
619/// Raw row payloads and unbounded application values are deliberately absent.
620#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
621pub struct IntegrityFinding {
622    diagnostic_code: u16,
623    class: IntegrityFindingClass,
624    severity: IntegritySeverity,
625    kind: IntegrityFindingKind,
626    entity: IntegrityEntityIdentity,
627    store_path: String,
628    phase: IntegrityPhase,
629    verifier_family: IntegrityVerifierFamily,
630    physical_key: Vec<u8>,
631    primary_key: Option<Vec<u8>>,
632    field_paths: Vec<String>,
633    value_path: Option<Box<ConstraintValuePath>>,
634    constraint_id: Option<u32>,
635    constraint_name: Option<String>,
636    schema_index_id: Option<u32>,
637    relation_id: Option<u32>,
638    expected: Option<String>,
639    observed: Option<String>,
640}
641
642impl IntegrityFinding {
643    /// Return the stable compact diagnostic code.
644    #[must_use]
645    pub const fn diagnostic_code(&self) -> u16 {
646        self.diagnostic_code
647    }
648
649    /// Return the broad finding class.
650    #[must_use]
651    pub const fn class(&self) -> IntegrityFindingClass {
652        self.class
653    }
654
655    /// Return the finding severity.
656    #[must_use]
657    pub const fn severity(&self) -> IntegritySeverity {
658        self.severity
659    }
660
661    /// Return the stable semantic finding family.
662    #[must_use]
663    pub const fn kind(&self) -> IntegrityFindingKind {
664        self.kind
665    }
666
667    /// Borrow the accepted entity identity.
668    #[must_use]
669    pub const fn entity(&self) -> &IntegrityEntityIdentity {
670        &self.entity
671    }
672
673    /// Borrow the affected store path.
674    #[must_use]
675    pub const fn store_path(&self) -> &str {
676        self.store_path.as_str()
677    }
678
679    /// Return the Deep phase that observed this finding.
680    #[must_use]
681    pub const fn phase(&self) -> IntegrityPhase {
682        self.phase
683    }
684
685    /// Return the deterministic verifier family that observed this finding.
686    #[must_use]
687    pub const fn verifier_family(&self) -> IntegrityVerifierFamily {
688        self.verifier_family
689    }
690
691    /// Borrow the bounded exact physical key.
692    #[must_use]
693    pub const fn physical_key(&self) -> &[u8] {
694        self.physical_key.as_slice()
695    }
696
697    /// Borrow the canonical primary-key suffix after successful key decoding.
698    #[must_use]
699    pub fn primary_key(&self) -> Option<&[u8]> {
700        self.primary_key.as_deref()
701    }
702
703    /// Borrow bounded accepted field paths relevant to the finding.
704    #[must_use]
705    pub const fn field_paths(&self) -> &[String] {
706        self.field_paths.as_slice()
707    }
708
709    /// Borrow the concrete accepted value path for targeted-rule findings.
710    #[must_use]
711    pub fn value_path(&self) -> Option<&ConstraintValuePath> {
712        self.value_path.as_deref()
713    }
714
715    /// Return the accepted constraint identity when applicable.
716    #[must_use]
717    pub const fn constraint_id(&self) -> Option<u32> {
718        self.constraint_id
719    }
720
721    /// Borrow the accepted constraint name when applicable.
722    #[must_use]
723    pub fn constraint_name(&self) -> Option<&str> {
724        self.constraint_name.as_deref()
725    }
726
727    /// Return the accepted logical index identity when applicable.
728    #[must_use]
729    pub const fn schema_index_id(&self) -> Option<u32> {
730        self.schema_index_id
731    }
732
733    /// Return the accepted relation identity when applicable.
734    #[must_use]
735    pub const fn relation_id(&self) -> Option<u32> {
736        self.relation_id
737    }
738
739    /// Borrow the bounded expected-state label, when applicable.
740    #[must_use]
741    pub fn expected(&self) -> Option<&str> {
742        self.expected.as_deref()
743    }
744
745    /// Borrow the bounded observed-state label, when applicable.
746    #[must_use]
747    pub fn observed(&self) -> Option<&str> {
748        self.observed.as_deref()
749    }
750}
751
752/// Typed reason that accepted authority could not be inspected.
753#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
754pub struct IntegrityAuthorityDiagnostic {
755    diagnostic_code: u16,
756    class: IntegrityAuthorityClass,
757}
758
759impl IntegrityAuthorityDiagnostic {
760    pub(in crate::db) fn from_internal(error: &InternalError) -> Self {
761        let class = match error.class {
762            ErrorClass::Corruption => IntegrityAuthorityClass::Corruption,
763            ErrorClass::IncompatiblePersistedFormat => {
764                IntegrityAuthorityClass::IncompatiblePersistedFormat
765            }
766            ErrorClass::InvariantViolation => IntegrityAuthorityClass::InvariantViolation,
767            ErrorClass::Unsupported | ErrorClass::NotFound | ErrorClass::Conflict => {
768                IntegrityAuthorityClass::Unsupported
769            }
770            ErrorClass::Internal => IntegrityAuthorityClass::Internal,
771        };
772        Self {
773            diagnostic_code: error.diagnostic_code().error_code().raw(),
774            class,
775        }
776    }
777
778    /// Return the stable compact diagnostic code.
779    #[must_use]
780    pub const fn diagnostic_code(&self) -> u16 {
781        self.diagnostic_code
782    }
783
784    /// Return the broad failure class.
785    #[must_use]
786    pub const fn class(&self) -> IntegrityAuthorityClass {
787        self.class
788    }
789}
790
791/// Typed bounded-resource failure.
792#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
793pub struct IntegrityResourceDiagnostic {
794    diagnostic_code: u16,
795}
796
797impl IntegrityResourceDiagnostic {
798    /// Return the stable compact diagnostic code.
799    #[must_use]
800    pub const fn diagnostic_code(&self) -> u16 {
801        self.diagnostic_code
802    }
803}
804
805/// Outcome of one bounded Quick integrity inspection.
806#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
807pub enum QuickIntegrityStatus {
808    /// Every bounded Quick family was inspected without findings.
809    CompleteClean,
810    /// Every bounded Quick family was inspected and definite findings exist.
811    CompleteWithFindings,
812    /// Load-bearing accepted authority could not be inspected.
813    Uninspectable(IntegrityAuthorityDiagnostic),
814    /// The minimum bounded inspection atom could not be completed.
815    ResourceLimited(IntegrityResourceDiagnostic),
816}
817
818/// Complete result of one bounded accepted-native Quick inspection.
819#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
820pub struct QuickIntegrityResult {
821    entity: IntegrityEntityIdentity,
822    database_incarnation_id: DatabaseIncarnationId,
823    accepted_schema_version: u32,
824    accepted_schema_fingerprint: [u8; 16],
825    status: QuickIntegrityStatus,
826    total_findings: u64,
827    omitted_findings: u64,
828    findings: Vec<IntegrityFinding>,
829}
830
831impl QuickIntegrityResult {
832    /// Borrow the accepted entity identity.
833    #[must_use]
834    pub const fn entity(&self) -> &IntegrityEntityIdentity {
835        &self.entity
836    }
837
838    /// Return the durable database incarnation inspected by this call.
839    #[must_use]
840    pub const fn database_incarnation_id(&self) -> DatabaseIncarnationId {
841        self.database_incarnation_id
842    }
843
844    /// Return the accepted entity schema version.
845    #[must_use]
846    pub const fn accepted_schema_version(&self) -> u32 {
847        self.accepted_schema_version
848    }
849
850    /// Return the accepted entity schema fingerprint.
851    #[must_use]
852    pub const fn accepted_schema_fingerprint(&self) -> [u8; 16] {
853        self.accepted_schema_fingerprint
854    }
855
856    /// Borrow the Quick completion status.
857    #[must_use]
858    pub const fn status(&self) -> &QuickIntegrityStatus {
859        &self.status
860    }
861
862    /// Return the exact number of findings observed.
863    #[must_use]
864    pub const fn total_findings(&self) -> u64 {
865        self.total_findings
866    }
867
868    /// Return the number of findings omitted from the bounded response prefix.
869    #[must_use]
870    pub const fn omitted_findings(&self) -> u64 {
871        self.omitted_findings
872    }
873
874    /// Borrow the bounded canonical finding prefix.
875    #[must_use]
876    pub const fn findings(&self) -> &[IntegrityFinding] {
877        self.findings.as_slice()
878    }
879}
880
881struct QuickIntegrityAccumulator {
882    total_findings: u64,
883    findings: Vec<IntegrityFinding>,
884}
885
886impl QuickIntegrityAccumulator {
887    const fn new() -> Self {
888        Self {
889            total_findings: 0,
890            findings: Vec::new(),
891        }
892    }
893
894    fn record(&mut self, finding: IntegrityFinding) -> Result<(), IntegrityResourceDiagnostic> {
895        self.total_findings =
896            self.total_findings
897                .checked_add(1)
898                .ok_or(IntegrityResourceDiagnostic {
899                    diagnostic_code: icydb_diagnostic_code::ErrorCode::RUNTIME_INTERNAL.raw(),
900                })?;
901        if self.findings.len() < MAX_QUICK_RETURNED_FINDINGS {
902            self.findings.push(finding);
903        }
904        Ok(())
905    }
906
907    fn complete(
908        self,
909        plan: &AcceptedInspectionPlan,
910        incarnation: DatabaseIncarnationId,
911    ) -> Result<QuickIntegrityResult, InternalError> {
912        let status = if self.total_findings == 0 {
913            QuickIntegrityStatus::CompleteClean
914        } else {
915            QuickIntegrityStatus::CompleteWithFindings
916        };
917        let omitted_findings = self.omitted_findings()?;
918        let identity = plan.identity();
919
920        Ok(QuickIntegrityResult {
921            entity: IntegrityEntityIdentity::from_plan(plan),
922            database_incarnation_id: incarnation,
923            accepted_schema_version: identity.accepted_schema_version().get(),
924            accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
925            status,
926            total_findings: self.total_findings,
927            omitted_findings,
928            findings: self.findings,
929        })
930    }
931
932    fn resource_limited(
933        self,
934        plan: &AcceptedInspectionPlan,
935        incarnation: DatabaseIncarnationId,
936        diagnostic: IntegrityResourceDiagnostic,
937    ) -> Result<QuickIntegrityResult, InternalError> {
938        let omitted_findings = self.omitted_findings()?;
939        let identity = plan.identity();
940
941        Ok(QuickIntegrityResult {
942            entity: IntegrityEntityIdentity::from_plan(plan),
943            database_incarnation_id: incarnation,
944            accepted_schema_version: identity.accepted_schema_version().get(),
945            accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
946            status: QuickIntegrityStatus::ResourceLimited(diagnostic),
947            total_findings: self.total_findings,
948            omitted_findings,
949            findings: self.findings,
950        })
951    }
952
953    fn omitted_findings(&self) -> Result<u64, InternalError> {
954        let returned = u64::try_from(self.findings.len()).map_err(|_| {
955            InternalError::classified(ErrorClass::InvariantViolation, ErrorOrigin::Response)
956        })?;
957        self.total_findings.checked_sub(returned).ok_or_else(|| {
958            InternalError::classified(ErrorClass::InvariantViolation, ErrorOrigin::Response)
959        })
960    }
961}
962
963pub(in crate::db) fn uninspectable_quick_integrity(
964    identity: crate::db::schema::AcceptedCatalogIdentity,
965    incarnation: DatabaseIncarnationId,
966    diagnostic: IntegrityAuthorityDiagnostic,
967) -> QuickIntegrityResult {
968    QuickIntegrityResult {
969        entity: IntegrityEntityIdentity::from_accepted_identity(&identity),
970        database_incarnation_id: incarnation,
971        accepted_schema_version: identity.accepted_schema_version().get(),
972        accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
973        status: QuickIntegrityStatus::Uninspectable(diagnostic),
974        total_findings: 0,
975        omitted_findings: 0,
976        findings: Vec::new(),
977    }
978}
979
980pub(in crate::db) fn execute_quick_integrity<C: CanisterKind>(
981    db: &crate::db::Db<C>,
982    plan: &AcceptedInspectionPlan,
983    incarnation: DatabaseIncarnationId,
984) -> Result<QuickIntegrityResult, InternalError> {
985    ensure_recovery_admitted(db)?;
986    let findings = match validate_quick_integrity_control(db, plan, incarnation) {
987        Ok(findings) => findings,
988        Err(error) => {
989            let IntegrityTerminalOutcome::Uninspectable(diagnostic) =
990                IntegrityTerminalOutcome::from_internal(&error)
991            else {
992                return Err(error);
993            };
994            return Ok(uninspectable_quick_integrity(
995                plan.identity(),
996                incarnation,
997                diagnostic,
998            ));
999        }
1000    };
1001    let mut accumulator = QuickIntegrityAccumulator::new();
1002    for finding in findings {
1003        if let Err(diagnostic) = accumulator.record(finding) {
1004            return accumulator.resource_limited(plan, incarnation, diagnostic);
1005        }
1006    }
1007
1008    accumulator.complete(plan, incarnation)
1009}
1010
1011pub(crate) fn generate_database_incarnation_id() -> Result<DatabaseIncarnationId, InternalError> {
1012    DatabaseIncarnationId::generate()
1013}
1014
1015/// Generate the database-lifecycle secret used to authenticate scalar cursors.
1016///
1017/// The key is persisted beside the database incarnation before runtime
1018/// authority becomes visible. It uses the same process-local generation
1019/// authority as generated ULID keys and is not exposed through an outward API.
1020pub(crate) fn generate_cursor_authentication_key() -> Result<[u8; 32], InternalError> {
1021    let first = <crate::types::Ulid as crate::types::GenerateKey>::generate()?;
1022    let second = <crate::types::Ulid as crate::types::GenerateKey>::generate()?;
1023    let mut bytes = [0_u8; 32];
1024    bytes[..16].copy_from_slice(&first.to_bytes());
1025    bytes[16..].copy_from_slice(&second.to_bytes());
1026    if bytes == [0; 32] {
1027        return Err(InternalError::database_incarnation_generation_failed());
1028    }
1029
1030    Ok(bytes)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use crate::{
1037        db::schema::{FieldStorageDecode, LeafCodec, ScalarCodec},
1038        db::{
1039            commit::CommitSchemaFingerprint,
1040            schema::{
1041                AcceptedCatalogIdentity, AcceptedCompositeCatalog, AcceptedFieldKind,
1042                AcceptedSchemaRevision, AcceptedSchemaSnapshot, AcceptedValueCatalogHandle,
1043                FieldId, IdentityState, IdentityStateOwner, PersistedFieldSnapshot,
1044                PersistedSchemaSnapshot, SchemaFieldSlot, SchemaInsertDefault, SchemaRowLayout,
1045                SchemaVersion, empty_accepted_enum_catalog_for_tests,
1046            },
1047        },
1048        types::EntityTag,
1049    };
1050
1051    fn plan() -> AcceptedInspectionPlan {
1052        let revision = AcceptedSchemaRevision::INITIAL;
1053        let identity = AcceptedCatalogIdentity::new(
1054            EntityTag::new(23),
1055            "tests::QuickEntity",
1056            "tests::QuickStore",
1057            revision,
1058            SchemaVersion::initial(),
1059            CommitSchemaFingerprint::from([0x44; 16]),
1060        );
1061        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1062            SchemaVersion::initial(),
1063            "tests::QuickEntity".to_string(),
1064            "QuickEntity".to_string(),
1065            FieldId::new(1),
1066            SchemaRowLayout::initial(vec![(FieldId::new(1), SchemaFieldSlot::new(0))]),
1067            vec![PersistedFieldSnapshot::new_initial(
1068                FieldId::new(1),
1069                "id".to_string(),
1070                SchemaFieldSlot::new(0),
1071                AcceptedFieldKind::Nat64,
1072                Vec::new(),
1073                false,
1074                SchemaInsertDefault::None,
1075                FieldStorageDecode::ByKind,
1076                LeafCodec::Scalar(ScalarCodec::Nat64),
1077            )],
1078        ));
1079        let value_catalog = AcceptedValueCatalogHandle::new_for_tests(
1080            empty_accepted_enum_catalog_for_tests(),
1081            AcceptedCompositeCatalog::empty(),
1082            revision,
1083        );
1084
1085        AcceptedInspectionPlan::compile_relation_free_for_tests(
1086            identity,
1087            snapshot.into(),
1088            value_catalog,
1089        )
1090        .expect("accepted Quick plan should compile")
1091    }
1092
1093    fn finding(plan: &AcceptedInspectionPlan) -> IntegrityFinding {
1094        IntegrityFinding {
1095            diagnostic_code: icydb_diagnostic_code::ErrorCode::STORE_CORRUPTION.raw(),
1096            class: IntegrityFindingClass::Corruption,
1097            severity: IntegritySeverity::Error,
1098            kind: IntegrityFindingKind::MalformedRow,
1099            entity: IntegrityEntityIdentity::from_plan(plan),
1100            store_path: plan.identity().store_path().to_string(),
1101            phase: IntegrityPhase::Rows,
1102            verifier_family: IntegrityVerifierFamily::RowEnvelope,
1103            physical_key: vec![1],
1104            primary_key: None,
1105            field_paths: Vec::new(),
1106            value_path: None,
1107            constraint_id: None,
1108            constraint_name: None,
1109            schema_index_id: None,
1110            relation_id: None,
1111            expected: None,
1112            observed: None,
1113        }
1114    }
1115
1116    #[test]
1117    fn database_incarnation_rejects_zero_and_round_trips_current_bytes() {
1118        assert!(DatabaseIncarnationId::try_from_bytes([0; 16]).is_err());
1119
1120        let identity = DatabaseIncarnationId::for_tests(7);
1121        assert_eq!(
1122            DatabaseIncarnationId::try_from_bytes(identity.to_bytes())
1123                .expect("nonzero incarnation should decode"),
1124            identity,
1125        );
1126    }
1127
1128    #[test]
1129    fn integrity_finding_candid_preserves_targeted_constraint_path() {
1130        let plan = plan();
1131        let mut finding = finding(&plan);
1132        let path = ConstraintValuePath::new(vec![
1133            crate::error::ConstraintValuePathComponent::RootField { field_id: 1 },
1134            crate::error::ConstraintValuePathComponent::ListElement { index: 2 },
1135        ]);
1136        finding.kind = IntegrityFindingKind::ConstraintViolation;
1137        finding.value_path = Some(Box::new(path.clone()));
1138        finding.constraint_id = Some(7);
1139        finding.constraint_name = Some("nested_limit".to_string());
1140
1141        let bytes = candid::encode_one(&finding).expect("integrity finding should encode");
1142        let decoded: IntegrityFinding =
1143            candid::decode_one(&bytes).expect("integrity finding should decode");
1144        assert_eq!(decoded.value_path(), Some(&path));
1145        assert_eq!(decoded.constraint_id(), Some(7));
1146        assert_eq!(decoded.constraint_name(), Some("nested_limit"));
1147    }
1148
1149    #[test]
1150    fn quick_clean_result_binds_incarnation_and_accepted_plan_identity() {
1151        let plan = plan();
1152        let incarnation = DatabaseIncarnationId::for_tests(8);
1153        let result = QuickIntegrityAccumulator::new()
1154            .complete(&plan, incarnation)
1155            .expect("clean Quick accounting should remain valid");
1156
1157        assert_eq!(result.status(), &QuickIntegrityStatus::CompleteClean);
1158        assert_eq!(result.database_incarnation_id(), incarnation);
1159        assert_eq!(result.accepted_schema_version(), 1);
1160        assert_eq!(result.accepted_schema_fingerprint(), [0x44; 16]);
1161        assert_eq!(result.total_findings(), 0);
1162        assert_eq!(result.omitted_findings(), 0);
1163    }
1164
1165    #[test]
1166    fn quick_findings_keep_a_bounded_prefix_and_exact_omitted_count() {
1167        let plan = plan();
1168        let mut accumulator = QuickIntegrityAccumulator::new();
1169        for _ in 0..=MAX_QUICK_RETURNED_FINDINGS {
1170            accumulator
1171                .record(finding(&plan))
1172                .expect("bounded test finding count should fit");
1173        }
1174        let result = accumulator
1175            .complete(&plan, DatabaseIncarnationId::for_tests(9))
1176            .expect("one-over-cap Quick accounting should remain valid");
1177
1178        assert_eq!(result.status(), &QuickIntegrityStatus::CompleteWithFindings,);
1179        assert_eq!(result.total_findings(), 65);
1180        assert_eq!(result.findings().len(), MAX_QUICK_RETURNED_FINDINGS);
1181        assert_eq!(result.omitted_findings(), 1);
1182        assert_eq!(
1183            result.total_findings(),
1184            result.findings().len() as u64 + result.omitted_findings(),
1185        );
1186    }
1187
1188    #[test]
1189    fn quick_findings_at_the_exact_returned_cap_have_no_omissions() {
1190        let plan = plan();
1191        let mut accumulator = QuickIntegrityAccumulator::new();
1192        for _ in 0..MAX_QUICK_RETURNED_FINDINGS {
1193            accumulator
1194                .record(finding(&plan))
1195                .expect("exact-cap finding count should fit");
1196        }
1197        let result = accumulator
1198            .complete(&plan, DatabaseIncarnationId::for_tests(10))
1199            .expect("exact-cap Quick accounting should remain valid");
1200
1201        assert_eq!(result.total_findings(), 64);
1202        assert_eq!(result.findings().len(), MAX_QUICK_RETURNED_FINDINGS);
1203        assert_eq!(result.omitted_findings(), 0);
1204    }
1205
1206    #[test]
1207    fn quick_selected_authority_failure_is_not_a_clean_completion() {
1208        let plan = plan();
1209        let error = InternalError::accepted_row_constraint_program_corrupt();
1210        let result = uninspectable_quick_integrity(
1211            plan.identity(),
1212            DatabaseIncarnationId::for_tests(11),
1213            IntegrityAuthorityDiagnostic::from_internal(&error),
1214        );
1215
1216        assert!(matches!(
1217            result.status(),
1218            QuickIntegrityStatus::Uninspectable(IntegrityAuthorityDiagnostic {
1219                class: IntegrityAuthorityClass::Corruption,
1220                ..
1221            }),
1222        ));
1223        assert_eq!(result.total_findings(), 0);
1224        assert_eq!(result.omitted_findings(), 0);
1225    }
1226
1227    #[test]
1228    fn quick_identity_inventory_rejects_active_retired_owner_collision_first() {
1229        let incarnation = DatabaseIncarnationId::for_tests(12);
1230        let owner = IdentityStateOwner::try_new(incarnation, EntityTag::new(31), FieldId::new(1))
1231            .expect("identity owner should admit");
1232        let active = IdentityState::new_active(owner, AcceptedFieldKind::Nat64)
1233            .expect("active identity state should admit");
1234        let retired = active.retire().expect("active state should retire");
1235        let mut owners = BTreeMap::new();
1236
1237        record_quick_identity_owner(&mut owners, "tests::first", &active)
1238            .expect("the first owner should admit");
1239        let error = record_quick_identity_owner(&mut owners, "tests::second", &retired)
1240            .expect_err("an active/retired owner collision must reject");
1241
1242        assert_eq!(error.class(), ErrorClass::Corruption);
1243        assert_eq!(error.origin(), ErrorOrigin::Identity);
1244    }
1245}