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        }
244        StoreRuntimeStorageMode::Journaled => {
245            if capabilities != StoreRuntimeStorageCapabilities::journaled()
246                || !allocations.matches_storage_capabilities(capabilities)
247            {
248                return Err(InternalError::store_invariant());
249            }
250            let journal = store
251                .journal_tail_store()
252                .ok_or_else(InternalError::store_invariant)?
253                .with_borrow(crate::db::journal::JournalTailStore::proof_identity)?;
254            if !journal.is_well_formed() {
255                return Ok(Some(quick_journal_control_finding(plan, store_path)));
256            }
257        }
258    }
259    Ok(None)
260}
261
262fn quick_journal_control_finding(
263    plan: &AcceptedInspectionPlan,
264    store_path: &str,
265) -> IntegrityFinding {
266    let error = InternalError::store_corruption();
267    IntegrityFinding {
268        diagnostic_code: error.diagnostic_code().error_code().raw(),
269        class: IntegrityFindingClass::Corruption,
270        severity: IntegritySeverity::Error,
271        kind: IntegrityFindingKind::JournalControlMismatch,
272        entity: IntegrityEntityIdentity::from_plan(plan),
273        store_path: store_path.to_string(),
274        phase: IntegrityPhase::QuickMetadata,
275        verifier_family: IntegrityVerifierFamily::JournalEnvelope,
276        physical_key: Vec::new(),
277        primary_key: None,
278        field_paths: Vec::new(),
279        value_path: None,
280        constraint_id: None,
281        constraint_name: None,
282        schema_index_id: None,
283        relation_id: None,
284        expected: Some("well-formed-journal-control".to_string()),
285        observed: Some("inconsistent-journal-control".to_string()),
286    }
287}
288
289fn relation_field_paths(plan: &AcceptedInspectionPlan, relation_id: u32) -> Vec<String> {
290    let snapshot = plan.snapshot().persisted_snapshot();
291    let Some(relation) = snapshot
292        .relations()
293        .iter()
294        .find(|relation| relation.id().get() == relation_id)
295    else {
296        return Vec::new();
297    };
298
299    relation
300        .source()
301        .root_field_ids()
302        .iter()
303        .filter_map(|field_id| {
304            snapshot
305                .fields()
306                .iter()
307                .find(|field| field.id() == *field_id)
308                .map(|field| field.name().to_string())
309        })
310        .collect()
311}
312
313const MAX_QUICK_RETURNED_FINDINGS: usize = 64;
314#[cfg(target_arch = "wasm32")]
315const DATABASE_INCARNATION_DOMAIN: &[u8] = b"icydb.database-incarnation.v1";
316static DATABASE_INCARNATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
317
318/// Durable identity of one database lifecycle.
319///
320/// The identity is independent of accepted schema, row, index, relation, and
321/// journal revisions. Ordinary reopen preserves it. Any future restore,
322/// replacement, or import lane that can reuse those revisions must mint and
323/// publish a fresh identity before the restored database becomes available.
324#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)]
325pub struct DatabaseIncarnationId([u8; 16]);
326
327impl DatabaseIncarnationId {
328    /// Decode one current-form nonzero incarnation identity.
329    pub(crate) fn try_from_bytes(bytes: [u8; 16]) -> Result<Self, InternalError> {
330        if bytes == [0; 16] {
331            return Err(InternalError::database_incarnation_invalid());
332        }
333
334        Ok(Self(bytes))
335    }
336
337    /// Return the canonical persisted identity bytes.
338    #[must_use]
339    pub const fn to_bytes(self) -> [u8; 16] {
340        self.0
341    }
342
343    fn generate() -> Result<Self, InternalError> {
344        #[allow(
345            deprecated,
346            reason = "Atomic try_update requires Rust 1.95; MSRV is 1.88."
347        )]
348        let sequence = DATABASE_INCARNATION_SEQUENCE
349            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
350                current.checked_add(1)
351            })
352            .map_err(|_| InternalError::database_incarnation_generation_failed())?
353            .checked_add(1)
354            .ok_or_else(InternalError::database_incarnation_generation_failed)?;
355
356        #[cfg(not(target_arch = "wasm32"))]
357        let bytes = {
358            let mut bytes = [0_u8; 16];
359            getrandom::fill(&mut bytes)
360                .map_err(|_| InternalError::database_incarnation_generation_failed())?;
361            bytes
362        };
363
364        #[cfg(target_arch = "wasm32")]
365        let bytes = {
366            use sha2::{Digest, Sha256};
367
368            let mut hasher = Sha256::new();
369            hasher.update(DATABASE_INCARNATION_DOMAIN);
370            hasher.update(ic_cdk::api::canister_self().as_slice());
371            hasher.update(ic_cdk::api::time().to_be_bytes());
372            hasher.update(sequence.to_be_bytes());
373            let digest = hasher.finalize();
374            let mut bytes = [0_u8; 16];
375            bytes.copy_from_slice(&digest[..16]);
376            bytes
377        };
378
379        let _ = sequence;
380        Self::try_from_bytes(bytes)
381    }
382
383    #[cfg(test)]
384    pub(crate) const fn for_tests(fill: u8) -> Self {
385        let mut bytes = [fill; 16];
386        if fill == 0 {
387            bytes[15] = 1;
388        }
389        Self(bytes)
390    }
391}
392
393/// Stable entity identity projected into integrity responses.
394#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
395pub struct IntegrityEntityIdentity {
396    entity_tag: u64,
397    entity_path: String,
398    store_path: String,
399}
400
401impl IntegrityEntityIdentity {
402    fn from_plan(plan: &AcceptedInspectionPlan) -> Self {
403        Self::from_accepted_identity(plan.identity_ref())
404    }
405
406    pub(in crate::db) fn from_accepted_identity(
407        identity: &crate::db::schema::AcceptedCatalogIdentity,
408    ) -> Self {
409        Self {
410            entity_tag: identity.entity_tag().value(),
411            entity_path: identity.entity_path().to_string(),
412            store_path: identity.store_path().to_string(),
413        }
414    }
415
416    pub(in crate::db) const fn validate(&self) -> Result<(), IntegrityJobError> {
417        if self.entity_tag == 0
418            || self.entity_path.is_empty()
419            || self.entity_path.len() > MAX_INTEGRITY_PATH_BYTES
420            || self.store_path.is_empty()
421            || self.store_path.len() > MAX_INTEGRITY_PATH_BYTES
422        {
423            return Err(IntegrityJobError::InvalidEntityIdentity);
424        }
425        Ok(())
426    }
427
428    /// Return the stable accepted entity tag.
429    #[must_use]
430    pub const fn entity_tag(&self) -> u64 {
431        self.entity_tag
432    }
433
434    /// Borrow the accepted entity path.
435    #[must_use]
436    pub const fn entity_path(&self) -> &str {
437        self.entity_path.as_str()
438    }
439
440    /// Borrow the accepted store path.
441    #[must_use]
442    pub const fn store_path(&self) -> &str {
443        self.store_path.as_str()
444    }
445}
446
447/// Broad machine-readable accepted-authority failure class.
448#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
449pub enum IntegrityAuthorityClass {
450    /// Accepted authority bytes or closure are corrupt.
451    Corruption,
452    /// Accepted authority uses an unsupported persisted form.
453    IncompatiblePersistedFormat,
454    /// Accepted authority violates an internal invariant.
455    InvariantViolation,
456    /// The selected entity or storage contract is unsupported.
457    Unsupported,
458    /// The engine could not complete accepted-authority inspection.
459    Internal,
460}
461
462/// Broad machine-readable integrity finding class.
463#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
464pub enum IntegrityFindingClass {
465    /// Accepted or physical bytes are corrupt.
466    Corruption,
467    /// Current-form persisted bytes cannot be decoded by this build.
468    IncompatiblePersistedFormat,
469    /// A required bounded proof could not be completed.
470    ResourceLimited,
471}
472
473/// Stable semantic family of one integrity finding.
474
475#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
476pub enum IntegrityFindingKind {
477    /// The physical data key is not a valid current key for its entity interval.
478    MalformedDataKey,
479
480    /// The maintained row envelope or slot table is malformed.
481    MalformedRow,
482
483    /// The row exceeds the maintained current raw-byte bound.
484    OversizedRow,
485
486    /// One active accepted field payload violates its exact field contract.
487    InvalidFieldValue,
488
489    /// The physical key and decoded primary-key field values disagree.
490    PrimaryKeyMismatch,
491
492    /// An Identity primary key is zero or has the wrong exact unsigned shape.
493    InvalidIdentityValue,
494
495    /// A live Identity primary key is above committed high-water.
496    IdentityHighWaterExceeded,
497
498    /// One validated accepted row-local constraint is violated.
499    ConstraintViolation,
500
501    /// One row-derived active forward-index witness is absent.
502    MissingIndexEntry,
503
504    /// One row-derived active forward-index witness has invalid value bytes.
505    DivergentIndexEntry,
506
507    /// One active forward-index entry has malformed key, identity, or value framing.
508    MalformedIndexEntry,
509
510    /// One active forward-index entry points at no authoritative source row.
511    OrphanIndexEntry,
512
513    /// One unique logical key has more than one physical row witness.
514    DuplicateUniqueIndexKey,
515
516    /// One accepted relation points to an absent target row.
517    MissingRelationTarget,
518
519    /// One expected active reverse-relation witness is absent.
520    MissingReverseRelationEntry,
521
522    /// One expected active reverse-relation witness has invalid value bytes.
523    DivergentReverseRelationEntry,
524
525    /// One active reverse-relation entry has malformed key, identity, or value framing.
526    MalformedReverseRelationEntry,
527
528    /// One active reverse-relation entry points at no authoritative source row.
529    OrphanReverseRelationEntry,
530
531    /// One durable journal batch is not a valid current-form envelope.
532    MalformedJournalBatch,
533
534    /// The durable journal tail omits one or more expected sequence values.
535    JournalSequenceGap,
536
537    /// Two durable journal batches carry the same logical batch identity.
538    DuplicateJournalBatchIdentity,
539
540    /// Bounded journal control records disagree without requiring tail traversal.
541    JournalControlMismatch,
542}
543
544/// Canonical Deep inspection phase.
545
546#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
547pub enum IntegrityPhase {
548    /// Bounded accepted metadata and control closure.
549    QuickMetadata,
550
551    /// Canonical physical row storage.
552    Rows,
553
554    /// Active forward-index storage.
555    IndexEntries,
556
557    /// Active source-owned reverse-relation storage.
558    ReverseRelations,
559
560    /// Durable journal tails.
561    JournalTails,
562
563    /// Final unchanged-proof-vector comparison.
564    FinalProofVectorCheck,
565}
566
567/// Deterministic verifier family within one physical inspection unit.
568
569#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
570pub enum IntegrityVerifierFamily {
571    /// Current physical data-key framing and identity.
572    DataKey,
573
574    /// Current row envelope, layout stamp, slot count, and table framing.
575    RowEnvelope,
576
577    /// One accepted field payload or frozen historical fill.
578    FieldValue,
579
580    /// Physical key versus accepted row primary-key fields.
581    PrimaryKey,
582
583    /// Accepted Identity owner and committed high-water.
584    IdentityState,
585
586    /// Validated accepted row-local constraints.
587    ValidatedConstraints,
588
589    /// One expected active forward-index witness.
590    ForwardIndex,
591
592    /// One physical active forward-index entry.
593    IndexEntry,
594
595    /// One unique-key multiplicity proof.
596    UniqueIndex,
597
598    /// One accepted relation's target and reverse witness projection.
599    Relation,
600
601    /// One physical active source-owned reverse-relation entry.
602    ReverseRelationEntry,
603
604    /// Current durable journal batch framing and sequence continuity.
605    JournalEnvelope,
606
607    /// Durable journal batch identity uniqueness.
608    JournalBatchIdentity,
609}
610
611/// Severity of one definite integrity finding.
612#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
613pub enum IntegritySeverity {
614    /// The finding identifies invalid maintained state.
615    Error,
616    /// The finding is an operator advisory and does not invalidate a clean proof.
617    Advisory,
618}
619
620/// One bounded machine-readable integrity finding.
621///
622/// Raw row payloads and unbounded application values are deliberately absent.
623#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
624pub struct IntegrityFinding {
625    diagnostic_code: u16,
626    class: IntegrityFindingClass,
627    severity: IntegritySeverity,
628    kind: IntegrityFindingKind,
629    entity: IntegrityEntityIdentity,
630    store_path: String,
631    phase: IntegrityPhase,
632    verifier_family: IntegrityVerifierFamily,
633    physical_key: Vec<u8>,
634    primary_key: Option<Vec<u8>>,
635    field_paths: Vec<String>,
636    value_path: Option<Box<ConstraintValuePath>>,
637    constraint_id: Option<u32>,
638    constraint_name: Option<String>,
639    schema_index_id: Option<u32>,
640    relation_id: Option<u32>,
641    expected: Option<String>,
642    observed: Option<String>,
643}
644
645impl IntegrityFinding {
646    /// Return the stable compact diagnostic code.
647    #[must_use]
648    pub const fn diagnostic_code(&self) -> u16 {
649        self.diagnostic_code
650    }
651
652    /// Return the broad finding class.
653    #[must_use]
654    pub const fn class(&self) -> IntegrityFindingClass {
655        self.class
656    }
657
658    /// Return the finding severity.
659    #[must_use]
660    pub const fn severity(&self) -> IntegritySeverity {
661        self.severity
662    }
663
664    /// Return the stable semantic finding family.
665    #[must_use]
666    pub const fn kind(&self) -> IntegrityFindingKind {
667        self.kind
668    }
669
670    /// Borrow the accepted entity identity.
671    #[must_use]
672    pub const fn entity(&self) -> &IntegrityEntityIdentity {
673        &self.entity
674    }
675
676    /// Borrow the affected store path.
677    #[must_use]
678    pub const fn store_path(&self) -> &str {
679        self.store_path.as_str()
680    }
681
682    /// Return the Deep phase that observed this finding.
683    #[must_use]
684    pub const fn phase(&self) -> IntegrityPhase {
685        self.phase
686    }
687
688    /// Return the deterministic verifier family that observed this finding.
689    #[must_use]
690    pub const fn verifier_family(&self) -> IntegrityVerifierFamily {
691        self.verifier_family
692    }
693
694    /// Borrow the bounded exact physical key.
695    #[must_use]
696    pub const fn physical_key(&self) -> &[u8] {
697        self.physical_key.as_slice()
698    }
699
700    /// Borrow the canonical primary-key suffix after successful key decoding.
701    #[must_use]
702    pub fn primary_key(&self) -> Option<&[u8]> {
703        self.primary_key.as_deref()
704    }
705
706    /// Borrow bounded accepted field paths relevant to the finding.
707    #[must_use]
708    pub const fn field_paths(&self) -> &[String] {
709        self.field_paths.as_slice()
710    }
711
712    /// Borrow the concrete accepted value path for targeted-rule findings.
713    #[must_use]
714    pub fn value_path(&self) -> Option<&ConstraintValuePath> {
715        self.value_path.as_deref()
716    }
717
718    /// Return the accepted constraint identity when applicable.
719    #[must_use]
720    pub const fn constraint_id(&self) -> Option<u32> {
721        self.constraint_id
722    }
723
724    /// Borrow the accepted constraint name when applicable.
725    #[must_use]
726    pub fn constraint_name(&self) -> Option<&str> {
727        self.constraint_name.as_deref()
728    }
729
730    /// Return the accepted logical index identity when applicable.
731    #[must_use]
732    pub const fn schema_index_id(&self) -> Option<u32> {
733        self.schema_index_id
734    }
735
736    /// Return the accepted relation identity when applicable.
737    #[must_use]
738    pub const fn relation_id(&self) -> Option<u32> {
739        self.relation_id
740    }
741
742    /// Borrow the bounded expected-state label, when applicable.
743    #[must_use]
744    pub fn expected(&self) -> Option<&str> {
745        self.expected.as_deref()
746    }
747
748    /// Borrow the bounded observed-state label, when applicable.
749    #[must_use]
750    pub fn observed(&self) -> Option<&str> {
751        self.observed.as_deref()
752    }
753}
754
755/// Typed reason that accepted authority could not be inspected.
756#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
757pub struct IntegrityAuthorityDiagnostic {
758    diagnostic_code: u16,
759    class: IntegrityAuthorityClass,
760}
761
762impl IntegrityAuthorityDiagnostic {
763    pub(in crate::db) fn from_internal(error: &InternalError) -> Self {
764        let class = match error.class {
765            ErrorClass::Corruption => IntegrityAuthorityClass::Corruption,
766            ErrorClass::IncompatiblePersistedFormat => {
767                IntegrityAuthorityClass::IncompatiblePersistedFormat
768            }
769            ErrorClass::InvariantViolation => IntegrityAuthorityClass::InvariantViolation,
770            ErrorClass::Unsupported | ErrorClass::NotFound | ErrorClass::Conflict => {
771                IntegrityAuthorityClass::Unsupported
772            }
773            ErrorClass::Internal => IntegrityAuthorityClass::Internal,
774        };
775        Self {
776            diagnostic_code: error.diagnostic_code().error_code().raw(),
777            class,
778        }
779    }
780
781    /// Return the stable compact diagnostic code.
782    #[must_use]
783    pub const fn diagnostic_code(&self) -> u16 {
784        self.diagnostic_code
785    }
786
787    /// Return the broad failure class.
788    #[must_use]
789    pub const fn class(&self) -> IntegrityAuthorityClass {
790        self.class
791    }
792}
793
794/// Typed bounded-resource failure.
795#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
796pub struct IntegrityResourceDiagnostic {
797    diagnostic_code: u16,
798}
799
800impl IntegrityResourceDiagnostic {
801    /// Return the stable compact diagnostic code.
802    #[must_use]
803    pub const fn diagnostic_code(&self) -> u16 {
804        self.diagnostic_code
805    }
806}
807
808/// Outcome of one bounded Quick integrity inspection.
809#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
810pub enum QuickIntegrityStatus {
811    /// Every bounded Quick family was inspected without findings.
812    CompleteClean,
813    /// Every bounded Quick family was inspected and definite findings exist.
814    CompleteWithFindings,
815    /// Load-bearing accepted authority could not be inspected.
816    Uninspectable(IntegrityAuthorityDiagnostic),
817    /// The minimum bounded inspection atom could not be completed.
818    ResourceLimited(IntegrityResourceDiagnostic),
819}
820
821/// Complete result of one bounded accepted-native Quick inspection.
822#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
823pub struct QuickIntegrityResult {
824    entity: IntegrityEntityIdentity,
825    database_incarnation_id: DatabaseIncarnationId,
826    accepted_schema_version: u32,
827    accepted_schema_fingerprint: [u8; 16],
828    status: QuickIntegrityStatus,
829    total_findings: u64,
830    omitted_findings: u64,
831    findings: Vec<IntegrityFinding>,
832}
833
834impl QuickIntegrityResult {
835    /// Borrow the accepted entity identity.
836    #[must_use]
837    pub const fn entity(&self) -> &IntegrityEntityIdentity {
838        &self.entity
839    }
840
841    /// Return the durable database incarnation inspected by this call.
842    #[must_use]
843    pub const fn database_incarnation_id(&self) -> DatabaseIncarnationId {
844        self.database_incarnation_id
845    }
846
847    /// Return the accepted entity schema version.
848    #[must_use]
849    pub const fn accepted_schema_version(&self) -> u32 {
850        self.accepted_schema_version
851    }
852
853    /// Return the accepted entity schema fingerprint.
854    #[must_use]
855    pub const fn accepted_schema_fingerprint(&self) -> [u8; 16] {
856        self.accepted_schema_fingerprint
857    }
858
859    /// Borrow the Quick completion status.
860    #[must_use]
861    pub const fn status(&self) -> &QuickIntegrityStatus {
862        &self.status
863    }
864
865    /// Return the exact number of findings observed.
866    #[must_use]
867    pub const fn total_findings(&self) -> u64 {
868        self.total_findings
869    }
870
871    /// Return the number of findings omitted from the bounded response prefix.
872    #[must_use]
873    pub const fn omitted_findings(&self) -> u64 {
874        self.omitted_findings
875    }
876
877    /// Borrow the bounded canonical finding prefix.
878    #[must_use]
879    pub const fn findings(&self) -> &[IntegrityFinding] {
880        self.findings.as_slice()
881    }
882}
883
884struct QuickIntegrityAccumulator {
885    total_findings: u64,
886    findings: Vec<IntegrityFinding>,
887}
888
889impl QuickIntegrityAccumulator {
890    const fn new() -> Self {
891        Self {
892            total_findings: 0,
893            findings: Vec::new(),
894        }
895    }
896
897    fn record(&mut self, finding: IntegrityFinding) -> Result<(), IntegrityResourceDiagnostic> {
898        self.total_findings =
899            self.total_findings
900                .checked_add(1)
901                .ok_or(IntegrityResourceDiagnostic {
902                    diagnostic_code: icydb_diagnostic_code::ErrorCode::RUNTIME_INTERNAL.raw(),
903                })?;
904        if self.findings.len() < MAX_QUICK_RETURNED_FINDINGS {
905            self.findings.push(finding);
906        }
907        Ok(())
908    }
909
910    fn complete(
911        self,
912        plan: &AcceptedInspectionPlan,
913        incarnation: DatabaseIncarnationId,
914    ) -> Result<QuickIntegrityResult, InternalError> {
915        let status = if self.total_findings == 0 {
916            QuickIntegrityStatus::CompleteClean
917        } else {
918            QuickIntegrityStatus::CompleteWithFindings
919        };
920        let omitted_findings = self.omitted_findings()?;
921        let identity = plan.identity();
922
923        Ok(QuickIntegrityResult {
924            entity: IntegrityEntityIdentity::from_plan(plan),
925            database_incarnation_id: incarnation,
926            accepted_schema_version: identity.accepted_schema_version().get(),
927            accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
928            status,
929            total_findings: self.total_findings,
930            omitted_findings,
931            findings: self.findings,
932        })
933    }
934
935    fn resource_limited(
936        self,
937        plan: &AcceptedInspectionPlan,
938        incarnation: DatabaseIncarnationId,
939        diagnostic: IntegrityResourceDiagnostic,
940    ) -> Result<QuickIntegrityResult, InternalError> {
941        let omitted_findings = self.omitted_findings()?;
942        let identity = plan.identity();
943
944        Ok(QuickIntegrityResult {
945            entity: IntegrityEntityIdentity::from_plan(plan),
946            database_incarnation_id: incarnation,
947            accepted_schema_version: identity.accepted_schema_version().get(),
948            accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
949            status: QuickIntegrityStatus::ResourceLimited(diagnostic),
950            total_findings: self.total_findings,
951            omitted_findings,
952            findings: self.findings,
953        })
954    }
955
956    fn omitted_findings(&self) -> Result<u64, InternalError> {
957        let returned = u64::try_from(self.findings.len()).map_err(|_| {
958            InternalError::classified(ErrorClass::InvariantViolation, ErrorOrigin::Response)
959        })?;
960        self.total_findings.checked_sub(returned).ok_or_else(|| {
961            InternalError::classified(ErrorClass::InvariantViolation, ErrorOrigin::Response)
962        })
963    }
964}
965
966pub(in crate::db) fn uninspectable_quick_integrity(
967    identity: crate::db::schema::AcceptedCatalogIdentity,
968    incarnation: DatabaseIncarnationId,
969    diagnostic: IntegrityAuthorityDiagnostic,
970) -> QuickIntegrityResult {
971    QuickIntegrityResult {
972        entity: IntegrityEntityIdentity::from_accepted_identity(&identity),
973        database_incarnation_id: incarnation,
974        accepted_schema_version: identity.accepted_schema_version().get(),
975        accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
976        status: QuickIntegrityStatus::Uninspectable(diagnostic),
977        total_findings: 0,
978        omitted_findings: 0,
979        findings: Vec::new(),
980    }
981}
982
983pub(in crate::db) fn execute_quick_integrity<C: CanisterKind>(
984    db: &crate::db::Db<C>,
985    plan: &AcceptedInspectionPlan,
986    incarnation: DatabaseIncarnationId,
987) -> Result<QuickIntegrityResult, InternalError> {
988    ensure_recovery_admitted(db)?;
989    let findings = match validate_quick_integrity_control(db, plan, incarnation) {
990        Ok(findings) => findings,
991        Err(error) => {
992            let IntegrityTerminalOutcome::Uninspectable(diagnostic) =
993                IntegrityTerminalOutcome::from_internal(&error)
994            else {
995                return Err(error);
996            };
997            return Ok(uninspectable_quick_integrity(
998                plan.identity(),
999                incarnation,
1000                diagnostic,
1001            ));
1002        }
1003    };
1004    let mut accumulator = QuickIntegrityAccumulator::new();
1005    for finding in findings {
1006        if let Err(diagnostic) = accumulator.record(finding) {
1007            return accumulator.resource_limited(plan, incarnation, diagnostic);
1008        }
1009    }
1010
1011    accumulator.complete(plan, incarnation)
1012}
1013
1014pub(crate) fn generate_database_incarnation_id() -> Result<DatabaseIncarnationId, InternalError> {
1015    DatabaseIncarnationId::generate()
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021    use crate::{
1022        db::schema::{FieldStorageDecode, LeafCodec, ScalarCodec},
1023        db::{
1024            commit::CommitSchemaFingerprint,
1025            schema::{
1026                AcceptedCatalogIdentity, AcceptedCompositeCatalog, AcceptedFieldKind,
1027                AcceptedSchemaRevision, AcceptedSchemaSnapshot, AcceptedValueCatalogHandle,
1028                FieldId, IdentityState, IdentityStateOwner, PersistedFieldSnapshot,
1029                PersistedSchemaSnapshot, SchemaFieldSlot, SchemaInsertDefault, SchemaRowLayout,
1030                SchemaVersion, empty_accepted_enum_catalog_for_tests,
1031            },
1032        },
1033        types::EntityTag,
1034    };
1035
1036    fn plan() -> AcceptedInspectionPlan {
1037        let revision = AcceptedSchemaRevision::INITIAL;
1038        let identity = AcceptedCatalogIdentity::new(
1039            EntityTag::new(23),
1040            "tests::QuickEntity",
1041            "tests::QuickStore",
1042            revision,
1043            SchemaVersion::initial(),
1044            CommitSchemaFingerprint::from([0x44; 16]),
1045        );
1046        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1047            SchemaVersion::initial(),
1048            "tests::QuickEntity".to_string(),
1049            "QuickEntity".to_string(),
1050            FieldId::new(1),
1051            SchemaRowLayout::initial(vec![(FieldId::new(1), SchemaFieldSlot::new(0))]),
1052            vec![PersistedFieldSnapshot::new_initial(
1053                FieldId::new(1),
1054                "id".to_string(),
1055                SchemaFieldSlot::new(0),
1056                AcceptedFieldKind::Nat64,
1057                Vec::new(),
1058                false,
1059                SchemaInsertDefault::None,
1060                FieldStorageDecode::ByKind,
1061                LeafCodec::Scalar(ScalarCodec::Nat64),
1062            )],
1063        ));
1064        let value_catalog = AcceptedValueCatalogHandle::new_for_tests(
1065            empty_accepted_enum_catalog_for_tests(),
1066            AcceptedCompositeCatalog::empty(),
1067            revision,
1068        );
1069
1070        AcceptedInspectionPlan::compile_relation_free_for_tests(
1071            identity,
1072            snapshot.into(),
1073            value_catalog,
1074        )
1075        .expect("accepted Quick plan should compile")
1076    }
1077
1078    fn finding(plan: &AcceptedInspectionPlan) -> IntegrityFinding {
1079        IntegrityFinding {
1080            diagnostic_code: icydb_diagnostic_code::ErrorCode::STORE_CORRUPTION.raw(),
1081            class: IntegrityFindingClass::Corruption,
1082            severity: IntegritySeverity::Error,
1083            kind: IntegrityFindingKind::MalformedRow,
1084            entity: IntegrityEntityIdentity::from_plan(plan),
1085            store_path: plan.identity().store_path().to_string(),
1086            phase: IntegrityPhase::Rows,
1087            verifier_family: IntegrityVerifierFamily::RowEnvelope,
1088            physical_key: vec![1],
1089            primary_key: None,
1090            field_paths: Vec::new(),
1091            value_path: None,
1092            constraint_id: None,
1093            constraint_name: None,
1094            schema_index_id: None,
1095            relation_id: None,
1096            expected: None,
1097            observed: None,
1098        }
1099    }
1100
1101    #[test]
1102    fn database_incarnation_rejects_zero_and_round_trips_current_bytes() {
1103        assert!(DatabaseIncarnationId::try_from_bytes([0; 16]).is_err());
1104
1105        let identity = DatabaseIncarnationId::for_tests(7);
1106        assert_eq!(
1107            DatabaseIncarnationId::try_from_bytes(identity.to_bytes())
1108                .expect("nonzero incarnation should decode"),
1109            identity,
1110        );
1111    }
1112
1113    #[test]
1114    fn integrity_finding_candid_preserves_targeted_constraint_path() {
1115        let plan = plan();
1116        let mut finding = finding(&plan);
1117        let path = ConstraintValuePath::new(vec![
1118            crate::error::ConstraintValuePathComponent::RootField { field_id: 1 },
1119            crate::error::ConstraintValuePathComponent::ListElement { index: 2 },
1120        ]);
1121        finding.kind = IntegrityFindingKind::ConstraintViolation;
1122        finding.value_path = Some(Box::new(path.clone()));
1123        finding.constraint_id = Some(7);
1124        finding.constraint_name = Some("nested_limit".to_string());
1125
1126        let bytes = candid::encode_one(&finding).expect("integrity finding should encode");
1127        let decoded: IntegrityFinding =
1128            candid::decode_one(&bytes).expect("integrity finding should decode");
1129        assert_eq!(decoded.value_path(), Some(&path));
1130        assert_eq!(decoded.constraint_id(), Some(7));
1131        assert_eq!(decoded.constraint_name(), Some("nested_limit"));
1132    }
1133
1134    #[test]
1135    fn quick_clean_result_binds_incarnation_and_accepted_plan_identity() {
1136        let plan = plan();
1137        let incarnation = DatabaseIncarnationId::for_tests(8);
1138        let result = QuickIntegrityAccumulator::new()
1139            .complete(&plan, incarnation)
1140            .expect("clean Quick accounting should remain valid");
1141
1142        assert_eq!(result.status(), &QuickIntegrityStatus::CompleteClean);
1143        assert_eq!(result.database_incarnation_id(), incarnation);
1144        assert_eq!(result.accepted_schema_version(), 1);
1145        assert_eq!(result.accepted_schema_fingerprint(), [0x44; 16]);
1146        assert_eq!(result.total_findings(), 0);
1147        assert_eq!(result.omitted_findings(), 0);
1148    }
1149
1150    #[test]
1151    fn quick_findings_keep_a_bounded_prefix_and_exact_omitted_count() {
1152        let plan = plan();
1153        let mut accumulator = QuickIntegrityAccumulator::new();
1154        for _ in 0..=MAX_QUICK_RETURNED_FINDINGS {
1155            accumulator
1156                .record(finding(&plan))
1157                .expect("bounded test finding count should fit");
1158        }
1159        let result = accumulator
1160            .complete(&plan, DatabaseIncarnationId::for_tests(9))
1161            .expect("one-over-cap Quick accounting should remain valid");
1162
1163        assert_eq!(result.status(), &QuickIntegrityStatus::CompleteWithFindings,);
1164        assert_eq!(result.total_findings(), 65);
1165        assert_eq!(result.findings().len(), MAX_QUICK_RETURNED_FINDINGS);
1166        assert_eq!(result.omitted_findings(), 1);
1167        assert_eq!(
1168            result.total_findings(),
1169            result.findings().len() as u64 + result.omitted_findings(),
1170        );
1171    }
1172
1173    #[test]
1174    fn quick_findings_at_the_exact_returned_cap_have_no_omissions() {
1175        let plan = plan();
1176        let mut accumulator = QuickIntegrityAccumulator::new();
1177        for _ in 0..MAX_QUICK_RETURNED_FINDINGS {
1178            accumulator
1179                .record(finding(&plan))
1180                .expect("exact-cap finding count should fit");
1181        }
1182        let result = accumulator
1183            .complete(&plan, DatabaseIncarnationId::for_tests(10))
1184            .expect("exact-cap Quick accounting should remain valid");
1185
1186        assert_eq!(result.total_findings(), 64);
1187        assert_eq!(result.findings().len(), MAX_QUICK_RETURNED_FINDINGS);
1188        assert_eq!(result.omitted_findings(), 0);
1189    }
1190
1191    #[test]
1192    fn quick_selected_authority_failure_is_not_a_clean_completion() {
1193        let plan = plan();
1194        let error = InternalError::accepted_row_constraint_program_corrupt();
1195        let result = uninspectable_quick_integrity(
1196            plan.identity(),
1197            DatabaseIncarnationId::for_tests(11),
1198            IntegrityAuthorityDiagnostic::from_internal(&error),
1199        );
1200
1201        assert!(matches!(
1202            result.status(),
1203            QuickIntegrityStatus::Uninspectable(IntegrityAuthorityDiagnostic {
1204                class: IntegrityAuthorityClass::Corruption,
1205                ..
1206            }),
1207        ));
1208        assert_eq!(result.total_findings(), 0);
1209        assert_eq!(result.omitted_findings(), 0);
1210    }
1211
1212    #[test]
1213    fn quick_identity_inventory_rejects_active_retired_owner_collision_first() {
1214        let incarnation = DatabaseIncarnationId::for_tests(12);
1215        let owner = IdentityStateOwner::try_new(incarnation, EntityTag::new(31), FieldId::new(1))
1216            .expect("identity owner should admit");
1217        let active = IdentityState::new_active(owner, AcceptedFieldKind::Nat64)
1218            .expect("active identity state should admit");
1219        let retired = active.retire().expect("active state should retire");
1220        let mut owners = BTreeMap::new();
1221
1222        record_quick_identity_owner(&mut owners, "tests::first", &active)
1223            .expect("the first owner should admit");
1224        let error = record_quick_identity_owner(&mut owners, "tests::second", &retired)
1225            .expect_err("an active/retired owner collision must reject");
1226
1227        assert_eq!(error.class(), ErrorClass::Corruption);
1228        assert_eq!(error.origin(), ErrorOrigin::Identity);
1229    }
1230}